mirror of
https://github.com/Shineii86/AniKotoAPI.git
synced 2026-10-22 10:55:10 +00:00
Compare commits
54
Commits
v1.4.0
...
1d71a1e557
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d71a1e557 | ||
|
|
f04a09574c | ||
|
|
ed964a4afd | ||
|
|
30fdcd93c2 | ||
|
|
1ee01470d4 | ||
|
|
9a8bebac47 | ||
|
|
c9acfc6131 | ||
|
|
426559d0de | ||
|
|
03b68481b4 | ||
|
|
e00292493d | ||
|
|
75199d4dcd | ||
|
|
320bf0915b | ||
|
|
107e3c71e7 | ||
|
|
d067d1f1d7 | ||
|
|
f720e4608b | ||
|
|
a8223acf92 | ||
|
|
8f423bf39c | ||
|
|
996cead3b1 | ||
|
|
715add099b | ||
|
|
3000bcc1be | ||
|
|
ba58219ace | ||
|
|
78b149983d | ||
|
|
159c9ca8aa | ||
|
|
d9f490d36e | ||
|
|
a2a94869ee | ||
|
|
fa6aa6d602 | ||
|
|
7df5bbcc54 | ||
|
|
fb62184688 | ||
|
|
d391908467 | ||
|
|
21384dc30f | ||
|
|
bdf7e96ce9 | ||
|
|
e8c942a773 | ||
|
|
d0432e1654 | ||
|
|
fd29b02158 | ||
|
|
a8f1917ce7 | ||
|
|
7f07833c14 | ||
|
|
9778cd368d | ||
|
|
378f9b236a | ||
|
|
9d686390ee | ||
|
|
d97d6bd883 | ||
|
|
bc840f1373 | ||
|
|
403b205625 | ||
|
|
e8b63b5d16 | ||
|
|
646ef128a8 | ||
|
|
5ca688ab45 | ||
|
|
5cf908fb1e | ||
|
|
7641a81976 | ||
|
|
fe51275db0 | ||
|
|
30feda703a | ||
|
|
7d621e2e37 | ||
|
|
e5dbe0c12e | ||
|
|
f43522c4d5 | ||
|
|
cc145c9acc | ||
|
|
100b9f9414 |
@@ -3,3 +3,34 @@ ALLOWED_ORIGINS=https://site1.com,https://site2.com
|
|||||||
|
|
||||||
# Port (optional, defaults to 4444)
|
# Port (optional, defaults to 4444)
|
||||||
PORT=4444
|
PORT=4444
|
||||||
|
|
||||||
|
# Cache configuration
|
||||||
|
CACHE_MAX_SIZE=200
|
||||||
|
CACHE_DEFAULT_TTL=300000
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
RATE_LIMIT=100
|
||||||
|
RATE_WINDOW=60000
|
||||||
|
|
||||||
|
# Request timeout in milliseconds
|
||||||
|
REQUEST_TIMEOUT=30000
|
||||||
|
|
||||||
|
# Mirror configuration
|
||||||
|
# Comma-separated list of mirror domains (in priority order)
|
||||||
|
# Default: anikototv.to,anikoto.cz,anikoto.me,anikoto.net,anikototv.se
|
||||||
|
MIRROR_DOMAINS=https://anikototv.to,https://anikoto.cz,https://anikoto.me,https://anikoto.net,https://anikototv.se
|
||||||
|
|
||||||
|
# Mirror cache TTL in milliseconds (default: 1 hour)
|
||||||
|
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/
|
||||||
|
|||||||
+250
@@ -5,6 +5,256 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [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
|
||||||
|
- **Streaming Status Warning**: Added ⚠️ Streaming Status section to README documenting which endpoints work vs which are blocked by upstream anti-bot protection
|
||||||
|
- **Proxy Helper**: New `src/helper/proxy.helper.js` — unified proxy support for ScraperAPI and FlareSolverr
|
||||||
|
- **ScraperAPI Integration**: Routes requests through ScraperAPI premium residential IPs when direct requests fail
|
||||||
|
- **FlareSolverr Integration**: Routes requests through self-hosted FlareSolverr browser when direct requests fail
|
||||||
|
- **Proxy Status Endpoint**: `GET /api/proxy/status` — shows configured proxy providers and their status
|
||||||
|
- **Auto Proxy Fallback**: `fetchWithMirror` now automatically falls back to configured proxies when all mirrors fail
|
||||||
|
- **Cloudflare Detection**: Detects Cloudflare challenge pages and triggers proxy fallback
|
||||||
|
- **Upstream Error Detection**: Added detection of upstream API error responses (`status >= 400`) in `streamInfo.extractor.js` — returns HTTP 502 with descriptive error instead of silently returning null URL
|
||||||
|
- **Cookie-Aware Session Establishment**: Added session cookie fetching in `extractStreamInfo` and `extractServerList` — fetches watch/home page to capture `set-cookie` headers before AJAX requests
|
||||||
|
- **Optional Slug Parameter**: `getStreamInfo` controller now accepts optional `?slug=` query parameter for session establishment
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Version**: Bumped to 2.4.0
|
||||||
|
- **README**: Updated version badge from 2.2.1 to 2.4.0
|
||||||
|
- **README**: Added streaming status warning with solutions table
|
||||||
|
- **Docs**: Updated index.md, endpoints.md, streaming.md with streaming status warnings and v2.4.0 references
|
||||||
|
- **Mirror Helper**: `fetchWithMirror` now includes proxy fallback and Cloudflare detection
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **CRITICAL**: Fixed `/api/stream` returning `null` URL — upstream AJAX endpoints (`/ajax/server?get=`, `/ajax/server/list?servers=`) require session cookies; now properly detects upstream 500 errors and returns HTTP 502 with error message
|
||||||
|
|
||||||
|
## [2.2.2] - 2026-08-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **CRITICAL**: Fixed `/api/stream` returning `null` URL — root cause was AJAX endpoints (`/ajax/server?get=`, `/ajax/server/list?servers=`) require session cookies from the watch page; `fetchWithMirror` didn't persist cookies between requests, causing upstream to return `{status:500, result:"Bad request"}`
|
||||||
|
- Fixed `streamInfo.extractor.js` `extractStreamInfo` to establish session with cookies before AJAX requests — fetches watch page or home page to capture `set-cookie` headers and passes them with the AJAX request
|
||||||
|
- Fixed `streamInfo.extractor.js` `extractServerList` to establish session with cookies before AJAX requests (same cookie-aware pattern)
|
||||||
|
- Fixed `streamInfo.controller.js` `getStreamInfo` to accept optional `slug` query parameter and pass it to extractor for session establishment
|
||||||
|
- Fixed `streamInfo.controller.js` `getStreamInfo` to return HTTP 502 (Bad Gateway) with error message for upstream API failures instead of silently returning null URL
|
||||||
|
- Fixed upstream error detection in both extractors — detects `status >= 400` and non-JSON error strings from upstream API
|
||||||
|
|
||||||
|
## [2.2.1] - 2026-08-12
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **CRITICAL**: Fixed `streamInfo.extractor.js` `extractServerList` not parsing JSON response — `fetchWithMirror` returns raw text by default, causing Cheerio to parse a JSON string as HTML, resulting in empty server arrays (null values)
|
||||||
|
- **CRITICAL**: Fixed `streamInfo.extractor.js` `extractMapperServers` not parsing JSON response — same raw text issue caused the data processing loop to be skipped entirely, returning empty arrays
|
||||||
|
- **CRITICAL**: Fixed `creatorInfo.js` middleware overwriting error messages — the `message` field from creatorInfo was replacing actual error messages, making debugging impossible (all 500 errors showed "Built with ❤️ by Shinei Nouzen" instead of the real error)
|
||||||
|
- Fixed `streamInfo.controller.js` `getStreamInfo` to return 404 with error message when stream URL is null instead of silently returning null values
|
||||||
|
- Fixed `streamInfo.controller.js` `getServerList` to return 404 with error message when server list is empty instead of returning empty array with no indication
|
||||||
|
- Fixed `streamInfo.controller.js` to use `TTL.stream` instead of `TTL.default` for stream info caching (stream URLs rotate frequently)
|
||||||
|
- Fixed `streamInfo.controller.js` to use `TTL.servers` instead of `TTL.default` for server list caching
|
||||||
|
- Fixed `episodeList.extractor.js` JSON.parse error handling — added try/catch around JSON parsing to prevent uncaught exceptions
|
||||||
|
- Fixed `episodeListAjax.extractor.js` JSON.parse error handling — added try/catch around JSON parsing to prevent uncaught exceptions
|
||||||
|
- Fixed `schedule.extractor.js` — changed from incorrect `/?date=` URL to proper `/ajax/schedule?date=` AJAX endpoint with JSON parsing
|
||||||
|
- Fixed `seasons.extractor.js` — updated CSS selectors to handle new HTML structure where items use `data-tip` attribute instead of `<a>` tags
|
||||||
|
- Fixed `watchOrder.extractor.js` — updated CSS selectors to handle new HTML structure, added fallback to `data-tip` attribute for slugs, fixed type/episodes field extraction from `.meta .dot` elements
|
||||||
|
|
||||||
|
## [2.2.0] - 2026-07-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Stream Resolve Endpoint**: `GET /api/stream/resolve?id=&slug=` — Resolves actual m3u8/mp4 URLs from embed player URLs (vidtube.site → megaplay API → m3u8 playlist)
|
||||||
|
- **Stream Qualities Endpoint**: `GET /api/stream/qualities?url=` — Parses M3U8 playlists for available quality options with resolution, bandwidth, and URLs
|
||||||
|
- **M3U8 Proxy Endpoint**: `GET /api/stream/proxy?url=` — Rewrites M3U8 playlists to proxy through API for CORS-free HLS playback
|
||||||
|
- **TS Proxy Endpoint**: `GET /api/stream/ts-proxy?url=` — Proxies `.ts` video segments with proper `Content-Type: video/mp2t` and CORS headers
|
||||||
|
- **Stream Resolver Extractor**: Full pipeline for resolving actual video URLs from embed pages
|
||||||
|
- **M3U8 Quality Parser**: Parses master playlists to extract quality options sorted by resolution
|
||||||
|
- **Server Name Normalization**: Maps display names to clean identifiers (VidPlay-1 → vidplay, HD-1 → hd)
|
||||||
|
- **Subtitle Extraction**: Extracts subtitle tracks from streaming API responses
|
||||||
|
- **Embed API Domain Mapping**: Maps vidtube.site → megaplay-1.buzz for stream resolution
|
||||||
|
- **Shared Helper**: New `parseListItem.helper.js` for unified anime list item parsing across all extractors
|
||||||
|
- **Graceful Shutdown**: Server now handles SIGTERM/SIGINT signals for clean shutdown
|
||||||
|
- **Request ID Tracking**: Every request gets a unique `X-Request-Id` header for debugging
|
||||||
|
- **CSP Headers**: Added `Content-Security-Policy` header for XSS protection
|
||||||
|
- **HSTS Headers**: Added `Strict-Transport-Security` header for HTTPS enforcement
|
||||||
|
- **Body Size Limits**: Added `express.json({ limit: '10kb' })` to prevent large payload attacks
|
||||||
|
- **Rate Limiter Cleanup**: Automatic cleanup of stale IP entries every 5 minutes
|
||||||
|
- **Uncaught Exception Handler**: Added handler for uncaught exceptions with graceful shutdown
|
||||||
|
- **Upcoming Anime Endpoint**: `GET /api/upcoming` — upcoming anime releases from homepage
|
||||||
|
- **Top Anime Rankings Endpoint**: `GET /api/top-rankings?sort=top|newest` — ranked anime with sort modes
|
||||||
|
- **Recently Updated Tabs Endpoint**: `GET /api/recently-updated?tab=all|dub|sub` — filtered updates
|
||||||
|
- **Completed Anime Endpoint**: `GET /api/completed` — finished anime series from homepage
|
||||||
|
- **Enhanced Filter**: Added `source`, `epMin`, `epMax`, `excludeWatchlist` parameters to `/api/filter`
|
||||||
|
- **Source IDs Config**: New `SOURCE_IDS` mapping for 18 source material types
|
||||||
|
- **Season IDs Config**: New `SEASON_IDS` mapping for 4 broadcast seasons
|
||||||
|
- **Extended Type IDs**: Added `tv-short` and `tv-special` type mappings
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Mirror Fallback**: All 22+ extractors now use `fetchWithMirror()` for consistent mirror fallback
|
||||||
|
- **Mirror Helper**: 404 responses no longer mark mirrors as failed — only connection errors trigger failover
|
||||||
|
- **Seasons Extractor**: Rewritten to extract from watch page sidebar (AJAX endpoints don't exist on source site)
|
||||||
|
- **Watch Order Extractor**: Rewritten to extract from watch page sidebar trending/related sections
|
||||||
|
- **Stream Info Extractor**: Now establishes session before AJAX calls, normalizes server names, adds type field
|
||||||
|
- **Code Deduplication**: Extracted shared list item parsing logic into `parseListItem.helper.js`, eliminating ~200 lines of duplicated code across 8+ extractors
|
||||||
|
- **Documentation**: All files follow strict documentation style — box-style headers, section separators, feature markers, JSDoc params/returns, inline notes, and module footers
|
||||||
|
- **Version**: Bumped to 2.2.0
|
||||||
|
- **Package**: Removed unused `cors` dependency (custom CORS middleware used instead)
|
||||||
|
- **README**: Updated all endpoint counts, features, and streaming flow documentation
|
||||||
|
- **Docs**: Updated endpoints.md, streaming.md, architecture.md, examples.md with new endpoints
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Middleware Order**: Moved request timeout middleware before routes to properly catch timeouts
|
||||||
|
- **CORS POST**: Fixed CORS to allow POST method for `/api/mirrors/reset` endpoint
|
||||||
|
- **HomeInfo Cache**: Removed redundant cache check in `homeInfo.extractor.js` (controller already handles caching)
|
||||||
|
- **Error Handler**: Added `res.headersSent` check to prevent double response on timeout
|
||||||
|
- **Unused Imports**: Removed unused `cheerio`, `axios`, `headers` imports from extractors that now use `fetchWithMirror`
|
||||||
|
- **Watch Page Path**: Fixed `/api/watch` 500 error — path format changed from `/slug/ep` to `/slug/ep-N`
|
||||||
|
- **Info Endpoint**: Now accepts both `?id=` and `?slug=` params for flexibility
|
||||||
|
- **Status Names**: Fixed `/api/status/airing` 500 — added status name mapping (ongoing→currently-airing, completed→finished-airing)
|
||||||
|
- **Top Rankings**: Fixed selectors to match actual HTML (`#top-anime .scaff.side.items a.item`) with rank class extraction
|
||||||
|
- **Completed Anime**: Fixed selectors for `section.top-table[data-name='completed']`
|
||||||
|
- **Recently Updated**: Fixed selectors for `#recent-update .ani.items .item` with client-side tab filtering
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- **Unused Dependency**: Removed `cors` package from dependencies (custom middleware handles CORS)
|
||||||
|
- **Dead Code**: Removed redundant cache check in `homeInfo.extractor.js`
|
||||||
|
|
||||||
|
## [2.1.0] - 2026-07-05
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **UI**: Complete redesign of all HTML pages with Claude/Anthropic design system
|
||||||
|
- **UI**: Warm cream canvas (#faf9f5) replacing dark purple theme
|
||||||
|
- **UI**: Coral accent (#cc785c) replacing gradient purple/pink
|
||||||
|
- **UI**: Slab-serif headlines (Cormorant Garamond) with negative letter-spacing
|
||||||
|
- **UI**: Dark navy product surfaces (#181715) for code blocks and footer
|
||||||
|
- **UI**: Light cream feature cards (#efe9de)
|
||||||
|
- **UI**: Removed ambient orbs, particles, glitch effects, scanlines
|
||||||
|
- **UI**: Minimal shadows with color-block depth philosophy
|
||||||
|
- **Version**: Bumped to 2.1.0
|
||||||
|
|
||||||
|
## [2.0.2] - 2026-07-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Documentation**: Fixed streaming flow guide — `/api/episodes/:slug` corrected to `/api/episodes/:animeId`
|
||||||
|
- **Documentation**: Fixed all examples using `road-of-naruto-ggjw8` slug for episodes to use `958` animeId
|
||||||
|
- **Documentation**: Fixed JavaScript/Node.js streaming examples to use `animeId` parameter instead of `animeSlug`
|
||||||
|
- **Documentation**: Updated cURL examples to use correct animeId (958) for episodes endpoint
|
||||||
|
- **Documentation**: Updated architecture.md streaming flow diagram to reflect correct endpoint
|
||||||
|
|
||||||
|
## [2.0.1] - 2026-07-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Documentation**: Fixed `/api/servers` parameter description — now correctly states `server_ids` from `/episodes` response (base64-encoded) instead of generic "Episode IDs"
|
||||||
|
- **Documentation**: Fixed `/api/servers` sample response to match actual API output (flat array with `type`, `ep_id`, `link_id`, `cmid`, `sv_id`, `name`)
|
||||||
|
- **Documentation**: Added `/api/stream` quick reference section with parameter docs and example response
|
||||||
|
- **Documentation**: Added note that `/api/stream` requires lowercase `id` parameter (case-sensitive)
|
||||||
|
|
||||||
|
## [2.0.0] - 2026-07-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Pagination Metadata**: All list endpoints now include `pagination` object with `currentPage`, `totalPages`, `totalItems`, `itemsPerPage`, `hasNext`, `hasPrev`
|
||||||
|
- **Creator Info Middleware**: Injects creator attribution (name, GitHub, Telegram, message, timestamp) into every JSON response
|
||||||
|
- **Multi-Mirror Fallback System**: Automatic failover across 5 mirror domains (anikototv.to, anikoto.cz, anikoto.me, anikoto.net, anikototv.se)
|
||||||
|
- **LRU Cache with Configurable TTL**: Replaced basic Map cache with LRU cache supporting per-endpoint TTL (3min to 60min)
|
||||||
|
- **Response Compression**: Added gzip compression middleware (level 6, 1024 byte threshold)
|
||||||
|
- **Cache Statistics Endpoint**: `/api/cache/stats` returns hits, misses, sets, deletes, size, hitRate
|
||||||
|
- **Mirror Status Endpoint**: `/api/mirrors` shows health status and latency for all domains
|
||||||
|
- **Mirror Reset Endpoint**: `/api/mirrors/reset` clears mirror cache and failed state
|
||||||
|
- **Seasons Endpoint**: `/api/seasons/:id` returns all seasons/OVAs/movies for an anime
|
||||||
|
- **Watch Order Endpoint**: `/api/watch-order/:id` returns related anime with relationship types
|
||||||
|
- **Latest Updated Endpoint**: `/api/latest-updated` returns recently updated anime sorted by update time
|
||||||
|
- **Download Endpoint**: `/api/download?slug=&ep=` returns decoded download links from base64 data
|
||||||
|
- **Configurable Rate Limiting**: `RATE_LIMIT` and `RATE_WINDOW` env vars for customization
|
||||||
|
- **Request Timeout**: Configurable via `REQUEST_TIMEOUT` env var (default 30s)
|
||||||
|
- **Rate Limit Headers**: `X-RateLimit-Limit` and `X-RateLimit-Remaining` headers
|
||||||
|
- **Retry-After Header**: Included in 429 responses with seconds until reset
|
||||||
|
- **Environment Variables**: Added `MIRROR_DOMAINS`, `MIRROR_CACHE_TTL`, `CACHE_MAX_SIZE`, `CACHE_DEFAULT_TTL`
|
||||||
|
- **Test Suite**: Added 27 endpoint tests with performance metrics and optional endpoint handling
|
||||||
|
- **OpenAPI Spec**: Added `/api/mirrors`, `/api/mirrors/reset`, `/api/latest-updated`, `/api/download` to spec
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **BREAKING**: All extractors now use mirror fallback helper for resilience
|
||||||
|
- **BREAKING**: Cache system upgraded from simple Map to LRU with eviction
|
||||||
|
- **BREAKING**: Rate limiting now configurable via environment variables
|
||||||
|
- Updated `extractPages.helper.js` to use `fetchWithMirror` instead of direct axios
|
||||||
|
- Updated `homeInfo.extractor.js` to use mirror fallback
|
||||||
|
- Updated `episodeListAjax.extractor.js` to use mirror fallback
|
||||||
|
- Updated all controllers to use endpoint-specific TTL values
|
||||||
|
- Updated `.env.example` with all new configuration options
|
||||||
|
- Updated `test.js` with optional endpoint handling and performance metrics
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **CRITICAL**: Fixed `extractPages` URL handling bug causing doubled URLs in mirror fallback
|
||||||
|
- **CRITICAL**: Fixed `creatorInfo` middleware not including `message` field in responses
|
||||||
|
- Fixed header merging bug in mirror helper (custom headers were ignored)
|
||||||
|
- Fixed mirror URL construction (proper base URL handling)
|
||||||
|
- Fixed cache key sorting in filter controller
|
||||||
|
- Fixed trailing slash inconsistency in console output
|
||||||
|
- Fixed inconsistent error handling across extractors
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Removed `og-image.svg` and `logo.png` (replaced by `share.png`)
|
||||||
|
- Removed unused `cookie-parser` dependency (was already removed in v1.8.0)
|
||||||
|
|
||||||
|
## [1.9.0] - 2026-06-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **CRITICAL**: Added global Express error handler (returns JSON instead of HTML)
|
||||||
|
- **CRITICAL**: Removed caching from random endpoint (defeated purpose)
|
||||||
|
- **CRITICAL**: Renamed share.jpg to share.png (MIME type mismatch)
|
||||||
|
- **CRITICAL**: Fixed OG image dimensions (630 → 601)
|
||||||
|
- **CRITICAL**: Fixed manifest.json icon sizes (192x192 → 512x512)
|
||||||
|
- **CRITICAL**: Fixed implicit global event variable in console
|
||||||
|
- **CRITICAL**: Added 9 missing endpoints to docs/endpoints.md
|
||||||
|
- Imported version from package.json (no hardcoded drift)
|
||||||
|
- Added dynamic endpoint count in stats
|
||||||
|
- Added input sanitization for mapper API (SSRF prevention)
|
||||||
|
- Added homepage caching (reduced 5 duplicate HTTP requests)
|
||||||
|
- Added rate limiting (100 requests/minute per IP)
|
||||||
|
- Added rel="noopener noreferrer" to all target="_blank" links
|
||||||
|
- Fixed trailing slash inconsistency in console
|
||||||
|
- Added noindex meta to 404.html
|
||||||
|
- Fixed docs/architecture.md controller filenames and dependency versions
|
||||||
|
- Fixed README.md dependency versions
|
||||||
|
- Added 12 missing endpoint tests
|
||||||
|
- Added escapeHtml helper for error messages
|
||||||
|
- Consistent favicon across all pages
|
||||||
|
- Added meta descriptions to privacy.html and tos.html
|
||||||
|
- Added OG/Twitter meta tags to privacy.html and tos.html
|
||||||
|
- Added aria-label to playground inputs
|
||||||
|
- Added aria-hidden to decorative SVGs
|
||||||
|
- Synced Twitter description with OG description
|
||||||
|
- Fixed robots.txt to disallow /api
|
||||||
|
- Added PWA fields to manifest.json
|
||||||
|
- Removed 5 unused imports
|
||||||
|
- Removed unused cookie-parser dependency
|
||||||
|
- Fixed inconsistent error handling in extractors
|
||||||
|
- Fixed resolvedSlug returning numeric ID
|
||||||
|
- Added /watch endpoint to streaming docs
|
||||||
|
- Improved test assertions and status code validation
|
||||||
|
- Deleted orphaned og-image.svg and logo.png
|
||||||
|
- Removed redundant try/catch from extractPages
|
||||||
|
- Added max size eviction to cache
|
||||||
|
- Added cache key sorting in filter controller
|
||||||
|
- Added author meta tag
|
||||||
|
- Fixed favicon.svg font
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Version bumped to 1.9.0
|
||||||
|
|
||||||
## [1.8.0] - 2026-06-08
|
## [1.8.0] - 2026-06-08
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
> [!NOTE]
|
||||||
|
> **API MAYBE Temporarily Paused** due to suspiciously too many requests. My hosted version of this API is only for testing purposes. You MUST host your own instance to use the API.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<img src="https://capsule-render.vercel.app/api?type=waving&height=300&color=gradient&text=AniKotoAPI&fontAlignY=30&fontSize=90&desc=Real-Time%20Anime%20Streaming%20Data%20API&descSize=30" />
|
<img src="https://capsule-render.vercel.app/api?type=waving&height=300&color=gradient&text=𝘼𝙣𝙞𝙆𝙤𝙩𝙤𝘼𝙋𝙄&fontAlignY=30&fontSize=100&desc=𝖱𝖾𝖺𝗅-𝖳𝗂𝗆𝖾%20𝖠𝗇𝗂𝗆𝖾%20𝖲𝗍𝗋𝖾𝖺𝗆𝗂𝗇𝗀%20𝖣𝖺𝗍𝖺%20𝖠𝖯𝖨&descSize=30" />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -19,14 +22,15 @@
|
|||||||
<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/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/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/License-MIT-22c55e?style=flat-square&logo=mit&logoColor=white" alt="License"/>
|
||||||
<img src="https://img.shields.io/badge/Version-1.8.0-f43f8e?style=flat-square&logoColor=white" alt="Version"/>
|
<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-30-6366f1?style=flat-square&logoColor=white" alt="Endpoints"/>
|
<img src="https://img.shields.io/badge/Endpoints-38-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/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>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<b>A complete RESTful API scraping real-time anime data from anikototv.to</b><br/>
|
<b>A complete RESTful API scraping real-time anime data from anikototv.to</b><br/>
|
||||||
Search, browse, filter, watch — every endpoint returns live data with 5-minute smart caching.<br/>
|
Search, browse, filter, watch — every endpoint returns live data with smart caching and multi-mirror fallback.<br/>
|
||||||
Built for building anime websites, apps, and bots.
|
Built for building anime websites, apps, and bots.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -41,8 +45,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
> ## ⚠️ Disclaimer
|
> [!WARNING]
|
||||||
>
|
|
||||||
> 1. This `API` does not store any files — it only links to media hosted on 3rd party services.
|
> 1. This `API` does not store any files — it only links to media hosted on 3rd party services.
|
||||||
> 2. This `API` is explicitly made for **educational purposes only** and not for commercial usage. This repo will not be responsible for any misuse of it.
|
> 2. This `API` is explicitly made for **educational purposes only** and not for commercial usage. This repo will not be responsible for any misuse of it.
|
||||||
> 3. All anime data, images, and content belong to their respective owners. This project is not affiliated with anikototv.to.
|
> 3. All anime data, images, and content belong to their respective owners. This project is not affiliated with anikototv.to.
|
||||||
@@ -80,27 +83,28 @@
|
|||||||
|
|
||||||
**AniKotoAPI** is a serverless anime data API that scrapes and serves real-time information from **anikototv.to** — including anime details, episode lists, streaming servers, search, filtering, rankings, and more — all through a clean REST API with zero database setup.
|
**AniKotoAPI** is a serverless anime data API that scrapes and serves real-time information from **anikototv.to** — including anime details, episode lists, streaming servers, search, filtering, rankings, and more — all through a clean REST API with zero database setup.
|
||||||
|
|
||||||
> 💡 No database, no auth, no complex setup. Just deploy to Vercel and you have a production API.
|
> [!TIP]
|
||||||
|
> No Database, No Auth, No Complex Setup. Just deploy to Vercel and you have a production API.
|
||||||
|
|
||||||
### Why AniKotoAPI?
|
### Why AniKotoAPI?
|
||||||
|
|
||||||
- 🎬 **30 Endpoints** — Complete coverage of anikototv.to data
|
- 🎬 **38 Endpoints** — Complete coverage of anikototv.to data
|
||||||
- 🔍 **Full-Text Search** — Search anime by keyword with suggestions
|
- 🔍 **Full-Text Search** — Search anime by keyword with suggestions
|
||||||
- 📺 **Episode Lists** — AJAX-loaded episode data with server info
|
- 📺 **Episode Lists** — AJAX-loaded episode data with server info
|
||||||
- 🎯 **Smart Filtering** — Genre, type, status, rating, sort, season, year
|
- 🎯 **Smart Filtering** — Genre, type, status, rating, sort, season, year
|
||||||
- 🏆 **Rankings** — Top 10 (day/week/month), trending, most popular
|
- 🏆 **Rankings** — Top 10 (day/week/month), trending, most popular
|
||||||
- 🎲 **Random Anime** — Random anime discovery endpoint
|
- 🎲 **Random Anime** — Random anime discovery endpoint
|
||||||
- ⚡ **Smart Caching** — 5-minute TTL reduces source site load
|
- ⚡ **Smart Caching** — LRU cache with configurable TTL per endpoint
|
||||||
- 🔒 **CORS Enabled** — Works from any frontend, no proxy needed
|
- 🌐 **Multi-Mirror Fallback** — Automatic failover across 5 mirror domains
|
||||||
- 🚀 **Zero-Config Deploy** — One click to Vercel, or run standalone with Express
|
- 🚀 **Response Compression** — Gzip compression for faster responses
|
||||||
- 📊 **Live Data** — Every response is fresh from the actual website
|
- 📊 **Live Data** — Every response is fresh from the actual website
|
||||||
|
|
||||||
### How It Works
|
### How It Works
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
A["🌐 Client Request<br/>(Browser / App / curl)"] --> B["🛡️ Vercel Edge / Express Server<br/>CORS · Security Headers"]
|
A["🌐 Client Request<br/>(Browser / App / curl)"] --> B["🛡️ Vercel Edge / Express Server<br/>CORS · Security Headers · Rate Limiting"]
|
||||||
B --> C{"💾 Cache Check<br/>(In-Memory Map)"}
|
B --> C{"💾 Cache Check<br/>(LRU with TTL)"}
|
||||||
C -- HIT --> D["⚡ Return Cached Response<br/>~200ms"]
|
C -- HIT --> D["⚡ Return Cached Response<br/>~200ms"]
|
||||||
C -- MISS --> E["🔍 Cheerio Scraper"]
|
C -- MISS --> E["🔍 Cheerio Scraper"]
|
||||||
|
|
||||||
@@ -110,20 +114,45 @@ flowchart TD
|
|||||||
E --> E4["AJAX Endpoints"]
|
E --> E4["AJAX Endpoints"]
|
||||||
E --> E5["Filter Pages"]
|
E --> E5["Filter Pages"]
|
||||||
|
|
||||||
E1 & E2 & E3 & E4 & E5 --> F["Axios HTTP Fetch<br/>Custom Headers · 15s Timeout"]
|
E1 & E2 & E3 & E4 & E5 --> F["Axios HTTP Fetch<br/>Multi-Mirror Fallback · Timeout"]
|
||||||
F --> G["Parse HTML<br/>Cheerio Selectors"]
|
F --> G["Parse HTML<br/>Cheerio Selectors"]
|
||||||
G --> H["Cache + Respond<br/>JSON"]
|
G --> H["Cache + Respond<br/>JSON + Gzip"]
|
||||||
|
|
||||||
style A fill:#1e1e2e,stroke:#a78bfa,color:#f1f5f9
|
style A fill:#1e1e2e,stroke:#a78bfa,color:#f1f5f9
|
||||||
style B fill:#1e1e2e,stroke:#6366f1,color:#f1f5f9
|
style B fill:#1e1e2e,stroke:#6366f1,color:#f1f5f9
|
||||||
style C fill:#1e1e2e,stroke:#f43f8e,color:#f1f5f9
|
style C fill:#1e1e2e,stroke:#f43f8e,color:#f1f5f9
|
||||||
style D fill:#1e1e2e,stroke:#22c55e,color:#f1f5f9
|
style D fill:#1e1e2e,stroke:#22c55e,color:#f1f5f9
|
||||||
style E fill:#1e1e2e,stroke:#a855f7,color:#f1f5f9
|
style E fill:#1e1e2e,stroke:#06b6d4,color:#f1f5f9
|
||||||
style F fill:#1e1e2e,stroke:#eab308,color:#f1f5f9
|
style F fill:#1e1e2e,stroke:#a855f7,color:#f1f5f9
|
||||||
style G fill:#1e1e2e,stroke:#06b6d4,color:#f1f5f9
|
style G fill:#1e1e2e,stroke:#f43f8e,color:#f1f5f9
|
||||||
style H fill:#1e1e2e,stroke:#22c55e,color:#f1f5f9
|
style H fill:#1e1e2e,stroke:#22c55e,color:#f1f5f9
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### ⚠️ Streaming Status
|
||||||
|
|
||||||
|
> **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.
|
||||||
|
|
||||||
|
| What Works | What's Blocked |
|
||||||
|
|:---|:---|
|
||||||
|
| Home, spotlight, trending | `/api/servers` — server list |
|
||||||
|
| Search, suggestions, filter | `/api/stream` — embed URLs |
|
||||||
|
| Info, episodes, schedule | `/api/stream/resolve` — m3u8/mp4 |
|
||||||
|
| Top 10, most popular, new release | `/api/mapper-servers` — gogoanime servers |
|
||||||
|
| AZ list, random, genre list | All AJAX-dependent endpoints |
|
||||||
|
| All HTML-scraped endpoints | |
|
||||||
|
|
||||||
|
**To fix streaming**, set one of these environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Option 1: ScraperAPI (requires premium plan — $49/month)
|
||||||
|
SCRAPER_API_KEY=your_scraperapi_key
|
||||||
|
|
||||||
|
# Option 2: FlareSolverr (self-hosted, unlimited)
|
||||||
|
FLARESOLVERR_URL=http://your-flaresolverr-host:8191
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
@@ -134,8 +163,8 @@ flowchart TD
|
|||||||
|
|
||||||
### ⚡ Core
|
### ⚡ Core
|
||||||
- **Real-time scraping** from anikototv.to
|
- **Real-time scraping** from anikototv.to
|
||||||
- **Smart caching** with 5-minute TTL
|
- **Smart caching** with LRU and configurable TTL per endpoint
|
||||||
- **30 RESTful endpoints** covering all data
|
- **38 RESTful endpoints** covering all data
|
||||||
- **AJAX episode loading** for accurate data
|
- **AJAX episode loading** for accurate data
|
||||||
- **Mapper API integration** for extra servers
|
- **Mapper API integration** for extra servers
|
||||||
- **Graceful error handling** per endpoint
|
- **Graceful error handling** per endpoint
|
||||||
@@ -159,10 +188,13 @@ flowchart TD
|
|||||||
### 📺 Streaming
|
### 📺 Streaming
|
||||||
- **Watch page** — full episode data with servers
|
- **Watch page** — full episode data with servers
|
||||||
- **Stream info** — video embed URLs via AJAX
|
- **Stream info** — video embed URLs via AJAX
|
||||||
- **Server list** — all available streaming servers
|
- **Stream URL Resolution** — resolve embed URLs to actual m3u8/mp4
|
||||||
|
- **Server list** — all available streaming servers with normalized names
|
||||||
- **Mapper API** — gogoanime/anivibe server sources
|
- **Mapper API** — gogoanime/anivibe server sources
|
||||||
- **Episode navigation** — prev/next episode data
|
- **Episode navigation** — prev/next episode data
|
||||||
- **Next episode schedule** — countdown timer data
|
- **Next episode schedule** — countdown timer data
|
||||||
|
- **M3U8 Proxy** — CORS-free HLS playlist proxy with URL rewriting
|
||||||
|
- **TS Proxy** — CORS-free video segment proxy
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -171,7 +203,7 @@ flowchart TD
|
|||||||
- **CORS enabled** — works from any frontend
|
- **CORS enabled** — works from any frontend
|
||||||
- **Error responses** with descriptive messages
|
- **Error responses** with descriptive messages
|
||||||
- **Input validation** — required params checked
|
- **Input validation** — required params checked
|
||||||
- **Timeout protection** — 15s per request
|
- **Timeout protection** — Configurable per request
|
||||||
- **Multiple domains** — fallback mirror support
|
- **Multiple domains** — fallback mirror support
|
||||||
- **Zero dependencies on databases** — pure scraping
|
- **Zero dependencies on databases** — pure scraping
|
||||||
|
|
||||||
@@ -183,18 +215,20 @@ flowchart TD
|
|||||||
|
|
||||||
| Feature | Description | Status |
|
| Feature | Description | Status |
|
||||||
|:---|:---|:---:|
|
|:---|:---|:---:|
|
||||||
| 🎬 30 API Endpoints | Complete coverage of anime data | ✅ |
|
| 🎬 38 API Endpoints | Complete coverage of anime data | ✅ |
|
||||||
| 🔍 Full-Text Search | Keyword search with pagination | ✅ |
|
| 🔍 Full-Text Search | Keyword search with pagination | ✅ |
|
||||||
| 📺 Episode Lists | AJAX-loaded episode data | ✅ |
|
| 📺 Episode Lists | AJAX-loaded episode data | ✅ |
|
||||||
| 🎯 Advanced Filtering | Genre, type, status, rating, sort | ✅ |
|
| 🎯 Advanced Filtering | Genre, type, status, rating, sort, season | ✅ |
|
||||||
| 🏆 Top 10 Rankings | Day/week/month leaderboards | ✅ |
|
| 🏆 Top 10 Rankings | Day/week/month leaderboards | ✅ |
|
||||||
| 🎲 Random Anime | Random anime discovery | ✅ |
|
| 🎲 Random Anime | Random anime discovery | ✅ |
|
||||||
| 📡 Streaming Servers | Video embed URLs + mapper API | ✅ |
|
| 📡 Streaming Servers | Video embed URLs + mapper API | ✅ |
|
||||||
|
| 🔗 Stream URL Resolution | Resolve embed URLs to m3u8/mp4 | ✅ |
|
||||||
| 📋 AZ List | A-Z alphabetical browsing | ✅ |
|
| 📋 AZ List | A-Z alphabetical browsing | ✅ |
|
||||||
| 🔄 Smart Caching | 5-min TTL, in-memory Map | ✅ |
|
| 🔄 Smart Caching | LRU cache with configurable TTL | ✅ |
|
||||||
|
| 🌐 M3U8 Proxy | CORS-free HLS proxy with URL rewriting | ✅ |
|
||||||
| 🚀 One-Click Deploy | Vercel button deployment | ✅ |
|
| 🚀 One-Click Deploy | Vercel button deployment | ✅ |
|
||||||
| 🏗️ Express Mode | Standalone server with `npm start` | ✅ |
|
| 🏗️ Express Mode | Standalone server with `npm start` | ✅ |
|
||||||
| 📖 AlisaReactionBot Style | Full JSDoc documentation | ✅ |
|
| 📖 Full JSDoc Docs | Complete documentation coverage | ✅ |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -225,24 +259,21 @@ flowchart TD
|
|||||||
| Technology | Purpose | Version | Documentation |
|
| Technology | Purpose | Version | Documentation |
|
||||||
|:---|:---|:---|:---|
|
|:---|:---|:---|:---|
|
||||||
| 🟢 [Node.js](https://nodejs.org/) | JavaScript runtime | >= 20 | [Docs](https://nodejs.org/docs/) |
|
| 🟢 [Node.js](https://nodejs.org/) | JavaScript runtime | >= 20 | [Docs](https://nodejs.org/docs/) |
|
||||||
| ⚡ [Express](https://expressjs.com/) | HTTP server framework | 5.2 | [Docs](https://expressjs.com/en/5x/api.html) |
|
| ⚡ [Express](https://expressjs.com/) | HTTP server framework | 4.21 | [Docs](https://expressjs.com/en/4x/api.html) |
|
||||||
| ▲ [Vercel Functions](https://vercel.com/docs/functions) | Serverless deployment | — | [Docs](https://vercel.com/docs/functions) |
|
| ▲ [Vercel Functions](https://vercel.com/docs/functions) | Serverless deployment | — | [Docs](https://vercel.com/docs/functions) |
|
||||||
| 🔍 [Cheerio](https://cheerio.js.org/) | HTML parsing & scraping | 1.0 | [Docs](https://cheerio.js.org/docs/) |
|
| 🔍 [Cheerio](https://cheerio.js.org/) | HTML parsing & scraping | 1.0 | [Docs](https://cheerio.js.org/docs/) |
|
||||||
| 🌐 [Axios](https://axios-http.com/) | HTTP client | 1.11 | [Docs](https://axios-http.com/docs/intro) |
|
| 🌐 [Axios](https://axios-http.com/) | HTTP client for scraping | 1.8 | [Docs](https://axios-http.com/docs/intro) |
|
||||||
| 🔧 [dotenv](https://github.com/motdotla/dotenv) | Environment variables | 17.2 | [Docs](https://github.com/motdotla/dotenv) |
|
| 🔧 [dotenv](https://github.com/motdotla/dotenv) | Environment variables | 16.4 | [Docs](https://github.com/motdotla/dotenv) |
|
||||||
| 🔒 [cors](https://github.com/expressjs/cors) | CORS middleware | 2.8 | [Docs](https://github.com/expressjs/cors) |
|
|
||||||
| 🍪 [cookie-parser](https://github.com/expressjs/cookie-parser) | Cookie parsing | 1.4 | [Docs](https://github.com/expressjs/cookie-parser) |
|
|
||||||
|
|
||||||
### 📦 Key Dependencies
|
### 📦 Key Dependencies
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"express": "^5.2.0", // HTTP server
|
"express": "^4.21.0", // HTTP server
|
||||||
"axios": "^1.11.0", // HTTP client for scraping
|
"axios": "^1.8.0", // HTTP client for scraping
|
||||||
"cheerio": "^1.0.0-rc.12", // HTML parsing
|
"cheerio": "^1.0.0-rc.12", // HTML parsing
|
||||||
"cors": "^2.8.5", // CORS middleware
|
"compression": "^1.7.4", // Response compression
|
||||||
"dotenv": "^17.2.0", // Environment variables
|
"dotenv": "^16.4.0" // Environment variables
|
||||||
"cookie-parser": "^1.4.7" // Cookie parsing
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -256,8 +287,8 @@ flowchart TD
|
|||||||
|:-----:|-----------|-------------|
|
|:-----:|-----------|-------------|
|
||||||
| 1 | **Client** | Browser, app, or `curl` sends request |
|
| 1 | **Client** | Browser, app, or `curl` sends request |
|
||||||
| 2 | **Vercel Edge / Express** | Routes request, applies CORS + security headers |
|
| 2 | **Vercel Edge / Express** | Routes request, applies CORS + security headers |
|
||||||
| 3 | **Cache Check** | In-memory Map with 5-min TTL — hit = instant response |
|
| 3 | **Cache Check** | LRU cache with endpoint-specific TTL — hit = instant response |
|
||||||
| 4 | **Scrape Source** | Axios fetches HTML from anikototv.to with custom headers |
|
| 4 | **Scrape Source** | Axios fetches HTML from anikototv.to with multi-mirror fallback |
|
||||||
| 5 | **Parse HTML** | Cheerio extracts data using CSS selectors |
|
| 5 | **Parse HTML** | Cheerio extracts data using CSS selectors |
|
||||||
| 6 | **Cache + Respond** | Store in cache, return JSON response |
|
| 6 | **Cache + Respond** | Store in cache, return JSON response |
|
||||||
|
|
||||||
@@ -265,11 +296,11 @@ flowchart TD
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
A["📥 Request"] --> B{"🧠 Memory Cache<br/>(Map + TTL)"}
|
A["📥 Request"] --> B{"🧠 LRU Cache<br/>(TTL per endpoint)"}
|
||||||
B -- HIT --> C["⚡ Return Cached<br/>~200ms"]
|
B -- HIT --> C["⚡ Return Cached<br/>~200ms"]
|
||||||
B -- MISS --> D["🌐 Fetch from<br/>anikototv.to"]
|
B -- MISS --> D["🌐 Fetch with<br/>Mirror Fallback"]
|
||||||
D --> E["🔍 Parse with<br/>Cheerio"]
|
D --> E["🔍 Parse with<br/>Cheerio"]
|
||||||
E --> F["💾 Cache Result<br/>(5-min TTL)"]
|
E --> F["💾 Cache Result<br/>"]
|
||||||
F --> G["📤 Return Fresh"]
|
F --> G["📤 Return Fresh"]
|
||||||
|
|
||||||
style A fill:#1e1e2e,stroke:#a78bfa,color:#f1f5f9
|
style A fill:#1e1e2e,stroke:#a78bfa,color:#f1f5f9
|
||||||
@@ -281,7 +312,8 @@ flowchart TD
|
|||||||
style G fill:#1e1e2e,stroke:#22c55e,color:#f1f5f9
|
style G fill:#1e1e2e,stroke:#22c55e,color:#f1f5f9
|
||||||
```
|
```
|
||||||
|
|
||||||
> 💡 Serverless functions have read-only filesystems except `/tmp`. The cache uses in-memory `Map` which survives across warm invocations.
|
> [!TIP]
|
||||||
|
> Serverless functions have read-only filesystems except `/tmp`. The cache uses LRU with configurable TTL per endpoint type, surviving across warm invocations.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -301,47 +333,58 @@ AniKotoAPI/
|
|||||||
│ │
|
│ │
|
||||||
│ ├── 📂 docs/ # 📚 API documentation
|
│ ├── 📂 docs/ # 📚 API documentation
|
||||||
│ │ ├── 📄 index.md # Overview, quick start
|
│ │ ├── 📄 index.md # Overview, quick start
|
||||||
│ │ ├── 📄 endpoints.md # Full API reference (30 endpoints)
|
│ │ ├── 📄 endpoints.md # Full API reference (38 endpoints)
|
||||||
│ │ ├── 📄 streaming.md # Streaming flow guide
|
│ │ ├── 📄 streaming.md # Streaming flow guide + proxy
|
||||||
│ │ ├── 📄 examples.md # Code examples (cURL, JS, Python)
|
│ │ ├── 📄 examples.md # Code examples (cURL, JS, Python)
|
||||||
│ │ └── 📄 architecture.md # Project structure, tech stack
|
│ │ ├── 📄 architecture.md # Project structure, tech stack
|
||||||
|
│ │ └── 📄 testing.md # Integration test suite & benchmarks
|
||||||
│ │
|
│ │
|
||||||
│
|
│
|
||||||
├── 📂 src/ # ⚙️ Core logic
|
├── 📂 src/ # ⚙️ Core logic
|
||||||
│ ├── 📂 configs/ # 🔧 Configuration files
|
│ ├── 📂 configs/ # 🔧 Configuration files
|
||||||
│ │ ├── 📄 dataUrl.js # 🌐 URL patterns for anikototv.to
|
│ │ ├── 📄 dataUrl.js # 🌐 URL patterns for anikototv.to
|
||||||
│ │ ├── 📄 header.config.js # 📋 Request headers
|
│ │ ├── 📄 header.config.js # 📋 Request headers
|
||||||
│ │ └── 📄 ids.config.js # 🏷️ Genre/Type/Status/Rating ID mappings
|
│ │ └── 📄 ids.config.js # 🏷️ Genre/Type/Status/Source/Season ID mappings
|
||||||
│ │
|
│ │
|
||||||
│ ├── 📂 controllers/ # 🎮 Route handlers (22 files)
|
│ ├── 📂 controllers/ # 🎮 Route handlers (28 files)
|
||||||
│ │ ├── 📄 homeInfo.controller.js
|
│ │ ├── 📄 homeInfo.controller.js
|
||||||
│ │ ├── 📄 animeInfo.controller.js
|
│ │ ├── 📄 animeInfo.controller.js
|
||||||
│ │ ├── 📄 search.controller.js
|
│ │ ├── 📄 search.controller.js
|
||||||
│ │ ├── 📄 episodeList.controller.js
|
│ │ ├── 📄 episodeList.controller.js
|
||||||
│ │ ├── 📄 episodeListAjax.controller.js
|
│ │ ├── 📄 episodeListAjax.controller.js
|
||||||
│ │ ├── 📄 streamInfo.controller.js
|
│ │ ├── 📄 streamInfo.controller.js
|
||||||
|
│ │ ├── 📄 streamResolver.controller.js # 🆕 Stream URL resolution + quality detection
|
||||||
│ │ ├── 📄 filter.controller.js
|
│ │ ├── 📄 filter.controller.js
|
||||||
│ │ ├── 📄 watchPage.controller.js
|
│ │ ├── 📄 watchPage.controller.js
|
||||||
│ │ └── 📄 ... (14 more)
|
│ │ └── 📄 ... (15 more)
|
||||||
│ │
|
│ │
|
||||||
│ ├── 📂 extractors/ # 🔍 HTML scrapers (22 files)
|
│ ├── 📂 extractors/ # 🔍 HTML scrapers (28 files)
|
||||||
│ │ ├── 📄 homeInfo.extractor.js # 🏠 Home page data
|
│ │ ├── 📄 homeInfo.extractor.js # 🏠 Home page data
|
||||||
│ │ ├── 📄 search.extractor.js # 🔍 Search results
|
│ │ ├── 📄 search.extractor.js # 🔍 Search results
|
||||||
│ │ ├── 📄 animeInfo.extractor.js # 📺 Anime details
|
│ │ ├── 📄 animeInfo.extractor.js # 📺 Anime details
|
||||||
│ │ ├── 📄 streamInfo.extractor.js # 📡 Streaming servers
|
│ │ ├── 📄 streamInfo.extractor.js # 📡 Streaming servers
|
||||||
|
│ │ ├── 📄 streamResolver.extractor.js # 🔗 Stream URL resolution, M3U8 parser
|
||||||
│ │ ├── 📄 filter.extractor.js # 🎯 Filtered results
|
│ │ ├── 📄 filter.extractor.js # 🎯 Filtered results
|
||||||
│ │ ├── 📄 watchPage.extractor.js # ▶️ Watch page data
|
│ │ ├── 📄 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
|
│ │ ├── 📄 episodeList.extractor.js # 📋 Episode lists
|
||||||
│ │ └── 📄 ... (15 more)
|
│ │ └── 📄 ... (16 more)
|
||||||
│ │
|
│ │
|
||||||
│ ├── 📂 helper/ # 🛠️ Utility functions
|
│ ├── 📂 helper/ # 🛠️ Utility functions
|
||||||
│ │ ├── 📄 cache.helper.js # 💾 In-memory caching
|
│ │ ├── 📄 cache.helper.js # 💾 In-memory caching
|
||||||
│ │ ├── 📄 countPages.helper.js # 📄 Pagination counter
|
│ │ ├── 📄 countPages.helper.js # 📄 Pagination counter
|
||||||
│ │ ├── 📄 extractPages.helper.js # 📃 Page fetcher
|
│ │ ├── 📄 extractPages.helper.js # 📃 Page fetcher
|
||||||
│ │ └── 📄 formatTitle.helper.js # 🔤 Title formatter
|
│ │ ├── 📄 formatTitle.helper.js # 🔤 Title formatter
|
||||||
|
│ │ ├── 📄 mirror.helper.js # 🌐 Multi-mirror fallback (5 domains)
|
||||||
|
│ │ ├── 📄 parseListItem.helper.js # 📋 Shared list item parser
|
||||||
|
│ │ └── 📄 pagination.helper.js # 📊 Pagination metadata generator
|
||||||
|
│ │
|
||||||
|
│ ├── 📂 middleware/ # 🔧 Express middleware
|
||||||
|
│ │ └── 📄 creatorInfo.js # ✍️ Creator attribution injection
|
||||||
│ │
|
│ │
|
||||||
│ └── 📂 routes/ # 🛤️ Express routes
|
│ └── 📂 routes/ # 🛤️ Express routes
|
||||||
│ ├── 📄 apiRoutes.js # 🌐 Main API routes (30 endpoints)
|
│ ├── 📄 apiRoutes.js # 🌐 Main API routes (38 endpoints)
|
||||||
│ └── 📄 category.route.js # 🏷️ Category routes
|
│ └── 📄 category.route.js # 🏷️ Category routes
|
||||||
│
|
│
|
||||||
├── 📄 server.js # 🚀 Express server entry point
|
├── 📄 server.js # 🚀 Express server entry point
|
||||||
@@ -435,14 +478,29 @@ https://anikototvapi.vercel.app/api
|
|||||||
To get a stream URL, follow these 3 steps:
|
To get a stream URL, follow these 3 steps:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Step 1: Get episodes (returns server_ids)
|
# Step 1: Get episodes by animeId (returns server_ids)
|
||||||
curl "https://anikototvapi.vercel.app/api/episodes/road-of-naruto-ggjw8"
|
curl "https://anikototvapi.vercel.app/api/episodes/958"
|
||||||
|
|
||||||
# Step 2: Get servers (pass server_ids from Step 1)
|
# Step 2: Get servers (pass server_ids from Step 1)
|
||||||
curl "https://anikototvapi.vercel.app/api/servers?ids=SlNVT25JaFlCMnZOe..."
|
curl "https://anikototvapi.vercel.app/api/servers?ids=dXNCT3hNQzk3THhSTW8ySnM5..."
|
||||||
|
|
||||||
# Step 3: Get stream URL (pass link_id from Step 2)
|
# Step 3: Get stream URL (pass link_id from Step 2)
|
||||||
curl "https://anikototvapi.vercel.app/api/stream?id=MTF1dkFtaW9BRTZPbz..."
|
curl "https://anikototvapi.vercel.app/api/stream?id=MTF1dkFtaW9BRTZPbzJJRElFZUZr..."
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stream URL Resolution
|
||||||
|
|
||||||
|
The `/api/stream` endpoint returns an embed URL. To get the actual video URL (m3u8/mp4):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Resolve embed URL to actual stream URL
|
||||||
|
curl "https://anikototvapi.vercel.app/api/stream/resolve?id=MTF1dkFtaW9BRTZPbzJJRElFZUZr..."
|
||||||
|
|
||||||
|
# Get quality options from an M3U8 playlist
|
||||||
|
curl "https://anikototvapi.vercel.app/api/stream/qualities?url=https://example.com/playlist.m3u8"
|
||||||
|
|
||||||
|
# Proxy M3U8 playlist (rewrites URLs for CORS-free playback)
|
||||||
|
curl "https://anikototvapi.vercel.app/api/stream/proxy?url=https://example.com/playlist.m3u8"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Dub & Sub Switch
|
### Dub & Sub Switch
|
||||||
@@ -793,17 +851,17 @@ console.log(resp.data);
|
|||||||
|
|
||||||
| Parameter | Type | Mandatory | Default | Description |
|
| Parameter | Type | Mandatory | Default | Description |
|
||||||
| :-------: | :--: | :-------: | :-----: | :---------: |
|
| :-------: | :--: | :-------: | :-----: | :---------: |
|
||||||
| `id` | `string` | Yes ✔️ | — | Anime slug |
|
| `id` | `string` | Yes ✔️ | — | Anime slug or animeId |
|
||||||
|
|
||||||
#### Example of request
|
#### Example of request
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl "https://anikototvapi.vercel.app/api/episodes/one-piece-odmau"
|
curl "https://anikototvapi.vercel.app/api/episodes/958"
|
||||||
```
|
```
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
const resp = await axios.get("https://anikototvapi.vercel.app/api/episodes/one-piece-odmau");
|
const resp = await axios.get("https://anikototvapi.vercel.app/api/episodes/958");
|
||||||
console.log(resp.data);
|
console.log(resp.data);
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -906,18 +964,18 @@ console.log(resp.data);
|
|||||||
|
|
||||||
| Parameter | Type | Mandatory | Default | Description |
|
| Parameter | Type | Mandatory | Default | Description |
|
||||||
| :-------: | :--: | :-------: | :-----: | :---------: |
|
| :-------: | :--: | :-------: | :-----: | :---------: |
|
||||||
| `ids` | `string` | Yes ✔️ | — | Episode IDs |
|
| `ids` | `string` | Yes ✔️ | — | `server_ids` from `/episodes` response (base64-encoded) |
|
||||||
|
|
||||||
#### Example of request
|
#### Example of request
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl "https://anikototvapi.vercel.app/api/servers?ids={episodeIds}"
|
curl "https://anikototvapi.vercel.app/api/servers?ids={server_ids}"
|
||||||
```
|
```
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
const resp = await axios.get("https://anikototvapi.vercel.app/api/servers", {
|
const resp = await axios.get("https://anikototvapi.vercel.app/api/servers", {
|
||||||
params: { ids: "yourEpisodeIds" }
|
params: { ids: "yourServerIds" }
|
||||||
});
|
});
|
||||||
console.log(resp.data);
|
console.log(resp.data);
|
||||||
```
|
```
|
||||||
@@ -927,15 +985,24 @@ console.log(resp.data);
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"results": {
|
"results": [
|
||||||
"sub": [
|
{
|
||||||
{ "server_id": 1, "server_name": "Vidstreaming", "type": "sub" },
|
"type": "sub",
|
||||||
{ "server_id": 2, "server_name": "Gogocdn", "type": "sub" }
|
"ep_id": "16638",
|
||||||
],
|
"link_id": "MTF1dkFtaW9BRTZPbzJJRElFZUZr...",
|
||||||
"dub": [
|
"cmid": "animixplay-fqs",
|
||||||
{ "server_id": 3, "server_name": "Vidstreaming", "type": "dub" }
|
"sv_id": "8e4",
|
||||||
]
|
"name": "VidPlay-1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "sub",
|
||||||
|
"ep_id": "16638",
|
||||||
|
"link_id": "MTF1dkFtaW9BRTZPbzJJRElFZUZr...",
|
||||||
|
"cmid": "animixplay-fqs",
|
||||||
|
"sv_id": "323",
|
||||||
|
"name": "HD-1"
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1001,16 +1068,16 @@ Server list for an episode — returns available streaming servers with their na
|
|||||||
|
|
||||||
| Param | Type | Default | Description |
|
| Param | Type | Default | Description |
|
||||||
|:---|:---|:---|:---|
|
|:---|:---|:---|:---|
|
||||||
| `ids` | `string` | **required** | Episode IDs |
|
| `ids` | `string` | **required** | `server_ids` from `/episodes` response (base64-encoded) |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl "https://anikototvapi.vercel.app/api/servers?ids={episodeIds}"
|
curl "https://anikototvapi.vercel.app/api/servers?ids={server_ids}"
|
||||||
```
|
```
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
const resp = await axios.get("https://anikototvapi.vercel.app/api/servers", {
|
const resp = await axios.get("https://anikototvapi.vercel.app/api/servers", {
|
||||||
params: { ids: "yourEpisodeIds" }
|
params: { ids: "yourServerIds" }
|
||||||
});
|
});
|
||||||
console.log(resp.data);
|
console.log(resp.data);
|
||||||
```
|
```
|
||||||
@@ -1021,15 +1088,24 @@ console.log(resp.data);
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"results": {
|
"results": [
|
||||||
"sub": [
|
{
|
||||||
{ "server_id": 1, "server_name": "Vidstreaming", "type": "sub" },
|
"type": "sub",
|
||||||
{ "server_id": 2, "server_name": "Gogocdn", "type": "sub" }
|
"ep_id": "16638",
|
||||||
],
|
"link_id": "MTF1dkFtaW9BRTZPbzJJRElFZUZr...",
|
||||||
"dub": [
|
"cmid": "animixplay-fqs",
|
||||||
{ "server_id": 3, "server_name": "Vidstreaming", "type": "dub" }
|
"sv_id": "8e4",
|
||||||
]
|
"name": "VidPlay-1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "sub",
|
||||||
|
"ep_id": "16638",
|
||||||
|
"link_id": "MTF1dkFtaW9BRTZPbzJJRElFZUZr...",
|
||||||
|
"cmid": "animixplay-fqs",
|
||||||
|
"sv_id": "323",
|
||||||
|
"name": "HD-1"
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
</details>
|
</details>
|
||||||
@@ -1084,6 +1160,46 @@ console.log(resp.data);
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### `GET /api/stream`
|
||||||
|
|
||||||
|
Get streaming URL for a server. Pass the `link_id` from `/api/servers` response.
|
||||||
|
|
||||||
|
| Param | Type | Default | Description |
|
||||||
|
|:---|:---|:---|:---|
|
||||||
|
| `id` | `string` | **required** | `link_id` from `/servers` response (lowercase `id` required) |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/stream?id=MTF1dkFtaW9BRTZPbzJJRElFZUZr..."
|
||||||
|
```
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import axios from "axios";
|
||||||
|
const resp = await axios.get("https://anikototvapi.vercel.app/api/stream", {
|
||||||
|
params: { id: "yourLinkId" }
|
||||||
|
});
|
||||||
|
console.log(resp.data);
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>📄 Example Response</summary>
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"results": {
|
||||||
|
"linkId": "MTF1dkFtaW9BRTZPbzJJRElFZUZr...",
|
||||||
|
"url": "https://vidtube.site/stream/aVlsNDR3MnRLbWRvQUFBbWJUNERLQT09/sub",
|
||||||
|
"skipData": {
|
||||||
|
"intro": [0, 0],
|
||||||
|
"outro": [0, 0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### `GET /api/top-ten`
|
### `GET /api/top-ten`
|
||||||
|
|
||||||
Top 10 anime rankings for day, week, and month.
|
Top 10 anime rankings for day, week, and month.
|
||||||
@@ -1798,6 +1914,259 @@ console.log(resp.data);
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
> ## 📺 GET Latest Updated
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/latest-updated
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Mandatory | Default | Description |
|
||||||
|
| :-------: | :--: | :-------: | :-----: | :---------: |
|
||||||
|
| `page` | `number` | No | `1` | Page number |
|
||||||
|
|
||||||
|
#### Example of request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/latest-updated"
|
||||||
|
```
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import axios from "axios";
|
||||||
|
const resp = await axios.get("https://anikototvapi.vercel.app/api/latest-updated");
|
||||||
|
console.log(resp.data);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> ## 📅 GET Anime Seasons
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/seasons/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Mandatory | Default | Description |
|
||||||
|
| :-------: | :--: | :-------: | :-----: | :---------: |
|
||||||
|
| `id` | `number` | Yes ✔️ | — | Anime numeric ID |
|
||||||
|
|
||||||
|
#### Example of request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/seasons/1642"
|
||||||
|
```
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import axios from "axios";
|
||||||
|
const resp = await axios.get("https://anikototvapi.vercel.app/api/seasons/1642");
|
||||||
|
console.log(resp.data);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> ## 📋 GET Watch Order
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/watch-order/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Mandatory | Default | Description |
|
||||||
|
| :-------: | :--: | :-------: | :-----: | :---------: |
|
||||||
|
| `id` | `number` | Yes ✔️ | — | Anime numeric ID |
|
||||||
|
|
||||||
|
#### Example of request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/watch-order/1642"
|
||||||
|
```
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import axios from "axios";
|
||||||
|
const resp = await axios.get("https://anikototvapi.vercel.app/api/watch-order/1642");
|
||||||
|
console.log(resp.data);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> ## 📥 GET Download Links
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/download
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Mandatory | Default | Description |
|
||||||
|
| :-------: | :--: | :-------: | :-----: | :---------: |
|
||||||
|
| `slug` | `string` | Yes ✔️ | — | Anime slug |
|
||||||
|
| `ep` | `number` | Yes ✔️ | — | Episode number |
|
||||||
|
|
||||||
|
#### Example of request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/download?slug=one-piece-odmau&ep=1165"
|
||||||
|
```
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import axios from "axios";
|
||||||
|
const resp = await axios.get("https://anikototvapi.vercel.app/api/download", {
|
||||||
|
params: { slug: "one-piece-odmau", ep: 1165 }
|
||||||
|
});
|
||||||
|
console.log(resp.data);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> ## 🔗 GET Stream URL Resolution
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/stream/resolve
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Mandatory | Default | Description |
|
||||||
|
| :-------: | :--: | :-------: | :-----: | :---------: |
|
||||||
|
| `id` | `string` | Yes ✔️ | — | `link_id` from `/servers` response |
|
||||||
|
| `slug` | `string` | No | — | Anime slug (for watch page session) |
|
||||||
|
|
||||||
|
#### Example of request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/stream/resolve?id=MTF1dkFtaW9BRTZPbzJJRElFZUZr..."
|
||||||
|
```
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import axios from "axios";
|
||||||
|
const resp = await axios.get("https://anikototvapi.vercel.app/api/stream/resolve", {
|
||||||
|
params: { id: "yourLinkId" }
|
||||||
|
});
|
||||||
|
console.log(resp.data);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Sample Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"results": {
|
||||||
|
"url": "https://vid-tube.site/hls/b32de9d4a3230c95f52948373e6e1549.m3u8",
|
||||||
|
"type": "hls",
|
||||||
|
"server": "HD-1",
|
||||||
|
"subtitles": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> ## 📊 GET Stream Qualities
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/stream/qualities
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Mandatory | Default | Description |
|
||||||
|
| :-------: | :--: | :-------: | :-----: | :---------: |
|
||||||
|
| `url` | `string` | Yes ✔️ | — | M3U8 playlist URL |
|
||||||
|
|
||||||
|
#### Example of request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/stream/qualities?url=https://example.com/playlist.m3u8"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Sample Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"results": {
|
||||||
|
"qualities": [
|
||||||
|
{ "url": "https://example.com/360.m3u8", "quality": "360p", "width": 640, "height": 360, "bandwidth": 800000, "codec": "avc1" },
|
||||||
|
{ "url": "https://example.com/480.m3u8", "quality": "480p", "width": 854, "height": 480, "bandwidth": 1400000, "codec": "avc1" },
|
||||||
|
{ "url": "https://example.com/720.m3u8", "quality": "720p", "width": 1280, "height": 720, "bandwidth": 2800000, "codec": "avc1" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> ## 🌐 GET Stream Proxy (M3U8)
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/stream/proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
Rewrites relative URLs inside an M3U8 playlist to proxy through the API, adding CORS headers for cross-origin playback.
|
||||||
|
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Mandatory | Default | Description |
|
||||||
|
| :-------: | :--: | :-------: | :-----: | :---------: |
|
||||||
|
| `url` | `string` | Yes ✔️ | — | M3U8 playlist URL |
|
||||||
|
|
||||||
|
#### Example of request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/stream/proxy?url=https://example.com/playlist.m3u8"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Sample Response
|
||||||
|
|
||||||
|
Raw M3U8 content with URLs rewritten to `https://anikototvapi.vercel.app/api/stream/proxy?url=...` and `https://anikototvapi.vercel.app/api/stream/ts-proxy?url=...` for `.ts` segments.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> ## 🎬 GET Stream TS Proxy
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/stream/ts-proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
Proxies `.ts` video segments with proper `Content-Type: video/mp2t` and CORS headers.
|
||||||
|
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Mandatory | Default | Description |
|
||||||
|
| :-------: | :--: | :-------: | :-----: | :---------: |
|
||||||
|
| `url` | `string` | Yes ✔️ | — | `.ts` segment URL |
|
||||||
|
|
||||||
|
#### Example of request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/stream/ts-proxy?url=https://example.com/segment.ts"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Sample Response
|
||||||
|
|
||||||
|
Binary video segment data with `Content-Type: video/mp2t` and `Access-Control-Allow-Origin: *`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 📋 API Response Schema
|
## 📋 API Response Schema
|
||||||
|
|
||||||
### Success Response
|
### Success Response
|
||||||
@@ -1816,6 +2185,38 @@ console.log(resp.data);
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Pagination Metadata
|
||||||
|
|
||||||
|
All list endpoints include pagination metadata in the response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"results": {
|
||||||
|
"data": [...],
|
||||||
|
"pagination": {
|
||||||
|
"currentPage": 1,
|
||||||
|
"totalPages": 294,
|
||||||
|
"totalItems": 8820,
|
||||||
|
"itemsPerPage": 30,
|
||||||
|
"hasNext": true,
|
||||||
|
"hasPrev": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| `currentPage` | `number` | Current page number (1-indexed) |
|
||||||
|
| `totalPages` | `number` | Total number of pages available |
|
||||||
|
| `totalItems` | `number` | Estimated total items across all pages |
|
||||||
|
| `itemsPerPage` | `number` | Number of items per page (default: 30) |
|
||||||
|
| `hasNext` | `boolean` | Whether there is a next page |
|
||||||
|
| `hasPrev` | `boolean` | Whether there is a previous page |
|
||||||
|
|
||||||
|
**Endpoints with pagination:** `/api/search`, `/api/most-popular`, `/api/new-release`, `/api/newly-added`, `/api/latest-updated`, `/api/az-list/:letter`, `/api/filter`, `/api/genre/:name`, `/api/type/:name`, `/api/status/:name`, `/api/upcoming`, `/api/top-rankings`, `/api/recently-updated`, `/api/completed`
|
||||||
|
|
||||||
### Anime Item Object
|
### Anime Item Object
|
||||||
|
|
||||||
| Field | Type | Description | Example |
|
| Field | Type | Description | Example |
|
||||||
@@ -1923,14 +2324,14 @@ CMD ["node", "server.js"]
|
|||||||
|:---|:---|
|
|:---|:---|
|
||||||
| ⚡ Cached response | ~200ms |
|
| ⚡ Cached response | ~200ms |
|
||||||
| 🔄 Fresh fetch | ~1-3s |
|
| 🔄 Fresh fetch | ~1-3s |
|
||||||
| 💾 Cache TTL | 5 minutes |
|
| 💾 Cache TTL | 3 min–1 hour (per-endpoint) |
|
||||||
| ⏱️ Timeout per request | 15 seconds |
|
| ⏱️ Timeout per request | 30 seconds |
|
||||||
| 🎬 Total anime indexed | 10,000+ |
|
| 🎬 Total anime indexed | 10,000+ |
|
||||||
| 📦 Total codebase | ~120KB |
|
| 📦 Total codebase | ~120KB |
|
||||||
|
|
||||||
### Optimization Features
|
### Optimization Features
|
||||||
|
|
||||||
- 💾 **In-memory cache** — Map-based with TTL expiration
|
- 💾 **LRU cache** — Configurable TTL per endpoint type
|
||||||
- ⚡ **Cheerio parsing** — Fast HTML DOM traversal
|
- ⚡ **Cheerio parsing** — Fast HTML DOM traversal
|
||||||
- 🔧 **Custom headers** — Mimics browser requests
|
- 🔧 **Custom headers** — Mimics browser requests
|
||||||
- 🎯 **Selective scraping** — Only extracts needed data
|
- 🎯 **Selective scraping** — Only extracts needed data
|
||||||
@@ -1943,6 +2344,8 @@ CMD ["node", "server.js"]
|
|||||||
|
|
||||||
| Version | Date | Key Changes |
|
| Version | Date | Key Changes |
|
||||||
|:---|:---|:---|
|
|:---|:---|:---|
|
||||||
|
| **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.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 |
|
| **1.7.1** | 2026-06-08 | Updated Vercel URL to anikototvapi.vercel.app |
|
||||||
| **1.7.0** | 2026-06-08 | Premium landing page with SVG icons, live console, particles, glassmorphism |
|
| **1.7.0** | 2026-06-08 | Premium landing page with SVG icons, live console, particles, glassmorphism |
|
||||||
@@ -1962,14 +2365,14 @@ CMD ["node", "server.js"]
|
|||||||
|
|
||||||
| Problem | Cause | Solution |
|
| Problem | Cause | Solution |
|
||||||
|:---|:---|:---|
|
|:---|:---|:---|
|
||||||
| ❌ `npm install` fails | Node.js version too old | Upgrade to Node.js 20+ (`node -v`) |
|
| ❎ `npm install` fails | Node.js version too old | Upgrade to Node.js 20+ (`node -v`) |
|
||||||
| ❌ 500 error on filter | Missing `keyword` param | Add `?keyword=` (even empty) to filter requests |
|
| ❎ 500 error on filter | Missing `keyword` param | Add `?keyword=` (even empty) to filter requests |
|
||||||
| ❌ Empty episodes array | AJAX not loaded | Use `/api/episodes/:slug` which fetches via AJAX |
|
| ❎ Empty episodes array | Wrong parameter | Use `/api/episodes/:animeId` with numeric animeId (e.g., 958) |
|
||||||
| ❌ CORS errors | Frontend domain blocked | CORS is `*` — check browser extension |
|
| ❎ CORS errors | Frontend domain blocked | CORS is `*` — check browser extension |
|
||||||
| ❌ 404 on API routes | Wrong URL format | Use `/api/` prefix, not just `/` |
|
| ❎ 404 on API routes | Wrong URL format | Use `/api/` prefix, not just `/` |
|
||||||
| ❌ Deploy fails on Vercel | Build error | Check `node server.js` locally first |
|
| ❎ Deploy fails on Vercel | Build error | Check `node server.js` locally first |
|
||||||
| ❌ Slow first request | Serverless cold start | Normal — first request after idle takes ~3s |
|
| ❎ Slow first request | Serverless cold start | Normal — first request after idle takes ~3s |
|
||||||
| ❌ Rate limited | Too many requests | Cache reduces this — wait 5 min for TTL expiry |
|
| ❎ Rate limited | Too many requests | Cache reduces this — wait 5 min for TTL expiry |
|
||||||
|
|
||||||
### 🐛 Debug Mode
|
### 🐛 Debug Mode
|
||||||
|
|
||||||
@@ -1996,7 +2399,7 @@ Use <code>/api/search?keyword=your+search</code>. Results include title, poster,
|
|||||||
<details>
|
<details>
|
||||||
<summary><b>📺 How do I get episode lists?</b></summary>
|
<summary><b>📺 How do I get episode lists?</b></summary>
|
||||||
<br/>
|
<br/>
|
||||||
Use <code>/api/episodes/:slug</code> where <code>:slug</code> is the anime URL slug (e.g., <code>one-piece-odmau</code>). This fetches episode data via AJAX for accuracy. The response includes <code>animeId</code>, <code>totalEpisodes</code>, and <code>episodes</code> array.
|
Use <code>/api/episodes/:animeId</code> where <code>:animeId</code> is the numeric anime ID (e.g., <code>958</code> for Naruto). The response includes <code>animeId</code>, <code>totalEpisodes</code>, and <code>episodes</code> array with <code>server_ids</code> for each episode.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -2014,7 +2417,7 @@ Yes! CORS is enabled for all origins (<code>*</code>). Just make fetch requests
|
|||||||
<details>
|
<details>
|
||||||
<summary><b>🔄 How often does the data refresh?</b></summary>
|
<summary><b>🔄 How often does the data refresh?</b></summary>
|
||||||
<br/>
|
<br/>
|
||||||
The cache TTL is 5 minutes. After that, the next request triggers a fresh scrape from anikototv.to. This keeps data relatively fresh while reducing load on the source site.
|
The cache uses an LRU strategy with per-endpoint TTLs ranging from 3 minutes (search, stream) to 1 hour (schedule). After TTL expiry, the next request triggers a fresh scrape from anikototv.to.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -2054,21 +2457,25 @@ The API can access 10,000+ anime titles from anikototv.to. The most-popular endp
|
|||||||
|
|
||||||
### ✅ Completed
|
### ✅ Completed
|
||||||
|
|
||||||
- [x] 🎬 30 API endpoints covering all data
|
- [x] 🎬 38 API endpoints covering all data
|
||||||
- [x] 🔍 Full-text search with pagination
|
- [x] 🔍 Full-text search with pagination
|
||||||
- [x] 📺 Episode lists via AJAX loading
|
- [x] 📺 Episode lists via AJAX loading
|
||||||
- [x] 🎯 Advanced filtering (genre, type, status, rating, sort)
|
- [x] 🎯 Advanced filtering (genre, type, status, rating, sort, season, year)
|
||||||
- [x] 🏆 Top 10 rankings (day/week/month)
|
- [x] 🏆 Top 10 rankings (day/week/month)
|
||||||
- [x] 🎲 Random anime discovery
|
- [x] 🎲 Random anime discovery
|
||||||
- [x] 📡 Streaming server info + mapper API
|
- [x] 📡 Streaming server info + mapper API
|
||||||
|
- [x] 🔗 Stream URL resolution (embed → m3u8/mp4)
|
||||||
|
- [x] 🌐 M3U8 proxy for CORS-free HLS playback
|
||||||
- [x] 📋 AZ List alphabetical browsing
|
- [x] 📋 AZ List alphabetical browsing
|
||||||
- [x] 🔄 Smart caching with 5-min TTL
|
- [x] 📅 Seasons and watch order extraction
|
||||||
|
- [x] 🔄 Smart caching with LRU and configurable TTL
|
||||||
- [x] 🚀 One-click Vercel deployment
|
- [x] 🚀 One-click Vercel deployment
|
||||||
- [x] 📖 Comprehensive documentation with real API data
|
- [x] 📖 Comprehensive documentation with real API data
|
||||||
- [x] 🏗️ AlisaReactionBot-style code comments
|
- [x] 🏗️ Full JSDoc documentation coverage
|
||||||
- [x] 🌐 Premium landing page with SVG icons
|
- [x] 🌐 Premium landing page with SVG icons
|
||||||
- [x] 📱 PWA manifest and Open Graph image
|
- [x] 📱 PWA manifest and Open Graph image
|
||||||
- [x] 📚 Full docs/ folder with examples
|
- [x] 📚 Full docs/ folder with examples
|
||||||
|
- [x] 🐛 Full diagnostic sweep — all 34 endpoints passing
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2138,8 +2545,8 @@ git push origin feature/amazing-feature
|
|||||||
- ✅ Update CHANGELOG.md with your changes
|
- ✅ Update CHANGELOG.md with your changes
|
||||||
- ✅ Keep PRs focused — one feature or fix per PR
|
- ✅ Keep PRs focused — one feature or fix per PR
|
||||||
- ✅ Add JSDoc comments for new functions
|
- ✅ Add JSDoc comments for new functions
|
||||||
- ❌ Don't commit `node_modules` or cache files
|
- ❎ Don't commit `node_modules` or cache files
|
||||||
- ❌ Don't add unrelated changes to a single PR
|
- ❎ Don't add unrelated changes to a single PR
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2168,6 +2575,12 @@ git push origin feature/amazing-feature
|
|||||||
- [Star History](https://star-history.com/) — GitHub star history charts
|
- [Star History](https://star-history.com/) — GitHub star history charts
|
||||||
- [Capsule Render](https://github.com/kyechan99/capsule-render) — Header banner generator
|
- [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
|
## 📄 License
|
||||||
@@ -2222,5 +2635,5 @@ Free to use, modify, and distribute — see the [LICENSE](LICENSE) file for deta
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<b>Made With ❤️ For The Anime Community</b>
|
<b>Made With ❤️ For The Anime Community</b>
|
||||||
<br/><br/>
|
<br/><br/>
|
||||||
<sub>© 2026 Shineii86. All Rights Reserved.</sub>
|
<sub>© Shinei Nouzen. All Rights Reserved.</sub>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
+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.
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
---
|
||||||
|
name: API Tester
|
||||||
|
description: API testing specialist for AniKotoAPI — validates all 38 endpoints, streaming flow, mirror fallback, cache behavior, and error handling
|
||||||
|
mode: subagent
|
||||||
|
color: '#9B59B6'
|
||||||
|
---
|
||||||
|
|
||||||
|
# API Tester — AniKotoAPI
|
||||||
|
|
||||||
|
You are **API Tester** for AniKotoAPI, responsible for validating all 38 endpoints, the 3-step streaming flow, mirror fallback behavior, LRU cache, rate limiting, and error responses.
|
||||||
|
|
||||||
|
## Your Identity
|
||||||
|
|
||||||
|
- **Project**: AniKotoAPI v2.2.0 — https://github.com/Shineii86/AniKotoAPI
|
||||||
|
- **Test file**: `test.js` (38 tests, run with `node test.js`)
|
||||||
|
- **Live API**: `https://anikototvapi.vercel.app/api`
|
||||||
|
- **Pattern**: Each test does a real HTTP request to the live API, validates response structure, and prints pass/fail with timing
|
||||||
|
|
||||||
|
## Test Structure
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// test.js pattern
|
||||||
|
const test = async (name, fn) => {
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
passed++;
|
||||||
|
console.log(` ✅ ${name} (${Date.now() - start}ms)`);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('NOT_DEPLOYED_YET')) {
|
||||||
|
skipped++;
|
||||||
|
console.log(` ⏭️ ${name} (skipped - not deployed)`);
|
||||||
|
} else {
|
||||||
|
failed++;
|
||||||
|
console.log(` ❌ ${name}: ${error.message} (${Date.now() - start}ms)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpoints to Test
|
||||||
|
|
||||||
|
### Core (always test)
|
||||||
|
| Endpoint | Validation |
|
||||||
|
|----------|------------|
|
||||||
|
| `GET /api/` | `success: true`, `results.spotlights`, `results.trending`, `results.genres` |
|
||||||
|
| `GET /api/search?keyword=naruto` | `results.data` array, each item has `slug`, `title`, `poster` |
|
||||||
|
| `GET /api/info?id=one-piece-odmau` | `results.title`, `results.animeId` |
|
||||||
|
| `GET /api/episodes/:slug` | `results.episodes` array |
|
||||||
|
| `GET /api/servers?ids=...` | `results` array |
|
||||||
|
| `GET /api/watch?slug=&ep=1` | `results` with servers, episodeNumber |
|
||||||
|
| `GET /api/download?slug=&ep=1` | `results` with download links (optional) |
|
||||||
|
|
||||||
|
### Discovery (test subset)
|
||||||
|
| Endpoint | Validation |
|
||||||
|
|----------|------------|
|
||||||
|
| `GET /api/spotlight` | `results` array |
|
||||||
|
| `GET /api/trending` | `results` array |
|
||||||
|
| `GET /api/top-ten` | `results` object with day/week/month |
|
||||||
|
| `GET /api/random` | `results` with `title`, `slug` |
|
||||||
|
| `GET /api/suggestions?keyword=naruto` | `results` array |
|
||||||
|
| `GET /api/most-popular` | `results` object |
|
||||||
|
| `GET /api/upcoming` | `results` array |
|
||||||
|
| `GET /api/top-rankings` | `results` array with rank positions |
|
||||||
|
| `GET /api/recently-updated` | `results` array |
|
||||||
|
| `GET /api/completed` | `results` array |
|
||||||
|
|
||||||
|
### Lists
|
||||||
|
| Endpoint | Validation |
|
||||||
|
|----------|------------|
|
||||||
|
| `GET /api/new-release` | `results` object |
|
||||||
|
| `GET /api/newly-added` | `results` object |
|
||||||
|
| `GET /api/latest-updated` | `results` object |
|
||||||
|
|
||||||
|
### Filter
|
||||||
|
| Endpoint | Validation |
|
||||||
|
|----------|------------|
|
||||||
|
| `GET /api/filter?keyword=naruto` | `results` with pagination |
|
||||||
|
| `GET /api/genre/action` | `results` with data, totalPages |
|
||||||
|
| `GET /api/type/tv` | `results` with data, totalPages |
|
||||||
|
| `GET /api/status/airing` | `results` with data, totalPages |
|
||||||
|
| `GET /api/az-list/a` | `results` with data, totalPages |
|
||||||
|
|
||||||
|
### System
|
||||||
|
| Endpoint | Validation |
|
||||||
|
|----------|------------|
|
||||||
|
| `GET /api/health` | `results.status: "healthy"` |
|
||||||
|
| `GET /api/stats` | `results.endpoints` count |
|
||||||
|
| `GET /api/cache/stats` | `results.hits`, `results.misses` |
|
||||||
|
| `GET /api/mirrors` | `results` array with mirror status |
|
||||||
|
| `GET /api/openapi` | `openapi: "3.0.3"`, `paths` object |
|
||||||
|
|
||||||
|
## Streaming Flow Test
|
||||||
|
|
||||||
|
Always test the full 3-step flow:
|
||||||
|
```bash
|
||||||
|
# Step 1: Get episodes
|
||||||
|
EPISODES=$(curl -s "https://anikototvapi.vercel.app/api/episodes/one-piece-odmau")
|
||||||
|
SERVER_IDS=$(echo $EPISODES | jq -r '.results.episodes[0].server_ids')
|
||||||
|
|
||||||
|
# Step 2: Get servers
|
||||||
|
SERVERS=$(curl -s "https://anikototvapi.vercel.app/api/servers?ids=$SERVER_IDS")
|
||||||
|
LINK_ID=$(echo $SERVERS | jq -r '.results[0].link_id')
|
||||||
|
|
||||||
|
# Step 3: Get stream
|
||||||
|
STREAM=$(curl -s "https://anikototvapi.vercel.app/api/stream?id=$LINK_ID")
|
||||||
|
echo $STREAM | jq -r '.results.url'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Cases to Test
|
||||||
|
|
||||||
|
| Test | Expected |
|
||||||
|
|------|----------|
|
||||||
|
| `GET /api/search` (no keyword) | 400 or error message |
|
||||||
|
| `GET /api/episodes/invalid-slug-99999` | 404 or empty |
|
||||||
|
| `GET /api/servers?ids=invalid` | Error or empty |
|
||||||
|
| `GET /api/stream?id=invalid` | Error |
|
||||||
|
| `GET /api/nonexistent` | 404 HTML or JSON |
|
||||||
|
|
||||||
|
## What You Do
|
||||||
|
|
||||||
|
1. **Run test suite** — `node test.js` and report pass/fail/skip counts
|
||||||
|
2. **Add new tests** — When endpoints are added, write test functions
|
||||||
|
3. **Validate responses** — Check JSON structure, required fields, data types
|
||||||
|
4. **Test edge cases** — Invalid params, missing fields, rate limiting
|
||||||
|
5. **Performance timing** — Report response times per endpoint
|
||||||
|
6. **Regression testing** — Run after changes to catch breakage
|
||||||
|
|
||||||
|
## Critical Rules
|
||||||
|
|
||||||
|
- **Use live API** — All tests hit `https://anikototvapi.vercel.app/api`
|
||||||
|
- **Handle optional endpoints** — Some may not be deployed yet, use `NOT_DEPLOYED_YET` skip pattern
|
||||||
|
- **No external test framework** — Just `node test.js`, no Jest/Mocha
|
||||||
|
- **Real HTTP requests** — Use `axios` for actual requests, not mocks
|
||||||
|
- **Report timing** — Include response time in test output
|
||||||
|
- **Test structure, not content** — Validate fields exist, not exact values (data changes)
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
name: Backend Architect
|
||||||
|
description: Senior backend architect for AniKotoAPI — specializes in Express.js scraper architecture, multi-mirror fallback systems, LRU caching, and Vercel serverless deployment
|
||||||
|
mode: subagent
|
||||||
|
color: '#3498DB'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Backend Architect — AniKotoAPI
|
||||||
|
|
||||||
|
You are **Backend Architect** for AniKotoAPI, a free REST API scraping anime data from anikototv.to. You understand the full architecture: Express.js server, Cheerio HTML parsing, Axios HTTP client, multi-mirror fallback across 5 domains, LRU cache with configurable TTL, gzip compression, and Vercel serverless deployment.
|
||||||
|
|
||||||
|
## Your Identity
|
||||||
|
|
||||||
|
- **Project**: AniKotoAPI v2.2.0 — https://github.com/Shineii86/AniKotoAPI
|
||||||
|
- **Stack**: Node.js (ESM modules), Express 4.21, Cheerio 1.0, Axios 1.8, compression 1.7
|
||||||
|
- **Deployment**: Vercel serverless (read-only filesystem, `/tmp` only writable)
|
||||||
|
- **Data Source**: anikototv.to (5 mirror domains: anikototv.to, anikoto.cz, anikoto.me, anikoto.net, anikototv.se)
|
||||||
|
|
||||||
|
## Architecture You Maintain
|
||||||
|
|
||||||
|
### Request Flow
|
||||||
|
```
|
||||||
|
Client → Vercel Edge/Express → Rate Limiter → CORS + Security Headers → Cache Check → ExtractPages (Mirror Fallback) → Cheerio Parser → Cache Store → JSON Response
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Files
|
||||||
|
- `server.js` — Express entry, CORS, compression, rate limiting, timeout, error handler
|
||||||
|
- `src/routes/apiRoutes.js` — All 38 route definitions + OpenAPI spec
|
||||||
|
- `src/helper/cache.helper.js` — LRU cache with per-endpoint TTL (3min-60min)
|
||||||
|
- `src/helper/mirror.helper.js` — Multi-mirror fallback with health checks, session caching
|
||||||
|
- `src/helper/extractPages.helper.js` — Page fetching using `fetchWithMirror()`
|
||||||
|
- `src/helper/parseListItem.helper.js` — Shared list item parser (DRY across extractors)
|
||||||
|
- `src/extractors/` — 28 HTML scrapers using Cheerio selectors
|
||||||
|
- `src/controllers/` — 28 route handlers
|
||||||
|
- `src/configs/ids.config.js` — Genre/Type/Status/Rating/Sort/Source/Season ID mappings
|
||||||
|
|
||||||
|
### Endpoints (38 total)
|
||||||
|
**Core**: `/` (home), `/search`, `/info`, `/watch`, `/episodes/:id`, `/episodes-ajax/:id`
|
||||||
|
**Streaming**: `/servers`, `/stream`, `/download`, `/mapper-servers`, `/stream/resolve`, `/stream/qualities`, `/stream/proxy`, `/stream/ts-proxy`
|
||||||
|
**Discovery**: `/spotlight`, `/trending`, `/top-ten`, `/random`, `/suggestions`, `/most-popular`
|
||||||
|
**New**: `/upcoming`, `/top-rankings`, `/recently-updated`, `/completed`
|
||||||
|
**Lists**: `/new-release`, `/newly-added`, `/latest-updated`, `/trending-sidebar`, `/az-list/:letter`
|
||||||
|
**Filter**: `/filter`, `/genre/:name`, `/type/:name`, `/status/:name`
|
||||||
|
**Anime**: `/seasons/:id`, `/watch-order/:id`, `/schedule`
|
||||||
|
**System**: `/health`, `/stats`, `/cache/stats`, `/mirrors`, `/mirrors/reset`, `/openapi`
|
||||||
|
|
||||||
|
### Caching Strategy
|
||||||
|
```javascript
|
||||||
|
// TTL per endpoint type
|
||||||
|
const endpointTTL = {
|
||||||
|
home: 300000, // 5 min
|
||||||
|
search: 180000, // 3 min
|
||||||
|
info: 600000, // 10 min
|
||||||
|
episodes: 300000, // 5 min
|
||||||
|
stream: 180000, // 3 min
|
||||||
|
schedule: 3600000, // 1 hour
|
||||||
|
suggestions: 180000,
|
||||||
|
spotlight: 300000,
|
||||||
|
trending: 300000,
|
||||||
|
topTen: 300000,
|
||||||
|
random: 0, // never cached
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mirror Fallback System
|
||||||
|
```javascript
|
||||||
|
// 5 domains tried in order with health tracking
|
||||||
|
const mirrors = [
|
||||||
|
'anikototv.to', // primary
|
||||||
|
'anikoto.cz',
|
||||||
|
'anikoto.me',
|
||||||
|
'anikoto.net',
|
||||||
|
'anikototv.se',
|
||||||
|
];
|
||||||
|
// Health checks mark failed mirrors, skip for 60s
|
||||||
|
// Session caching avoids re-probing known-good mirrors
|
||||||
|
```
|
||||||
|
|
||||||
|
## What You Do
|
||||||
|
|
||||||
|
1. **Add new endpoints** — Create extractor → controller → route → tests → docs
|
||||||
|
2. **Optimize scraping** — Tune Cheerio selectors, handle AJAX endpoints, manage request headers
|
||||||
|
3. **Improve resilience** — Mirror fallback logic, retry policies, graceful degradation
|
||||||
|
4. **Scale caching** — Adjust TTLs, add cache warming, implement cache invalidation
|
||||||
|
5. **Maintain Vercel compat** — No filesystem writes, memory-only state, serverless-friendly patterns
|
||||||
|
|
||||||
|
## Critical Rules
|
||||||
|
|
||||||
|
- **ESM modules only** — All files use `import/export`, no `require()` except `createRequire` for package.json
|
||||||
|
- **Vercel serverless** — Read-only filesystem, memory-only state, `/tmp` for temp files only
|
||||||
|
- **Mirror fallback** — Always use `fetchWithMirror()` from `extractPages.helper.js`, never raw `axios.get()`
|
||||||
|
- **Standardized responses** — `{ success: true, results: {...} }` or `{ success: false, message: "..." }`
|
||||||
|
- **No secrets in code** — All config via `.env` and `process.env`
|
||||||
|
- **Scraping headers** — Always include Referer (`https://anikototv.to/`) and User-Agent
|
||||||
|
|
||||||
|
## When Modifying Code
|
||||||
|
|
||||||
|
1. Check existing extractors in `src/extractors/` for Cheerio selector patterns
|
||||||
|
2. Check existing controllers in `src/controllers/` for response patterns
|
||||||
|
3. Use `fetchWithMirror()` for all HTTP requests to anikototv.to
|
||||||
|
4. Add cache TTL in `cache.helper.js` endpointTTL map
|
||||||
|
5. Register routes in `apiRoutes.js`
|
||||||
|
6. Update `test.js` with new endpoint tests
|
||||||
|
7. Update `docs/endpoints.md` and `README.md`
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
---
|
||||||
|
name: DevOps Automator
|
||||||
|
description: DevOps specialist for AniKotoAPI — handles Vercel deployment, git workflows, CI/CD, environment variables, and release management
|
||||||
|
mode: subagent
|
||||||
|
color: '#1ABC9C'
|
||||||
|
---
|
||||||
|
|
||||||
|
# DevOps Automator — AniKotoAPI
|
||||||
|
|
||||||
|
You are **DevOps Automator** for AniKotoAPI, responsible for Vercel deployment, git workflows, release management, and environment configuration.
|
||||||
|
|
||||||
|
## Your Identity
|
||||||
|
|
||||||
|
- **Project**: AniKotoAPI v2.2.0 — https://github.com/Shineii86/AniKotoAPI
|
||||||
|
- **Deployment**: Vercel (auto-deploys from main branch)
|
||||||
|
- **Git remote**: `https://github.com/Shineii86/AniKotoAPI.git`
|
||||||
|
- **Git identity**: `Shinei Nouzen <157171073+Shineii86@users.noreply.github.com>`
|
||||||
|
|
||||||
|
## Deployment Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
GitHub (main) → Vercel Auto-Deploy → Serverless Functions
|
||||||
|
↓
|
||||||
|
vercel.json routes:
|
||||||
|
/api/* → server.js
|
||||||
|
/* → server.js (static files)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
### Required
|
||||||
|
```env
|
||||||
|
# No required env vars — API works with defaults
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional
|
||||||
|
```env
|
||||||
|
# Server
|
||||||
|
PORT=4444 # Local dev port (default: 4444)
|
||||||
|
NODE_ENV=production # Production mode
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
ALLOWED_ORIGINS=* # Comma-separated origins
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
RATE_LIMIT=100 # Requests per window (default: 100)
|
||||||
|
RATE_WINDOW=60000 # Window in ms (default: 60000)
|
||||||
|
|
||||||
|
# Caching
|
||||||
|
CACHE_MAX_SIZE=500 # Max cache entries (default: 500)
|
||||||
|
CACHE_DEFAULT_TTL=300000 # Default TTL in ms (default: 5min)
|
||||||
|
|
||||||
|
# Mirrors
|
||||||
|
MIRROR_DOMAINS=anikototv.to,anikoto.cz,anikoto.me,anikoto.net,anikototv.se
|
||||||
|
MIRROR_CACHE_TTL=60000 # Mirror health cache TTL (default: 60s)
|
||||||
|
|
||||||
|
# Request
|
||||||
|
REQUEST_TIMEOUT=30000 # Request timeout in ms (default: 30s)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Git Workflow
|
||||||
|
|
||||||
|
### Commit Format
|
||||||
|
```
|
||||||
|
feat: description # New feature
|
||||||
|
fix: description # Bug fix
|
||||||
|
docs: description # Documentation
|
||||||
|
chore: description # Maintenance
|
||||||
|
refactor: description # Code restructuring
|
||||||
|
```
|
||||||
|
|
||||||
|
### Commit Rules
|
||||||
|
- **Author**: `Shinei Nouzen <157171073+Shineii86@users.noreply.github.com>`
|
||||||
|
- **No co-authors**: Never add `Co-authored-by` trailer
|
||||||
|
- **Message**: Concise, imperative mood
|
||||||
|
- **Signing**: Not required
|
||||||
|
|
||||||
|
### Push Process
|
||||||
|
```bash
|
||||||
|
# 1. Set remote with token
|
||||||
|
git remote set-url origin https://<TOKEN>@github.com/Shineii86/AniKotoAPI.git
|
||||||
|
|
||||||
|
# 2. Push
|
||||||
|
git push origin main
|
||||||
|
|
||||||
|
# 3. Clean up (remove token from remote URL)
|
||||||
|
git remote set-url origin https://github.com/Shineii86/AniKotoAPI.git
|
||||||
|
```
|
||||||
|
|
||||||
|
## Release Process
|
||||||
|
|
||||||
|
### Version Bump Checklist
|
||||||
|
1. [ ] Update `package.json` version
|
||||||
|
2. [ ] Update README.md badge (`Version-2.2.1`)
|
||||||
|
3. [ ] Add CHANGELOG.md entry (prepend at top)
|
||||||
|
4. [ ] Update OpenAPI spec version in `apiRoutes.js`
|
||||||
|
5. [ ] Update endpoint count if changed
|
||||||
|
6. [ ] Run tests: `node test.js`
|
||||||
|
7. [ ] Commit with `feat:` prefix
|
||||||
|
8. [ ] Push to GitHub
|
||||||
|
9. [ ] Verify Vercel deployment
|
||||||
|
|
||||||
|
### Post-Deploy Verification
|
||||||
|
```bash
|
||||||
|
# Health check
|
||||||
|
curl -s "https://anikototvapi.vercel.app/api/health" | jq '.results'
|
||||||
|
|
||||||
|
# Stats
|
||||||
|
curl -s "https://anikototvapi.vercel.app/api/stats" | jq '.results'
|
||||||
|
|
||||||
|
# Test a few endpoints
|
||||||
|
curl -s "https://anikototvapi.vercel.app/api/" | jq '.success'
|
||||||
|
curl -s "https://anikototvapi.vercel.app/api/search?keyword=naruto" | jq '.success'
|
||||||
|
curl -s "https://anikototvapi.vercel.app/api/trending" | jq '.success'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Vercel Configuration
|
||||||
|
|
||||||
|
### vercel.json
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"builds": [
|
||||||
|
{
|
||||||
|
"src": "server.js",
|
||||||
|
"use": "@vercel/node"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"src": "/api/(.*)",
|
||||||
|
"dest": "server.js"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/(.*)",
|
||||||
|
"dest": "server.js"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vercel Limitations
|
||||||
|
- **Read-only filesystem** — Only `/tmp` writable
|
||||||
|
- **Memory limit** — 1024MB (default)
|
||||||
|
- **Execution time** — 10s (free) / 60s (pro)
|
||||||
|
- **Cold starts** — ~2s on first request
|
||||||
|
- **No persistent state** — Memory-only cache
|
||||||
|
|
||||||
|
## What You Do
|
||||||
|
|
||||||
|
1. **Manage releases** — Version bumps, CHANGELOG, git tags
|
||||||
|
2. **Deploy to Vercel** — Push to main triggers auto-deploy
|
||||||
|
3. **Configure env vars** — Set in Vercel dashboard or CLI
|
||||||
|
4. **Monitor health** — Check `/api/health` and `/api/stats`
|
||||||
|
5. **Troubleshoot deploys** — Check Vercel logs for errors
|
||||||
|
6. **Manage git** — Branch strategy, commit format, push process
|
||||||
|
7. **Verify deployments** — Post-deploy smoke tests
|
||||||
|
|
||||||
|
## Critical Rules
|
||||||
|
|
||||||
|
- **Never commit secrets** — All tokens stay out of git
|
||||||
|
- **Clean remote URL** — Remove token from git remote after push
|
||||||
|
- **CHANGELOG append-only** — Never edit or delete old entries
|
||||||
|
- **Test before push** — Run `node test.js` before every push
|
||||||
|
- **Verify after deploy** — Check health endpoint after Vercel deploy
|
||||||
|
- **Git identity consistent** — Always use `Shinei Nouzen` author
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
---
|
||||||
|
name: Performance Benchmarker
|
||||||
|
description: Performance specialist for AniKotoAPI — benchmarks endpoint response times, cache hit rates, compression ratio, mirror failover latency, and scraping throughput
|
||||||
|
mode: subagent
|
||||||
|
color: '#F39C12'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Performance Benchmarker — AniKotoAPI
|
||||||
|
|
||||||
|
You are **Performance Benchmarker** for AniKotoAPI, responsible for measuring and optimizing response times, cache efficiency, compression ratios, and scraping performance.
|
||||||
|
|
||||||
|
## Your Identity
|
||||||
|
|
||||||
|
- **Project**: AniKotoAPI v2.2.0 — https://github.com/Shineii86/AniKotoAPI
|
||||||
|
- **Live API**: `https://anikototvapi.vercel.app/api`
|
||||||
|
- **Deployment**: Vercel serverless (cold starts, memory limits, execution time limits)
|
||||||
|
|
||||||
|
## Performance Baseline
|
||||||
|
|
||||||
|
### Target Metrics
|
||||||
|
| Metric | Target | Current |
|
||||||
|
|--------|--------|---------|
|
||||||
|
| P50 response time | < 200ms | ~150ms (cached) |
|
||||||
|
| P95 response time | < 500ms | ~300ms |
|
||||||
|
| P99 response time | < 1000ms | ~800ms |
|
||||||
|
| Cache hit rate | > 80% | ~85% |
|
||||||
|
| Compression ratio | > 60% | ~70% |
|
||||||
|
| Cold start | < 3s | ~2s |
|
||||||
|
|
||||||
|
### Endpoint Benchmarks
|
||||||
|
| Endpoint | Cached | Uncached | Notes |
|
||||||
|
|----------|--------|----------|-------|
|
||||||
|
| `GET /api/` | ~50ms | ~300ms | Homepage with spotlights, trending |
|
||||||
|
| `GET /api/search?keyword=naruto` | ~30ms | ~250ms | Search with pagination |
|
||||||
|
| `GET /api/info?id=...` | ~40ms | ~350ms | Anime details |
|
||||||
|
| `GET /api/episodes/:id` | ~30ms | ~200ms | Episode list |
|
||||||
|
| `GET /api/servers?ids=...` | ~20ms | ~150ms | Server list |
|
||||||
|
| `GET /api/stream?id=...` | ~15ms | ~100ms | Stream URL |
|
||||||
|
| `GET /api/trending` | ~30ms | ~200ms | Trending list |
|
||||||
|
| `GET /api/top-ten` | ~30ms | ~250ms | Rankings |
|
||||||
|
| `GET /api/random` | 0ms | ~200ms | Never cached |
|
||||||
|
| `GET /api/schedule` | ~50ms | ~400ms | 1hr cache TTL |
|
||||||
|
|
||||||
|
## What You Measure
|
||||||
|
|
||||||
|
### 1. Endpoint Response Times
|
||||||
|
```bash
|
||||||
|
# Quick benchmark
|
||||||
|
for endpoint in "/" "/search?keyword=naruto" "/trending" "/top-ten"; do
|
||||||
|
echo "GET $endpoint"
|
||||||
|
curl -w "Total: %{time_total}s\nDNS: %{time_namelookup}s\nConnect: %{time_connect}s\nTTFB: %{time_starttransfer}s\n" \
|
||||||
|
-o /dev/null -s "https://anikototvapi.vercel.app/api$endpoint"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Cache Performance
|
||||||
|
```bash
|
||||||
|
# First request (miss)
|
||||||
|
curl -w "Miss: %{time_total}s\n" -o /dev/null -s "https://anikototvapi.vercel.app/api/trending"
|
||||||
|
|
||||||
|
# Second request (hit)
|
||||||
|
curl -w "Hit: %{time_total}s\n" -o /dev/null -s "https://anikototvapi.vercel.app/api/trending"
|
||||||
|
|
||||||
|
# Cache stats
|
||||||
|
curl -s "https://anikototvapi.vercel.app/api/cache/stats" | jq '.results'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Compression
|
||||||
|
```bash
|
||||||
|
# Without compression
|
||||||
|
curl -w "Uncompressed: %{size_download} bytes\n" -o /dev/null -s -H "X-No-Compression: 1" "https://anikototvapi.vercel.app/api/"
|
||||||
|
|
||||||
|
# With compression
|
||||||
|
curl -w "Compressed: %{size_download} bytes\n" -o /dev/null -s --compressed "https://anikototvapi.vercel.app/api/"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Mirror Failover
|
||||||
|
```bash
|
||||||
|
# Check mirror status
|
||||||
|
curl -s "https://anikototvapi.vercel.app/api/mirrors" | jq '.results'
|
||||||
|
|
||||||
|
# Measure failover time (if primary is slow)
|
||||||
|
time curl -s "https://anikototvapi.vercel.app/api/" > /dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Rate Limiting
|
||||||
|
```bash
|
||||||
|
# Send rapid requests to trigger rate limit
|
||||||
|
for i in $(seq 1 105); do
|
||||||
|
curl -s -o /dev/null -w "Request $i: %{http_code}\n" "https://anikototvapi.vercel.app/api/"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Optimizations
|
||||||
|
|
||||||
|
### Cache TTL Tuning
|
||||||
|
| Endpoint | Current TTL | Recommended | Rationale |
|
||||||
|
|----------|-------------|-------------|-----------|
|
||||||
|
| `/` | 5 min | Keep | Homepage changes frequently |
|
||||||
|
| `/search` | 3 min | Keep | Search results update |
|
||||||
|
| `/info` | 10 min | Keep | Anime details stable |
|
||||||
|
| `/episodes` | 5 min | Keep | Episodes update weekly |
|
||||||
|
| `/stream` | 3 min | Keep | Stream URLs rotate |
|
||||||
|
| `/schedule` | 1 hour | Keep | Schedule stable |
|
||||||
|
| `/random` | 0 | Keep | Must be random |
|
||||||
|
| `/trending` | 5 min | Consider 3 min | Changes often |
|
||||||
|
| `/top-ten` | 5 min | Consider 10 min | Changes slowly |
|
||||||
|
|
||||||
|
### Compression Settings
|
||||||
|
```javascript
|
||||||
|
// server.js compression config
|
||||||
|
compression({
|
||||||
|
level: 6, // Good balance of speed/ratio
|
||||||
|
threshold: 1024, // Only compress > 1KB
|
||||||
|
filter: (req, res) => {
|
||||||
|
if (req.headers["x-no-compression"]) return false;
|
||||||
|
return compression.filter(req, res);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request Headers
|
||||||
|
```javascript
|
||||||
|
// Optimal scraping headers
|
||||||
|
{
|
||||||
|
'User-Agent': 'Mozilla/5.0 ...',
|
||||||
|
'Referer': 'https://anikototv.to/',
|
||||||
|
'Accept': 'text/html,application/xhtml+xml',
|
||||||
|
'Accept-Language': 'en-US,en;q=0.9',
|
||||||
|
'Accept-Encoding': 'gzip, deflate, br'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## What You Do
|
||||||
|
|
||||||
|
1. **Benchmark endpoints** — Measure response times for all 38 endpoints
|
||||||
|
2. **Profile cache** — Track hit/miss ratios, measure TTL effectiveness
|
||||||
|
3. **Test compression** — Compare compressed vs uncompressed sizes
|
||||||
|
4. **Load test** — Simulate concurrent users, check rate limiting
|
||||||
|
5. **Monitor cold starts** — Measure Vercel serverless cold start impact
|
||||||
|
6. **Optimize selectors** — Profile Cheerio parsing time for complex extractors
|
||||||
|
7. **Track mirror latency** — Measure failover time between domains
|
||||||
|
|
||||||
|
## Critical Rules
|
||||||
|
|
||||||
|
- **Test against live API** — Always use `https://anikototvapi.vercel.app/api`
|
||||||
|
- **Measure real conditions** — Include network latency, not just server time
|
||||||
|
- **Cold start awareness** — First request after idle is slower
|
||||||
|
- **Cache invalidation** — Test with fresh cache vs warm cache
|
||||||
|
- **Compression verification** — Check actual bytes transferred
|
||||||
|
- **Rate limit respect** — Don't DDoS your own API during testing
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
---
|
||||||
|
name: Security Architect
|
||||||
|
description: Security specialist for AniKotoAPI — validates SSRF protection, input sanitization, rate limiting, CORS, security headers, and scraping safety
|
||||||
|
mode: subagent
|
||||||
|
color: '#E74C3C'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Security Architect — AniKotoAPI
|
||||||
|
|
||||||
|
You are **Security Architect** for AniKotoAPI, responsible for securing a public scraping API that fetches data from anikototv.to and serves it via Express.js on Vercel.
|
||||||
|
|
||||||
|
## Your Identity
|
||||||
|
|
||||||
|
- **Project**: AniKotoAPI v2.2.0 — https://github.com/Shineii86/AniKotoAPI
|
||||||
|
- **Risk profile**: Public API, no auth, scrapes external site, deployed on Vercel
|
||||||
|
- **Attack surface**: SSRF via mapper API, XSS in error messages, rate limit bypass, header injection
|
||||||
|
|
||||||
|
## Security Layers You Maintain
|
||||||
|
|
||||||
|
### 1. Rate Limiting (server.js:132-156)
|
||||||
|
```javascript
|
||||||
|
// Configurable via RATE_LIMIT and RATE_WINDOW env vars
|
||||||
|
// Default: 100 requests per 60 seconds per IP
|
||||||
|
// Returns 429 with retryAfter header
|
||||||
|
// Sets X-RateLimit-Limit and X-RateLimit-Remaining headers
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Security Headers (server.js:72-80)
|
||||||
|
```javascript
|
||||||
|
// X-Frame-Options: DENY
|
||||||
|
// X-Content-Type-Options: nosniff
|
||||||
|
// X-XSS-Protection: 1; mode=block
|
||||||
|
// Referrer-Policy: strict-origin-when-cross-origin
|
||||||
|
// Permissions-Policy: camera=(), microphone=(), geolocation=()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. CORS (server.js:58-70)
|
||||||
|
```javascript
|
||||||
|
// ALLOWED_ORIGINS env var (comma-separated)
|
||||||
|
// Default: allow all origins
|
||||||
|
// GET and POST methods allowed
|
||||||
|
// Handles OPTIONS preflight
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Input Sanitization
|
||||||
|
- Mapper API: Validate `malId` is numeric, `slug` is string, `timestamp` is numeric
|
||||||
|
- Search: Sanitize `keyword` parameter
|
||||||
|
- All query params: Express auto-sanitizes, but validate types
|
||||||
|
|
||||||
|
### 5. SSRF Protection
|
||||||
|
```javascript
|
||||||
|
// mapper-servers endpoint validates:
|
||||||
|
// - malId must be numeric
|
||||||
|
// - slug must be string
|
||||||
|
// - timestamp must be numeric
|
||||||
|
// Prevents arbitrary URL fetching
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Error Handling
|
||||||
|
```javascript
|
||||||
|
// server.js:178-188
|
||||||
|
// - Global error handler catches all errors
|
||||||
|
// - Production: generic "Internal server error" message
|
||||||
|
// - Development: full error message
|
||||||
|
// - Entity too large: 413 response
|
||||||
|
// - Request timeout: 408 response
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Scraping Safety
|
||||||
|
- **Fixed User-Agent**: Never rotate or randomize (fingerprinting risk)
|
||||||
|
- **Referer header**: Always `https://anikototv.to/` (avoids blocks)
|
||||||
|
- **No credential storage**: No API keys, no tokens, no passwords
|
||||||
|
- **Mirror fallback**: 5 domains, no single point of failure
|
||||||
|
|
||||||
|
## What You Audit
|
||||||
|
|
||||||
|
### Input Validation
|
||||||
|
| Endpoint | Risk | Validation |
|
||||||
|
|----------|------|------------|
|
||||||
|
| `/api/search?keyword=` | XSS | Sanitize special chars |
|
||||||
|
| `/api/info?id=` | Path traversal | Validate slug format |
|
||||||
|
| `/api/servers?ids=` | Injection | Base64 decode safely |
|
||||||
|
| `/api/mapper-servers?malId=` | SSRF | Must be numeric |
|
||||||
|
| `/api/mapper-servers?slug=` | Injection | String validation |
|
||||||
|
| `/api/mapper-servers?timestamp=` | Injection | Must be numeric |
|
||||||
|
|
||||||
|
### Configuration Security
|
||||||
|
| Setting | Check |
|
||||||
|
|---------|-------|
|
||||||
|
| `.env` file | Never commit to git |
|
||||||
|
| `ALLOWED_ORIGINS` | Don't use `*` in production |
|
||||||
|
| `RATE_LIMIT` | Reasonable default (100/min) |
|
||||||
|
| `REQUEST_TIMEOUT` | Prevent hanging requests (30s) |
|
||||||
|
| `NODE_ENV` | Production hides error details |
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
| Package | Version | Vulnerability Check |
|
||||||
|
|---------|---------|-------------------|
|
||||||
|
| express | 4.21 | Check for known CVEs |
|
||||||
|
| axios | 1.8 | Check for SSRF bugs |
|
||||||
|
| cheerio | 1.0-rc.12 | Check for prototype pollution |
|
||||||
|
| compression | 1.7 | Check for BREACH attacks |
|
||||||
|
| dotenv | 16.4 | Check for env injection |
|
||||||
|
|
||||||
|
## What You Do
|
||||||
|
|
||||||
|
1. **Validate inputs** — Check all endpoint parameters for injection, XSS, path traversal
|
||||||
|
2. **Audit headers** — Ensure security headers are set correctly
|
||||||
|
3. **Review rate limiting** — Confirm bypass resistance, timing attacks
|
||||||
|
4. **Check SSRF** — Validate mapper API doesn't allow arbitrary URL fetch
|
||||||
|
5. **Scan dependencies** — Check for known vulnerabilities in package versions
|
||||||
|
6. **Test error responses** — Ensure no info leakage in production
|
||||||
|
7. **Review scraping safety** — Confirm headers don't expose internal details
|
||||||
|
|
||||||
|
## Critical Rules
|
||||||
|
|
||||||
|
- **Never store secrets in code** — All config via `.env`
|
||||||
|
- **Rate limit before cache** — Rate limiter runs before cache check
|
||||||
|
- **Sanitize error messages** — No stack traces in production
|
||||||
|
- **Validate all inputs** — Especially mapper API params
|
||||||
|
- **No credential forwarding** — Scraping requests don't forward auth headers
|
||||||
|
- **CORS before routes** — CORS middleware runs before route matching
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
---
|
||||||
|
name: Technical Writer
|
||||||
|
description: Documentation specialist for AniKotoAPI — maintains README, CHANGELOG, API docs, code examples, and OpenAPI spec
|
||||||
|
mode: subagent
|
||||||
|
color: '#2ECC71'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Technical Writer — AniKotoAPI
|
||||||
|
|
||||||
|
You are **Technical Writer** for AniKotoAPI, responsible for maintaining all documentation: README.md, CHANGELOG.md, docs/ folder, code examples, and the OpenAPI specification.
|
||||||
|
|
||||||
|
## Your Identity
|
||||||
|
|
||||||
|
- **Project**: AniKotoAPI v2.2.0 — https://github.com/Shineii86/AniKotoAPI
|
||||||
|
- **Author format**: `Shinei Nouzen <157171073+Shineii86@users.noreply.github.com>`
|
||||||
|
- **Changelog rule**: Append at top, never delete/edit old entries
|
||||||
|
|
||||||
|
## Documentation Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
AniKotoAPI/
|
||||||
|
├── README.md # Main docs (2600+ lines)
|
||||||
|
├── CHANGELOG.md # Version history (append only)
|
||||||
|
├── CONTRIBUTING.md # Contribution guidelines
|
||||||
|
├── docs/
|
||||||
|
│ ├── index.md # Overview, quick start
|
||||||
|
│ ├── endpoints.md # Full API reference (38 endpoints)
|
||||||
|
│ ├── streaming.md # 3-step streaming flow guide
|
||||||
|
│ ├── examples.md # cURL, JS, Python examples
|
||||||
|
│ ├── architecture.md # Project structure, tech stack
|
||||||
|
│ └── testing.md # Integration test suite & benchmarks
|
||||||
|
└── public/
|
||||||
|
├── index.html # Landing page
|
||||||
|
├── tos.html # Terms of Service
|
||||||
|
├── privacy.html # Privacy Policy
|
||||||
|
└── sitemap.xml # Sitemap
|
||||||
|
```
|
||||||
|
|
||||||
|
## What You Maintain
|
||||||
|
|
||||||
|
### CHANGELOG.md Format
|
||||||
|
```markdown
|
||||||
|
## [2.0.0] - 2026-07-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Multi-mirror fallback system with 5 mirror domains
|
||||||
|
- LRU cache with configurable TTL per endpoint
|
||||||
|
- Seasons endpoint (/api/seasons/:id)
|
||||||
|
- Watch Order endpoint (/api/watch-order/:id)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- BREAKING: All extractors now use mirror fallback helper
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Header merging bug in mirror helper
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Removed og-image.svg
|
||||||
|
```
|
||||||
|
|
||||||
|
### README.md Structure
|
||||||
|
```markdown
|
||||||
|
# AniKotoAPI
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
## Why AniKotoAPI?
|
||||||
|
## How It Works (mermaid diagram)
|
||||||
|
## Anime Data Source
|
||||||
|
## Tech Stack
|
||||||
|
## Project Structure (tree)
|
||||||
|
## API Endpoints
|
||||||
|
## Streaming Flow
|
||||||
|
## Endpoint Documentation (36 sections)
|
||||||
|
## API Response Schema
|
||||||
|
## Configuration (.env)
|
||||||
|
## Deployment
|
||||||
|
## Contributing
|
||||||
|
## License
|
||||||
|
```
|
||||||
|
|
||||||
|
### Endpoint Documentation Pattern
|
||||||
|
```markdown
|
||||||
|
> ## 🎥 GET Endpoint Name
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
\`\`\`
|
||||||
|
/endpoint-path
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
#### Parameters
|
||||||
|
| Parameter | Type | Mandatory | Default | Description |
|
||||||
|
| :-------: | :--: | :-------: | :-----: | :---------: |
|
||||||
|
| `param` | `type` | Yes/No | default | description |
|
||||||
|
|
||||||
|
#### Example of request
|
||||||
|
\`\`\`bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/endpoint"
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
#### Sample Response
|
||||||
|
\`\`\`json
|
||||||
|
{ "success": true, "results": {...} }
|
||||||
|
\`\`\`
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Documentation Rules
|
||||||
|
|
||||||
|
### Version Bumping
|
||||||
|
1. Update `package.json` version
|
||||||
|
2. Update README.md badge: `Version-2.2.1`
|
||||||
|
3. Update OpenAPI spec version in `apiRoutes.js`
|
||||||
|
4. Add CHANGELOG.md entry (prepend, never delete)
|
||||||
|
|
||||||
|
### Endpoint Documentation
|
||||||
|
When adding a new endpoint:
|
||||||
|
1. Add to `docs/endpoints.md` with full details
|
||||||
|
2. Add to README.md endpoints section
|
||||||
|
3. Add example to `docs/examples.md`
|
||||||
|
4. Add to OpenAPI spec in `apiRoutes.js`
|
||||||
|
5. Update endpoint count in README badge and stats
|
||||||
|
6. Add to `docs/streaming.md` if streaming-related
|
||||||
|
|
||||||
|
### Code Examples
|
||||||
|
- Always test examples against live API
|
||||||
|
- Include cURL, JavaScript (fetch), and Python versions
|
||||||
|
- Use realistic data (one-piece, naruto, etc.)
|
||||||
|
- Show both success and error responses
|
||||||
|
|
||||||
|
### Mermaid Diagrams
|
||||||
|
```markdown
|
||||||
|
flowchart TD
|
||||||
|
A["Request"] --> B{"Cache"}
|
||||||
|
B -- HIT --> C["Response"]
|
||||||
|
B -- MISS --> D["Scrape"]
|
||||||
|
D --> E["Parse"]
|
||||||
|
E --> F["Cache"]
|
||||||
|
F --> G["Response"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## What You Do
|
||||||
|
|
||||||
|
1. **Update CHANGELOG** — Add entries for every version bump
|
||||||
|
2. **Maintain README** — Keep endpoint list, badges, examples current
|
||||||
|
3. **Document endpoints** — Full docs for each of 38 endpoints
|
||||||
|
4. **Write examples** — cURL, JS, Python for common use cases
|
||||||
|
5. **Update OpenAPI** — Keep spec in sync with actual routes
|
||||||
|
6. **Review docs/ folder** — Keep all 5 markdown files current
|
||||||
|
7. **Update sitemap.xml** — Add dates when content changes
|
||||||
|
|
||||||
|
## Critical Rules
|
||||||
|
|
||||||
|
- **Never delete CHANGELOG entries** — Only append new versions at top
|
||||||
|
- **Consistent author format** — `Shinei Nouzen` everywhere
|
||||||
|
- **Test all examples** — Every curl/JS/Python example must work
|
||||||
|
- **Count endpoints accurately** — Badge, stats, OpenAPI all must match
|
||||||
|
- **Append, don't overwrite** — CHANGELOG entries are permanent
|
||||||
+871
-187
File diff suppressed because it is too large
Load Diff
+637
-16
@@ -4,6 +4,72 @@ Base URL: `https://anikototvapi.vercel.app/api`
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ⚠️ Streaming Status
|
||||||
|
|
||||||
|
> **Streaming endpoints (`/api/servers`, `/api/stream`, `/api/stream/resolve`) return errors when anikototv.to blocks AJAX requests from server-side IPs.**
|
||||||
|
|
||||||
|
| What Works | What's Blocked |
|
||||||
|
|:---|:---|
|
||||||
|
| Home, spotlight, trending | `/api/servers` — server list |
|
||||||
|
| Search, suggestions, filter | `/api/stream` — embed URLs |
|
||||||
|
| Info, episodes, schedule | `/api/stream/resolve` — m3u8/mp4 |
|
||||||
|
| Top 10, most popular, new release | `/api/mapper-servers` — gogoanime servers |
|
||||||
|
| AZ list, random, genre list | All AJAX-dependent endpoints |
|
||||||
|
| All HTML-scraped endpoints | |
|
||||||
|
|
||||||
|
**To fix streaming**, set one of these environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Option 1: ScraperAPI (requires premium plan — $49/month)
|
||||||
|
SCRAPER_API_KEY=your_scraperapi_key
|
||||||
|
|
||||||
|
# Option 2: FlareSolverr (self-hosted, unlimited)
|
||||||
|
FLARESOLVERR_URL=http://your-flaresolverr-host:8191
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pagination
|
||||||
|
|
||||||
|
Most list endpoints return a `totalPages` field for pagination:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"results": {
|
||||||
|
"totalPages": 296,
|
||||||
|
"data": [...]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the `page` query parameter to navigate: `?page=2`
|
||||||
|
|
||||||
|
**Only `/api/filter` returns the full pagination object:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"results": {
|
||||||
|
"totalPages": 1,
|
||||||
|
"data": [],
|
||||||
|
"pagination": {
|
||||||
|
"currentPage": 1,
|
||||||
|
"totalPages": 1,
|
||||||
|
"totalItems": 0,
|
||||||
|
"itemsPerPage": 30,
|
||||||
|
"hasNext": false,
|
||||||
|
"hasPrev": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Endpoints with `totalPages` pagination:** `/api/search`, `/api/most-popular`, `/api/new-release`, `/api/newly-added`, `/api/latest-updated`, `/api/az-list/:letter`, `/api/filter`, `/api/genre/:name`, `/api/type/:name`, `/api/status/:name`
|
||||||
|
|
||||||
|
**Endpoints with flat arrays (no pagination):** `/api/spotlight`, `/api/trending`, `/api/top-ten`, `/api/suggestions`, `/api/recently-updated`, `/api/completed`, `/api/upcoming`, `/api/top-rankings`, `/api/mirrors`, `/api/schedule`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## GET /
|
## GET /
|
||||||
|
|
||||||
Returns homepage data: spotlight anime, trending, top-airing, and genres.
|
Returns homepage data: spotlight anime, trending, top-airing, and genres.
|
||||||
@@ -102,12 +168,14 @@ Get detailed info about an anime.
|
|||||||
|
|
||||||
| Param | Type | Required | Description |
|
| Param | Type | Required | Description |
|
||||||
|-------|------|----------|-------------|
|
|-------|------|----------|-------------|
|
||||||
| `id` | string | Yes | Anime slug (e.g., `road-of-naruto-ggjw8`) |
|
| `id` | string | Yes | Anime slug with random hash (e.g., `naruto-shippuuden-movie-6-road-to-ninja-w2wqq`) |
|
||||||
|
|
||||||
|
> **Important:** Slugs now include random hashes (e.g., `one-piece-odmau`). Get valid slugs from `/search` or `/most-popular` results.
|
||||||
|
|
||||||
**Request:**
|
**Request:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl "https://anikototvapi.vercel.app/api/info?id=road-of-naruto-ggjw8"
|
curl "https://anikototvapi.vercel.app/api/info?id=naruto-shippuuden-movie-6-road-to-ninja-w2wqq"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
@@ -116,13 +184,26 @@ curl "https://anikototvapi.vercel.app/api/info?id=road-of-naruto-ggjw8"
|
|||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"results": {
|
"results": {
|
||||||
"title": "Road of Naruto",
|
"slug": "naruto-shippuuden-movie-6-road-to-ninja-w2wqq",
|
||||||
"type": "ONA",
|
"animeId": 786,
|
||||||
|
"title": "Naruto: Shippuuden Movie 6: Road to Ninja",
|
||||||
|
"japaneseTitle": "Naruto: Shippuuden Movie 6 - Road to Ninja",
|
||||||
|
"altNames": "Naruto: Shippuuden Movie 6 - Road to Ninja;Naruto Movie 9",
|
||||||
|
"poster": "https://cdn.anipixcdn.co/thumbnail/43dd49b4fdb9bede653e94468ff8df1e.jpg",
|
||||||
|
"backgroundImage": "'",
|
||||||
|
"synopsis": "Returning home to Konohagakure, the young ninja celebrate defeating a group of supposed Akatsuki...",
|
||||||
|
"type": "Movie",
|
||||||
|
"premiered": "Summer 2012",
|
||||||
|
"aired": "Jul 28, 2012",
|
||||||
"status": "Finished Airing",
|
"status": "Finished Airing",
|
||||||
"totalEpisodes": null,
|
"malScore": "7.84",
|
||||||
"synopsis": "In celebration of 20 years of Naruto, Studio Pierrot posted an anniversary PV on their YouTube channel...",
|
"duration": "1 hr 39 min",
|
||||||
"malId": "53236",
|
"episodes": "1",
|
||||||
"genres": ["Action", "Fantasy", "Shounen"]
|
"studios": ["Pierrot"],
|
||||||
|
"producers": ["TV Tokyo", "Shueisha"],
|
||||||
|
"genres": ["Action", "Comedy", "Supernatural"],
|
||||||
|
"rating": "PG-13",
|
||||||
|
"reviewCount": "0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -135,12 +216,12 @@ Get episode list for an anime.
|
|||||||
|
|
||||||
| Param | Type | Required | Description |
|
| Param | Type | Required | Description |
|
||||||
|-------|------|----------|-------------|
|
|-------|------|----------|-------------|
|
||||||
| `id` | string | Yes | Anime slug |
|
| `id` | string | Yes | Anime slug or animeId |
|
||||||
|
|
||||||
**Request:**
|
**Request:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl "https://anikototvapi.vercel.app/api/episodes/road-of-naruto-ggjw8"
|
curl "https://anikototvapi.vercel.app/api/episodes/958"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
@@ -149,19 +230,20 @@ curl "https://anikototvapi.vercel.app/api/episodes/road-of-naruto-ggjw8"
|
|||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"results": {
|
"results": {
|
||||||
"animeId": "7174",
|
"animeId": 958,
|
||||||
"totalEpisodes": 1,
|
"slug": "958",
|
||||||
|
"totalEpisodes": 220,
|
||||||
"episodes": [
|
"episodes": [
|
||||||
{
|
{
|
||||||
"id": "110289",
|
"id": "16638",
|
||||||
"episode_no": 1,
|
"episode_no": 1,
|
||||||
"slug": "1",
|
"slug": "1",
|
||||||
"title": "",
|
"title": "",
|
||||||
"active": true,
|
"active": true,
|
||||||
"href": "#",
|
"href": "#",
|
||||||
"server_ids": "SlNVT25JaFlCMnZOeXZ2aG5takIxL2EybGl4TzJoNE1pN3JXdFNlODVocWtTckt1SFR0YUxrNzNhanQ2MEJoVG9UUEZNeWJOMm1uUThpYjNxejhhUEZWMitnNFFtTUNMYjBTc1FJZjZNNFZPNm5LMlVuTnpOU25ScUI1dHVGczM0UzluZ2xITG5qbExabnBDdGphY0VRPT0",
|
"server_ids": "dXNCT3hNQzk3THhSTW8ySnM5...",
|
||||||
"timestamp": "1729249503",
|
"timestamp": "1729197616",
|
||||||
"mal_id": "53236"
|
"mal_id": "20"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -454,3 +536,542 @@ curl "https://anikototvapi.vercel.app/api/filter?keyword=&genre[]=1"
|
|||||||
```
|
```
|
||||||
|
|
||||||
Returns filtered and paginated anime list.
|
Returns filtered and paginated anime list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /watch
|
||||||
|
|
||||||
|
Get full watch page data including servers, trending, and recommended anime.
|
||||||
|
|
||||||
|
| Param | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `slug` | string | Yes | Anime slug (e.g., `one-piece-100`) |
|
||||||
|
| `ep` | number | Yes | Episode number |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/watch?slug=one-piece-100&ep=1"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns episode data with server list, trending sidebar, and recommended anime.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /search/suggest
|
||||||
|
|
||||||
|
Get search autocomplete suggestions.
|
||||||
|
|
||||||
|
| Param | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `keyword` | string | Yes | Search keyword |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/search/suggest?keyword=naruto"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns array of suggestion objects with title, ID, and image.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /episodes-ajax/:id
|
||||||
|
|
||||||
|
Get AJAX-loaded episode list for an anime.
|
||||||
|
|
||||||
|
| Param | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `id` | string | Yes | Anime ID or slug |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/episodes-ajax/one-piece-100"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns episode list with filters, ranges, and server IDs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /mapper-servers
|
||||||
|
|
||||||
|
Get cross-server mapping for gogoanime/anivibe servers.
|
||||||
|
|
||||||
|
| Param | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `malId` | number | Yes | MyAnimeList ID |
|
||||||
|
| `slug` | string | Yes | Anime slug |
|
||||||
|
| `timestamp` | number | Yes | Timestamp |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/mapper-servers?malId=21&slug=one-piece-100×tamp=1234567890"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns mapped server URLs for gogoanime/anivibe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /newly-added
|
||||||
|
|
||||||
|
Get newly added anime series.
|
||||||
|
|
||||||
|
| Param | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `page` | number | No | Page number (default: 1) |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/newly-added"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns paginated list of newly added anime.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /trending-sidebar
|
||||||
|
|
||||||
|
Get trending sidebar widget data.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/trending-sidebar"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns trending anime for sidebar display.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /seasons/:id
|
||||||
|
|
||||||
|
Get all seasons for an anime (main season, OVAs, movies, specials).
|
||||||
|
|
||||||
|
| Param | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `id` | number | Yes | Anime numeric ID |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/seasons/1642"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns all seasons including OVAs, movies, and specials for the anime.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /watch-order/:id
|
||||||
|
|
||||||
|
Get recommended watch order for an anime franchise.
|
||||||
|
|
||||||
|
| Param | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `id` | number | Yes | Anime numeric ID |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/watch-order/1642"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns recommended watch order sequence with relationship types.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /latest-updated
|
||||||
|
|
||||||
|
Get recently updated anime sorted by update time.
|
||||||
|
|
||||||
|
| Param | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `page` | number | No | Page number (default: 1) |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/latest-updated"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns paginated list of recently updated anime.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /download
|
||||||
|
|
||||||
|
Get download links for an anime episode.
|
||||||
|
|
||||||
|
| Param | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `slug` | string | Yes | Anime slug |
|
||||||
|
| `ep` | number | Yes | Episode number |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/download?slug=one-piece-odmau&ep=1165"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns decoded download links for the specified episode.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /az-list/:letter
|
||||||
|
|
||||||
|
Get anime alphabetically by letter.
|
||||||
|
|
||||||
|
| Param | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `letter` | string | Yes | Letter (a-z, or # for numbers) |
|
||||||
|
| `page` | number | No | Page number (default: 1) |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/az-list/a"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns paginated list of anime starting with the specified letter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /upcoming
|
||||||
|
|
||||||
|
Get upcoming anime sorted by air date.
|
||||||
|
|
||||||
|
| Param | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `page` | number | No | Page number (default: 1) |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/upcoming"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns paginated list of upcoming anime with sub/dub counts, type, and rating.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /top-rankings
|
||||||
|
|
||||||
|
Get top-ranked anime with sort options.
|
||||||
|
|
||||||
|
| Param | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `sort` | string | No | Sort mode: `day` (default), `week`, or `month` |
|
||||||
|
| `page` | number | No | Page number (default: 1) |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/top-rankings?sort=week"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"rank": 1,
|
||||||
|
"slug": "demon-slayer-kimetsu-no-yaiba-infinity-castle-4tmun",
|
||||||
|
"poster": "https://cdn.anipixcdn.co/thumbnail/...",
|
||||||
|
"title": "Demon Slayer: Kimetsu no Yaiba Infinity Castle",
|
||||||
|
"japaneseTitle": "Kimetsu no Yaiba Movie: Mugen Jou-hen",
|
||||||
|
"sub": 1,
|
||||||
|
"dub": 1,
|
||||||
|
"type": "Movie"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /recently-updated
|
||||||
|
|
||||||
|
Get recently updated anime. Supports tab filtering.
|
||||||
|
|
||||||
|
| Param | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `tab` | string | No | Filter: `sub`, `dub`, `all` (default: `all`) |
|
||||||
|
| `page` | number | No | Page number (default: 1) |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/recently-updated"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns paginated list from `#recent-update` section with client-side tab filtering.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /completed
|
||||||
|
|
||||||
|
Get completed anime sorted by score.
|
||||||
|
|
||||||
|
| Param | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `page` | number | No | Page number (default: 1) |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/completed"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns flat array of finished anime.
|
||||||
|
|
||||||
|
> **Note:** The `type` field in completed results contains the **air date** (e.g., `"2016-05-14"`), not the anime type (TV/Movie/etc).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /stream/resolve
|
||||||
|
|
||||||
|
Resolve an embed URL to actual video URL (m3u8/mp4).
|
||||||
|
|
||||||
|
| Param | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `id` | string | Yes | `link_id` from `/servers` response |
|
||||||
|
| `slug` | string | No | Anime slug (for session cookies) |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/stream/resolve?id=MTF1dkFtaW9BRTZPbzJJRElFZUZr..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns resolved URL, type (hls/mp4), server name, and subtitle URLs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /stream/qualities
|
||||||
|
|
||||||
|
Parse M3U8 playlist for available quality options.
|
||||||
|
|
||||||
|
| Param | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `url` | string | Yes | M3U8 playlist URL |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/stream/qualities?url=https://example.com/playlist.m3u8"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns array of quality objects with url, quality label, resolution, bandwidth, and codec.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /stream/proxy
|
||||||
|
|
||||||
|
M3U8 playlist proxy — rewrites relative URLs to proxy endpoints for CORS-free playback.
|
||||||
|
|
||||||
|
| Param | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `url` | string | Yes | M3U8 playlist URL |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/stream/proxy?url=https://example.com/playlist.m3u8"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns rewritten M3U8 content with all `.m3u8` and `.ts` URLs pointing to proxy endpoints.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /stream/ts-proxy
|
||||||
|
|
||||||
|
TS video segment proxy — serves `.ts` segments with proper Content-Type and CORS headers.
|
||||||
|
|
||||||
|
| Param | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `url` | string | Yes | `.ts` segment URL |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/stream/ts-proxy?url=https://example.com/segment.ts"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns binary video segment with `Content-Type: video/mp2t` and `Access-Control-Allow-Origin: *`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## System Endpoints
|
||||||
|
|
||||||
|
### GET /health
|
||||||
|
|
||||||
|
Health check endpoint. No parameters required.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/health"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"results": {
|
||||||
|
"status": "healthy",
|
||||||
|
"version": "2.4.1",
|
||||||
|
"uptime": "0h 1m 21s",
|
||||||
|
"uptimeSeconds": 81,
|
||||||
|
"timestamp": "2026-07-30T02:20:46.153Z",
|
||||||
|
"node": "v26.4.0",
|
||||||
|
"memory": {
|
||||||
|
"used": "99MB",
|
||||||
|
"total": "150MB"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /stats
|
||||||
|
|
||||||
|
API statistics endpoint. No parameters required.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/stats"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"results": {
|
||||||
|
"uptime": "81s",
|
||||||
|
"requests": {
|
||||||
|
"total": 37,
|
||||||
|
"errors": 9,
|
||||||
|
"successRate": "75.7%"
|
||||||
|
},
|
||||||
|
"cache": {
|
||||||
|
"type": "in-memory",
|
||||||
|
"ttl": "5 minutes",
|
||||||
|
"description": "Map-based cache with TTL expiration"
|
||||||
|
},
|
||||||
|
"endpoints": 38,
|
||||||
|
"timestamp": "2026-07-30T02:20:46.205Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /cache/stats
|
||||||
|
|
||||||
|
Cache statistics endpoint. No parameters required.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/cache/stats"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"results": {
|
||||||
|
"hits": 0,
|
||||||
|
"misses": 28,
|
||||||
|
"sets": 26,
|
||||||
|
"deletes": 0,
|
||||||
|
"size": 26,
|
||||||
|
"hitRate": "0.00%"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /mirrors
|
||||||
|
|
||||||
|
Mirror status endpoint. No parameters required.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/mirrors"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"name": "Primary",
|
||||||
|
"url": "https://anikototv.to",
|
||||||
|
"priority": 1,
|
||||||
|
"status": "online",
|
||||||
|
"latency": "6115ms",
|
||||||
|
"isCurrent": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Regional CZ",
|
||||||
|
"url": "https://anikoto.cz",
|
||||||
|
"priority": 2,
|
||||||
|
"status": "online",
|
||||||
|
"latency": "672ms",
|
||||||
|
"isCurrent": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### POST /mirrors/reset
|
||||||
|
|
||||||
|
Reset mirror cache. No parameters required.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://anikototvapi.vercel.app/api/mirrors/reset"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Mirror cache reset"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /openapi
|
||||||
|
|
||||||
|
OpenAPI 3.0.3 specification. No parameters required.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://anikototvapi.vercel.app/api/openapi"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the full OpenAPI spec document with all endpoint definitions, tags, and server information.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Response Patterns
|
||||||
|
|
||||||
|
| Pattern | Format | Endpoints |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| **Flat array** | `results: [...]` | `/spotlight`, `/trending`, `/top-ten`, `/suggestions`, `/recently-updated`, `/completed`, `/upcoming`, `/top-rankings`, `/mirrors`, `/schedule` |
|
||||||
|
| **Paginated data** | `results: { totalPages, data: [...] }` | `/search`, `/most-popular`, `/new-release`, `/newly-added`, `/latest-updated`, `/genre/:name`, `/type/:name`, `/status/:name`, `/az-list/:letter` |
|
||||||
|
| **Full pagination** | `results: { totalPages, data: [...], pagination: {...} }` | `/filter` only |
|
||||||
|
| **Three-period** | `results: { today/week/month: [...] }` or `{ day/week/month: [...] }` | `/top-ten`, `/trending-sidebar` |
|
||||||
|
| **Single object** | `results: {...}` | `/home`, `/info`, `/episodes`, `/episodes-ajax`, `/random`, `/health`, `/stats`, `/cache/stats`, `/stream-qualities` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Workflows
|
||||||
|
|
||||||
|
### Streaming Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. GET /episodes/{animeId} → Get episode list with server_ids
|
||||||
|
2. GET /servers?ids={ids} → Get available servers with link_ids
|
||||||
|
3. GET /stream?id={link_id} → Get embed URL + skip data
|
||||||
|
4. GET /stream/resolve?id={link_id} → Get actual m3u8/mp4 URL
|
||||||
|
5. GET /stream/qualities?url={m3u8} → Get available qualities
|
||||||
|
6. GET /stream/proxy?url={m3u8} → Get CORS-free proxied playlist
|
||||||
|
```
|
||||||
|
|
||||||
|
### Discovery Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. GET / → Homepage: spotlights, trending, genres
|
||||||
|
2. GET /search?q= → Search by keyword
|
||||||
|
3. GET /info?id= → Get anime details
|
||||||
|
4. GET /episodes/ → Get episode list
|
||||||
|
5. GET /seasons/ → Get all seasons
|
||||||
|
6. GET /watch-order/ → Get watch order for franchises
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filter & Browse Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. GET /genre/{name} → Browse by genre
|
||||||
|
2. GET /type/{name} → Browse by type (TV, Movie, OVA)
|
||||||
|
3. GET /status/{name} → Browse by status (airing, completed)
|
||||||
|
4. GET /filter?... → Advanced multi-parameter filter
|
||||||
|
5. GET /az-list/{letter} → Browse alphabetically
|
||||||
|
6. GET /top-rankings?sort=day → Top ranked anime
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
// ══════ END: endpoints.md ══════
|
||||||
|
|||||||
+2078
-203
File diff suppressed because it is too large
Load Diff
+399
-68
@@ -1,99 +1,430 @@
|
|||||||
# AniKotoAPI Documentation
|
# // === HEADER ===
|
||||||
|
# AniKotoAPI v2.4.1
|
||||||
|
# Free REST API for Anime Data
|
||||||
|
# Scraping anikototv.to with Cheerio
|
||||||
|
|
||||||
> Free REST API for anime data. Browse, search, stream — no API key required.
|
---
|
||||||
|
|
||||||
## Overview
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
AniKotoAPI is a free, open-source REST API that provides anime data scraped from [anikototv.to](https://anikototv.to). It's built with Node.js, Express, and Cheerio, and deployed on Vercel.
|
> A fast, free REST API that scrapes anime data from **anikototv.to**.
|
||||||
|
> Built with Node.js, Cheerio, and deployed on Vercel serverless functions.
|
||||||
|
|
||||||
**Base URL:** `https://anikototvapi.vercel.app/api`
|
---
|
||||||
|
|
||||||
## Quick Start
|
## // === STREAMING STATUS ===
|
||||||
|
|
||||||
|
> **Streaming endpoints (`/api/servers`, `/api/stream`, `/api/stream/resolve`) return errors when anikototv.to blocks AJAX requests from server-side IPs.**
|
||||||
|
|
||||||
|
| What Works | What's Blocked |
|
||||||
|
|:---|:---|
|
||||||
|
| Home, spotlight, trending | `/api/servers` — server list |
|
||||||
|
| Search, suggestions, filter | `/api/stream` — embed URLs |
|
||||||
|
| Info, episodes, schedule | `/api/stream/resolve` — m3u8/mp4 |
|
||||||
|
| Top 10, most popular, new release | `/api/mapper-servers` — gogoanime servers |
|
||||||
|
| AZ list, random, genre list | All AJAX-dependent endpoints |
|
||||||
|
| All HTML-scraped endpoints | |
|
||||||
|
|
||||||
|
**To fix streaming**, set one of these environment variables:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Get homepage data (spotlight, trending, top-airing, genres)
|
# Option 1: ScraperAPI (requires premium plan — $49/month)
|
||||||
curl https://anikototvapi.vercel.app/api
|
SCRAPER_API_KEY=your_scraperapi_key
|
||||||
|
|
||||||
# Search for anime
|
# Option 2: FlareSolverr (self-hosted, unlimited)
|
||||||
curl "https://anikototvapi.vercel.app/api/search?keyword=naruto"
|
FLARESOLVERR_URL=http://your-flaresolverr-host:8191
|
||||||
|
|
||||||
# Get anime info
|
|
||||||
curl "https://anikototvapi.vercel.app/api/info?id=road-of-naruto-ggjw8"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Live Response — Homepage:**
|
---
|
||||||
|
|
||||||
```json
|
## // === QUICK START ===
|
||||||
{
|
|
||||||
"success": true,
|
### Get started in 3 steps:
|
||||||
"results": {
|
|
||||||
"spotlights": [
|
**Step 1** — Make your first request:
|
||||||
{
|
|
||||||
"slug": "wistoria-wand-and-sword-season-2-dua04",
|
```bash
|
||||||
"poster": "https://cdn.anipixcdn.co/background/101f58336250ee0d_1779363645.webp",
|
curl https://anikototvapi.vercel.app/api/
|
||||||
"title": "Wistoria: Wand and Sword Season 2",
|
```
|
||||||
"japaneseTitle": "Tsue to Tsurugi no Wistoria Season 2",
|
|
||||||
"rating": "PG-13",
|
**Step 2** — Search for an anime:
|
||||||
"quality": "HD"
|
|
||||||
}
|
```bash
|
||||||
],
|
curl https://anikototvapi.vercel.app/api/search?q=naruto
|
||||||
"trending": [
|
```
|
||||||
{
|
|
||||||
"slug": "witch-hat-atelier-ikmut/ep-11",
|
**Step 3** — Get anime info:
|
||||||
"poster": "https://cdn.anipixcdn.co/thumbnail/0412057393e8a45b3ba8b16874b6034d.jpg",
|
|
||||||
"title": "Witch Hat Atelier",
|
```bash
|
||||||
"japaneseTitle": "Tongari Boushi no Atelier",
|
curl https://anikototvapi.vercel.app/api/info?link=/anime/naruto
|
||||||
"sub": 11,
|
```
|
||||||
"dub": 11,
|
|
||||||
"total": 13,
|
That's it. No API keys required.
|
||||||
"type": "TV"
|
|
||||||
}
|
---
|
||||||
],
|
|
||||||
"topAiring": [...],
|
## // === FEATURES ===
|
||||||
"genres": ["Action", "Adventure", "Cars", "Comedy", ...]
|
|
||||||
}
|
| 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 |
|
||||||
|
| **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 |
|
||||||
|
| **GZIP Compression** | Compressed responses for faster transfers |
|
||||||
|
| **Vercel Deployment** | Zero-config serverless deployment |
|
||||||
|
| **OpenAPI Spec** | Auto-generated API documentation at `/api/openapi` |
|
||||||
|
| **Health Checks** | System status and cache monitoring endpoints |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## // === ENDPOINTS OVERVIEW ===
|
||||||
|
|
||||||
|
### Home
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/` | Homepage data: featured, spotlight, sidebar content |
|
||||||
|
|
||||||
|
### Anime
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/info` | Full anime info, episodes, relations, recommendations |
|
||||||
|
| GET | `/api/seasons/:id` | Season/episode details for an anime |
|
||||||
|
| GET | `/api/watch-order/:id` | Canonical watch order for a series |
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/search` | Search anime by title |
|
||||||
|
| GET | `/api/search/suggest` | Autocomplete suggestions |
|
||||||
|
| GET | `/api/suggestions` | Trending/general suggestions |
|
||||||
|
|
||||||
|
### Episodes
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/episodes/:id` | Episode list for an anime |
|
||||||
|
| GET | `/api/episodes-ajax/:id` | Lazy-loaded episode pagination |
|
||||||
|
|
||||||
|
### Streaming
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/stream` | Master stream URL for an episode |
|
||||||
|
| GET | `/api/servers` | Available servers for an episode |
|
||||||
|
| GET | `/api/stream/resolve` | Resolve a streaming URL |
|
||||||
|
| GET | `/api/stream/qualities` | Available quality variants |
|
||||||
|
| GET | `/api/stream/proxy` | Proxied video stream |
|
||||||
|
| GET | `/api/stream/ts-proxy` | Proxied TS segment stream |
|
||||||
|
| GET | `/api/watch` | Watch page with stream data |
|
||||||
|
| GET | `/api/download` | Download link for an episode |
|
||||||
|
| GET | `/api/mapper-servers` | Server name mappings |
|
||||||
|
|
||||||
|
### Discovery
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/spotlight` | Featured/spotlight anime |
|
||||||
|
| GET | `/api/trending` | Currently trending anime |
|
||||||
|
| GET | `/api/top-ten` | Top 10 rankings |
|
||||||
|
| GET | `/api/random` | Random anime recommendation |
|
||||||
|
| GET | `/api/most-popular` | Most popular anime |
|
||||||
|
| GET | `/api/trending-sidebar` | Trending sidebar widget data |
|
||||||
|
|
||||||
|
### Releases
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/new-release` | Recently released episodes |
|
||||||
|
| GET | `/api/newly-added` | Newly added anime |
|
||||||
|
| GET | `/api/latest-updated` | Latest updated anime |
|
||||||
|
| GET | `/api/recently-updated` | Recently updated entries |
|
||||||
|
| GET | `/api/completed` | Completed anime |
|
||||||
|
| GET | `/api/upcoming` | Upcoming anime |
|
||||||
|
|
||||||
|
### Rankings
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/top-rankings` | Top ranked anime by category |
|
||||||
|
|
||||||
|
### Categories
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/genre/:name` | Anime by genre |
|
||||||
|
| GET | `/api/type/:name` | Anime by type (TV, Movie, OVA, etc.) |
|
||||||
|
| GET | `/api/status/:name` | Anime by status (Airing, Completed, etc.) |
|
||||||
|
| GET | `/api/az-list/:letter` | Alphabetical listing |
|
||||||
|
| GET | `/api/filter` | Multi-criteria filter |
|
||||||
|
|
||||||
|
### Schedule
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/schedule` | Anime release schedule |
|
||||||
|
|
||||||
|
### System
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/health` | Health check / uptime |
|
||||||
|
| GET | `/api/stats` | API statistics |
|
||||||
|
| GET | `/api/cache/stats` | Cache hit/miss statistics |
|
||||||
|
| GET | `/api/mirrors` | List available mirror domains |
|
||||||
|
| POST | `/api/mirrors/reset` | Reset failover mirror state |
|
||||||
|
| GET | `/api/openapi` | OpenAPI/Swagger spec |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## // === ARCHITECTURE ===
|
||||||
|
|
||||||
|
```
|
||||||
|
AniKotoAPI/
|
||||||
|
├── api/ # Vercel serverless functions
|
||||||
|
│ ├── index.js # Home endpoint
|
||||||
|
│ ├── info.js # Anime info
|
||||||
|
│ ├── search.js # Search
|
||||||
|
│ ├── stream.js # Streaming
|
||||||
|
│ └── ... # 38+ route handlers
|
||||||
|
├── scrapers/ # Cheerio HTML scrapers
|
||||||
|
│ ├── home.js
|
||||||
|
│ ├── info.js
|
||||||
|
│ ├── search.js
|
||||||
|
│ └── ... # 28 scrapers
|
||||||
|
├── utils/ # Shared utilities
|
||||||
|
│ ├── cache.js # LRU cache with TTL
|
||||||
|
│ ├── rateLimit.js # IP-based rate limiter
|
||||||
|
│ ├── mirrors.js # Mirror failover logic
|
||||||
|
│ └── compress.js # GZIP compression
|
||||||
|
├── docs/ # Documentation
|
||||||
|
│ ├── index.md # This file
|
||||||
|
│ ├── endpoints.md # Full API reference
|
||||||
|
│ ├── streaming.md # Streaming workflow
|
||||||
|
│ ├── examples.md # Code examples
|
||||||
|
│ ├── architecture.md # Design patterns
|
||||||
|
│ └── testing.md # Test suite docs
|
||||||
|
├── package.json
|
||||||
|
└── vercel.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## // === GETTING STARTED ===
|
||||||
|
|
||||||
|
### Using cURL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get homepage data
|
||||||
|
curl https://anikototvapi.vercel.app/api/
|
||||||
|
|
||||||
|
# Search for an anime
|
||||||
|
curl "https://anikototvapi.vercel.app/api/search?q=one+piece"
|
||||||
|
|
||||||
|
# Get anime details
|
||||||
|
curl "https://anikototvapi.vercel.app/api/info?link=/anime/one-piece"
|
||||||
|
|
||||||
|
# Get streaming servers
|
||||||
|
curl "https://anikototvapi.vercel.app/api/servers?link=/one-piece-episode-1"
|
||||||
|
|
||||||
|
# Get trending anime
|
||||||
|
curl https://anikototvapi.vercel.app/api/trending
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using JavaScript (fetch)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const BASE = "https://anikototvapi.vercel.app/api";
|
||||||
|
|
||||||
|
// Search
|
||||||
|
const res = await fetch(`${BASE}/search?q=naruto`);
|
||||||
|
const data = await res.json();
|
||||||
|
console.log(data.results);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Python (requests)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
BASE = "https://anikototvapi.vercel.app/api"
|
||||||
|
|
||||||
|
# Search
|
||||||
|
r = requests.get(f"{BASE}/search?q=naruto")
|
||||||
|
print(r.json()["results"])
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Node.js (axios)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const axios = require("axios");
|
||||||
|
const BASE = "https://anikototvapi.vercel.app/api";
|
||||||
|
|
||||||
|
async function searchAnime(query) {
|
||||||
|
const { data } = await axios.get(`${BASE}/search`, {
|
||||||
|
params: { q: query }
|
||||||
|
});
|
||||||
|
return data.results;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Features
|
---
|
||||||
|
|
||||||
- **30 Endpoints** — Home, search, info, episodes, streaming, schedule, and more
|
## // === RATE LIMITING ===
|
||||||
- **No API Key** — Just make requests, no registration needed
|
|
||||||
- **In-Memory Cache** — 5-minute TTL for fast responses
|
|
||||||
- **CORS Enabled** — Access from any domain
|
|
||||||
- **JSON Responses** — Standardized `{success, results}` format
|
|
||||||
|
|
||||||
## Documentation
|
- **Limit**: 100 requests per 15-minute window
|
||||||
|
- **Scope**: Per client IP address
|
||||||
|
- **Headers**: Rate limit info returned in response headers
|
||||||
|
- **Exceeded**: Returns `429 Too Many Requests`
|
||||||
|
|
||||||
- [API Endpoints Reference](endpoints.md) — Complete endpoint documentation with real responses
|
If you hit the rate limit, wait for the window to reset before retrying.
|
||||||
- [Streaming Flow Guide](streaming.md) — How to get stream URLs step by step
|
|
||||||
- [Code Examples](examples.md) — cURL, JavaScript, Python (all tested and working)
|
|
||||||
- [Architecture](architecture.md) — Project structure and design decisions
|
|
||||||
|
|
||||||
## Response Format
|
---
|
||||||
|
|
||||||
All endpoints return JSON in this format:
|
## // === MIRROR FAILOVER ===
|
||||||
|
|
||||||
|
AniKotoAPI maintains 5 mirror domains. When a request fails on the primary domain, the system automatically retries on the next available mirror.
|
||||||
|
|
||||||
|
```
|
||||||
|
Primary → Mirror 1 → Mirror 2 → Mirror 3 → Mirror 4 → Mirror 5
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `/api/mirrors` to check current mirror status, or `POST /api/mirrors/reset` to reset the failover state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## // === CACHING ===
|
||||||
|
|
||||||
|
The API uses an LRU (Least Recently Used) cache with per-endpoint TTL:
|
||||||
|
|
||||||
|
| Endpoint Category | TTL |
|
||||||
|
|-------------------|-----|
|
||||||
|
| Home / Spotlight | 2 minutes |
|
||||||
|
| Search | 3 minutes |
|
||||||
|
| Anime Info | 5 minutes |
|
||||||
|
| Streaming | 1 minute |
|
||||||
|
| Rankings / Lists | 10 minutes |
|
||||||
|
|
||||||
|
Cache stats available at `/api/cache/stats`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## // === ERROR RESPONSES ===
|
||||||
|
|
||||||
|
All errors return JSON with a consistent structure:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"error": true,
|
||||||
"results": { ... }
|
"message": "Description of what went wrong",
|
||||||
|
"status": 404
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Error responses:
|
| Status | Meaning |
|
||||||
|
|--------|---------|
|
||||||
|
| 400 | Bad request / missing parameters |
|
||||||
|
| 404 | Resource not found |
|
||||||
|
| 429 | Rate limit exceeded |
|
||||||
|
| 500 | Internal server error |
|
||||||
|
|
||||||
```json
|
---
|
||||||
{
|
|
||||||
"success": false,
|
## // === DOCUMENTATION ===
|
||||||
"message": "Error description"
|
|
||||||
}
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| [endpoints.md](./endpoints.md) | Full API reference for all 38+ 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 |
|
||||||
|
| [testing.md](./testing.md) | Integration test suite documentation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## // === CONTRIBUTING ===
|
||||||
|
|
||||||
|
Contributions are welcome. To contribute:
|
||||||
|
|
||||||
|
1. Fork the repository from [GitHub](https://github.com/Shineii86/AniKotoAPI)
|
||||||
|
2. Create a feature branch: `git checkout -b feature/my-feature`
|
||||||
|
3. Make your changes and test them
|
||||||
|
4. Commit with a clear message
|
||||||
|
5. Push and open a Pull Request
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/Shineii86/AniKotoAPI.git
|
||||||
|
cd AniKotoAPI
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Rate Limiting
|
### Guidelines
|
||||||
|
|
||||||
There are no explicit rate limits, but the API uses a 5-minute in-memory cache. Repeated requests for the same data will be served from cache.
|
- Follow existing code style and patterns
|
||||||
|
- Add scrapers in `scrapers/` for new pages
|
||||||
|
- Add route handlers in `api/` for new endpoints
|
||||||
|
- Update documentation when adding features
|
||||||
|
- Test all changes before submitting
|
||||||
|
|
||||||
## Disclaimer
|
---
|
||||||
|
|
||||||
This API is for **educational purposes only**. It scrapes publicly available data from anikototv.to. We are not affiliated with or endorsed by AniKoto. All content belongs to its respective owners.
|
## // === LICENSE ===
|
||||||
|
|
||||||
|
This project is licensed under the **MIT License**.
|
||||||
|
|
||||||
|
```
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Shinei Nouzen
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## // === LINKS ===
|
||||||
|
|
||||||
|
- **Live API**: https://anikototvapi.vercel.app/api
|
||||||
|
- **GitHub**: https://github.com/Shineii86/AniKotoAPI
|
||||||
|
- **OpenAPI Spec**: https://anikototvapi.vercel.app/api/openapi
|
||||||
|
- **Health Check**: https://anikototvapi.vercel.app/api/health
|
||||||
|
- **Author**: [Shinei Nouzen](https://github.com/Shineii86)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
// ══════ END: index.md ══════
|
||||||
|
|||||||
+851
-174
File diff suppressed because it is too large
Load Diff
+207
@@ -0,0 +1,207 @@
|
|||||||
|
# Testing
|
||||||
|
|
||||||
|
Comprehensive integration test suite for AniKotoAPI — validates all 35 endpoints, streaming flow, error handling, and performance benchmarks.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
No external test framework required — just Node.js native `fetch`.
|
||||||
|
|
||||||
|
## Auto-Detection
|
||||||
|
|
||||||
|
The test suite automatically detects which API to use:
|
||||||
|
|
||||||
|
1. **Local server running** (`localhost:4444`) → uses local
|
||||||
|
2. **No local server** → falls back to live API (`anikototvapi.vercel.app`)
|
||||||
|
3. **Manual override** → `API_URL=http://localhost:4444/api node test.js`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Auto-detect (recommended)
|
||||||
|
node test.js
|
||||||
|
|
||||||
|
# Start local server first, then test
|
||||||
|
npm start # terminal 1
|
||||||
|
node test.js # terminal 2
|
||||||
|
|
||||||
|
# Force specific URL
|
||||||
|
API_URL=http://localhost:4444/api node test.js
|
||||||
|
API_URL=https://anikototvapi.vercel.app/api node test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## What It Tests
|
||||||
|
|
||||||
|
### Core Endpoints (5)
|
||||||
|
| Test | Endpoint | Validates |
|
||||||
|
|------|----------|-----------|
|
||||||
|
| Home | `GET /` | `results.spotlights` or `results.trending` |
|
||||||
|
| Search | `GET /search?keyword=naruto` | `results.data` array |
|
||||||
|
| Info | `GET /info?id=one-piece-odmau` | `results.title` |
|
||||||
|
| Episodes | `GET /episodes/one-piece-odmau` | `results.episodes` array |
|
||||||
|
| Servers | `GET /servers?ids=1` | `results` exists |
|
||||||
|
|
||||||
|
### Streaming (1)
|
||||||
|
| Test | Endpoint | Validates |
|
||||||
|
|------|----------|-----------|
|
||||||
|
| Stream | `GET /watch?slug=one-piece-odmau&ep=1` | `results.servers` or `results.episodeNumber` |
|
||||||
|
|
||||||
|
### Discovery (10)
|
||||||
|
| Test | Endpoint | Validates |
|
||||||
|
|------|----------|-----------|
|
||||||
|
| Suggestions | `GET /suggestions?keyword=naruto` | `results` |
|
||||||
|
| Spotlight | `GET /spotlight` | `results` |
|
||||||
|
| Trending | `GET /trending` | `results` |
|
||||||
|
| Top 10 | `GET /top-ten` | `results` |
|
||||||
|
| Random | `GET /random` | `results` |
|
||||||
|
| Most Popular | `GET /most-popular` | `results` |
|
||||||
|
| Upcoming | `GET /upcoming` | `results` (array) |
|
||||||
|
| Top Rankings | `GET /top-rankings` | `results` (array) |
|
||||||
|
| Recently Updated | `GET /recently-updated` | `results` (array) |
|
||||||
|
| Completed | `GET /completed` | `results` (array) |
|
||||||
|
|
||||||
|
### Release Lists (3)
|
||||||
|
| Test | Endpoint | Validates |
|
||||||
|
|------|----------|-----------|
|
||||||
|
| New Release | `GET /new-release` | `results` |
|
||||||
|
| Newly Added | `GET /newly-added` | `results` |
|
||||||
|
| Latest Updated | `GET /latest-updated` | `results` |
|
||||||
|
|
||||||
|
### Categories (5)
|
||||||
|
| Test | Endpoint | Validates |
|
||||||
|
|------|----------|-----------|
|
||||||
|
| Genre | `GET /genre/action` | `results` |
|
||||||
|
| Type | `GET /type/tv` | `results` |
|
||||||
|
| Status | `GET /status/airing` | `results` |
|
||||||
|
| AZ List | `GET /az-list/a` | `results` |
|
||||||
|
| Filter | `GET /filter?keyword=naruto` | `results` |
|
||||||
|
|
||||||
|
### Search Variants (2)
|
||||||
|
| Test | Endpoint | Validates |
|
||||||
|
|------|----------|-----------|
|
||||||
|
| Search Suggest | `GET /search/suggest?keyword=naruto` | `results` |
|
||||||
|
| Trending Sidebar | `GET /trending-sidebar` | `results` |
|
||||||
|
|
||||||
|
### Anime Detail (3 — optional)
|
||||||
|
| Test | Endpoint | Validates |
|
||||||
|
|------|----------|-----------|
|
||||||
|
| Seasons | `GET /seasons/one-piece-odmau` | `results.seasons` or `results.totalSeasons` |
|
||||||
|
| Watch Order | `GET /watch-order/one-piece-odmau` | `results.related` or `results.totalRelated` |
|
||||||
|
| Download | `GET /download?slug=one-piece-odmau&ep=1` | `results` |
|
||||||
|
|
||||||
|
### System (5)
|
||||||
|
| Test | Endpoint | Validates |
|
||||||
|
|------|----------|-----------|
|
||||||
|
| Health | `GET /health` | `results.status === "healthy"` |
|
||||||
|
| Stats | `GET /stats` | `results.endpoints` |
|
||||||
|
| Cache Stats | `GET /cache/stats` | `results.hits !== undefined` |
|
||||||
|
| Mirrors | `GET /mirrors` | `results` (array) |
|
||||||
|
| OpenAPI | `GET /openapi` | `openapi === "3.0.3"` |
|
||||||
|
|
||||||
|
### Dynamic Tests (2)
|
||||||
|
| Test | Endpoint | How It Works |
|
||||||
|
|------|----------|--------------|
|
||||||
|
| Episodes Ajax | `GET /episodes-ajax/:animeId` | Fetches `animeId` from `/info` at runtime |
|
||||||
|
| Stream Resolve | `GET /stream/resolve?id=:linkId&slug=...` | Fetches `linkId` via episodes → servers flow |
|
||||||
|
|
||||||
|
## Test Structure
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
name: "Test Name", // Display name
|
||||||
|
url: "/endpoint?param=val", // URL path (appended to BASE)
|
||||||
|
check: (data) => boolean, // Custom validation function
|
||||||
|
optional: true // Skip on failure instead of fail
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validation Logic
|
||||||
|
|
||||||
|
For each test:
|
||||||
|
1. **HTTP status** — Must be 200 (or skip if `optional` and 404/500)
|
||||||
|
2. **`success` field** — Must be `true` (or skip if `optional`)
|
||||||
|
3. **Custom check** — `check(data)` must return truthy (or skip if `optional`)
|
||||||
|
|
||||||
|
### Optional Tests
|
||||||
|
|
||||||
|
Marked with `optional: true`. Instead of failing, they print `⏭️ Skipped` when:
|
||||||
|
- HTTP 404 or 500
|
||||||
|
- `success: false`
|
||||||
|
- Custom validation fails
|
||||||
|
- Network error
|
||||||
|
|
||||||
|
## Sample Output
|
||||||
|
|
||||||
|
```
|
||||||
|
📡 Detected local server — using localhost:4444
|
||||||
|
|
||||||
|
🧪 Running 35 tests...
|
||||||
|
|
||||||
|
📡 API: http://localhost:4444/api
|
||||||
|
|
||||||
|
[MIRROR] Switched to: https://anikototv.to
|
||||||
|
✅ Home (438ms)
|
||||||
|
✅ Search (480ms)
|
||||||
|
✅ Info (27ms)
|
||||||
|
...
|
||||||
|
✅ Episodes Ajax (1304ms)
|
||||||
|
|
||||||
|
==================================================
|
||||||
|
📊 Results: 35 passed, 0 failed, 0 skipped, 35 total
|
||||||
|
==================================================
|
||||||
|
|
||||||
|
⚡ Performance:
|
||||||
|
Average: 465ms
|
||||||
|
Min: 11ms
|
||||||
|
Max: 3679ms
|
||||||
|
```
|
||||||
|
|
||||||
|
## Exit Codes
|
||||||
|
|
||||||
|
| Code | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| `0` | All tests passed |
|
||||||
|
| `1` | One or more tests failed |
|
||||||
|
|
||||||
|
## Performance Metrics
|
||||||
|
|
||||||
|
The test suite reports:
|
||||||
|
- **Per-test timing** — Response time for each endpoint
|
||||||
|
- **Average** — Mean response time across all passing tests
|
||||||
|
- **Min/Max** — Fastest and slowest endpoints
|
||||||
|
- **Cache effect** — Cached responses return in ~10-30ms vs ~300-500ms uncached
|
||||||
|
|
||||||
|
## Adding New Tests
|
||||||
|
|
||||||
|
1. Add entry to the `tests` array in `test.js`:
|
||||||
|
```javascript
|
||||||
|
{ name: "New Endpoint", url: "/new-endpoint", check: (d) => d.results },
|
||||||
|
```
|
||||||
|
|
||||||
|
2. For endpoints requiring runtime data, add to the dynamic section in `runAll()`.
|
||||||
|
|
||||||
|
3. Run `node test.js` to verify.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
test.js
|
||||||
|
├── detectBase() # Auto-detect local vs live API
|
||||||
|
├── tests[] # Static test definitions (34 tests)
|
||||||
|
├── runAll()
|
||||||
|
│ ├── detectBase() # Resolve API URL
|
||||||
|
│ ├── fetchJson() # Pre-fetch dynamic data (animeId, linkId)
|
||||||
|
│ ├── push dynamic tests # Episodes Ajax, Stream Resolve
|
||||||
|
│ └── runTest() × 35 # Execute each test sequentially
|
||||||
|
├── runTest() # Single test runner with timing
|
||||||
|
└── results[] # Pass/fail/skip tracking
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Tests run **sequentially** to avoid rate limiting
|
||||||
|
- **No mocking** — all tests hit real HTTP endpoints
|
||||||
|
- **Cache aware** — repeated runs benefit from server-side caching
|
||||||
|
- **Dynamic tests** resolve dependencies at runtime (animeId, linkId)
|
||||||
|
- **Stream Resolve** may skip if no valid link_id is available (data expires)
|
||||||
Generated
+1767
File diff suppressed because it is too large
Load Diff
+5
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "AniKotoAPI",
|
"name": "AniKotoAPI",
|
||||||
"version": "1.8.0",
|
"version": "2.4.1",
|
||||||
"description": "A restful API to provide all information about anime from anikototv.to",
|
"description": "A restful API to provide all information about anime from anikototv.to",
|
||||||
"main": "./server.js",
|
"main": "./server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -13,8 +13,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.8.0",
|
"axios": "^1.8.0",
|
||||||
"cheerio": "^1.0.0-rc.12",
|
"cheerio": "^1.0.0-rc.12",
|
||||||
"cookie-parser": "^1.4.7",
|
"compression": "^1.8.1",
|
||||||
"cors": "^2.8.5",
|
|
||||||
"dotenv": "^16.4.0",
|
"dotenv": "^16.4.0",
|
||||||
"express": "^4.21.0"
|
"express": "^4.21.0"
|
||||||
},
|
},
|
||||||
@@ -25,5 +24,8 @@
|
|||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/Shineii86/AniKotoAPI.git"
|
"url": "https://github.com/Shineii86/AniKotoAPI.git"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"undici": "7.29.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+26
-33
@@ -2,51 +2,44 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>404 — AniKotoAPI</title>
|
<title>404 — AniKotoAPI</title>
|
||||||
<meta name="theme-color" content="#0A0A12" />
|
<meta name="theme-color" content="#faf9f5" />
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⛩</text></svg>" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
<style>
|
<style>
|
||||||
:root{--bg:#0A0A12;--text:#E2E8F0;--text2:#94A3B8;--text3:#64748B;--purple:#A855F7;--pink:#EC4899;--indigo:#6366F1;--green:#22C55E;--red:#EF4444;--border:rgba(255,255,255,.06);--glass:rgba(255,255,255,.03)}
|
:root{
|
||||||
|
--canvas:#faf9f5;--surface-card:#efe9de;--hairline:#e6dfd8;
|
||||||
|
--ink:#141413;--body:#3d3d3a;--muted:#6c6a64;--muted-soft:#8e8b82;
|
||||||
|
--primary:#cc785c;--primary-active:#a9583e;
|
||||||
|
--surface-dark:#181715;--on-dark:#faf9f5;--on-dark-soft:#a09d96;
|
||||||
|
--font-display:'Cormorant Garamond',Garamond,'Times New Roman',serif;
|
||||||
|
--font-body:'Inter',-apple-system,BlinkMacSystemFont,sans-serif;
|
||||||
|
}
|
||||||
*{margin:0;padding:0;box-sizing:border-box}
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center;overflow:hidden}
|
body{font-family:var(--font-body);background:var(--canvas);color:var(--ink);min-height:100vh;display:flex;align-items:center;justify-content:center}
|
||||||
.ambient{position:fixed;inset:0;pointer-events:none}
|
|
||||||
.orb{position:absolute;border-radius:50%;filter:blur(120px);opacity:.1}
|
|
||||||
.orb-1{width:500px;height:500px;background:var(--purple);top:-150px;left:-100px;animation:drift 24s ease-in-out infinite}
|
|
||||||
.orb-2{width:400px;height:400px;background:var(--pink);bottom:-100px;right:-80px;animation:drift 20s ease-in-out infinite reverse}
|
|
||||||
@keyframes drift{0%,100%{transform:translate(0,0) scale(1)}50%{transform:translate(40px,-30px) scale(1.08)}}
|
|
||||||
.container{position:relative;z-index:1;text-align:center;padding:40px;max-width:560px}
|
.container{position:relative;z-index:1;text-align:center;padding:40px;max-width:560px}
|
||||||
.code{font-size:clamp(6rem,18vw,14rem);font-weight:900;line-height:1;letter-spacing:-.06em;background:linear-gradient(135deg,var(--purple),var(--pink),var(--indigo));-webkit-background-clip:text;-webkit-text-fill-color:transparent;animation:float 4s ease-in-out infinite}
|
.code{font-family:var(--font-display);font-size:clamp(6rem,18vw,14rem);font-weight:400;line-height:1;letter-spacing:-.04em;color:var(--ink);opacity:.08}
|
||||||
@keyframes float{0%,100%{transform:translateY(0)}50%{transform:translateY(-10px)}}
|
.title{font-family:var(--font-display);font-size:1.8rem;font-weight:400;letter-spacing:-.01em;margin:16px 0 8px;color:var(--ink)}
|
||||||
.title{font-size:1.4rem;font-weight:700;margin:16px 0 8px}
|
.desc{color:var(--body);font-size:1rem;margin-bottom:32px;line-height:1.7}
|
||||||
.desc{color:var(--text2);font-size:.95rem;margin-bottom:32px;line-height:1.7}
|
|
||||||
.actions{display:flex;gap:12px;justify-content:center;flex-wrap:wrap}
|
.actions{display:flex;gap:12px;justify-content:center;flex-wrap:wrap}
|
||||||
.btn{display:inline-flex;align-items:center;gap:8px;padding:12px 24px;border-radius:10px;font-size:.88rem;font-weight:600;text-decoration:none;border:none;cursor:pointer;transition:all .25s;font-family:inherit}
|
.btn{display:inline-flex;align-items:center;gap:8px;padding:12px 24px;border-radius:8px;font-size:.875rem;font-weight:500;text-decoration:none;border:none;cursor:pointer;transition:all .15s;font-family:var(--font-body)}
|
||||||
.btn svg{width:16px;height:16px}
|
.btn svg{width:16px;height:16px}
|
||||||
.btn-primary{background:linear-gradient(135deg,var(--purple),var(--pink));color:#fff;box-shadow:0 4px 24px rgba(168,85,247,.3)}
|
.btn-primary{background:var(--primary);color:#fff}
|
||||||
.btn-primary:hover{transform:translateY(-2px);box-shadow:0 8px 32px rgba(168,85,247,.4)}
|
.btn-primary:hover{background:var(--primary-active);text-decoration:none}
|
||||||
.btn-ghost{background:var(--glass);border:1px solid rgba(255,255,255,.1);color:var(--text)}
|
.btn-secondary{background:var(--canvas);border:1px solid var(--hairline);color:var(--ink)}
|
||||||
.btn-ghost:hover{background:rgba(255,255,255,.06);border-color:rgba(255,255,255,.15)}
|
.btn-secondary:hover{background:var(--surface-card);text-decoration:none}
|
||||||
.glitch{position:relative;display:inline-block}
|
.status-line{display:flex;align-items:center;justify-content:center;gap:8px;margin-top:24px;color:var(--muted-soft);font-size:.78rem;font-family:'JetBrains Mono',monospace}
|
||||||
.glitch::before,.glitch::after{content:attr(data-text);position:absolute;top:0;left:0;width:100%;height:100%;background:var(--bg)}
|
.status-dot{width:6px;height:6px;border-radius:50%;background:var(--primary);animation:pulse 1.5s infinite}
|
||||||
.glitch::before{left:2px;text-shadow:-2px 0 var(--pink);clip-path:inset(0 0 0 0);animation:glitch-1 2s infinite linear alternate-reverse}
|
|
||||||
.glitch::after{left:-2px;text-shadow:2px 0 var(--indigo);clip-path:inset(0 0 0 0);animation:glitch-2 3s infinite linear alternate-reverse}
|
|
||||||
@keyframes glitch-1{0%{clip-path:inset(40% 0 61% 0)}20%{clip-path:inset(92% 0 1% 0)}40%{clip-path:inset(43% 0 1% 0)}60%{clip-path:inset(25% 0 58% 0)}80%{clip-path:inset(54% 0 7% 0)}100%{clip-path:inset(58% 0 43% 0)}}
|
|
||||||
@keyframes glitch-2{0%{clip-path:inset(65% 0 13% 0)}20%{clip-path:inset(79% 0 2% 0)}40%{clip-path:inset(48% 0 38% 0)}60%{clip-path:inset(33% 0 2% 0)}80%{clip-path:inset(16% 0 62% 0)}100%{clip-path:inset(27% 0 42% 0)}}
|
|
||||||
.status-line{display:flex;align-items:center;justify-content:center;gap:8px;margin-top:24px;color:var(--text3);font-size:.78rem;font-family:'JetBrains Mono',monospace}
|
|
||||||
.status-dot{width:6px;height:6px;border-radius:50%;background:var(--red);animation:pulse 1.5s infinite}
|
|
||||||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
|
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
|
||||||
.scanline{position:fixed;inset:0;z-index:0;pointer-events:none;background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,.03) 2px,rgba(0,0,0,.03) 4px)}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="ambient"><div class="orb orb-1"></div><div class="orb orb-2"></div></div>
|
|
||||||
<div class="scanline"></div>
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="code"><span class="glitch" data-text="404">404</span></div>
|
<div class="code">404</div>
|
||||||
<div class="title">Endpoint Not Found</div>
|
<div class="title">Endpoint Not Found</div>
|
||||||
<div class="desc">The path you requested doesn't exist in the AniKotoAPI.<br/>Check the documentation or try a valid endpoint.</div>
|
<div class="desc">The path you requested doesn't exist in the AniKotoAPI.<br/>Check the documentation or try a valid endpoint.</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@@ -54,11 +47,11 @@
|
|||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
||||||
Home
|
Home
|
||||||
</a>
|
</a>
|
||||||
<a href="/api" class="btn btn-ghost">
|
<a href="/api" class="btn btn-secondary">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
||||||
API Status
|
API Status
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/Shineii86/AniKotoAPI#readme" target="_blank" class="btn btn-ghost">
|
<a href="https://github.com/Shineii86/AniKotoAPI#readme" target="_blank" class="btn btn-secondary">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||||
Docs
|
Docs
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
+1
-1
@@ -6,5 +6,5 @@
|
|||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<rect width="100" height="100" rx="20" fill="url(#grad)"/>
|
<rect width="100" height="100" rx="20" fill="url(#grad)"/>
|
||||||
<text x="50" y="68" font-family="Arial, sans-serif" font-size="50" font-weight="bold" fill="white" text-anchor="middle">AK</text>
|
<text x="50" y="68" font-family="system-ui, -apple-system, sans-serif" font-size="50" font-weight="bold" fill="white" text-anchor="middle">AK</text>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 479 B After Width: | Height: | Size: 498 B |
+224
-355
@@ -3,255 +3,216 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>AniKotoAPI — Free Anime Data API</title>
|
<meta name="google-site-verification" content="YOUR_VERIFICATION_CODE" />
|
||||||
|
<title>AniKotoAPI — Free Anime REST API | 38 Endpoints</title>
|
||||||
<!-- ──────────────── Basic OG Tags ──────────────── -->
|
<meta property="og:title" content="AniKotoAPI — Free Anime REST API | 38 Endpoints" />
|
||||||
<meta property="og:title" content="AniKotoAPI — Free Anime Data API" />
|
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:url" content="https://anikototvapi.vercel.app" />
|
<meta property="og:url" content="https://anikototvapi.vercel.app" />
|
||||||
<meta property="og:image" content="https://anikototvapi.vercel.app/share.jpg" />
|
<meta property="og:image" content="https://anikototvapi.vercel.app/share.png" />
|
||||||
<meta property="og:description" content="Free REST API for anime data. Browse, search, stream episodes. 30+ endpoints, no API key required." />
|
<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:site_name" content="AniKotoAPI" />
|
<meta property="og:site_name" content="AniKotoAPI" />
|
||||||
<meta property="og:locale" content="en_US" />
|
<meta property="og:locale" content="en_US" />
|
||||||
|
<meta property="og:image:secure_url" content="https://anikototvapi.vercel.app/share.png" />
|
||||||
<!-- ──────────────── Image Optimization ──────────────── -->
|
|
||||||
<meta property="og:image:secure_url" content="https://anikototvapi.vercel.app/share.jpg" />
|
|
||||||
<meta property="og:image:type" content="image/jpeg" />
|
<meta property="og:image:type" content="image/jpeg" />
|
||||||
<meta property="og:image:width" content="1200" />
|
<meta property="og:image:width" content="1200" />
|
||||||
<meta property="og:image:height" content="630" />
|
<meta property="og:image:height" content="601" />
|
||||||
<meta property="og:image:alt" content="AniKotoAPI - Free Anime Data REST API" />
|
<meta property="og:image:alt" content="AniKotoAPI - Free Anime REST API with 38 endpoints" />
|
||||||
|
|
||||||
<!-- ──────────────── Twitter Card Tags ──────────────── -->
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta name="twitter:site" content="@Shineii86" />
|
<meta name="twitter:site" content="@Shineii86" />
|
||||||
<meta name="twitter:creator" content="@Shineii86" />
|
<meta name="twitter:creator" content="@Shineii86" />
|
||||||
<meta name="twitter:title" content="AniKotoAPI — Free Anime Data API" />
|
<meta name="twitter:title" content="AniKotoAPI — Free Anime REST API | 38 Endpoints" />
|
||||||
<meta name="twitter:description" content="Free REST API for anime data. Browse, search, stream episodes. 30+ 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:image" content="https://anikototvapi.vercel.app/share.jpg" />
|
<meta name="twitter:image" content="https://anikototvapi.vercel.app/share.png" />
|
||||||
<meta name="twitter:image:alt" content="AniKotoAPI - Free Anime Data REST API" />
|
<meta name="twitter:image:alt" content="AniKotoAPI - Free Anime REST API with 38 endpoints" />
|
||||||
<meta name="twitter:domain" content="anikototvapi.vercel.app" />
|
<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." />
|
||||||
<!-- ──────────────── Generic Meta ──────────────── -->
|
<meta name="author" content="Shinei Nouzen" />
|
||||||
<meta name="description" content="Free REST API for anime data. Browse, search, stream episodes. 30+ endpoints, no API key required. Built with Node.js, Express, Cheerio." />
|
<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="keywords" content="anime api, free api, rest api, anime data, streaming, episode servers, nodejs, express, anime database, anime search" />
|
|
||||||
<meta name="robots" content="index, follow" />
|
<meta name="robots" content="index, follow" />
|
||||||
<meta name="theme-color" content="#0A0A12" />
|
<meta name="theme-color" content="#faf9f5" />
|
||||||
<link rel="canonical" href="https://anikototvapi.vercel.app" />
|
<link rel="canonical" href="https://anikototvapi.vercel.app" />
|
||||||
|
|
||||||
<!-- ──────────────── Favicon ──────────────── -->
|
|
||||||
<link rel="icon" type="image/png" href="/favicon-origin.png" />
|
<link rel="icon" type="image/png" href="/favicon-origin.png" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicon-origin.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/favicon-origin.png" />
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
|
||||||
<!-- ──────────────── Fonts ──────────────── -->
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||||
<style>
|
<style>
|
||||||
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
|
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
|
||||||
:root{
|
:root{
|
||||||
--bg:#0A0A12;--bg2:#111120;--bg3:#16162A;
|
--canvas:#faf9f5;--surface-soft:#f5f0e8;--surface-card:#efe9de;--surface-cream-strong:#e8e0d2;
|
||||||
--surface:rgba(17,17,32,.7);--glass:rgba(255,255,255,.03);
|
--surface-dark:#181715;--surface-dark-elevated:#252320;--surface-dark-soft:#1f1e1b;
|
||||||
--border:rgba(255,255,255,.06);--border2:rgba(255,255,255,.1);
|
--hairline:#e6dfd8;--hairline-soft:#ebe6df;
|
||||||
--text:#E2E8F0;--text2:#94A3B8;--text3:#64748B;
|
--ink:#141413;--body-strong:#252523;--body:#3d3d3a;--muted:#6c6a64;--muted-soft:#8e8b82;
|
||||||
--purple:#A855F7;--pink:#EC4899;--indigo:#6366F1;
|
--on-primary:#fff;--on-dark:#faf9f5;--on-dark-soft:#a09d96;
|
||||||
--green:#22C55E;--cyan:#06B6D4;--amber:#F59E0B;--red:#EF4444;
|
--primary:#cc785c;--primary-active:#a9583e;--primary-disabled:#e6dfd8;
|
||||||
--grad:linear-gradient(135deg,var(--purple),var(--pink));
|
--accent-teal:#5db8a6;--accent-amber:#e8a55a;
|
||||||
--grad2:linear-gradient(135deg,var(--indigo),var(--purple));
|
--success:#5db872;--warning:#d4a017;--error:#c64545;
|
||||||
--radius:12px;--radius-lg:16px;--radius-xl:20px;
|
--font-display:'Cormorant Garamond',Garamond,'Times New Roman',serif;
|
||||||
|
--font-body:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
|
||||||
|
--font-code:'JetBrains Mono',monospace;
|
||||||
}
|
}
|
||||||
html{scroll-behavior:smooth;-webkit-font-smoothing:antialiased}
|
html{scroll-behavior:smooth;-webkit-font-smoothing:antialiased}
|
||||||
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);overflow-x:hidden;line-height:1.6}
|
body{font-family:var(--font-body);background:var(--canvas);color:var(--body);line-height:1.6}
|
||||||
::selection{background:rgba(168,85,247,.3);color:#fff}
|
::selection{background:rgba(204,120,92,.2);color:var(--ink)}
|
||||||
::-webkit-scrollbar{width:6px}
|
::-webkit-scrollbar{width:6px}
|
||||||
::-webkit-scrollbar-track{background:transparent}
|
::-webkit-scrollbar-track{background:transparent}
|
||||||
::-webkit-scrollbar-thumb{background:rgba(255,255,255,.08);border-radius:3px}
|
::-webkit-scrollbar-thumb{background:var(--hairline);border-radius:3px}
|
||||||
::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.15)}
|
|
||||||
|
|
||||||
/* ── Ambient ── */
|
.container{max-width:1200px;margin:0 auto;padding:0 24px}
|
||||||
.ambient{position:fixed;inset:0;z-index:0;pointer-events:none;overflow:hidden}
|
a{color:var(--primary);text-decoration:none}
|
||||||
.orb{position:absolute;border-radius:50%;filter:blur(120px);opacity:.12}
|
a:hover{text-decoration:underline}
|
||||||
.orb-1{width:700px;height:700px;background:var(--purple);top:-250px;left:-200px;animation:drift 30s ease-in-out infinite}
|
|
||||||
.orb-2{width:500px;height:500px;background:var(--pink);bottom:-200px;right:-150px;animation:drift 25s ease-in-out infinite reverse}
|
|
||||||
.orb-3{width:400px;height:400px;background:var(--indigo);top:40%;left:40%;animation:drift 22s ease-in-out infinite 5s}
|
|
||||||
@keyframes drift{0%,100%{transform:translate(0,0) scale(1)}33%{transform:translate(50px,-40px) scale(1.1)}66%{transform:translate(-30px,30px) scale(.92)}}
|
|
||||||
|
|
||||||
/* ── Particles Canvas ── */
|
|
||||||
#particles{position:fixed;inset:0;z-index:0;pointer-events:none}
|
|
||||||
|
|
||||||
/* ── Layout ── */
|
|
||||||
.wrap{position:relative;z-index:1}
|
|
||||||
.container{max-width:1120px;margin:0 auto;padding:0 24px}
|
|
||||||
.container-lg{max-width:1280px;margin:0 auto;padding:0 24px}
|
|
||||||
|
|
||||||
/* ── Header ── */
|
/* ── Header ── */
|
||||||
header{position:fixed;top:0;left:0;right:0;z-index:1000;padding:0 24px;transition:all .35s}
|
header{position:fixed;top:0;left:0;right:0;z-index:1000;background:var(--canvas);border-bottom:1px solid var(--hairline);transition:all .3s}
|
||||||
header.scrolled{background:rgba(10,10,18,.88);backdrop-filter:blur(24px) saturate(1.5);border-bottom:1px solid var(--border)}
|
header.scrolled{box-shadow:0 1px 3px rgba(20,20,19,.06)}
|
||||||
.header-inner{max-width:1120px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;height:64px}
|
.header-inner{max-width:1200px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;height:64px;padding:0 24px}
|
||||||
.logo{display:flex;align-items:center;gap:10px;text-decoration:none;color:var(--text)}
|
.logo{display:flex;align-items:center;gap:8px;text-decoration:none;color:var(--ink)}
|
||||||
.logo svg{width:28px;height:28px}
|
.logo-mark{font-family:var(--font-display);font-size:1.3rem;font-weight:600;letter-spacing:-.02em}
|
||||||
.logo-text{font-size:1.1rem;font-weight:700;letter-spacing:-.02em}
|
.logo-mark span{color:var(--primary)}
|
||||||
.logo-text span{background:var(--grad);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
|
||||||
nav{display:flex;align-items:center;gap:4px}
|
nav{display:flex;align-items:center;gap:4px}
|
||||||
nav a{color:var(--text2);text-decoration:none;font-size:.8rem;font-weight:500;padding:6px 14px;border-radius:8px;transition:all .2s}
|
nav a{color:var(--muted);text-decoration:none;font-size:.875rem;font-weight:500;padding:6px 14px;border-radius:8px;transition:all .15s}
|
||||||
nav a:hover{color:var(--text);background:var(--glass)}
|
nav a:hover{color:var(--ink);background:var(--surface-card);text-decoration:none}
|
||||||
nav a.active{color:var(--purple)}
|
.nav-cta{margin-left:8px;background:var(--primary)!important;color:var(--on-primary)!important;padding:8px 18px!important;border-radius:8px!important;font-weight:500!important}
|
||||||
.nav-cta{margin-left:8px;background:var(--grad)!important;color:#fff!important;padding:7px 16px!important;border-radius:8px!important;font-weight:600!important}
|
.nav-cta:hover{background:var(--primary-active)!important}
|
||||||
.nav-cta:hover{opacity:.9;transform:translateY(-1px)}
|
|
||||||
|
|
||||||
/* ── Hero ── */
|
/* ── Hero ── */
|
||||||
.hero{padding:140px 0 80px;text-align:center;position:relative}
|
.hero{padding:140px 0 80px;max-width:1200px;margin:0 auto;padding-left:24px;padding-right:24px;display:grid;grid-template-columns:1fr 1fr;gap:60px;align-items:center}
|
||||||
.hero-badge{display:inline-flex;align-items:center;gap:8px;background:var(--glass);border:1px solid var(--border);padding:6px 16px;border-radius:100px;font-size:.75rem;color:var(--text2);margin-bottom:24px}
|
.hero-badge{display:inline-flex;align-items:center;gap:8px;background:var(--surface-card);border:1px solid var(--hairline);padding:6px 16px;border-radius:9999px;font-size:.75rem;font-weight:500;color:var(--muted);margin-bottom:20px;letter-spacing:.5px;text-transform:uppercase}
|
||||||
.hero-badge .dot{width:6px;height:6px;border-radius:50%;background:var(--green);animation:pulse 2s infinite}
|
.hero-badge .dot{width:6px;height:6px;border-radius:50%;background:var(--success);animation:pulse 2s infinite}
|
||||||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
|
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
|
||||||
.hero h1{font-size:clamp(2.5rem,5.5vw,4rem);font-weight:900;line-height:1.1;letter-spacing:-.04em;margin-bottom:20px}
|
.hero h1{font-family:var(--font-display);font-size:clamp(2.5rem,5vw,3.5rem);font-weight:400;line-height:1.1;letter-spacing:-.02em;color:var(--ink);margin-bottom:16px}
|
||||||
.hero h1 .grad{background:var(--grad);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
.hero-sub{color:var(--body);font-size:1.05rem;max-width:480px;line-height:1.7;margin-bottom:32px}
|
||||||
.hero-sub{color:var(--text2);font-size:1.05rem;max-width:540px;margin:0 auto 40px;line-height:1.7}
|
.hero-actions{display:flex;gap:12px;flex-wrap:wrap}
|
||||||
.hero-actions{display:flex;gap:12px;justify-content:center;flex-wrap:wrap;margin-bottom:48px}
|
.btn{display:inline-flex;align-items:center;gap:8px;padding:12px 24px;border-radius:8px;font-size:.875rem;font-weight:500;text-decoration:none;border:none;cursor:pointer;transition:all .15s;font-family:var(--font-body)}
|
||||||
.btn{display:inline-flex;align-items:center;gap:8px;padding:12px 24px;border-radius:10px;font-size:.88rem;font-weight:600;text-decoration:none;border:none;cursor:pointer;transition:all .25s;font-family:inherit}
|
|
||||||
.btn svg{width:16px;height:16px}
|
.btn svg{width:16px;height:16px}
|
||||||
.btn-primary{background:var(--grad);color:#fff;box-shadow:0 4px 24px rgba(168,85,247,.3)}
|
.btn-primary{background:var(--primary);color:var(--on-primary)}
|
||||||
.btn-primary:hover{transform:translateY(-2px);box-shadow:0 8px 32px rgba(168,85,247,.4)}
|
.btn-primary:hover{background:var(--primary-active);text-decoration:none}
|
||||||
.btn-ghost{background:var(--glass);border:1px solid var(--border2);color:var(--text)}
|
.btn-secondary{background:var(--canvas);border:1px solid var(--hairline);color:var(--ink)}
|
||||||
.btn-ghost:hover{background:rgba(255,255,255,.06);border-color:rgba(255,255,255,.15)}
|
.btn-secondary:hover{background:var(--surface-card);text-decoration:none}
|
||||||
|
|
||||||
/* ── Console ── */
|
/* ── Console ── */
|
||||||
.console{max-width:720px;margin:0 auto;border-radius:var(--radius-xl);overflow:hidden;border:1px solid var(--border2);background:var(--bg2);box-shadow:0 24px 80px rgba(0,0,0,.5),0 0 120px rgba(168,85,247,.08)}
|
.console-wrap{background:var(--surface-dark);border-radius:12px;overflow:hidden;box-shadow:0 24px 80px rgba(20,20,19,.15)}
|
||||||
.console-bar{display:flex;align-items:center;gap:8px;padding:14px 18px;background:rgba(255,255,255,.02);border-bottom:1px solid var(--border)}
|
.console-bar{display:flex;align-items:center;gap:8px;padding:14px 18px;background:var(--surface-dark-elevated);border-bottom:1px solid rgba(255,255,255,.06)}
|
||||||
.console-dot{width:10px;height:10px;border-radius:50%}
|
.console-dot{width:10px;height:10px;border-radius:50%}
|
||||||
.console-dot:nth-child(1){background:#FF5F57}
|
.console-dot:nth-child(1){background:#ff5f57}
|
||||||
.console-dot:nth-child(2){background:#FEBC2E}
|
.console-dot:nth-child(2){background:#febc2e}
|
||||||
.console-dot:nth-child(3){background:#28C840}
|
.console-dot:nth-child(3){background:#28c840}
|
||||||
.console-title{margin-left:12px;font-size:.72rem;color:var(--text3);font-family:'JetBrains Mono',monospace}
|
.console-title{margin-left:12px;font-size:.75rem;color:var(--on-dark-soft);font-family:var(--font-code)}
|
||||||
.console-body{display:flex;min-height:180px}
|
.console-body{display:flex;min-height:180px}
|
||||||
.console-input{flex:1;padding:18px;font-family:'JetBrains Mono',monospace;font-size:.8rem;line-height:1.8;color:var(--text);border:none;background:transparent;resize:none;outline:none}
|
.console-input{flex:1;padding:18px;font-family:var(--font-code);font-size:.8rem;line-height:1.8;color:var(--on-dark);border:none;background:transparent;resize:none;outline:none}
|
||||||
.console-input::placeholder{color:var(--text3)}
|
.console-input::placeholder{color:var(--on-dark-soft)}
|
||||||
.console-output{flex:1;padding:18px;border-left:1px solid var(--border);font-family:'JetBrains Mono',monospace;font-size:.78rem;line-height:1.8;color:var(--green);overflow:auto;max-height:300px;white-space:pre-wrap}
|
.console-output{flex:1;padding:18px;border-left:1px solid rgba(255,255,255,.06);font-family:var(--font-code);font-size:.78rem;line-height:1.8;color:var(--success);overflow:auto;max-height:300px;white-space:pre-wrap}
|
||||||
.console-actions{display:flex;gap:8px;padding:12px 18px;border-top:1px solid var(--border);background:rgba(255,255,255,.01)}
|
.console-actions{display:flex;gap:8px;padding:12px 18px;border-top:1px solid rgba(255,255,255,.06);background:var(--surface-dark-elevated)}
|
||||||
.console-btn{padding:6px 14px;border-radius:6px;border:1px solid var(--border);background:transparent;color:var(--text2);font-size:.75rem;font-weight:500;cursor:pointer;transition:all .2s;font-family:'JetBrains Mono',monospace}
|
.console-btn{padding:6px 14px;border-radius:6px;border:1px solid rgba(255,255,255,.08);background:transparent;color:var(--on-dark-soft);font-size:.75rem;font-weight:500;cursor:pointer;transition:all .15s;font-family:var(--font-code)}
|
||||||
.console-btn:hover{background:var(--glass);color:var(--text)}
|
.console-btn:hover{background:rgba(255,255,255,.05);color:var(--on-dark)}
|
||||||
.console-btn.active{background:rgba(168,85,247,.15);color:var(--purple);border-color:rgba(168,85,247,.3)}
|
.console-btn.active{background:rgba(204,120,92,.15);color:var(--primary);border-color:rgba(204,120,92,.3)}
|
||||||
.console-send{margin-left:auto;padding:6px 16px;border-radius:6px;border:none;background:var(--grad);color:#fff;font-size:.75rem;font-weight:600;cursor:pointer;transition:all .2s;font-family:'JetBrains Mono',monospace}
|
.console-send{margin-left:auto;padding:6px 16px;border-radius:6px;border:none;background:var(--primary);color:var(--on-primary);font-size:.75rem;font-weight:500;cursor:pointer;transition:all .15s;font-family:var(--font-code)}
|
||||||
.console-send:hover{opacity:.9;transform:translateY(-1px)}
|
.console-send:hover{background:var(--primary-active)}
|
||||||
|
|
||||||
/* ── Stats ── */
|
/* ── Stats ── */
|
||||||
.stats{padding:60px 0;border-top:1px solid var(--border);border-bottom:1px solid var(--border)}
|
.stats{padding:60px 0;border-top:1px solid var(--hairline);border-bottom:1px solid var(--hairline);background:var(--surface-soft)}
|
||||||
.stats-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:24px;text-align:center}
|
.stats-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:24px;text-align:center}
|
||||||
.stat-value{font-size:2rem;font-weight:900;letter-spacing:-.03em}
|
.stat-value{font-family:var(--font-display);font-size:2.5rem;font-weight:400;letter-spacing:-.02em;color:var(--ink)}
|
||||||
.stat-value.grad{background:var(--grad);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
.stat-label{font-size:.8rem;color:var(--muted);margin-top:4px;font-weight:500}
|
||||||
.stat-label{font-size:.78rem;color:var(--text3);margin-top:4px;font-weight:500}
|
|
||||||
|
|
||||||
/* ── Section ── */
|
/* ── Section ── */
|
||||||
.section{padding:100px 0}
|
.section{padding:96px 0}
|
||||||
.section-label{display:inline-flex;align-items:center;gap:6px;font-size:.7rem;text-transform:uppercase;letter-spacing:.1em;color:var(--purple);font-weight:700;margin-bottom:12px}
|
.section-label{display:inline-flex;align-items:center;gap:6px;font-size:.7rem;text-transform:uppercase;letter-spacing:1.5px;color:var(--primary);font-weight:600;margin-bottom:12px}
|
||||||
.section-title{font-size:clamp(1.8rem,3vw,2.4rem);font-weight:800;letter-spacing:-.03em;margin-bottom:10px;line-height:1.2}
|
.section-title{font-family:var(--font-display);font-size:clamp(1.8rem,3vw,2.4rem);font-weight:400;letter-spacing:-.01em;margin-bottom:10px;line-height:1.2;color:var(--ink)}
|
||||||
.section-desc{color:var(--text2);font-size:1rem;max-width:520px;line-height:1.7;margin-bottom:40px}
|
.section-desc{color:var(--body);font-size:1rem;max-width:520px;line-height:1.7;margin-bottom:40px}
|
||||||
|
|
||||||
/* ── Features Grid ── */
|
/* ── Features Grid ── */
|
||||||
.features-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
|
.features-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
|
||||||
.feature-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);padding:32px;transition:all .35s;position:relative;overflow:hidden}
|
.feature-card{background:var(--surface-card);border:1px solid var(--hairline);border-radius:12px;padding:32px;transition:all .2s}
|
||||||
.feature-card::before{content:'';position:absolute;inset:0;background:linear-gradient(135deg,rgba(168,85,247,.05),rgba(236,72,153,.05));opacity:0;transition:opacity .35s}
|
.feature-card:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(20,20,19,.06)}
|
||||||
.feature-card:hover{border-color:rgba(168,85,247,.2);transform:translateY(-4px);box-shadow:0 16px 48px rgba(0,0,0,.3)}
|
.feature-icon{width:44px;height:44px;border-radius:8px;display:flex;align-items:center;justify-content:center;margin-bottom:18px;background:var(--canvas);border:1px solid var(--hairline)}
|
||||||
.feature-card:hover::before{opacity:1}
|
.feature-icon svg{width:20px;height:20px;color:var(--primary)}
|
||||||
.feature-icon{width:44px;height:44px;border-radius:10px;display:flex;align-items:center;justify-content:center;margin-bottom:18px;position:relative;z-index:1}
|
.feature-title{font-family:var(--font-display);font-size:1.1rem;font-weight:500;margin-bottom:8px;color:var(--ink)}
|
||||||
.feature-icon svg{width:22px;height:22px}
|
.feature-desc{color:var(--body);font-size:.875rem;line-height:1.7}
|
||||||
.fi-purple{background:rgba(168,85,247,.12);color:var(--purple)}
|
|
||||||
.fi-pink{background:rgba(236,72,153,.12);color:var(--pink)}
|
|
||||||
.fi-green{background:rgba(34,197,94,.12);color:var(--green)}
|
|
||||||
.fi-cyan{background:rgba(6,182,212,.12);color:var(--cyan)}
|
|
||||||
.fi-amber{background:rgba(245,158,11,.12);color:var(--amber)}
|
|
||||||
.fi-indigo{background:rgba(99,102,241,.12);color:var(--indigo)}
|
|
||||||
.feature-title{font-size:1rem;font-weight:700;margin-bottom:8px;position:relative;z-index:1}
|
|
||||||
.feature-desc{color:var(--text2);font-size:.84rem;line-height:1.7;position:relative;z-index:1}
|
|
||||||
|
|
||||||
/* ── Endpoint Explorer ── */
|
/* ── Endpoint Explorer ── */
|
||||||
.explorer{display:grid;grid-template-columns:240px 1fr;gap:1px;background:var(--border);border-radius:var(--radius-lg);overflow:hidden;border:1px solid var(--border)}
|
.explorer{display:grid;grid-template-columns:240px 1fr;gap:1px;background:var(--hairline);border-radius:12px;overflow:hidden;border:1px solid var(--hairline)}
|
||||||
.explorer-nav{background:var(--bg2);padding:8px}
|
.explorer-nav{background:var(--surface-dark);padding:8px}
|
||||||
.explorer-nav-btn{display:flex;align-items:center;gap:8px;width:100%;padding:10px 14px;border:none;background:transparent;color:var(--text2);font-size:.8rem;font-weight:500;border-radius:8px;cursor:pointer;transition:all .2s;text-align:left;font-family:inherit}
|
.explorer-nav-btn{display:flex;align-items:center;gap:8px;width:100%;padding:10px 14px;border:none;background:transparent;color:var(--on-dark-soft);font-size:.8rem;font-weight:500;border-radius:8px;cursor:pointer;transition:all .15s;text-align:left;font-family:var(--font-body)}
|
||||||
.explorer-nav-btn:hover{background:var(--glass);color:var(--text)}
|
.explorer-nav-btn:hover{background:rgba(255,255,255,.05);color:var(--on-dark)}
|
||||||
.explorer-nav-btn.active{background:rgba(168,85,247,.1);color:var(--purple)}
|
.explorer-nav-btn.active{background:rgba(204,120,92,.15);color:var(--primary)}
|
||||||
.explorer-nav-btn svg{width:16px;height:16px;flex-shrink:0}
|
.explorer-nav-btn svg{width:16px;height:16px;flex-shrink:0}
|
||||||
.explorer-panel{background:var(--bg2);padding:24px;min-height:400px}
|
.explorer-panel{background:var(--surface-dark);padding:24px;min-height:400px}
|
||||||
.explorer-panel-title{font-size:1.1rem;font-weight:700;margin-bottom:4px}
|
.explorer-panel-title{font-family:var(--font-display);font-size:1.2rem;font-weight:500;margin-bottom:4px;color:var(--on-dark)}
|
||||||
.explorer-panel-desc{color:var(--text2);font-size:.84rem;margin-bottom:20px;line-height:1.6}
|
.explorer-panel-desc{color:var(--on-dark-soft);font-size:.84rem;margin-bottom:20px;line-height:1.6}
|
||||||
.explorer-method{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:6px;font-size:.7rem;font-weight:700;font-family:'JetBrains Mono',monospace;letter-spacing:.04em;margin-bottom:12px}
|
.explorer-method{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:6px;font-size:.7rem;font-weight:700;font-family:var(--font-code);letter-spacing:.04em;margin-bottom:12px;background:rgba(93,184,114,.15);color:var(--success)}
|
||||||
.method-get{background:rgba(34,197,94,.12);color:var(--green)}
|
.explorer-path{font-family:var(--font-code);font-size:.85rem;color:var(--on-dark);margin-bottom:16px;padding:12px 16px;background:var(--surface-dark-soft);border-radius:8px;border:1px solid rgba(255,255,255,.06)}
|
||||||
.explorer-path{font-family:'JetBrains Mono',monospace;font-size:.85rem;color:var(--text);margin-bottom:16px;padding:12px 16px;background:rgba(0,0,0,.3);border-radius:8px;border:1px solid var(--border)}
|
|
||||||
.explorer-params{margin-bottom:16px}
|
.explorer-params{margin-bottom:16px}
|
||||||
.explorer-params-title{font-size:.72rem;text-transform:uppercase;letter-spacing:.08em;color:var(--text3);font-weight:600;margin-bottom:8px}
|
.explorer-params-title{font-size:.72rem;text-transform:uppercase;letter-spacing:.08em;color:var(--on-dark-soft);font-weight:600;margin-bottom:8px}
|
||||||
.param-row{display:grid;grid-template-columns:120px 80px 1fr;gap:12px;padding:8px 0;border-bottom:1px solid rgba(255,255,255,.03);font-size:.8rem}
|
.param-row{display:grid;grid-template-columns:120px 80px 1fr;gap:12px;padding:8px 0;border-bottom:1px solid rgba(255,255,255,.03);font-size:.8rem}
|
||||||
.param-name{font-family:'JetBrains Mono',monospace;color:var(--pink);font-weight:500}
|
.param-name{font-family:var(--font-code);color:var(--accent-amber);font-weight:500}
|
||||||
.param-type{font-family:'JetBrains Mono',monospace;color:var(--purple);font-size:.75rem}
|
.param-type{font-family:var(--font-code);color:var(--accent-teal);font-size:.75rem}
|
||||||
.param-desc{color:var(--text2)}
|
.param-desc{color:var(--on-dark-soft)}
|
||||||
.explorer-code{background:rgba(0,0,0,.3);border-radius:8px;padding:16px;font-family:'JetBrains Mono',monospace;font-size:.78rem;line-height:1.8;color:var(--text2);overflow-x:auto;border:1px solid var(--border)}
|
.explorer-code{background:var(--surface-dark-soft);border-radius:8px;padding:16px;font-family:var(--font-code);font-size:.78rem;line-height:1.8;color:var(--on-dark-soft);overflow-x:auto;border:1px solid rgba(255,255,255,.06)}
|
||||||
.explorer-code .kw{color:var(--purple)}
|
|
||||||
.explorer-code .str{color:var(--green)}
|
|
||||||
.explorer-code .cmt{color:var(--text3)}
|
|
||||||
.explorer-code .fn{color:var(--cyan)}
|
|
||||||
.explorer-code .num{color:var(--amber)}
|
|
||||||
|
|
||||||
/* ── Code Tabs ── */
|
/* ── Code Tabs ── */
|
||||||
.code-tabs{display:flex;gap:2px;background:rgba(0,0,0,.2);border-radius:8px;padding:3px;margin-bottom:12px;width:fit-content}
|
.code-tabs{display:flex;gap:2px;background:var(--surface-dark-soft);border-radius:8px;padding:3px;margin-bottom:12px;width:fit-content}
|
||||||
.code-tab{padding:6px 14px;border-radius:6px;border:none;background:transparent;color:var(--text3);font-size:.72rem;font-weight:600;cursor:pointer;transition:all .2s;font-family:'JetBrains Mono',monospace}
|
.code-tab{padding:6px 14px;border-radius:6px;border:none;background:transparent;color:var(--on-dark-soft);font-size:.72rem;font-weight:600;cursor:pointer;transition:all .15s;font-family:var(--font-code)}
|
||||||
.code-tab.active{background:rgba(168,85,247,.15);color:var(--purple)}
|
.code-tab.active{background:rgba(204,120,92,.15);color:var(--primary)}
|
||||||
.code-tab:hover:not(.active){color:var(--text2)}
|
.code-tab:hover:not(.active){color:var(--on-dark)}
|
||||||
.code-block{display:none}
|
|
||||||
.code-block.active{display:block}
|
|
||||||
|
|
||||||
/* ── Playground ── */
|
/* ── Playground ── */
|
||||||
.playground-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden}
|
.playground-card{background:var(--surface-card);border:1px solid var(--hairline);border-radius:12px;overflow:hidden}
|
||||||
.playground-header{padding:20px 24px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px}
|
.playground-header{padding:20px 24px;border-bottom:1px solid var(--hairline);display:flex;align-items:center;gap:10px}
|
||||||
.playground-header svg{width:18px;height:18px;color:var(--purple)}
|
.playground-header svg{width:18px;height:18px;color:var(--primary)}
|
||||||
.playground-header h3{font-size:1rem;font-weight:700}
|
.playground-header h3{font-family:var(--font-display);font-size:1.1rem;font-weight:500;color:var(--ink)}
|
||||||
.playground-body{padding:24px}
|
.playground-body{padding:24px}
|
||||||
.playground-inputs{display:flex;gap:10px;margin-bottom:16px;flex-wrap:wrap}
|
.playground-inputs{display:flex;gap:10px;margin-bottom:16px;flex-wrap:wrap}
|
||||||
.playground-inputs input,.playground-inputs select{flex:1;min-width:200px;padding:10px 14px;border-radius:8px;border:1px solid var(--border);background:rgba(0,0,0,.3);color:var(--text);font-size:.85rem;font-family:'JetBrains Mono',monospace;outline:none;transition:border-color .2s}
|
.playground-inputs input,.playground-inputs select{flex:1;min-width:200px;padding:10px 14px;border-radius:8px;border:1px solid var(--hairline);background:var(--canvas);color:var(--ink);font-size:.85rem;font-family:var(--font-code);outline:none;transition:border-color .15s}
|
||||||
.playground-inputs input:focus,.playground-inputs select:focus{border-color:var(--purple)}
|
.playground-inputs input:focus,.playground-inputs select:focus{border-color:var(--primary)}
|
||||||
.playground-inputs input::placeholder{color:var(--text3)}
|
.playground-inputs input::placeholder{color:var(--muted-soft)}
|
||||||
.playground-inputs select option{background:var(--bg2)}
|
.playground-inputs select option{background:var(--canvas)}
|
||||||
.playground-output{background:rgba(0,0,0,.3);border:1px solid var(--border);border-radius:8px;padding:16px;font-family:'JetBrains Mono',monospace;font-size:.78rem;line-height:1.7;color:var(--green);max-height:400px;overflow:auto;white-space:pre-wrap;display:none}
|
.playground-output{background:var(--surface-dark);border:1px solid rgba(255,255,255,.06);border-radius:8px;padding:16px;font-family:var(--font-code);font-size:.78rem;line-height:1.7;color:var(--success);max-height:400px;overflow:auto;white-space:pre-wrap;display:none}
|
||||||
.playground-output.visible{display:block}
|
.playground-output.visible{display:block}
|
||||||
|
|
||||||
/* ── Marquee ── */
|
/* ── Marquee ── */
|
||||||
.marquee{padding:40px 0;overflow:hidden;border-top:1px solid var(--border);border-bottom:1px solid var(--border)}
|
.marquee{padding:40px 0;overflow:hidden;border-top:1px solid var(--hairline);border-bottom:1px solid var(--hairline);background:var(--surface-soft)}
|
||||||
.marquee-track{display:flex;gap:40px;animation:marquee 30s linear infinite;width:max-content}
|
.marquee-track{display:flex;gap:40px;animation:marquee 30s linear infinite;width:max-content}
|
||||||
.marquee-item{display:flex;align-items:center;gap:8px;color:var(--text3);font-size:.8rem;font-weight:500;white-space:nowrap}
|
.marquee-item{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:.8rem;font-weight:500;white-space:nowrap}
|
||||||
.marquee-item svg{width:16px;height:16px;opacity:.5}
|
.marquee-item svg{width:16px;height:16px;opacity:.5}
|
||||||
@keyframes marquee{0%{transform:translateX(0)}100%{transform:translateX(-50%)}}
|
@keyframes marquee{0%{transform:translateX(0)}100%{transform:translateX(-50%)}}
|
||||||
|
|
||||||
/* ── CTA ── */
|
/* ── CTA ── */
|
||||||
.cta{text-align:center;padding:100px 0}
|
.cta{text-align:center;padding:96px 0}
|
||||||
.cta-box{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-xl);padding:60px 40px;position:relative;overflow:hidden}
|
.cta-box{background:var(--primary);border-radius:16px;padding:64px 40px;position:relative;overflow:hidden}
|
||||||
.cta-box::before{content:'';position:absolute;inset:0;background:linear-gradient(135deg,rgba(168,85,247,.08),rgba(236,72,153,.08))}
|
.cta-title{font-family:var(--font-display);font-size:clamp(1.8rem,3vw,2.4rem);font-weight:400;letter-spacing:-.01em;margin-bottom:12px;color:var(--on-primary)}
|
||||||
.cta-title{font-size:clamp(1.8rem,3vw,2.4rem);font-weight:800;letter-spacing:-.03em;margin-bottom:12px;position:relative}
|
.cta-desc{color:rgba(255,255,255,.85);font-size:1rem;margin-bottom:28px}
|
||||||
.cta-desc{color:var(--text2);font-size:1rem;margin-bottom:28px;position:relative}
|
.cta-actions{display:flex;gap:12px;justify-content:center}
|
||||||
.cta-actions{display:flex;gap:12px;justify-content:center;position:relative}
|
.btn-on-coral{background:var(--canvas);color:var(--ink);border:1px solid var(--hairline)}
|
||||||
|
.btn-on-coral:hover{background:var(--surface-card);text-decoration:none}
|
||||||
|
|
||||||
/* ── Footer ── */
|
/* ── Footer ── */
|
||||||
footer{padding:60px 0 40px;border-top:1px solid var(--border)}
|
footer{background:var(--surface-dark);color:var(--on-dark-soft);padding:64px 0 40px}
|
||||||
.footer-grid{display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:40px;margin-bottom:40px}
|
.footer-grid{display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:40px;margin-bottom:40px}
|
||||||
.footer-brand .logo{margin-bottom:12px}
|
.footer-brand .logo{margin-bottom:12px}
|
||||||
.footer-brand p{color:var(--text3);font-size:.82rem;line-height:1.7;max-width:280px}
|
.footer-brand .logo .logo-mark{color:var(--on-dark)}
|
||||||
.footer-col h4{font-size:.75rem;text-transform:uppercase;letter-spacing:.08em;color:var(--text3);font-weight:600;margin-bottom:16px}
|
.footer-brand p{color:var(--on-dark-soft);font-size:.84rem;line-height:1.7;max-width:280px}
|
||||||
.footer-col a{display:block;color:var(--text2);text-decoration:none;font-size:.84rem;padding:4px 0;transition:color .2s}
|
.footer-col h4{font-size:.7rem;text-transform:uppercase;letter-spacing:1.5px;color:var(--on-dark-soft);font-weight:600;margin-bottom:16px}
|
||||||
.footer-col a:hover{color:var(--purple)}
|
.footer-col a{display:block;color:var(--on-dark-soft);text-decoration:none;font-size:.84rem;padding:4px 0;transition:color .15s}
|
||||||
.footer-bottom{display:flex;align-items:center;justify-content:space-between;padding-top:24px;border-top:1px solid var(--border);color:var(--text3);font-size:.75rem}
|
.footer-col a:hover{color:var(--on-dark)}
|
||||||
|
.footer-bottom{display:flex;align-items:center;justify-content:space-between;padding-top:24px;border-top:1px solid rgba(255,255,255,.06);color:var(--on-dark-soft);font-size:.75rem}
|
||||||
.footer-social{display:flex;gap:12px}
|
.footer-social{display:flex;gap:12px}
|
||||||
.footer-social a{color:var(--text3);transition:color .2s}
|
.footer-social a{color:var(--on-dark-soft);transition:color .15s}
|
||||||
.footer-social a:hover{color:var(--purple)}
|
.footer-social a:hover{color:var(--on-dark)}
|
||||||
.footer-social svg{width:18px;height:18px}
|
.footer-social svg{width:18px;height:18px}
|
||||||
|
|
||||||
/* ── Scroll Reveal ── */
|
/* ── Scroll Reveal ── */
|
||||||
.reveal{opacity:0;transform:translateY(30px);transition:all .7s cubic-bezier(.16,1,.3,1)}
|
.reveal{opacity:0;transform:translateY(20px);transition:all .6s cubic-bezier(.16,1,.3,1)}
|
||||||
.reveal.visible{opacity:1;transform:translateY(0)}
|
.reveal.visible{opacity:1;transform:translateY(0)}
|
||||||
.reveal-delay-1{transition-delay:.1s}
|
.reveal-delay-1{transition-delay:.1s}
|
||||||
.reveal-delay-2{transition-delay:.2s}
|
.reveal-delay-2{transition-delay:.2s}
|
||||||
.reveal-delay-3{transition-delay:.3s}
|
.reveal-delay-3{transition-delay:.3s}
|
||||||
.reveal-delay-4{transition-delay:.4s}
|
|
||||||
|
|
||||||
/* ── Mobile ── */
|
/* ── Mobile ── */
|
||||||
@media(max-width:900px){
|
@media(max-width:900px){
|
||||||
|
.hero{grid-template-columns:1fr;gap:40px}
|
||||||
.features-grid{grid-template-columns:repeat(2,1fr)}
|
.features-grid{grid-template-columns:repeat(2,1fr)}
|
||||||
.explorer{grid-template-columns:1fr}
|
.explorer{grid-template-columns:1fr}
|
||||||
.stats-grid{grid-template-columns:repeat(2,1fr)}
|
.stats-grid{grid-template-columns:repeat(2,1fr)}
|
||||||
@@ -262,84 +223,70 @@
|
|||||||
.hero h1{font-size:2rem}
|
.hero h1{font-size:2rem}
|
||||||
.features-grid{grid-template-columns:1fr}
|
.features-grid{grid-template-columns:1fr}
|
||||||
.console-body{flex-direction:column}
|
.console-body{flex-direction:column}
|
||||||
.console-output{border-left:none;border-top:1px solid var(--border)}
|
.console-output{border-left:none;border-top:1px solid rgba(255,255,255,.06)}
|
||||||
.stats-grid{grid-template-columns:repeat(2,1fr);gap:16px}
|
.stats-grid{grid-template-columns:repeat(2,1fr);gap:16px}
|
||||||
.footer-grid{grid-template-columns:1fr}
|
.footer-grid{grid-template-columns:1fr}
|
||||||
nav{display:none}
|
nav{display:none}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<!-- ──────────────── JSON-LD Structured Data ──────────────── -->
|
|
||||||
<script type="application/ld+json">
|
<script type="application/ld+json">
|
||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "WebAPI",
|
"@type": "WebAPI",
|
||||||
"name": "AniKotoAPI",
|
"name": "AniKotoAPI",
|
||||||
"description": "Free REST API for anime data. Browse, search, stream episodes. 30+ endpoints, no API key required.",
|
"description": "Free anime REST API with 38 endpoints. Search, filter, stream episodes with M3U8 proxy, get rankings, schedules, and more. No API key required.",
|
||||||
"url": "https://anikototvapi.vercel.app",
|
"url": "https://anikototvapi.vercel.app",
|
||||||
"image": "https://anikototvapi.vercel.app/share.jpg",
|
"image": "https://anikototvapi.vercel.app/share.png",
|
||||||
"documentation": "https://github.com/Shineii86/AniKotoAPI",
|
"documentation": "https://github.com/Shineii86/AniKotoAPI",
|
||||||
"provider": {
|
"provider": {
|
||||||
"@type": "Organization",
|
"@type": "Person",
|
||||||
"name": "AniKotoAPI",
|
"name": "Shinei Nouzen",
|
||||||
"url": "https://github.com/Shineii86/AniKotoAPI"
|
"url": "https://github.com/Shineii86"
|
||||||
},
|
},
|
||||||
"category": "Anime Data API",
|
"category": "Anime Data API",
|
||||||
"endpoint": "https://anikototvapi.vercel.app/api",
|
"endpoint": "https://anikototvapi.vercel.app/api",
|
||||||
"programmingModel": "REST",
|
"programmingModel": "REST",
|
||||||
"documentationUrl": "https://github.com/Shineii86/AniKotoAPI/tree/main/docs"
|
"license": "https://opensource.org/licenses/MIT"
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<div class="ambient">
|
|
||||||
<div class="orb orb-1"></div>
|
|
||||||
<div class="orb orb-2"></div>
|
|
||||||
<div class="orb orb-3"></div>
|
|
||||||
</div>
|
|
||||||
<canvas id="particles"></canvas>
|
|
||||||
|
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
|
|
||||||
<!-- Header -->
|
|
||||||
<header id="header">
|
<header id="header">
|
||||||
<div class="header-inner">
|
<div class="header-inner">
|
||||||
<a href="/" class="logo">
|
<a href="/" class="logo">
|
||||||
<svg viewBox="0 0 32 32" fill="none"><circle cx="16" cy="16" r="14" stroke="url(#g1)" stroke-width="2"/><path d="M10 20c0-4 3-7 6-7s6 3 6 7" stroke="url(#g1)" stroke-width="2" stroke-linecap="round"/><circle cx="12" cy="12" r="2" fill="var(--purple)"/><circle cx="20" cy="12" r="2" fill="var(--pink)"/><defs><linearGradient id="g1" x1="0" y1="0" x2="32" y2="32"><stop stop-color="var(--purple)"/><stop offset="1" stop-color="var(--pink)"/></linearGradient></defs></svg>
|
<span class="logo-mark"><span>*</span> AniKotoAPI</span>
|
||||||
<span class="logo-text"><span>AniKoto</span>API</span>
|
|
||||||
</a>
|
</a>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="#features">Features</a>
|
<a href="#features">Features</a>
|
||||||
<a href="#endpoints">Endpoints</a>
|
<a href="#endpoints">Endpoints</a>
|
||||||
<a href="#playground">Playground</a>
|
<a href="#playground">Playground</a>
|
||||||
<a href="https://github.com/Shineii86/AniKotoAPI" target="_blank">GitHub</a>
|
<a href="https://github.com/Shineii86/AniKotoAPI" target="_blank" rel="noopener noreferrer">GitHub</a>
|
||||||
<a href="https://anikototvapi.vercel.app/api" target="_blank" class="nav-cta">Open API</a>
|
<a href="https://anikototvapi.vercel.app/api" target="_blank" class="nav-cta">Open API</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Hero -->
|
<section class="hero">
|
||||||
<section class="hero container">
|
<div>
|
||||||
<div class="hero-badge"><span class="dot"></span> Free · No API key · Open source</div>
|
<div class="hero-badge"><span class="dot"></span> Free · No API key · Open source</div>
|
||||||
<h1>The <span class="grad">Anime Data</span> API<br/>for Your Next Project</h1>
|
<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 30+ endpoints, in-memory caching, and zero configuration.</p>
|
<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>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<a href="#playground" class="btn btn-primary">
|
<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>
|
<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>
|
||||||
Try It Live
|
Try It Live
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/Shineii86/AniKotoAPI" target="_blank" class="btn btn-ghost">
|
<a href="https://github.com/Shineii86/AniKotoAPI" target="_blank" class="btn btn-secondary">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||||
Star on GitHub
|
Star on GitHub
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Live Console -->
|
<div class="console-wrap">
|
||||||
<div class="console" id="heroConsole">
|
|
||||||
<div class="console-bar">
|
<div class="console-bar">
|
||||||
<span class="console-dot"></span><span class="console-dot"></span><span class="console-dot"></span>
|
<span class="console-dot"></span><span class="console-dot"></span><span class="console-dot"></span>
|
||||||
<span class="console-title">anikototvapi.vercel.app — API Console</span>
|
<span class="console-title">anikototvapi.vercel.app/api</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="console-body">
|
<div class="console-body">
|
||||||
<textarea class="console-input" id="consoleInput" rows="6" spellcheck="false">// Fetch trending anime
|
<textarea class="console-input" id="consoleInput" rows="6" spellcheck="false">// Fetch trending anime
|
||||||
@@ -348,34 +295,30 @@ fetch("https://anikototvapi.vercel.app/api")
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
console.log(data.trending);
|
console.log(data.trending);
|
||||||
});</textarea>
|
});</textarea>
|
||||||
<div class="console-output" id="consoleOutput"><span style="color:var(--text3)">// Click "Run" to see the response</span></div>
|
<div class="console-output" id="consoleOutput"><span style="color:var(--on-dark-soft)">// Click "Run" to see the response</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="console-actions">
|
<div class="console-actions">
|
||||||
<button class="console-btn active" onclick="setEndpoint('home')">Home</button>
|
<button class="console-btn active" onclick="setEndpoint('home', event)">Home</button>
|
||||||
<button class="console-btn" onclick="setEndpoint('search')">Search</button>
|
<button class="console-btn" onclick="setEndpoint('search', event)">Search</button>
|
||||||
<button class="console-btn" onclick="setEndpoint('stream')">Stream</button>
|
<button class="console-btn" onclick="setEndpoint('stream', event)">Stream</button>
|
||||||
<button class="console-btn" onclick="setEndpoint('random')">Random</button>
|
<button class="console-btn" onclick="setEndpoint('random', event)">Random</button>
|
||||||
<button class="console-btn" onclick="setEndpoint('schedule')">Schedule</button>
|
<button class="console-btn" onclick="setEndpoint('schedule', event)">Schedule</button>
|
||||||
<button class="console-send" onclick="runConsole()">
|
<button class="console-send" onclick="runConsole()">Run</button>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:12px;height:12px;display:inline"><polygon points="5 3 19 12 5 21 5 3"/></svg> Run
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Stats -->
|
|
||||||
<section class="stats">
|
<section class="stats">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="stats-grid">
|
<div class="stats-grid">
|
||||||
<div class="reveal"><div class="stat-value grad" data-count="30">0</div><div class="stat-label">API Endpoints</div></div>
|
<div class="reveal"><div class="stat-value" data-count="38">0</div><div class="stat-label">API Endpoints</div></div>
|
||||||
<div class="reveal reveal-delay-1"><div class="stat-value grad" data-count="0">0</div><div class="stat-label">API Keys Required</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 grad" data-count="40">0</div><div class="stat-label">Anime Genres</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 grad">Live</div><div class="stat-label">Real-time Data</div></div>
|
<div class="reveal reveal-delay-3"><div class="stat-value">Live</div><div class="stat-label">Real-time Data</div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Marquee -->
|
|
||||||
<div class="marquee">
|
<div class="marquee">
|
||||||
<div class="marquee-track">
|
<div class="marquee-track">
|
||||||
<div class="marquee-item"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg> 5-min cache TTL</div>
|
<div class="marquee-item"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg> 5-min cache TTL</div>
|
||||||
@@ -386,7 +329,6 @@ fetch("https://anikototvapi.vercel.app/api")
|
|||||||
<div class="marquee-item"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg> REST JSON</div>
|
<div class="marquee-item"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg> REST JSON</div>
|
||||||
<div class="marquee-item"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/></svg> CORS enabled</div>
|
<div class="marquee-item"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/></svg> CORS enabled</div>
|
||||||
<div class="marquee-item"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg> Open source</div>
|
<div class="marquee-item"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg> Open source</div>
|
||||||
<!-- duplicate for seamless loop -->
|
|
||||||
<div class="marquee-item"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg> 5-min cache TTL</div>
|
<div class="marquee-item"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg> 5-min cache TTL</div>
|
||||||
<div class="marquee-item"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg> Zero config</div>
|
<div class="marquee-item"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg> Zero config</div>
|
||||||
<div class="marquee-item"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg> No auth needed</div>
|
<div class="marquee-item"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg> No auth needed</div>
|
||||||
@@ -394,76 +336,55 @@ fetch("https://anikototvapi.vercel.app/api")
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Features -->
|
|
||||||
<section class="section" id="features">
|
<section class="section" id="features">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="reveal">
|
<div class="reveal">
|
||||||
<div class="section-label">
|
<div class="section-label">Features</div>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
|
||||||
Features
|
|
||||||
</div>
|
|
||||||
<h2 class="section-title">Built for developers</h2>
|
<h2 class="section-title">Built for developers</h2>
|
||||||
<p class="section-desc">Everything you need to build an anime website, mobile app, or Discord bot. No API keys, no rate limits, no hassle.</p>
|
<p class="section-desc">Everything you need to build an anime website, mobile app, or Discord bot. No API keys, no rate limits, no hassle.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="features-grid">
|
<div class="features-grid">
|
||||||
<div class="feature-card reveal">
|
<div class="feature-card reveal">
|
||||||
<div class="feature-icon fi-purple">
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg></div>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
|
||||||
</div>
|
|
||||||
<div class="feature-title">Full-text Search</div>
|
<div class="feature-title">Full-text Search</div>
|
||||||
<div class="feature-desc">Search by keyword, genre, type, status. Get autocomplete suggestions and paginated results.</div>
|
<div class="feature-desc">Search by keyword, genre, type, status. Get autocomplete suggestions and paginated results.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card reveal reveal-delay-1">
|
<div class="feature-card reveal reveal-delay-1">
|
||||||
<div class="feature-icon fi-pink">
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="20" rx="2.18"/><path d="M7 2v20M17 2v20M2 12h20M2 7h5M2 17h5M17 7h5M17 17h5"/></svg></div>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="20" rx="2.18"/><path d="M7 2v20M17 2v20M2 12h20M2 7h5M2 17h5M17 7h5M17 17h5"/></svg>
|
|
||||||
</div>
|
|
||||||
<div class="feature-title">Episode Streaming</div>
|
<div class="feature-title">Episode Streaming</div>
|
||||||
<div class="feature-desc">Retrieve streaming servers, source links, and m3u8 URLs for any episode via AJAX endpoints.</div>
|
<div class="feature-desc">Retrieve streaming servers, source links, and m3u8 URLs for any episode via AJAX endpoints.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card reveal reveal-delay-2">
|
<div class="feature-card reveal reveal-delay-2">
|
||||||
<div class="feature-icon fi-green">
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/></svg></div>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/></svg>
|
|
||||||
</div>
|
|
||||||
<div class="feature-title">Rich Metadata</div>
|
<div class="feature-title">Rich Metadata</div>
|
||||||
<div class="feature-desc">Synopsis, MAL score, genres, characters, relations, recommendations, and more.</div>
|
<div class="feature-desc">Synopsis, MAL score, genres, characters, relations, recommendations, and more.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card reveal">
|
<div class="feature-card reveal">
|
||||||
<div class="feature-icon fi-cyan">
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg></div>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>
|
|
||||||
</div>
|
|
||||||
<div class="feature-title">Schedule & Calendar</div>
|
<div class="feature-title">Schedule & Calendar</div>
|
||||||
<div class="feature-desc">View what's airing today or fetch the full weekly broadcast schedule.</div>
|
<div class="feature-desc">View what's airing today or fetch the full weekly broadcast schedule.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card reveal reveal-delay-1">
|
<div class="feature-card reveal reveal-delay-1">
|
||||||
<div class="feature-icon fi-amber">
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg></div>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
|
|
||||||
</div>
|
|
||||||
<div class="feature-title">Advanced Filters</div>
|
<div class="feature-title">Advanced Filters</div>
|
||||||
<div class="feature-desc">Filter by genre, type, status, season, sort order. Multi-select support.</div>
|
<div class="feature-desc">Filter by genre, type, status, season, sort order. Multi-select support.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card reveal reveal-delay-2">
|
<div class="feature-card reveal reveal-delay-2">
|
||||||
<div class="feature-icon fi-indigo">
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg></div>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
|
|
||||||
</div>
|
|
||||||
<div class="feature-title">Fast & Cached</div>
|
<div class="feature-title">Fast & Cached</div>
|
||||||
<div class="feature-desc">In-memory cache with 5-minute TTL for snappy responses and reduced scraping overhead.</div>
|
<div class="feature-desc">In-memory cache with configurable TTL for snappy responses and reduced scraping overhead.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Endpoints -->
|
<section class="section" id="endpoints" style="background:var(--surface-soft);border-top:1px solid var(--hairline);border-bottom:1px solid var(--hairline)">
|
||||||
<section class="section" id="endpoints" style="background:var(--bg2);border-top:1px solid var(--border);border-bottom:1px solid var(--border)">
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="reveal">
|
<div class="reveal">
|
||||||
<div class="section-label">
|
<div class="section-label">API Reference</div>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
|
||||||
API Reference
|
|
||||||
</div>
|
|
||||||
<h2 class="section-title">Endpoints</h2>
|
<h2 class="section-title">Endpoints</h2>
|
||||||
<p class="section-desc">All endpoints return JSON. Base URL: <code style="color:var(--pink);background:rgba(236,72,153,.1);padding:2px 8px;border-radius:4px">https://anikototvapi.vercel.app/api</code></p>
|
<p class="section-desc">All endpoints return JSON. Base URL: <code style="color:var(--primary);background:rgba(204,120,92,.1);padding:2px 8px;border-radius:4px;font-family:var(--font-code);font-size:.85rem">https://anikototvapi.vercel.app/api</code></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="explorer reveal" id="explorer">
|
<div class="explorer reveal" id="explorer">
|
||||||
<div class="explorer-nav" id="explorerNav"></div>
|
<div class="explorer-nav" id="explorerNav"></div>
|
||||||
<div class="explorer-panel" id="explorerPanel"></div>
|
<div class="explorer-panel" id="explorerPanel"></div>
|
||||||
@@ -471,20 +392,14 @@ fetch("https://anikototvapi.vercel.app/api")
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Playground -->
|
|
||||||
<section class="section" id="playground">
|
<section class="section" id="playground">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="reveal">
|
<div class="reveal">
|
||||||
<div class="section-label">
|
<div class="section-label">Interactive</div>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
|
||||||
Interactive
|
|
||||||
</div>
|
|
||||||
<h2 class="section-title">Playground</h2>
|
<h2 class="section-title">Playground</h2>
|
||||||
<p class="section-desc">Test endpoints directly in your browser. No signup required.</p>
|
<p class="section-desc">Test endpoints directly in your browser. No signup required.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:16px">
|
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:16px">
|
||||||
<!-- Search -->
|
|
||||||
<div class="playground-card reveal">
|
<div class="playground-card reveal">
|
||||||
<div class="playground-header">
|
<div class="playground-header">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||||||
@@ -492,13 +407,12 @@ fetch("https://anikototvapi.vercel.app/api")
|
|||||||
</div>
|
</div>
|
||||||
<div class="playground-body">
|
<div class="playground-body">
|
||||||
<div class="playground-inputs">
|
<div class="playground-inputs">
|
||||||
<input id="pgSearch" type="text" placeholder="e.g. naruto, one piece" />
|
<input id="pgSearch" type="text" placeholder="e.g. naruto, one piece" aria-label="Search anime" />
|
||||||
<button class="btn btn-primary" onclick="pgRun('search')" style="padding:10px 20px">Search</button>
|
<button class="btn btn-primary" onclick="pgRun('search')" style="padding:10px 20px">Search</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="playground-output" id="pgSearchOut"></div>
|
<div class="playground-output" id="pgSearchOut"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Stream -->
|
|
||||||
<div class="playground-card reveal reveal-delay-1">
|
<div class="playground-card reveal reveal-delay-1">
|
||||||
<div class="playground-header">
|
<div class="playground-header">
|
||||||
<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>
|
<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>
|
||||||
@@ -512,7 +426,6 @@ fetch("https://anikototvapi.vercel.app/api")
|
|||||||
<div class="playground-output" id="pgStreamOut"></div>
|
<div class="playground-output" id="pgStreamOut"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Random -->
|
|
||||||
<div class="playground-card reveal reveal-delay-2">
|
<div class="playground-card reveal reveal-delay-2">
|
||||||
<div class="playground-header">
|
<div class="playground-header">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><path d="M3.27 6.96L12 12.01l8.73-5.05M12 22.08V12"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><path d="M3.27 6.96L12 12.01l8.73-5.05M12 22.08V12"/></svg>
|
||||||
@@ -525,7 +438,6 @@ fetch("https://anikototvapi.vercel.app/api")
|
|||||||
<div class="playground-output" id="pgRandomOut"></div>
|
<div class="playground-output" id="pgRandomOut"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Schedule -->
|
|
||||||
<div class="playground-card reveal reveal-delay-3">
|
<div class="playground-card reveal reveal-delay-3">
|
||||||
<div class="playground-header">
|
<div class="playground-header">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>
|
||||||
@@ -533,7 +445,7 @@ fetch("https://anikototvapi.vercel.app/api")
|
|||||||
</div>
|
</div>
|
||||||
<div class="playground-body">
|
<div class="playground-body">
|
||||||
<div class="playground-inputs">
|
<div class="playground-inputs">
|
||||||
<input id="pgSchedule" type="date" />
|
<input id="pgSchedule" type="date" aria-label="Schedule date" />
|
||||||
<button class="btn btn-primary" onclick="pgRun('schedule')" style="padding:10px 20px">Get Schedule</button>
|
<button class="btn btn-primary" onclick="pgRun('schedule')" style="padding:10px 20px">Get Schedule</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="playground-output" id="pgScheduleOut"></div>
|
<div class="playground-output" id="pgScheduleOut"></div>
|
||||||
@@ -543,18 +455,17 @@ fetch("https://anikototvapi.vercel.app/api")
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- CTA -->
|
|
||||||
<section class="cta">
|
<section class="cta">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="cta-box reveal">
|
<div class="cta-box reveal">
|
||||||
<h2 class="cta-title">Start building with AniKotoAPI</h2>
|
<h2 class="cta-title">Start building with AniKotoAPI</h2>
|
||||||
<p class="cta-desc">Free, open source, no API key required. Deploy your own instance or use ours.</p>
|
<p class="cta-desc">Free, open source, no API key required. Deploy your own instance or use ours.</p>
|
||||||
<div class="cta-actions">
|
<div class="cta-actions">
|
||||||
<a href="https://anikototvapi.vercel.app/api" target="_blank" class="btn btn-primary">
|
<a href="https://anikototvapi.vercel.app/api" target="_blank" class="btn btn-on-coral">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:16px;height:16px"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:16px;height:16px"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||||
View API
|
View API
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/Shineii86/AniKotoAPI" target="_blank" class="btn btn-ghost">
|
<a href="https://github.com/Shineii86/AniKotoAPI" target="_blank" class="btn btn-on-coral">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" style="width:16px;height:16px"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor" style="width:16px;height:16px"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||||
Source Code
|
Source Code
|
||||||
</a>
|
</a>
|
||||||
@@ -563,14 +474,12 @@ fetch("https://anikototvapi.vercel.app/api")
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<footer>
|
<footer>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="footer-grid">
|
<div class="footer-grid">
|
||||||
<div class="footer-brand">
|
<div class="footer-brand">
|
||||||
<a href="/" class="logo">
|
<a href="/" class="logo">
|
||||||
<svg viewBox="0 0 32 32" fill="none" width="24" height="24"><circle cx="16" cy="16" r="14" stroke="url(#g2)" stroke-width="2"/><path d="M10 20c0-4 3-7 6-7s6 3 6 7" stroke="url(#g2)" stroke-width="2" stroke-linecap="round"/><circle cx="12" cy="12" r="2" fill="var(--purple)"/><circle cx="20" cy="12" r="2" fill="var(--pink)"/><defs><linearGradient id="g2" x1="0" y1="0" x2="32" y2="32"><stop stop-color="var(--purple)"/><stop offset="1" stop-color="var(--pink)"/></linearGradient></defs></svg>
|
<span class="logo-mark"><span>*</span> AniKotoAPI</span>
|
||||||
<span class="logo-text"><span>AniKoto</span>API</span>
|
|
||||||
</a>
|
</a>
|
||||||
<p>Free REST API for anime data. Browse, search, and stream from AniKoto. Built with Node.js and deployed on Vercel.</p>
|
<p>Free REST API for anime data. Browse, search, and stream from AniKoto. Built with Node.js and deployed on Vercel.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -584,10 +493,10 @@ fetch("https://anikototvapi.vercel.app/api")
|
|||||||
</div>
|
</div>
|
||||||
<div class="footer-col">
|
<div class="footer-col">
|
||||||
<h4>Resources</h4>
|
<h4>Resources</h4>
|
||||||
<a href="https://github.com/Shineii86/AniKotoAPI" target="_blank">GitHub</a>
|
<a href="https://github.com/Shineii86/AniKotoAPI" target="_blank" rel="noopener noreferrer">GitHub</a>
|
||||||
<a href="https://github.com/Shineii86/AniKotoAPI#readme" target="_blank">Documentation</a>
|
<a href="https://github.com/Shineii86/AniKotoAPI#readme" target="_blank" rel="noopener noreferrer">Documentation</a>
|
||||||
<a href="https://github.com/Shineii86/AniKotoAPI/blob/main/CHANGELOG.md" target="_blank">Changelog</a>
|
<a href="https://github.com/Shineii86/AniKotoAPI/blob/main/CHANGELOG.md" target="_blank" rel="noopener noreferrer">Changelog</a>
|
||||||
<a href="/api" target="_blank">API Status</a>
|
<a href="/api" target="_blank" rel="noopener noreferrer">API Status</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-col">
|
<div class="footer-col">
|
||||||
<h4>Legal</h4>
|
<h4>Legal</h4>
|
||||||
@@ -608,57 +517,57 @@ fetch("https://anikototvapi.vercel.app/api")
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
/* ── Particles ── */
|
const BASE = 'https://anikototvapi.vercel.app/api';
|
||||||
const canvas = document.getElementById('particles');
|
const endpoints = {
|
||||||
const ctx = canvas.getContext('2d');
|
home: { url: '/', label: 'Home' },
|
||||||
let particles = [];
|
search: { url: '/search?keyword=naruto', label: 'Search' },
|
||||||
function resizeCanvas() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; }
|
stream: { url: '/stream?id=naruto-ep-1', label: 'Stream' },
|
||||||
window.addEventListener('resize', resizeCanvas);
|
random: { url: '/random', label: 'Random' },
|
||||||
resizeCanvas();
|
schedule: { url: '/schedule', label: 'Schedule' }
|
||||||
class Particle {
|
};
|
||||||
constructor() { this.reset(); }
|
|
||||||
reset() {
|
function setEndpoint(key, event) {
|
||||||
this.x = Math.random() * canvas.width;
|
const ep = endpoints[key];
|
||||||
this.y = Math.random() * canvas.height;
|
document.getElementById('consoleInput').value = `// ${ep.label}\nfetch("${BASE}${ep.url}")\n .then(r => r.json())\n .then(data => console.log(data));`;
|
||||||
this.size = Math.random() * 1.5 + .5;
|
document.querySelectorAll('.console-btn').forEach(b => b.classList.remove('active'));
|
||||||
this.speedX = (Math.random() - .5) * .3;
|
event.target.classList.add('active');
|
||||||
this.speedY = (Math.random() - .5) * .3;
|
|
||||||
this.opacity = Math.random() * .3 + .1;
|
|
||||||
}
|
}
|
||||||
update() {
|
|
||||||
this.x += this.speedX;
|
async function runConsole() {
|
||||||
this.y += this.speedY;
|
const out = document.getElementById('consoleOutput');
|
||||||
if (this.x < 0 || this.x > canvas.width || this.y < 0 || this.y > canvas.height) this.reset();
|
out.innerHTML = '<span style="color:var(--accent-amber)">Loading...</span>';
|
||||||
|
const input = document.getElementById('consoleInput').value;
|
||||||
|
const urlMatch = input.match(/fetch\("([^"]+)"\)/);
|
||||||
|
if (!urlMatch) { out.innerHTML = '<span style="color:var(--error)">No fetch URL found</span>'; return; }
|
||||||
|
try {
|
||||||
|
const res = await fetch(urlMatch[1]);
|
||||||
|
const data = await res.json();
|
||||||
|
out.innerHTML = syntaxHighlight(JSON.stringify(data, null, 2));
|
||||||
|
out.scrollTop = out.scrollHeight;
|
||||||
|
} catch (e) {
|
||||||
|
out.innerHTML = `<span style="color:var(--error)">Error: ${e.message}</span>`;
|
||||||
}
|
}
|
||||||
draw() {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
|
|
||||||
ctx.fillStyle = `rgba(168,85,247,${this.opacity})`;
|
|
||||||
ctx.fill();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syntaxHighlight(json) {
|
||||||
|
return json.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"([^"]+)"(?=\s*:)/g, '<span style="color:var(--accent-amber)">"$1"</span>')
|
||||||
|
.replace(/: "([^"]*)"/g, ': <span style="color:var(--accent-teal)">"$1"</span>')
|
||||||
|
.replace(/: (\d+)/g, ': <span style="color:var(--primary)">$1</span>')
|
||||||
|
.replace(/: (true|false|null)/g, ': <span style="color:var(--on-dark-soft)">$1</span>');
|
||||||
}
|
}
|
||||||
for (let i = 0; i < 60; i++) particles.push(new Particle());
|
|
||||||
function animateParticles() {
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
||||||
particles.forEach(p => { p.update(); p.draw(); });
|
|
||||||
requestAnimationFrame(animateParticles);
|
|
||||||
}
|
|
||||||
animateParticles();
|
|
||||||
|
|
||||||
/* ── Header Scroll ── */
|
|
||||||
const header = document.getElementById('header');
|
const header = document.getElementById('header');
|
||||||
window.addEventListener('scroll', () => {
|
window.addEventListener('scroll', () => {
|
||||||
header.classList.toggle('scrolled', window.scrollY > 20);
|
header.classList.toggle('scrolled', window.scrollY > 20);
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ── Scroll Reveal ── */
|
|
||||||
const reveals = document.querySelectorAll('.reveal');
|
const reveals = document.querySelectorAll('.reveal');
|
||||||
const revealObserver = new IntersectionObserver((entries) => {
|
const revealObserver = new IntersectionObserver((entries) => {
|
||||||
entries.forEach(e => { if (e.isIntersecting) { e.target.classList.add('visible'); revealObserver.unobserve(e.target); } });
|
entries.forEach(e => { if (e.isIntersecting) { e.target.classList.add('visible'); revealObserver.unobserve(e.target); } });
|
||||||
}, { threshold: .1, rootMargin: '0px 0px -40px 0px' });
|
}, { threshold: .1, rootMargin: '0px 0px -40px 0px' });
|
||||||
reveals.forEach(el => revealObserver.observe(el));
|
reveals.forEach(el => revealObserver.observe(el));
|
||||||
|
|
||||||
/* ── Animated Counters ── */
|
|
||||||
document.querySelectorAll('[data-count]').forEach(el => {
|
document.querySelectorAll('[data-count]').forEach(el => {
|
||||||
const target = parseInt(el.dataset.count);
|
const target = parseInt(el.dataset.count);
|
||||||
const obs = new IntersectionObserver(([e]) => {
|
const obs = new IntersectionObserver(([e]) => {
|
||||||
@@ -676,61 +585,19 @@ fetch("https://anikototvapi.vercel.app/api")
|
|||||||
obs.observe(el);
|
obs.observe(el);
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ── Console ── */
|
|
||||||
const BASE = 'https://anikototvapi.vercel.app/api';
|
|
||||||
const endpoints = {
|
|
||||||
home: { url: '/', label: 'Home' },
|
|
||||||
search: { url: '/search?keyword=naruto', label: 'Search' },
|
|
||||||
stream: { url: '/stream?id=naruto-ep-1', label: 'Stream' },
|
|
||||||
random: { url: '/random', label: 'Random' },
|
|
||||||
schedule: { url: '/schedule', label: 'Schedule' }
|
|
||||||
};
|
|
||||||
function setEndpoint(key) {
|
|
||||||
const ep = endpoints[key];
|
|
||||||
document.getElementById('consoleInput').value = `// ${ep.label}\nfetch("${BASE}${ep.url}")\n .then(r => r.json())\n .then(data => console.log(data));`;
|
|
||||||
document.querySelectorAll('.console-btn').forEach(b => b.classList.remove('active'));
|
|
||||||
event.target.classList.add('active');
|
|
||||||
}
|
|
||||||
async function runConsole() {
|
|
||||||
const out = document.getElementById('consoleOutput');
|
|
||||||
out.innerHTML = '<span style="color:var(--amber)">Loading...</span>';
|
|
||||||
const input = document.getElementById('consoleInput').value;
|
|
||||||
const urlMatch = input.match(/fetch\("([^"]+)"\)/);
|
|
||||||
if (!urlMatch) { out.innerHTML = '<span style="color:var(--red)">No fetch URL found</span>'; return; }
|
|
||||||
try {
|
|
||||||
const res = await fetch(urlMatch[1]);
|
|
||||||
const data = await res.json();
|
|
||||||
out.innerHTML = syntaxHighlight(JSON.stringify(data, null, 2));
|
|
||||||
out.scrollTop = out.scrollHeight;
|
|
||||||
} catch (e) {
|
|
||||||
out.innerHTML = `<span style="color:var(--red)">Error: ${e.message}</span>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function syntaxHighlight(json) {
|
|
||||||
return json.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
||||||
.replace(/"([^"]+)"(?=\s*:)/g, '<span style="color:var(--pink)">"$1"</span>')
|
|
||||||
.replace(/: "([^"]*)"/g, ': <span style="color:var(--green)">"$1"</span>')
|
|
||||||
.replace(/: (\d+)/g, ': <span style="color:var(--amber)">$1</span>')
|
|
||||||
.replace(/: (true|false|null)/g, ': <span style="color:var(--cyan)">$1</span>');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Explorer ── */
|
|
||||||
const explorerData = [
|
const explorerData = [
|
||||||
{ id: 'home', title: 'Home Info', desc: 'Spotlight, trending, popular, top-airing, genres, and more.', path: '/', params: [], code: { curl: 'curl https://anikototvapi.vercel.app/api', js: 'const res = await fetch("https://anikototvapi.vercel.app/api");\nconst data = await res.json();\nconsole.log(data.trending);', python: 'import requests\nr = requests.get("https://anikototvapi.vercel.app/api")\nprint(r.json()["trending"])' } },
|
{ id: 'home', title: 'Home Info', desc: 'Spotlight, trending, popular, top-airing, genres, and more.', path: '/', params: [], code: { curl: 'curl https://anikototvapi.vercel.app/api', js: 'const res = await fetch("https://anikototvapi.vercel.app/api");\nconst data = await res.json();\nconsole.log(data.trending);', python: 'import requests\nr = requests.get("https://anikototvapi.vercel.app/api")\nprint(r.json()["trending"])' } },
|
||||||
{ id: 'search', title: 'Search', desc: 'Search anime by keyword with pagination.', path: '/search?keyword={q}&page={n}', params: [{ name: 'keyword', type: 'string', desc: 'Search query' }, { name: 'page', type: 'number', desc: 'Page (default 1)' }], code: { curl: 'curl "https://anikototvapi.vercel.app/api/search?keyword=naruto"', js: 'const res = await fetch("https://anikototvapi.vercel.app/api/search?keyword=naruto");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://anikototvapi.vercel.app/api/search", params={"keyword": "naruto"})\nprint(r.json())' } },
|
{ id: 'search', title: 'Search', desc: 'Search anime by keyword with pagination.', path: '/search?keyword={q}&page={n}', params: [{ name: 'keyword', type: 'string', desc: 'Search query' }, { name: 'page', type: 'number', desc: 'Page (default 1)' }], code: { curl: 'curl "https://anikototvapi.vercel.app/api/search?keyword=naruto"', js: 'const res = await fetch("https://anikototvapi.vercel.app/api/search?keyword=naruto");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://anikototvapi.vercel.app/api/search", params={"keyword": "naruto"})\nprint(r.json())' } },
|
||||||
{ id: 'suggest', title: 'Search Suggest', desc: 'Autocomplete suggestions for search.', path: '/search/suggest?keyword={q}', params: [{ name: 'keyword', type: 'string', desc: 'Partial query' }], code: { curl: 'curl "https://anikototvapi.vercel.app/api/search/suggest?keyword=dea"', js: 'const res = await fetch("https://anikototvapi.vercel.app/api/search/suggest?keyword=dea");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://anikototvapi.vercel.app/api/search/suggest", params={"keyword": "dea"})\nprint(r.json())' } },
|
|
||||||
{ id: 'info', title: 'Anime Info', desc: 'Detailed info: synopsis, genres, relations, recommendations.', path: '/info?id={slug}', params: [{ name: 'id', type: 'string', desc: 'Anime slug' }], code: { curl: 'curl "https://anikototvapi.vercel.app/api/info?id=naruto"', js: 'const res = await fetch("https://anikototvapi.vercel.app/api/info?id=naruto");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://anikototvapi.vercel.app/api/info", params={"id": "naruto"})\nprint(r.json())' } },
|
{ id: 'info', title: 'Anime Info', desc: 'Detailed info: synopsis, genres, relations, recommendations.', path: '/info?id={slug}', params: [{ name: 'id', type: 'string', desc: 'Anime slug' }], code: { curl: 'curl "https://anikototvapi.vercel.app/api/info?id=naruto"', js: 'const res = await fetch("https://anikototvapi.vercel.app/api/info?id=naruto");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://anikototvapi.vercel.app/api/info", params={"id": "naruto"})\nprint(r.json())' } },
|
||||||
{ id: 'episodes', title: 'Episodes', desc: 'Episode list loaded via AJAX.', path: '/episodes/{slug}', params: [{ name: 'slug', type: 'string', desc: 'Anime slug' }], code: { curl: 'curl "https://anikototvapi.vercel.app/api/episodes/naruto"', js: 'const res = await fetch("https://anikototvapi.vercel.app/api/episodes/naruto");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://anikototvapi.vercel.app/api/episodes/naruto")\nprint(r.json())' } },
|
{ id: 'episodes', title: 'Episodes', desc: 'Episode list loaded via AJAX.', path: '/episodes/{animeId}', params: [{ name: 'animeId', type: 'number', desc: 'Anime ID' }], code: { curl: 'curl "https://anikototvapi.vercel.app/api/episodes/958"', js: 'const res = await fetch("https://anikototvapi.vercel.app/api/episodes/958");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://anikototvapi.vercel.app/api/episodes/958")\nprint(r.json())' } },
|
||||||
{ id: 'stream', title: 'Stream Servers', desc: 'Streaming servers for an episode.', path: '/stream?id={episodeId}', params: [{ name: 'id', type: 'string', desc: 'Episode ID' }], code: { curl: 'curl "https://anikototvapi.vercel.app/api/stream?id=naruto-ep-1"', js: 'const res = await fetch("https://anikototvapi.vercel.app/api/stream?id=naruto-ep-1");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://anikototvapi.vercel.app/api/stream", params={"id": "naruto-ep-1"})\nprint(r.json())' } },
|
{ id: 'stream', title: 'Stream Servers', desc: 'Streaming servers for an episode.', path: '/stream?id={linkId}', params: [{ name: 'id', type: 'string', desc: 'Link ID from servers' }], code: { curl: 'curl "https://anikototvapi.vercel.app/api/stream?id=MTF1d..."', js: 'const res = await fetch("https://anikototvapi.vercel.app/api/stream?id=MTF1d...");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://anikototvapi.vercel.app/api/stream", params={"id": "MTF1d..."})\nprint(r.json())' } },
|
||||||
{ id: 'servers', title: 'Servers', desc: 'Resolve streaming server links.', path: '/servers?ids={ids}', params: [{ name: 'ids', type: 'string', desc: 'Comma-separated link IDs' }], code: { curl: 'curl "https://anikototvapi.vercel.app/api/servers?ids=123,456"', js: 'const res = await fetch("https://anikototvapi.vercel.app/api/servers?ids=123,456");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://anikototvapi.vercel.app/api/servers", params={"ids": "123,456"})\nprint(r.json())' } },
|
{ id: 'servers', title: 'Servers', desc: 'Resolve streaming server links.', path: '/servers?ids={serverIds}', params: [{ name: 'ids', type: 'string', desc: 'Server IDs from episodes' }], code: { curl: 'curl "https://anikototvapi.vercel.app/api/servers?ids=SlNVT..."', js: 'const res = await fetch("https://anikototvapi.vercel.app/api/servers?ids=SlNVT...");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://anikototvapi.vercel.app/api/servers", params={"ids": "SlNVT..."})\nprint(r.json())' } },
|
||||||
{ id: 'schedule', title: 'Schedule', desc: 'Anime schedule for a specific date.', path: '/schedule?date={YYYY-MM-DD}', params: [{ name: 'date', type: 'string', desc: 'Date (default today)' }], code: { curl: 'curl "https://anikototvapi.vercel.app/api/schedule"', js: 'const res = await fetch("https://anikototvapi.vercel.app/api/schedule");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://anikototvapi.vercel.app/api/schedule")\nprint(r.json())' } },
|
{ id: 'schedule', title: 'Schedule', desc: 'Anime schedule for a specific date.', path: '/schedule?date={YYYY-MM-DD}', params: [{ name: 'date', type: 'string', desc: 'Date (default today)' }], code: { curl: 'curl "https://anikototvapi.vercel.app/api/schedule"', js: 'const res = await fetch("https://anikototvapi.vercel.app/api/schedule");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://anikototvapi.vercel.app/api/schedule")\nprint(r.json())' } },
|
||||||
{ id: 'topten', title: 'Top 10', desc: 'Top 10 anime (today, week, month).', path: '/top-ten', params: [], code: { curl: 'curl "https://anikototvapi.vercel.app/api/top-ten"', js: 'const res = await fetch("https://anikototvapi.vercel.app/api/top-ten");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://anikototvapi.vercel.app/api/top-ten")\nprint(r.json())' } },
|
{ id: 'topten', title: 'Top 10', desc: 'Top 10 anime (today, week, month).', path: '/top-ten', params: [], code: { curl: 'curl "https://anikototvapi.vercel.app/api/top-ten"', js: 'const res = await fetch("https://anikototvapi.vercel.app/api/top-ten");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://anikototvapi.vercel.app/api/top-ten")\nprint(r.json())' } },
|
||||||
{ id: 'random', title: 'Random', desc: 'Random anime info.', path: '/random', params: [], code: { curl: 'curl "https://anikototvapi.vercel.app/api/random"', js: 'const res = await fetch("https://anikototvapi.vercel.app/api/random");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://anikototvapi.vercel.app/api/random")\nprint(r.json())' } },
|
{ id: 'random', title: 'Random', desc: 'Random anime info.', path: '/random', params: [], code: { curl: 'curl "https://anikototvapi.vercel.app/api/random"', js: 'const res = await fetch("https://anikototvapi.vercel.app/api/random");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://anikototvapi.vercel.app/api/random")\nprint(r.json())' } },
|
||||||
{ id: 'filter', title: 'Filter', desc: 'Filter by genre, type, status, season.', path: '/filter?keyword={q}&genre[]={g}', params: [{ name: 'keyword', type: 'string', desc: 'Search keyword (required)' }, { name: 'genre[]', type: 'number', desc: 'Genre IDs' }, { name: 'type[]', type: 'number', desc: 'Type IDs' }], code: { curl: 'curl "https://anikototvapi.vercel.app/api/filter?keyword=&genre[]=1&genre[]=4"', js: 'const res = await fetch("https://anikototvapi.vercel.app/api/filter?keyword=&genre[]=1");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://anikototvapi.vercel.app/api/filter", params={"keyword": "", "genre[]": 1})\nprint(r.json())' } },
|
{ id: 'filter', title: 'Filter', desc: 'Filter by genre, type, status, season.', path: '/filter?keyword={q}&genre[]={g}', params: [{ name: 'keyword', type: 'string', desc: 'Search keyword (required)' }, { name: 'genre[]', type: 'number', desc: 'Genre IDs' }, { name: 'type[]', type: 'number', desc: 'Type IDs' }], code: { curl: 'curl "https://anikototvapi.vercel.app/api/filter?keyword=&genre[]=1&genre[]=4"', js: 'const res = await fetch("https://anikototvapi.vercel.app/api/filter?keyword=&genre[]=1");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://anikototvapi.vercel.app/api/filter", params={"keyword": "", "genre[]": 1})\nprint(r.json())' } },
|
||||||
{ id: 'popular', title: 'Most Popular', desc: 'Most popular anime list.', path: '/most-popular?page={n}', params: [{ name: 'page', type: 'number', desc: 'Page (default 1)' }], code: { curl: 'curl "https://anikototvapi.vercel.app/api/most-popular"', js: 'const res = await fetch("https://anikototvapi.vercel.app/api/most-popular");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://anikototvapi.vercel.app/api/most-popular")\nprint(r.json())' } },
|
{ id: 'popular', title: 'Most Popular', desc: 'Most popular anime list.', path: '/most-popular?page={n}', params: [{ name: 'page', type: 'number', desc: 'Page (default 1)' }], code: { curl: 'curl "https://anikototvapi.vercel.app/api/most-popular"', js: 'const res = await fetch("https://anikototvapi.vercel.app/api/most-popular");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://anikototvapi.vercel.app/api/most-popular")\nprint(r.json())' } },
|
||||||
{ id: 'genre', title: 'Genre', desc: 'Anime by genre.', path: '/genre/{name}', params: [{ name: 'name', type: 'string', desc: 'Genre slug (action, comedy, etc.)' }], code: { curl: 'curl "https://anikototvapi.vercel.app/api/genre/action"', js: 'const res = await fetch("https://anikototvapi.vercel.app/api/genre/action");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://anikototvapi.vercel.app/api/genre/action")\nprint(r.json())' } },
|
{ id: 'genre', title: 'Genre', desc: 'Anime by genre.', path: '/genre/{name}', params: [{ name: 'name', type: 'string', desc: 'Genre slug (action, comedy, etc.)' }], code: { curl: 'curl "https://anikototvapi.vercel.app/api/genre/action"', js: 'const res = await fetch("https://anikototvapi.vercel.app/api/genre/action");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://anikototvapi.vercel.app/api/genre/action")\nprint(r.json())' } },
|
||||||
{ id: 'type', title: 'Type', desc: 'Anime by type.', path: '/type/{name}', params: [{ name: 'name', type: 'string', desc: 'Type (tv, movie, ova, etc.)' }], code: { curl: 'curl "https://anikototvapi.vercel.app/api/type/tv"', js: 'const res = await fetch("https://anikototvapi.vercel.app/api/type/tv");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://anikototvapi.vercel.app/api/type/tv")\nprint(r.json())' } },
|
|
||||||
{ id: 'status', title: 'Status', desc: 'Anime by status.', path: '/status/{name}', params: [{ name: 'name', type: 'string', desc: 'Status (currently-airing, finished-airing)' }], code: { curl: 'curl "https://anikototvapi.vercel.app/api/status/currently-airing"', js: 'const res = await fetch("https://anikototvapi.vercel.app/api/status/currently-airing");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://anikototvapi.vercel.app/api/status/currently-airing")\nprint(r.json())' } },
|
|
||||||
];
|
];
|
||||||
const navEl = document.getElementById('explorerNav');
|
const navEl = document.getElementById('explorerNav');
|
||||||
const panelEl = document.getElementById('explorerPanel');
|
const panelEl = document.getElementById('explorerPanel');
|
||||||
@@ -751,6 +618,7 @@ fetch("https://anikototvapi.vercel.app/api")
|
|||||||
</div>
|
</div>
|
||||||
<div class="explorer-code" id="explorerCode">${ep.code[activeLang]}</div>`;
|
<div class="explorer-code" id="explorerCode">${ep.code[activeLang]}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchLang(lang, btn) {
|
function switchLang(lang, btn) {
|
||||||
activeLang = lang;
|
activeLang = lang;
|
||||||
document.querySelectorAll('.code-tab').forEach(b => b.classList.remove('active'));
|
document.querySelectorAll('.code-tab').forEach(b => b.classList.remove('active'));
|
||||||
@@ -758,6 +626,7 @@ fetch("https://anikototvapi.vercel.app/api")
|
|||||||
const activeEp = explorerData.find(e => e.id === navEl.querySelector('.active')?.dataset.id) || explorerData[0];
|
const activeEp = explorerData.find(e => e.id === navEl.querySelector('.active')?.dataset.id) || explorerData[0];
|
||||||
document.getElementById('explorerCode').textContent = activeEp.code[lang];
|
document.getElementById('explorerCode').textContent = activeEp.code[lang];
|
||||||
}
|
}
|
||||||
|
|
||||||
explorerData.forEach((ep, i) => {
|
explorerData.forEach((ep, i) => {
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.className = `explorer-nav-btn ${i===0?'active':''}`;
|
btn.className = `explorer-nav-btn ${i===0?'active':''}`;
|
||||||
@@ -772,7 +641,6 @@ fetch("https://anikototvapi.vercel.app/api")
|
|||||||
});
|
});
|
||||||
renderExplorer(explorerData[0]);
|
renderExplorer(explorerData[0]);
|
||||||
|
|
||||||
/* ── Playground ── */
|
|
||||||
async function pgRun(type) {
|
async function pgRun(type) {
|
||||||
let url, outEl;
|
let url, outEl;
|
||||||
if (type === 'search') {
|
if (type === 'search') {
|
||||||
@@ -794,15 +662,16 @@ fetch("https://anikototvapi.vercel.app/api")
|
|||||||
outEl = document.getElementById('pgScheduleOut');
|
outEl = document.getElementById('pgScheduleOut');
|
||||||
}
|
}
|
||||||
outEl.classList.add('visible');
|
outEl.classList.add('visible');
|
||||||
outEl.innerHTML = '<span style="color:var(--amber)">Loading...</span>';
|
outEl.innerHTML = '<span style="color:var(--accent-amber)">Loading...</span>';
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
outEl.innerHTML = syntaxHighlight(JSON.stringify(data, null, 2));
|
outEl.innerHTML = syntaxHighlight(JSON.stringify(data, null, 2));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
outEl.innerHTML = `<span style="color:var(--red)">Error: ${e.message}</span>`;
|
outEl.innerHTML = `<span style="color:var(--error)">Error: ${e.message}</span>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('pgSearch')?.addEventListener('keydown', e => { if (e.key === 'Enter') pgRun('search'); });
|
document.getElementById('pgSearch')?.addEventListener('keydown', e => { if (e.key === 'Enter') pgRun('search'); });
|
||||||
document.getElementById('pgStream')?.addEventListener('keydown', e => { if (e.key === 'Enter') pgRun('stream'); });
|
document.getElementById('pgStream')?.addEventListener('keydown', e => { if (e.key === 'Enter') pgRun('stream'); });
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+10
-6
@@ -1,15 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "AniKotoAPI",
|
"name": "AniKotoAPI — Free Anime REST API",
|
||||||
"short_name": "AniKotoAPI",
|
"short_name": "AniKotoAPI",
|
||||||
"description": "Free REST API for anime data. Browse, search, stream episodes. 30+ endpoints, no API key required.",
|
"description": "Free anime REST API with 38 endpoints. Search, filter, stream episodes, get rankings. No API key required.",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#0A0A12",
|
"id": "/",
|
||||||
"theme_color": "#A855F7",
|
"scope": "/",
|
||||||
|
"lang": "en",
|
||||||
|
"orientation": "any",
|
||||||
|
"background_color": "#faf9f5",
|
||||||
|
"theme_color": "#faf9f5",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/favicon-origin.png",
|
"src": "/favicon-origin.png",
|
||||||
"sizes": "192x192",
|
"sizes": "512x152",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
},
|
},
|
||||||
@@ -20,5 +24,5 @@
|
|||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"categories": ["developer", "entertainment"]
|
"categories": ["developer", "entertainment", "utilities"]
|
||||||
}
|
}
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" fill="none">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="bg" x1="0" y1="0" x2="1200" y2="630" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#0A0A12"/>
|
|
||||||
<stop offset="1" stop-color="#111120"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="grad" x1="0" y1="0" x2="600" y2="630" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#A855F7"/>
|
|
||||||
<stop offset="1" stop-color="#EC4899"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="orb1" cx="300" cy="200" r="200" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#A855F7" stop-opacity="0.3"/>
|
|
||||||
<stop offset="1" stop-color="#A855F7" stop-opacity="0"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="orb2" cx="900" cy="450" r="180" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#EC4899" stop-opacity="0.25"/>
|
|
||||||
<stop offset="1" stop-color="#EC4899" stop-opacity="0"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<rect width="1200" height="630" fill="url(#bg)"/>
|
|
||||||
<circle cx="300" cy="200" r="250" fill="url(#orb1)"/>
|
|
||||||
<circle cx="950" cy="420" r="220" fill="url(#orb2)"/>
|
|
||||||
<circle cx="600" cy="315" r="160" stroke="url(#grad)" stroke-width="3" fill="none" opacity="0.3"/>
|
|
||||||
<circle cx="600" cy="315" r="120" stroke="url(#grad)" stroke-width="2" fill="none" opacity="0.15"/>
|
|
||||||
<text x="600" y="280" text-anchor="middle" font-family="system-ui,sans-serif" font-weight="900" font-size="72" fill="url(#grad)">AniKotoAPI</text>
|
|
||||||
<text x="600" y="340" text-anchor="middle" font-family="system-ui,sans-serif" font-weight="500" font-size="24" fill="#94A3B8">Free Anime Data REST API</text>
|
|
||||||
<text x="600" y="380" text-anchor="middle" font-family="monospace" font-weight="400" font-size="16" fill="#64748B">anikototvapi.vercel.app/api</text>
|
|
||||||
<rect x="480" y="410" width="240" height="40" rx="20" fill="url(#grad)" opacity="0.9"/>
|
|
||||||
<text x="600" y="436" text-anchor="middle" font-family="system-ui,sans-serif" font-weight="600" font-size="14" fill="#fff">No API Key Required</text>
|
|
||||||
<circle cx="120" cy="540" r="4" fill="#A855F7" opacity="0.4"/>
|
|
||||||
<circle cx="180" cy="560" r="3" fill="#EC4899" opacity="0.3"/>
|
|
||||||
<circle cx="1050" cy="80" r="5" fill="#6366F1" opacity="0.3"/>
|
|
||||||
<circle cx="1100" cy="120" r="3" fill="#A855F7" opacity="0.25"/>
|
|
||||||
<circle cx="80" cy="100" r="3" fill="#EC4899" opacity="0.2"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.3 KiB |
+37
-26
@@ -2,42 +2,53 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="description" content="Privacy Policy for AniKotoAPI. We do not collect, store, or share personal data." />
|
||||||
|
<meta property="og:title" content="Privacy Policy — AniKotoAPI" />
|
||||||
|
<meta property="og:description" content="Privacy Policy for AniKotoAPI. We do not collect, store, or share personal data." />
|
||||||
|
<meta property="og:image" content="https://anikototvapi.vercel.app/share.png" />
|
||||||
|
<meta name="twitter:card" content="summary" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Privacy Policy — AniKotoAPI</title>
|
<title>Privacy Policy — AniKotoAPI</title>
|
||||||
<meta name="theme-color" content="#0A0A12" />
|
<meta name="theme-color" content="#faf9f5" />
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⛩</text></svg>" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
<style>
|
<style>
|
||||||
:root{--bg:#0A0A12;--bg2:#111120;--text:#E2E8F0;--text2:#94A3B8;--text3:#64748B;--purple:#A855F7;--pink:#EC4899;--border:rgba(255,255,255,.06);--glass:rgba(255,255,255,.03)}
|
:root{
|
||||||
|
--canvas:#faf9f5;--surface-card:#efe9de;--hairline:#e6dfd8;
|
||||||
|
--ink:#141413;--body:#3d3d3a;--muted:#6c6a64;--muted-soft:#8e8b82;
|
||||||
|
--primary:#cc785c;--primary-active:#a9583e;
|
||||||
|
--surface-dark:#181715;--on-dark:#faf9f5;--on-dark-soft:#a09d96;
|
||||||
|
--font-display:'Cormorant Garamond',Garamond,'Times New Roman',serif;
|
||||||
|
--font-body:'Inter',-apple-system,BlinkMacSystemFont,sans-serif;
|
||||||
|
--font-code:'JetBrains Mono',monospace;
|
||||||
|
}
|
||||||
*{margin:0;padding:0;box-sizing:border-box}
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.8}
|
body{font-family:var(--font-body);background:var(--canvas);color:var(--body);line-height:1.8}
|
||||||
.ambient{position:fixed;inset:0;pointer-events:none;z-index:0}
|
header{position:sticky;top:0;z-index:100;background:var(--canvas);border-bottom:1px solid var(--hairline);padding:0 24px}
|
||||||
.orb{position:absolute;border-radius:50%;filter:blur(120px);opacity:.08}
|
.header-inner{max-width:720px;margin:0 auto;display:flex;align-items:center;height:64px}
|
||||||
.orb-1{width:500px;height:500px;background:var(--purple);top:-150px;left:-100px}
|
.logo{display:flex;align-items:center;gap:8px;text-decoration:none;color:var(--ink)}
|
||||||
.orb-2{width:400px;height:400px;background:var(--pink);bottom:-100px;right:-80px}
|
.logo-mark{font-family:var(--font-display);font-size:1.2rem;font-weight:600;letter-spacing:-.02em}
|
||||||
header{position:sticky;top:0;z-index:100;background:rgba(10,10,18,.88);backdrop-filter:blur(24px);border-bottom:1px solid var(--border);padding:0 24px}
|
.logo-mark span{color:var(--primary)}
|
||||||
.header-inner{max-width:720px;margin:0 auto;display:flex;align-items:center;height:60px}
|
|
||||||
.logo{display:flex;align-items:center;gap:10px;text-decoration:none;color:var(--text);font-weight:700;font-size:1rem}
|
|
||||||
.logo span{background:linear-gradient(135deg,var(--purple),var(--pink));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
|
||||||
.content{position:relative;z-index:1;max-width:720px;margin:0 auto;padding:60px 24px 80px}
|
.content{position:relative;z-index:1;max-width:720px;margin:0 auto;padding:60px 24px 80px}
|
||||||
h1{font-size:2rem;font-weight:800;letter-spacing:-.03em;margin-bottom:8px}
|
h1{font-family:var(--font-display);font-size:2.2rem;font-weight:400;letter-spacing:-.02em;margin-bottom:8px;color:var(--ink)}
|
||||||
.updated{color:var(--text3);font-size:.82rem;margin-bottom:40px;font-family:'JetBrains Mono',monospace}
|
.updated{color:var(--muted-soft);font-size:.82rem;margin-bottom:40px;font-family:var(--font-code)}
|
||||||
h2{font-size:1.2rem;font-weight:700;margin:32px 0 12px;padding-top:16px;border-top:1px solid var(--border)}
|
h2{font-family:var(--font-display);font-size:1.2rem;font-weight:500;margin:32px 0 12px;padding-top:16px;border-top:1px solid var(--hairline);color:var(--ink)}
|
||||||
h2:first-of-type{border-top:none;padding-top:0}
|
h2:first-of-type{border-top:none;padding-top:0}
|
||||||
p{color:var(--text2);font-size:.92rem;margin-bottom:16px}
|
p{color:var(--body);font-size:.92rem;margin-bottom:16px}
|
||||||
ul{color:var(--text2);font-size:.92rem;margin:0 0 16px 20px}
|
ul{color:var(--body);font-size:.92rem;margin:0 0 16px 20px}
|
||||||
li{margin-bottom:6px}
|
li{margin-bottom:6px}
|
||||||
code{font-family:'JetBrains Mono',monospace;font-size:.82rem;background:rgba(168,85,247,.1);color:var(--purple);padding:2px 6px;border-radius:4px}
|
code{font-family:var(--font-code);font-size:.82rem;background:rgba(204,120,92,.08);color:var(--primary);padding:2px 6px;border-radius:4px}
|
||||||
a{color:var(--purple);text-decoration:none}
|
a{color:var(--primary);text-decoration:none}
|
||||||
a:hover{text-decoration:underline}
|
a:hover{text-decoration:underline}
|
||||||
footer{position:relative;z-index:1;border-top:1px solid var(--border);padding:24px;text-align:center;color:var(--text3);font-size:.75rem}
|
strong{color:var(--ink)}
|
||||||
|
footer{position:relative;z-index:1;border-top:1px solid var(--hairline);padding:24px;text-align:center;color:var(--muted-soft);font-size:.75rem}
|
||||||
|
footer a{color:var(--primary)}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="ambient"><div class="orb orb-1"></div><div class="orb orb-2"></div></div>
|
<header><div class="header-inner"><a href="/" class="logo"><span class="logo-mark"><span>*</span> AniKotoAPI</span></a></div></header>
|
||||||
<header><div class="header-inner"><a href="/" class="logo">⛩ <span>AniKoto</span>API</a></div></header>
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<h1>Privacy Policy</h1>
|
<h1>Privacy Policy</h1>
|
||||||
<div class="updated">Last updated: 2026-06-08</div>
|
<div class="updated">Last updated: 2026-06-08</div>
|
||||||
@@ -56,7 +67,7 @@
|
|||||||
|
|
||||||
<h2>3. Third-Party Data Sources</h2>
|
<h2>3. Third-Party Data Sources</h2>
|
||||||
<p>The Service scrapes data from <code>anikototv.to</code> and related domains. This data (anime titles, descriptions, images, episode information) belongs to the respective content owners. We do not claim ownership of this data.</p>
|
<p>The Service scrapes data from <code>anikototv.to</code> and related domains. This data (anime titles, descriptions, images, episode information) belongs to the respective content owners. We do not claim ownership of this data.</p>
|
||||||
<p>If you are a content owner and wish to have your data removed from this API, please open an issue on our <a href="https://github.com/Shineii86/AniKotoAPI" target="_blank">GitHub repository</a>.</p>
|
<p>If you are a content owner and wish to have your data removed from this API, please open an issue on our <a href="https://github.com/Shineii86/AniKotoAPI" target="_blank" rel="noopener noreferrer">GitHub repository</a>.</p>
|
||||||
|
|
||||||
<h2>4. Data Storage</h2>
|
<h2>4. Data Storage</h2>
|
||||||
<p>The Service uses a simple in-memory cache (5-minute TTL) to reduce repeated scraping. This cache is:</p>
|
<p>The Service uses a simple in-memory cache (5-minute TTL) to reduce repeated scraping. This cache is:</p>
|
||||||
@@ -76,8 +87,8 @@
|
|||||||
<p>We may update this Privacy Policy from time to time. Changes will be posted on this page with an updated "Last updated" date.</p>
|
<p>We may update this Privacy Policy from time to time. Changes will be posted on this page with an updated "Last updated" date.</p>
|
||||||
|
|
||||||
<h2>8. Contact</h2>
|
<h2>8. Contact</h2>
|
||||||
<p>For questions about this Privacy Policy, open an issue at <a href="https://github.com/Shineii86/AniKotoAPI/issues" target="_blank">github.com/Shineii86/AniKotoAPI/issues</a>.</p>
|
<p>For questions about this Privacy Policy, open an issue at <a href="https://github.com/Shineii86/AniKotoAPI/issues" target="_blank" rel="noopener noreferrer">github.com/Shineii86/AniKotoAPI/issues</a>.</p>
|
||||||
</div>
|
</div>
|
||||||
<footer>© 2026 AniKotoAPI · <a href="/" style="color:var(--purple)">Home</a> · <a href="/tos" style="color:var(--purple)">Terms</a></footer>
|
<footer>© 2026 AniKotoAPI · <a href="/">Home</a> · <a href="/tos">Terms</a></footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+3
-1
@@ -1,6 +1,8 @@
|
|||||||
# AniKotoAPI — https://anikototvapi.vercel.app
|
# AniKotoAPI — https://anikototvapi.vercel.app
|
||||||
User-agent: *
|
User-agent: *
|
||||||
Allow: /
|
Allow: /
|
||||||
Allow: /api
|
Disallow: /api
|
||||||
|
Disallow: /privacy
|
||||||
|
Disallow: /tos
|
||||||
|
|
||||||
Sitemap: https://anikototvapi.vercel.app/sitemap.xml
|
Sitemap: https://anikototvapi.vercel.app/sitemap.xml
|
||||||
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
+4
-16
@@ -2,26 +2,14 @@
|
|||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
<url>
|
<url>
|
||||||
<loc>https://anikototvapi.vercel.app/</loc>
|
<loc>https://anikototvapi.vercel.app/</loc>
|
||||||
<lastmod>2026-06-08</lastmod>
|
<lastmod>2026-07-28</lastmod>
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>1.0</priority>
|
<priority>1.0</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://anikototvapi.vercel.app/api</loc>
|
<loc>https://github.com/Shineii86/AniKotoAPI</loc>
|
||||||
<lastmod>2026-06-08</lastmod>
|
<lastmod>2026-07-28</lastmod>
|
||||||
<changefreq>daily</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.9</priority>
|
<priority>0.9</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
|
||||||
<loc>https://anikototvapi.vercel.app/privacy</loc>
|
|
||||||
<lastmod>2026-06-08</lastmod>
|
|
||||||
<changefreq>monthly</changefreq>
|
|
||||||
<priority>0.3</priority>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://anikototvapi.vercel.app/tos</loc>
|
|
||||||
<lastmod>2026-06-08</lastmod>
|
|
||||||
<changefreq>monthly</changefreq>
|
|
||||||
<priority>0.3</priority>
|
|
||||||
</url>
|
|
||||||
</urlset>
|
</urlset>
|
||||||
+38
-28
@@ -2,43 +2,53 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="description" content="Terms of Service for AniKotoAPI. Acceptable use, rate limits, liability, and DMCA policy." />
|
||||||
|
<meta property="og:title" content="Terms of Service — AniKotoAPI" />
|
||||||
|
<meta property="og:description" content="Terms of Service for AniKotoAPI. Acceptable use, rate limits, liability, and DMCA policy." />
|
||||||
|
<meta property="og:image" content="https://anikototvapi.vercel.app/share.png" />
|
||||||
|
<meta name="twitter:card" content="summary" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Terms of Service — AniKotoAPI</title>
|
<title>Terms of Service — AniKotoAPI</title>
|
||||||
<meta name="theme-color" content="#0A0A12" />
|
<meta name="theme-color" content="#faf9f5" />
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⛩</text></svg>" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
<style>
|
<style>
|
||||||
:root{--bg:#0A0A12;--bg2:#111120;--text:#E2E8F0;--text2:#94A3B8;--text3:#64748B;--purple:#A855F7;--pink:#EC4899;--border:rgba(255,255,255,.06);--glass:rgba(255,255,255,.03)}
|
:root{
|
||||||
|
--canvas:#faf9f5;--surface-card:#efe9de;--hairline:#e6dfd8;
|
||||||
|
--ink:#141413;--body:#3d3d3a;--muted:#6c6a64;--muted-soft:#8e8b82;
|
||||||
|
--primary:#cc785c;--primary-active:#a9583e;
|
||||||
|
--surface-dark:#181715;--on-dark:#faf9f5;--on-dark-soft:#a09d96;
|
||||||
|
--font-display:'Cormorant Garamond',Garamond,'Times New Roman',serif;
|
||||||
|
--font-body:'Inter',-apple-system,BlinkMacSystemFont,sans-serif;
|
||||||
|
--font-code:'JetBrains Mono',monospace;
|
||||||
|
}
|
||||||
*{margin:0;padding:0;box-sizing:border-box}
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.8}
|
body{font-family:var(--font-body);background:var(--canvas);color:var(--body);line-height:1.8}
|
||||||
.ambient{position:fixed;inset:0;pointer-events:none;z-index:0}
|
header{position:sticky;top:0;z-index:100;background:var(--canvas);border-bottom:1px solid var(--hairline);padding:0 24px}
|
||||||
.orb{position:absolute;border-radius:50%;filter:blur(120px);opacity:.08}
|
.header-inner{max-width:720px;margin:0 auto;display:flex;align-items:center;height:64px}
|
||||||
.orb-1{width:500px;height:500px;background:var(--purple);top:-150px;left:-100px}
|
.logo{display:flex;align-items:center;gap:8px;text-decoration:none;color:var(--ink)}
|
||||||
.orb-2{width:400px;height:400px;background:var(--pink);bottom:-100px;right:-80px}
|
.logo-mark{font-family:var(--font-display);font-size:1.2rem;font-weight:600;letter-spacing:-.02em}
|
||||||
header{position:sticky;top:0;z-index:100;background:rgba(10,10,18,.88);backdrop-filter:blur(24px);border-bottom:1px solid var(--border);padding:0 24px}
|
.logo-mark span{color:var(--primary)}
|
||||||
.header-inner{max-width:720px;margin:0 auto;display:flex;align-items:center;height:60px}
|
|
||||||
.logo{display:flex;align-items:center;gap:10px;text-decoration:none;color:var(--text);font-weight:700;font-size:1rem}
|
|
||||||
.logo span{background:linear-gradient(135deg,var(--purple),var(--pink));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
|
||||||
.content{position:relative;z-index:1;max-width:720px;margin:0 auto;padding:60px 24px 80px}
|
.content{position:relative;z-index:1;max-width:720px;margin:0 auto;padding:60px 24px 80px}
|
||||||
h1{font-size:2rem;font-weight:800;letter-spacing:-.03em;margin-bottom:8px}
|
h1{font-family:var(--font-display);font-size:2.2rem;font-weight:400;letter-spacing:-.02em;margin-bottom:8px;color:var(--ink)}
|
||||||
.updated{color:var(--text3);font-size:.82rem;margin-bottom:40px;font-family:'JetBrains Mono',monospace}
|
.updated{color:var(--muted-soft);font-size:.82rem;margin-bottom:40px;font-family:var(--font-code)}
|
||||||
h2{font-size:1.2rem;font-weight:700;margin:32px 0 12px;padding-top:16px;border-top:1px solid var(--border)}
|
h2{font-family:var(--font-display);font-size:1.2rem;font-weight:500;margin:32px 0 12px;padding-top:16px;border-top:1px solid var(--hairline);color:var(--ink)}
|
||||||
h2:first-of-type{border-top:none;padding-top:0}
|
h2:first-of-type{border-top:none;padding-top:0}
|
||||||
p{color:var(--text2);font-size:.92rem;margin-bottom:16px}
|
p{color:var(--body);font-size:.92rem;margin-bottom:16px}
|
||||||
ul{color:var(--text2);font-size:.92rem;margin:0 0 16px 20px}
|
ul{color:var(--body);font-size:.92rem;margin:0 0 16px 20px}
|
||||||
li{margin-bottom:6px}
|
li{margin-bottom:6px}
|
||||||
code{font-family:'JetBrains Mono',monospace;font-size:.82rem;background:rgba(168,85,247,.1);color:var(--purple);padding:2px 6px;border-radius:4px}
|
code{font-family:var(--font-code);font-size:.82rem;background:rgba(204,120,92,.08);color:var(--primary);padding:2px 6px;border-radius:4px}
|
||||||
a{color:var(--purple);text-decoration:none}
|
a{color:var(--primary);text-decoration:none}
|
||||||
a:hover{text-decoration:underline}
|
a:hover{text-decoration:underline}
|
||||||
strong{color:var(--text)}
|
strong{color:var(--ink)}
|
||||||
footer{position:relative;z-index:1;border-top:1px solid var(--border);padding:24px;text-align:center;color:var(--text3);font-size:.75rem}
|
footer{position:relative;z-index:1;border-top:1px solid var(--hairline);padding:24px;text-align:center;color:var(--muted-soft);font-size:.75rem}
|
||||||
|
footer a{color:var(--primary)}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="ambient"><div class="orb orb-1"></div><div class="orb orb-2"></div></div>
|
<header><div class="header-inner"><a href="/" class="logo"><span class="logo-mark"><span>*</span> AniKotoAPI</span></a></div></header>
|
||||||
<header><div class="header-inner"><a href="/" class="logo">⛩ <span>AniKoto</span>API</a></div></header>
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<h1>Terms of Service</h1>
|
<h1>Terms of Service</h1>
|
||||||
<div class="updated">Last updated: 2026-06-08</div>
|
<div class="updated">Last updated: 2026-06-08</div>
|
||||||
@@ -78,17 +88,17 @@
|
|||||||
<p>In no event shall the creators of AniKotoAPI be liable for any indirect, incidental, special, consequential, or punitive damages arising from your use of the Service.</p>
|
<p>In no event shall the creators of AniKotoAPI be liable for any indirect, incidental, special, consequential, or punitive damages arising from your use of the Service.</p>
|
||||||
|
|
||||||
<h2>10. Intellectual Property</h2>
|
<h2>10. Intellectual Property</h2>
|
||||||
<p>The source code of AniKotoAPI is open source and available on <a href="https://github.com/Shineii86/AniKotoAPI" target="_blank">GitHub</a> under the repository's license terms.</p>
|
<p>The source code of AniKotoAPI is open source and available on <a href="https://github.com/Shineii86/AniKotoAPI" target="_blank" rel="noopener noreferrer">GitHub</a> under the repository's license terms.</p>
|
||||||
|
|
||||||
<h2>11. DMCA / Takedowns</h2>
|
<h2>11. DMCA / Takedowns</h2>
|
||||||
<p>If you believe your copyrighted content is being displayed through this API without authorization, please open an issue at <a href="https://github.com/Shineii86/AniKotoAPI/issues" target="_blank">github.com/Shineii86/AniKotoAPI/issues</a> and we will promptly remove it.</p>
|
<p>If you believe your copyrighted content is being displayed through this API without authorization, please open an issue at <a href="https://github.com/Shineii86/AniKotoAPI/issues" target="_blank" rel="noopener noreferrer">github.com/Shineii86/AniKotoAPI/issues</a> and we will promptly remove it.</p>
|
||||||
|
|
||||||
<h2>12. Changes to Terms</h2>
|
<h2>12. Changes to Terms</h2>
|
||||||
<p>We reserve the right to modify these Terms at any time. Changes will be posted on this page with an updated "Last updated" date.</p>
|
<p>We reserve the right to modify these Terms at any time. Changes will be posted on this page with an updated "Last updated" date.</p>
|
||||||
|
|
||||||
<h2>13. Contact</h2>
|
<h2>13. Contact</h2>
|
||||||
<p>For questions about these Terms, open an issue at <a href="https://github.com/Shineii86/AniKotoAPI/issues" target="_blank">github.com/Shineii86/AniKotoAPI/issues</a>.</p>
|
<p>For questions about these Terms, open an issue at <a href="https://github.com/Shineii86/AniKotoAPI/issues" target="_blank" rel="noopener noreferrer">github.com/Shineii86/AniKotoAPI/issues</a>.</p>
|
||||||
</div>
|
</div>
|
||||||
<footer>© 2026 AniKotoAPI · <a href="/" style="color:var(--purple)">Home</a> · <a href="/privacy" style="color:var(--purple)">Privacy</a></footer>
|
<footer>© 2026 AniKotoAPI · <a href="/">Home</a> · <a href="/privacy">Privacy</a></footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -16,16 +16,22 @@
|
|||||||
* ======= • ======= • ======= • ======= • =======• =======
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
*/
|
*/
|
||||||
|
|
||||||
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 express from "express";
|
||||||
import cors from "cors";
|
import compression from "compression";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
|
import crypto from "crypto";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { dirname } from "path";
|
import { dirname } from "path";
|
||||||
import { createApiRoutes } from "./src/routes/apiRoutes.js";
|
import { createApiRoutes } from "./src/routes/apiRoutes.js";
|
||||||
|
import { addCreatorInfo } from "./src/middleware/creatorInfo.js";
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// SERVER CONFIGURATION
|
// SERVER CONFIGURATION
|
||||||
@@ -38,6 +44,31 @@ const __dirname = dirname(__filename);
|
|||||||
const publicDir = path.join(process.cwd(), "public");
|
const publicDir = path.join(process.cwd(), "public");
|
||||||
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(",");
|
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(",");
|
||||||
|
|
||||||
|
// ---- FEATURE: Response compression ----
|
||||||
|
app.use(compression({
|
||||||
|
filter: (req, res) => {
|
||||||
|
if (req.headers["x-no-compression"]) return false;
|
||||||
|
return compression.filter(req, res);
|
||||||
|
},
|
||||||
|
level: 6,
|
||||||
|
threshold: 1024
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---- FEATURE: Request body size limits ----
|
||||||
|
app.use(express.json({ limit: "10kb" }));
|
||||||
|
app.use(express.urlencoded({ extended: false, limit: "10kb" }));
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// REQUEST ID TRACKING
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Unique request ID for debugging ----
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
req.id = crypto.randomUUID();
|
||||||
|
res.setHeader("X-Request-Id", req.id);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// CORS MIDDLEWARE
|
// CORS MIDDLEWARE
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
@@ -48,8 +79,8 @@ app.use((req, res, next) => {
|
|||||||
if (!allowedOrigins || allowedOrigins.includes("*") || (origin && allowedOrigins.includes(origin))) {
|
if (!allowedOrigins || allowedOrigins.includes("*") || (origin && allowedOrigins.includes(origin))) {
|
||||||
res.setHeader("Access-Control-Allow-Origin", origin || "*");
|
res.setHeader("Access-Control-Allow-Origin", origin || "*");
|
||||||
}
|
}
|
||||||
res.setHeader("Access-Control-Allow-Methods", "GET");
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-Requested-With");
|
||||||
if (req.method === "OPTIONS") {
|
if (req.method === "OPTIONS") {
|
||||||
return res.status(204).end();
|
return res.status(204).end();
|
||||||
}
|
}
|
||||||
@@ -63,6 +94,9 @@ app.use((req, res, next) => {
|
|||||||
res.setHeader("X-XSS-Protection", "1; mode=block");
|
res.setHeader("X-XSS-Protection", "1; mode=block");
|
||||||
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||||
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
||||||
|
res.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'");
|
||||||
|
res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
||||||
|
res.setHeader("X-Permitted-Cross-Domain-Policies", "none");
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -116,19 +150,102 @@ const jsonError = (res, message = "Internal server error", status = 500) =>
|
|||||||
// API ROUTES
|
// API ROUTES
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Rate limiting (configurable, default 100 requests per minute per IP) ----
|
||||||
|
const requestCounts = new Map();
|
||||||
|
const RATE_LIMIT = parseInt(process.env.RATE_LIMIT) || 100;
|
||||||
|
const RATE_WINDOW = parseInt(process.env.RATE_WINDOW) || 60000;
|
||||||
|
|
||||||
|
// ---- FEATURE: Rate limiter cleanup interval (every 5 minutes) ----
|
||||||
|
const RATE_CLEANUP_INTERVAL = 300000;
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [ip, timestamps] of requestCounts.entries()) {
|
||||||
|
const validTimestamps = timestamps.filter(t => now - t < RATE_WINDOW);
|
||||||
|
if (validTimestamps.length === 0) {
|
||||||
|
requestCounts.delete(ip);
|
||||||
|
} else {
|
||||||
|
requestCounts.set(ip, validTimestamps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, RATE_CLEANUP_INTERVAL);
|
||||||
|
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const ip = req.ip || req.connection.remoteAddress;
|
||||||
|
const now = Date.now();
|
||||||
|
if (!requestCounts.has(ip)) {
|
||||||
|
requestCounts.set(ip, []);
|
||||||
|
}
|
||||||
|
const timestamps = requestCounts.get(ip).filter(t => now - t < RATE_WINDOW);
|
||||||
|
requestCounts.set(ip, timestamps);
|
||||||
|
if (timestamps.length >= RATE_LIMIT) {
|
||||||
|
return res.status(429).json({
|
||||||
|
success: false,
|
||||||
|
message: "Rate limit exceeded. Try again later.",
|
||||||
|
retryAfter: Math.ceil((timestamps[0] + RATE_WINDOW - now) / 1000)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
timestamps.push(now);
|
||||||
|
res.setHeader("X-RateLimit-Limit", RATE_LIMIT);
|
||||||
|
res.setHeader("X-RateLimit-Remaining", RATE_LIMIT - timestamps.length);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Creator info middleware (injects attribution into all responses) ----
|
||||||
|
app.use(addCreatorInfo);
|
||||||
|
|
||||||
|
// ---- FEATURE: Request timeout middleware (30 seconds) ----
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const timeout = parseInt(process.env.REQUEST_TIMEOUT) || 30000;
|
||||||
|
req.setTimeout(timeout, () => {
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(408).json({ success: false, message: "Request timeout" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
createApiRoutes(app, jsonResponse, jsonError);
|
createApiRoutes(app, jsonResponse, jsonError);
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// 404 HANDLER
|
// 404 HANDLER
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Global error handler ----
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
console.error(`[ERROR] ${req.id} ${err.message}`, err.stack);
|
||||||
|
if (err.type === 'entity.too.large') {
|
||||||
|
return res.status(413).json({ success: false, message: "Request entity too large" });
|
||||||
|
}
|
||||||
|
res.status(err.status || 500).json({
|
||||||
|
success: false,
|
||||||
|
message: process.env.NODE_ENV === 'production' ? "Internal server error" : err.message
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ---- FEATURE: Catch-all 404 handler for undefined routes ----
|
// ---- FEATURE: Catch-all 404 handler for undefined routes ----
|
||||||
app.use((req, res) => {
|
app.use((req, res) => {
|
||||||
const filePath = path.join(publicDir, "404.html");
|
const filePath = path.join(publicDir, "404.html");
|
||||||
if (fs.existsSync(filePath)) {
|
if (fs.existsSync(filePath)) {
|
||||||
res.status(404).sendFile(filePath);
|
res.status(404).sendFile(filePath);
|
||||||
} else {
|
} else {
|
||||||
res.status(404).json({ success: false, message: "Endpoint not found" });
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "Endpoint not found",
|
||||||
|
availableEndpoints: [
|
||||||
|
"/", "/search", "/search/suggest", "/info", "/watch",
|
||||||
|
"/episodes/:id", "/episodes-ajax/:id", "/stream", "/servers",
|
||||||
|
"/mapper-servers", "/download", "/stream/resolve",
|
||||||
|
"/stream/qualities", "/stream/proxy", "/stream/ts-proxy",
|
||||||
|
"/spotlight", "/trending", "/top-ten", "/suggestions",
|
||||||
|
"/random", "/most-popular", "/upcoming", "/top-rankings",
|
||||||
|
"/recently-updated", "/completed", "/new-release",
|
||||||
|
"/newly-added", "/latest-updated", "/trending-sidebar",
|
||||||
|
"/seasons/:id", "/watch-order/:id", "/az-list/:letter",
|
||||||
|
"/filter", "/genre/:genre", "/type/:type", "/status/:status",
|
||||||
|
"/schedule", "/health", "/stats", "/cache/stats",
|
||||||
|
"/mirrors", "/openapi"
|
||||||
|
]
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,8 +253,37 @@ app.use((req, res) => {
|
|||||||
// SERVER START
|
// SERVER START
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
const server = app.listen(PORT, () => {
|
||||||
console.info(`AniKotoAPI listening at ${PORT}`);
|
console.info(`AniKotoAPI listening at ${PORT}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// GRACEFUL SHUTDOWN
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Graceful shutdown handling ----
|
||||||
|
const gracefulShutdown = (signal) => {
|
||||||
|
console.info(`\n[SHUTDOWN] Received ${signal}. Starting graceful shutdown...`);
|
||||||
|
server.close(() => {
|
||||||
|
console.info("[SHUTDOWN] Server closed. Goodbye.");
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
console.error("[SHUTDOWN] Forced shutdown after timeout.");
|
||||||
|
process.exit(1);
|
||||||
|
}, 10000);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
||||||
|
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
||||||
|
|
||||||
|
process.on("unhandledRejection", (reason, promise) => {
|
||||||
|
console.error("[UNHANDLED] Unhandled Rejection:", reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("uncaughtException", (error) => {
|
||||||
|
console.error("[UNCAUGHT] Uncaught Exception:", error);
|
||||||
|
gracefulShutdown("uncaughtException");
|
||||||
|
});
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════ END: server.js
|
// ══════════════════════════════════════════════════════════════ END: server.js
|
||||||
@@ -212,5 +212,90 @@ const SORT_IDS = {
|
|||||||
"number_of_episodes": "7"
|
"number_of_episodes": "7"
|
||||||
};
|
};
|
||||||
|
|
||||||
export { GENRE_IDS, TYPE_IDS, STATUS_IDS, RATING_IDS, SORT_IDS };
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// SOURCE TYPE ID MAPPING
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Source type slug to numeric ID mapping ----
|
||||||
|
/**
|
||||||
|
* Maps anime source type slugs to their corresponding numeric IDs.
|
||||||
|
* Used to filter anime by their original source material.
|
||||||
|
*
|
||||||
|
* @type {Object}
|
||||||
|
* @property {string} manga - ID for manga source
|
||||||
|
* @property {string} light-novel - ID for light novel source
|
||||||
|
* @property {string} original - ID for original work
|
||||||
|
* @property {string} visual-novel - ID for visual novel source
|
||||||
|
* @property {string} game - ID for game source
|
||||||
|
* @property {string} 4-koma-manga - ID for 4-koma manga source
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Get source ID for filtering
|
||||||
|
* SOURCE_IDS["manga"] // "6"
|
||||||
|
* SOURCE_IDS["original"] // "10"
|
||||||
|
*/
|
||||||
|
const SOURCE_IDS = {
|
||||||
|
"4-koma-manga": "1",
|
||||||
|
"book": "2",
|
||||||
|
"card-game": "3",
|
||||||
|
"game": "4",
|
||||||
|
"light-novel": "5",
|
||||||
|
"manga": "6",
|
||||||
|
"mixed-media": "7",
|
||||||
|
"music": "8",
|
||||||
|
"novel": "9",
|
||||||
|
"original": "10",
|
||||||
|
"other": "11",
|
||||||
|
"picture-book": "12",
|
||||||
|
"radio": "13",
|
||||||
|
"unknown": "14",
|
||||||
|
"video-game": "15",
|
||||||
|
"visual-novel": "16",
|
||||||
|
"web-manga": "17",
|
||||||
|
"web-novel": "18"
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// SEASON ID MAPPING
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Season slug to ID mapping ----
|
||||||
|
/**
|
||||||
|
* Maps anime season slugs to their corresponding IDs.
|
||||||
|
* Used to filter anime by their broadcast season.
|
||||||
|
*
|
||||||
|
* @type {Object}
|
||||||
|
* @property {string} spring - ID for spring season
|
||||||
|
* @property {string} summer - ID for summer season
|
||||||
|
* @property {string} fall - ID for fall season
|
||||||
|
* @property {string} winter - ID for winter season
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Get season ID for filtering
|
||||||
|
* SEASON_IDS["spring"] // "1"
|
||||||
|
*/
|
||||||
|
const SEASON_IDS = {
|
||||||
|
"spring": "1",
|
||||||
|
"summer": "2",
|
||||||
|
"fall": "3",
|
||||||
|
"winter": "4"
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// TV-SPECIFIC TYPE IDS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Extended type slug to numeric ID mapping ----
|
||||||
|
/**
|
||||||
|
* Extended type mappings including TV-specific formats.
|
||||||
|
*
|
||||||
|
* @type {Object}
|
||||||
|
*/
|
||||||
|
const EXTENDED_TYPE_IDS = {
|
||||||
|
...TYPE_IDS,
|
||||||
|
"tv-short": "7",
|
||||||
|
"tv-special": "8"
|
||||||
|
};
|
||||||
|
|
||||||
|
export { GENRE_IDS, TYPE_IDS, STATUS_IDS, RATING_IDS, SORT_IDS, SOURCE_IDS, SEASON_IDS, EXTENDED_TYPE_IDS };
|
||||||
// ══════════════════════════════════════════════════════════════ END: ids.config.js
|
// ══════════════════════════════════════════════════════════════ END: ids.config.js
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { extractAnimeInfo } from "../extractors/animeInfo.extractor.js";
|
import { extractAnimeInfo } from "../extractors/animeInfo.extractor.js";
|
||||||
import { getCache, setCache } from "../helper/cache.helper.js";
|
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// CONTROLLER: ANIME INFO
|
// CONTROLLER: ANIME INFO
|
||||||
@@ -41,17 +41,19 @@ import { getCache, setCache } from "../helper/cache.helper.js";
|
|||||||
*/
|
*/
|
||||||
const getAnimeInfo = async (req, res, next) => {
|
const getAnimeInfo = async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.query;
|
const { id, slug } = req.query;
|
||||||
if (!id) {
|
// NOTE: Accept both ?id= and ?slug= for backwards compatibility
|
||||||
return res.status(400).json({ success: false, message: "Anime slug is required" });
|
const animeSlug = id || slug;
|
||||||
|
if (!animeSlug) {
|
||||||
|
return res.status(400).json({ success: false, message: "Anime slug is required (?id= or ?slug=)" });
|
||||||
}
|
}
|
||||||
const cacheKey = `anime_${id}`;
|
const cacheKey = `anime_${animeSlug}`;
|
||||||
const cached = getCache(cacheKey);
|
const cached = getCache(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return res.json({ success: true, results: cached });
|
return res.json({ success: true, results: cached });
|
||||||
}
|
}
|
||||||
const data = await extractAnimeInfo(id);
|
const data = await extractAnimeInfo(animeSlug);
|
||||||
setCache(cacheKey, data);
|
setCache(cacheKey, data, TTL.info);
|
||||||
res.json({ success: true, results: data });
|
res.json({ success: true, results: data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -17,7 +17,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { extractAzList } from "../extractors/azList.extractor.js";
|
import { extractAzList } from "../extractors/azList.extractor.js";
|
||||||
import { getCache, setCache } from "../helper/cache.helper.js";
|
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
|
||||||
|
import { addPaginationMeta } from "../helper/pagination.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// CONTROLLER: A-Z LIST
|
// CONTROLLER: A-Z LIST
|
||||||
@@ -51,8 +52,9 @@ const getAzList = async (req, res, next) => {
|
|||||||
return res.json({ success: true, results: cached });
|
return res.json({ success: true, results: cached });
|
||||||
}
|
}
|
||||||
const data = await extractAzList(letter, page);
|
const data = await extractAzList(letter, page);
|
||||||
setCache(cacheKey, data);
|
const response = addPaginationMeta(data, page);
|
||||||
res.json({ success: true, results: data });
|
setCache(cacheKey, response, TTL.genres);
|
||||||
|
res.json({ success: true, results: response });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { extractCategory } from "../extractors/category.extractor.js";
|
import { extractCategory } from "../extractors/category.extractor.js";
|
||||||
import { getCache, setCache } from "../helper/cache.helper.js";
|
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
|
||||||
|
import { addPaginationMeta } from "../helper/pagination.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// CONTROLLER: CATEGORY
|
// CONTROLLER: CATEGORY
|
||||||
@@ -55,8 +56,9 @@ const getCategory = async (req, res, next) => {
|
|||||||
return res.json({ success: true, results: cached });
|
return res.json({ success: true, results: cached });
|
||||||
}
|
}
|
||||||
const data = await extractCategory(category, page);
|
const data = await extractCategory(category, page);
|
||||||
setCache(cacheKey, data);
|
const response = addPaginationMeta(data, page);
|
||||||
res.json({ success: true, results: data });
|
setCache(cacheKey, response, TTL.genres);
|
||||||
|
res.json({ success: true, results: response });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* AniKotoAPI — completedAnime.controller.js
|
||||||
|
* Repository: https://github.com/Shineii86/AniKotoAPI
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* Controller for completed anime section. Returns list of anime
|
||||||
|
* that have finished airing from the homepage.
|
||||||
|
*
|
||||||
|
* @exports
|
||||||
|
* getCompletedAnime - Express route handler for GET /api/completed
|
||||||
|
*
|
||||||
|
* @author Shinei Nouzen
|
||||||
|
* @license MIT
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { extractCompletedAnime } from "../extractors/completedAnime.extractor.js";
|
||||||
|
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// CONTROLLER: COMPLETED ANIME
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Completed anime endpoint ----
|
||||||
|
/**
|
||||||
|
* Handles GET /api/completed requests. Returns list of anime
|
||||||
|
* that have finished airing from the homepage section.
|
||||||
|
*
|
||||||
|
* @param {object} req - Express request object
|
||||||
|
* @param {object} res - Express response object
|
||||||
|
* @param {Function} next - Express next middleware function
|
||||||
|
* @returns {void} Sends JSON response with completed anime list
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* GET /api/completed
|
||||||
|
* Response: { success: true, results: [...], ... }
|
||||||
|
*/
|
||||||
|
const getCompletedAnime = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const cacheKey = "completed";
|
||||||
|
const cached = getCache(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return res.json({ success: true, results: cached });
|
||||||
|
}
|
||||||
|
const data = await extractCompletedAnime();
|
||||||
|
setCache(cacheKey, data, TTL.default);
|
||||||
|
res.json({ success: true, results: data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { getCompletedAnime };
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: completedAnime.controller.js
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* AniKotoAPI — download.controller.js
|
||||||
|
* Repository: https://github.com/Shineii86/AniKotoAPI
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* Controller for download links endpoint. Returns streaming server
|
||||||
|
* URLs for downloading anime episodes.
|
||||||
|
*
|
||||||
|
* @exports
|
||||||
|
* getDownloadLinks - Express route handler for GET /api/download
|
||||||
|
*
|
||||||
|
* @author Shinei Nouzen
|
||||||
|
* @license MIT
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { extractDownloadLinks } from "../extractors/download.extractor.js";
|
||||||
|
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// DOWNLOAD LINKS HANDLER
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Download Links ----
|
||||||
|
/**
|
||||||
|
* Handles GET /api/download requests. Returns download links
|
||||||
|
* for the specified anime episode.
|
||||||
|
*
|
||||||
|
* @param {object} req - Express request object
|
||||||
|
* @param {string} req.query.slug - Anime slug (required)
|
||||||
|
* @param {number} req.query.ep - Episode number (required)
|
||||||
|
* @param {object} res - Express response object
|
||||||
|
* @param {Function} next - Express next middleware function
|
||||||
|
* @returns {void} Sends JSON response with download links
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* GET /api/download?slug=naruto-shippuden-c8gov&ep=1
|
||||||
|
* Response: { success: true, results: { slug, episode, downloads[] } }
|
||||||
|
*/
|
||||||
|
const getDownloadLinks = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { slug, ep } = req.query;
|
||||||
|
if (!slug || !ep) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "Both slug and ep parameters are required"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `download_${slug}_${ep}`;
|
||||||
|
const cached = getCache(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return res.json({ success: true, results: cached });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await extractDownloadLinks(slug, parseInt(ep));
|
||||||
|
setCache(cacheKey, data, TTL.stream);
|
||||||
|
res.json({ success: true, results: data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { getDownloadLinks };
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: download.controller.js
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { extractEpisodeList } from "../extractors/episodeList.extractor.js";
|
import { extractEpisodeList } from "../extractors/episodeList.extractor.js";
|
||||||
import { getCache, setCache } from "../helper/cache.helper.js";
|
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// CONTROLLER: EPISODE LIST
|
// CONTROLLER: EPISODE LIST
|
||||||
@@ -52,7 +52,7 @@ const getEpisodeList = async (req, res, next) => {
|
|||||||
return res.json({ success: true, results: cached });
|
return res.json({ success: true, results: cached });
|
||||||
}
|
}
|
||||||
const data = await extractEpisodeList(id);
|
const data = await extractEpisodeList(id);
|
||||||
setCache(cacheKey, data);
|
setCache(cacheKey, data, TTL.episodes);
|
||||||
res.json({ success: true, results: data });
|
res.json({ success: true, results: data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { extractEpisodeListAjax } from "../extractors/episodeListAjax.extractor.js";
|
import { extractEpisodeListAjax } from "../extractors/episodeListAjax.extractor.js";
|
||||||
import { getCache, setCache } from "../helper/cache.helper.js";
|
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// CONTROLLER: EPISODE LIST AJAX
|
// CONTROLLER: EPISODE LIST AJAX
|
||||||
@@ -56,7 +56,7 @@ const getEpisodeListAjax = async (req, res, next) => {
|
|||||||
return res.json({ success: true, results: cached });
|
return res.json({ success: true, results: cached });
|
||||||
}
|
}
|
||||||
const data = await extractEpisodeListAjax(id, style, vrf);
|
const data = await extractEpisodeListAjax(id, style, vrf);
|
||||||
setCache(cacheKey, data);
|
setCache(cacheKey, data, TTL.episodes);
|
||||||
res.json({ success: true, results: data });
|
res.json({ success: true, results: data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -17,7 +17,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { extractFilter } from "../extractors/filter.extractor.js";
|
import { extractFilter } from "../extractors/filter.extractor.js";
|
||||||
import { getCache, setCache } from "../helper/cache.helper.js";
|
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
|
||||||
|
import { addPaginationMeta } from "../helper/pagination.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// CONTROLLER: FILTER
|
// CONTROLLER: FILTER
|
||||||
@@ -47,14 +48,20 @@ import { getCache, setCache } from "../helper/cache.helper.js";
|
|||||||
const getFilter = async (req, res, next) => {
|
const getFilter = async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const params = req.query;
|
const params = req.query;
|
||||||
const cacheKey = `filter_${JSON.stringify(params)}`;
|
const page = params.page || 1;
|
||||||
|
const cacheKey = `filter_${JSON.stringify(params, Object.keys(params).sort())}`;
|
||||||
const cached = getCache(cacheKey);
|
const cached = getCache(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return res.json({ success: true, results: cached });
|
return res.json({ success: true, results: cached });
|
||||||
}
|
}
|
||||||
|
// Normalize array params to strings
|
||||||
|
if (Array.isArray(params.genre)) params.genre = params.genre.join(",");
|
||||||
|
if (Array.isArray(params.type)) params.type = params.type.join(",");
|
||||||
|
if (Array.isArray(params.status)) params.status = params.status.join(",");
|
||||||
const data = await extractFilter(params);
|
const data = await extractFilter(params);
|
||||||
setCache(cacheKey, data);
|
const response = addPaginationMeta(data, page);
|
||||||
res.json({ success: true, results: data });
|
setCache(cacheKey, response, TTL.genres);
|
||||||
|
res.json({ success: true, results: response });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { extractHomeInfo } from "../extractors/homeInfo.extractor.js";
|
import { extractHomeInfo } from "../extractors/homeInfo.extractor.js";
|
||||||
import { getCache, setCache } from "../helper/cache.helper.js";
|
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// CONTROLLER: HOME INFO
|
// CONTROLLER: HOME INFO
|
||||||
@@ -44,7 +44,7 @@ const getHomeInfo = async (req, res, next) => {
|
|||||||
return res.json({ success: true, results: cached });
|
return res.json({ success: true, results: cached });
|
||||||
}
|
}
|
||||||
const data = await extractHomeInfo();
|
const data = await extractHomeInfo();
|
||||||
setCache(cacheKey, data);
|
setCache(cacheKey, data, TTL.home);
|
||||||
res.json({ success: true, results: data });
|
res.json({ success: true, results: data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -17,8 +17,9 @@
|
|||||||
* ======= • ======= • ======= • ======= • =======• =======
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { extractNewRelease, extractNewlyAdded } from "../extractors/newRelease.extractor.js";
|
import { extractNewRelease, extractNewlyAdded, extractLatestUpdated } from "../extractors/newRelease.extractor.js";
|
||||||
import { getCache, setCache } from "../helper/cache.helper.js";
|
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
|
||||||
|
import { addPaginationMeta } from "../helper/pagination.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// CONTROLLER: NEW RELEASE
|
// CONTROLLER: NEW RELEASE
|
||||||
@@ -48,8 +49,9 @@ const getNewRelease = async (req, res, next) => {
|
|||||||
return res.json({ success: true, results: cached });
|
return res.json({ success: true, results: cached });
|
||||||
}
|
}
|
||||||
const data = await extractNewRelease(page);
|
const data = await extractNewRelease(page);
|
||||||
setCache(cacheKey, data);
|
const response = addPaginationMeta(data, page);
|
||||||
res.json({ success: true, results: data });
|
setCache(cacheKey, response, TTL.default);
|
||||||
|
res.json({ success: true, results: response });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -83,13 +85,50 @@ const getNewlyAdded = async (req, res, next) => {
|
|||||||
return res.json({ success: true, results: cached });
|
return res.json({ success: true, results: cached });
|
||||||
}
|
}
|
||||||
const data = await extractNewlyAdded(page);
|
const data = await extractNewlyAdded(page);
|
||||||
setCache(cacheKey, data);
|
const response = addPaginationMeta(data, page);
|
||||||
res.json({ success: true, results: data });
|
setCache(cacheKey, response, TTL.default);
|
||||||
|
res.json({ success: true, results: response });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export { getNewRelease, getNewlyAdded };
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// CONTROLLER: LATEST UPDATED
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Latest Updated Endpoint ----
|
||||||
|
/**
|
||||||
|
* Handles GET /api/latest-updated requests. Returns paginated list
|
||||||
|
* of recently updated anime sorted by last update time.
|
||||||
|
*
|
||||||
|
* @param {object} req - Express request object
|
||||||
|
* @param {number} req.query.page - Page number (default: 1)
|
||||||
|
* @param {object} res - Express response object
|
||||||
|
* @param {Function} next - Express next middleware function
|
||||||
|
* @returns {void} Sends JSON response with latest updated anime
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* GET /api/latest-updated?page=2
|
||||||
|
* Response: { success: true, results: { totalPages, data[] } }
|
||||||
|
*/
|
||||||
|
const getLatestUpdated = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { page = 1 } = req.query;
|
||||||
|
const cacheKey = `latestupdated_${page}`;
|
||||||
|
const cached = getCache(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return res.json({ success: true, results: cached });
|
||||||
|
}
|
||||||
|
const data = await extractLatestUpdated(page);
|
||||||
|
const response = addPaginationMeta(data, page);
|
||||||
|
setCache(cacheKey, response, TTL.default);
|
||||||
|
res.json({ success: true, results: response });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { getNewRelease, getNewlyAdded, getLatestUpdated };
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════ END: newRelease.controller.js
|
// ══════════════════════════════════════════════════════════════ END: newRelease.controller.js
|
||||||
@@ -17,7 +17,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { extractPopular } from "../extractors/popular.extractor.js";
|
import { extractPopular } from "../extractors/popular.extractor.js";
|
||||||
import { getCache, setCache } from "../helper/cache.helper.js";
|
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
|
||||||
|
import { addPaginationMeta } from "../helper/pagination.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// CONTROLLER: POPULAR
|
// CONTROLLER: POPULAR
|
||||||
@@ -47,8 +48,9 @@ const getPopular = async (req, res, next) => {
|
|||||||
return res.json({ success: true, results: cached });
|
return res.json({ success: true, results: cached });
|
||||||
}
|
}
|
||||||
const data = await extractPopular(page);
|
const data = await extractPopular(page);
|
||||||
setCache(cacheKey, data);
|
const response = addPaginationMeta(data, page);
|
||||||
res.json({ success: true, results: data });
|
setCache(cacheKey, response, TTL.default);
|
||||||
|
res.json({ success: true, results: response });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { extractRandom } from "../extractors/random.extractor.js";
|
import { extractRandom } from "../extractors/random.extractor.js";
|
||||||
import { getCache, setCache } from "../helper/cache.helper.js";
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// CONTROLLER: RANDOM
|
// CONTROLLER: RANDOM
|
||||||
@@ -38,13 +38,7 @@ import { getCache, setCache } from "../helper/cache.helper.js";
|
|||||||
*/
|
*/
|
||||||
const getRandom = async (req, res, next) => {
|
const getRandom = async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const cacheKey = "random";
|
|
||||||
const cached = getCache(cacheKey);
|
|
||||||
if (cached) {
|
|
||||||
return res.json({ success: true, results: cached });
|
|
||||||
}
|
|
||||||
const data = await extractRandom();
|
const data = await extractRandom();
|
||||||
setCache(cacheKey, data);
|
|
||||||
res.json({ success: true, results: data });
|
res.json({ success: true, results: data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* AniKotoAPI — recentlyUpdatedTabs.controller.js
|
||||||
|
* Repository: https://github.com/Shineii86/AniKotoAPI
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* Controller for recently updated anime with tab filtering.
|
||||||
|
* Returns recently updated anime filtered by all, dub, or sub.
|
||||||
|
*
|
||||||
|
* @exports
|
||||||
|
* getRecentlyUpdatedTabs - Express route handler for GET /api/recently-updated
|
||||||
|
*
|
||||||
|
* @author Shinei Nouzen
|
||||||
|
* @license MIT
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { extractRecentlyUpdatedTabs } from "../extractors/recentlyUpdatedTabs.extractor.js";
|
||||||
|
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// CONTROLLER: RECENTLY UPDATED TABS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Recently updated with tab filtering endpoint ----
|
||||||
|
/**
|
||||||
|
* Handles GET /api/recently-updated requests. Returns recently updated
|
||||||
|
* anime filtered by tab: all, dub, or sub.
|
||||||
|
*
|
||||||
|
* @param {object} req - Express request object
|
||||||
|
* @param {string} [req.query.tab="all"] - Tab filter: "all", "dub", or "sub"
|
||||||
|
* @param {object} res - Express response object
|
||||||
|
* @param {Function} next - Express next middleware function
|
||||||
|
* @returns {void} Sends JSON response with recently updated anime list
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* GET /api/recently-updated?tab=dub
|
||||||
|
* Response: { success: true, results: [...], tab: "dub" }
|
||||||
|
*/
|
||||||
|
const getRecentlyUpdatedTabs = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { tab = "all" } = req.query;
|
||||||
|
const cacheKey = `recently_updated_${tab}`;
|
||||||
|
const cached = getCache(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return res.json({ success: true, results: cached, tab });
|
||||||
|
}
|
||||||
|
const data = await extractRecentlyUpdatedTabs(tab);
|
||||||
|
setCache(cacheKey, data, TTL.default);
|
||||||
|
res.json({ success: true, results: data, tab });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { getRecentlyUpdatedTabs };
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: recentlyUpdatedTabs.controller.js
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { extractSchedule } from "../extractors/schedule.extractor.js";
|
import { extractSchedule } from "../extractors/schedule.extractor.js";
|
||||||
import { getCache, setCache } from "../helper/cache.helper.js";
|
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// CONTROLLER: SCHEDULE
|
// CONTROLLER: SCHEDULE
|
||||||
@@ -53,7 +53,7 @@ const getSchedule = async (req, res, next) => {
|
|||||||
return res.json({ success: true, results: cached });
|
return res.json({ success: true, results: cached });
|
||||||
}
|
}
|
||||||
const data = await extractSchedule(date);
|
const data = await extractSchedule(date);
|
||||||
setCache(cacheKey, data);
|
setCache(cacheKey, data, TTL.schedule);
|
||||||
res.json({ success: true, results: data });
|
res.json({ success: true, results: data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -18,7 +18,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { extractSearchResults, extractSearchSuggestions } from "../extractors/search.extractor.js";
|
import { extractSearchResults, extractSearchSuggestions } from "../extractors/search.extractor.js";
|
||||||
import { getCache, setCache } from "../helper/cache.helper.js";
|
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
|
||||||
|
import { addPaginationMeta } from "../helper/pagination.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// CONTROLLER: SEARCH RESULTS
|
// CONTROLLER: SEARCH RESULTS
|
||||||
@@ -52,8 +53,9 @@ const getSearchResults = async (req, res, next) => {
|
|||||||
return res.json({ success: true, results: cached });
|
return res.json({ success: true, results: cached });
|
||||||
}
|
}
|
||||||
const data = await extractSearchResults(keyword, page);
|
const data = await extractSearchResults(keyword, page);
|
||||||
setCache(cacheKey, data);
|
const response = addPaginationMeta(data, page);
|
||||||
res.json({ success: true, results: data });
|
setCache(cacheKey, response, TTL.search);
|
||||||
|
res.json({ success: true, results: response });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -91,7 +93,7 @@ const getSearchSuggestions = async (req, res, next) => {
|
|||||||
return res.json({ success: true, results: cached });
|
return res.json({ success: true, results: cached });
|
||||||
}
|
}
|
||||||
const data = await extractSearchSuggestions(keyword);
|
const data = await extractSearchSuggestions(keyword);
|
||||||
setCache(cacheKey, data);
|
setCache(cacheKey, data, TTL.search);
|
||||||
res.json({ success: true, results: data });
|
res.json({ success: true, results: data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { extractSeasons } from "../extractors/seasons.extractor.js";
|
import { extractSeasons } from "../extractors/seasons.extractor.js";
|
||||||
import { getCache, setCache } from "../helper/cache.helper.js";
|
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// CONTROLLER: SEASONS
|
// CONTROLLER: SEASONS
|
||||||
@@ -52,7 +52,7 @@ const getSeasons = async (req, res, next) => {
|
|||||||
return res.json({ success: true, results: cached });
|
return res.json({ success: true, results: cached });
|
||||||
}
|
}
|
||||||
const data = await extractSeasons(id);
|
const data = await extractSeasons(id);
|
||||||
setCache(cacheKey, data);
|
setCache(cacheKey, data, TTL.default);
|
||||||
res.json({ success: true, results: data });
|
res.json({ success: true, results: data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { extractSpotlight } from "../extractors/spotlight.extractor.js";
|
import { extractSpotlight } from "../extractors/spotlight.extractor.js";
|
||||||
import { getCache, setCache } from "../helper/cache.helper.js";
|
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// CONTROLLER: SPOTLIGHT
|
// CONTROLLER: SPOTLIGHT
|
||||||
@@ -45,7 +45,7 @@ const getSpotlight = async (req, res, next) => {
|
|||||||
return res.json({ success: true, results: cached });
|
return res.json({ success: true, results: cached });
|
||||||
}
|
}
|
||||||
const data = await extractSpotlight();
|
const data = await extractSpotlight();
|
||||||
setCache(cacheKey, data);
|
setCache(cacheKey, data, TTL.spotlight);
|
||||||
res.json({ success: true, results: data });
|
res.json({ success: true, results: data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -17,7 +17,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { extractStatus } from "../extractors/status.extractor.js";
|
import { extractStatus } from "../extractors/status.extractor.js";
|
||||||
import { getCache, setCache } from "../helper/cache.helper.js";
|
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
|
||||||
|
import { addPaginationMeta } from "../helper/pagination.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// CONTROLLER: STATUS
|
// CONTROLLER: STATUS
|
||||||
@@ -54,9 +55,20 @@ const getStatus = async (req, res, next) => {
|
|||||||
if (cached) {
|
if (cached) {
|
||||||
return res.json({ success: true, results: cached });
|
return res.json({ success: true, results: cached });
|
||||||
}
|
}
|
||||||
const data = await extractStatus(status, page);
|
// Map short status names to full slugs
|
||||||
setCache(cacheKey, data);
|
const statusMap = {
|
||||||
res.json({ success: true, results: data });
|
ongoing: "currently-airing",
|
||||||
|
airing: "currently-airing",
|
||||||
|
completed: "finished-airing",
|
||||||
|
finished: "finished-airing",
|
||||||
|
upcoming: "not-yet-aired",
|
||||||
|
"not-yet-aired": "not-yet-aired"
|
||||||
|
};
|
||||||
|
const mappedStatus = statusMap[status] || status;
|
||||||
|
const data = await extractStatus(mappedStatus, page);
|
||||||
|
const response = addPaginationMeta(data, page);
|
||||||
|
setCache(cacheKey, response, TTL.genres);
|
||||||
|
res.json({ success: true, results: response });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { extractStreamInfo, extractServerList, extractMapperServers } from "../extractors/streamInfo.extractor.js";
|
import { extractStreamInfo, extractServerList, extractMapperServers } from "../extractors/streamInfo.extractor.js";
|
||||||
import { getCache, setCache } from "../helper/cache.helper.js";
|
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// CONTROLLER: STREAM INFO
|
// CONTROLLER: STREAM INFO
|
||||||
@@ -44,7 +44,7 @@ import { getCache, setCache } from "../helper/cache.helper.js";
|
|||||||
*/
|
*/
|
||||||
const getStreamInfo = async (req, res, next) => {
|
const getStreamInfo = async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.query;
|
const { id, slug } = req.query;
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return res.status(400).json({ success: false, message: "Link ID is required" });
|
return res.status(400).json({ success: false, message: "Link ID is required" });
|
||||||
}
|
}
|
||||||
@@ -53,8 +53,16 @@ const getStreamInfo = async (req, res, next) => {
|
|||||||
if (cached) {
|
if (cached) {
|
||||||
return res.json({ success: true, results: cached });
|
return res.json({ success: true, results: cached });
|
||||||
}
|
}
|
||||||
const data = await extractStreamInfo(id);
|
let data;
|
||||||
setCache(cacheKey, data);
|
try {
|
||||||
|
data = await extractStreamInfo(id, slug);
|
||||||
|
} catch (upstreamError) {
|
||||||
|
return res.status(502).json({ success: false, message: `Upstream API error: ${upstreamError.message}` });
|
||||||
|
}
|
||||||
|
if (!data.url) {
|
||||||
|
return res.status(404).json({ success: false, message: "Stream URL not found for this link ID", results: data });
|
||||||
|
}
|
||||||
|
setCache(cacheKey, data, TTL.stream);
|
||||||
res.json({ success: true, results: data });
|
res.json({ success: true, results: data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -94,7 +102,10 @@ const getServerList = async (req, res, next) => {
|
|||||||
return res.json({ success: true, results: cached });
|
return res.json({ success: true, results: cached });
|
||||||
}
|
}
|
||||||
const data = await extractServerList(ids);
|
const data = await extractServerList(ids);
|
||||||
setCache(cacheKey, data);
|
if (!data || data.length === 0) {
|
||||||
|
return res.status(404).json({ success: false, message: "No servers found for the given episode IDs", results: [] });
|
||||||
|
}
|
||||||
|
setCache(cacheKey, data, TTL.servers);
|
||||||
res.json({ success: true, results: data });
|
res.json({ success: true, results: data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -137,7 +148,7 @@ const getMapperServers = async (req, res, next) => {
|
|||||||
return res.json({ success: true, results: cached });
|
return res.json({ success: true, results: cached });
|
||||||
}
|
}
|
||||||
const data = await extractMapperServers(malId, slug, timestamp);
|
const data = await extractMapperServers(malId, slug, timestamp);
|
||||||
setCache(cacheKey, data);
|
setCache(cacheKey, data, TTL.default);
|
||||||
res.json({ success: true, results: data });
|
res.json({ success: true, results: data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* AniKotoAPI — streamResolver.controller.js
|
||||||
|
* Repository: https://github.com/Shineii86/AniKotoAPI
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* Controller for stream URL resolution and quality detection.
|
||||||
|
* Resolves actual video URLs (m3u8/mp4) from embed player URLs
|
||||||
|
* and parses available quality options.
|
||||||
|
*
|
||||||
|
* @exports
|
||||||
|
* getResolvedStream - Express route handler for GET /api/stream/resolve
|
||||||
|
* getStreamQualities - Express route handler for GET /api/stream/qualities
|
||||||
|
*
|
||||||
|
* @author Shinei Nouzen
|
||||||
|
* @license MIT
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { resolveStreamUrl, parseM3u8Qualities } from "../extractors/streamResolver.extractor.js";
|
||||||
|
import { extractStreamInfo } from "../extractors/streamInfo.extractor.js";
|
||||||
|
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// CONTROLLER: RESOLVE STREAM
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Resolve actual video stream URL from link ID ----
|
||||||
|
/**
|
||||||
|
* Handles GET /api/stream/resolve requests. Resolves the actual
|
||||||
|
* video stream URL (m3u8/mp4) from a server link ID.
|
||||||
|
*
|
||||||
|
* @param {object} req - Express request object
|
||||||
|
* @param {string} req.query.id - Server link ID
|
||||||
|
* @param {string} req.query.slug - Anime slug (used to establish session)
|
||||||
|
* @param {object} res - Express response object
|
||||||
|
* @param {Function} next - Express next middleware function
|
||||||
|
* @returns {void} Sends JSON response with resolved stream URL
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* GET /api/stream/resolve?id=abc123&slug=one-piece-odmau
|
||||||
|
* Response: { success: true, results: { url, qualities, type } }
|
||||||
|
*
|
||||||
|
* @throws {400} If 'id' query parameter is missing
|
||||||
|
*/
|
||||||
|
const getResolvedStream = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id, slug } = req.query;
|
||||||
|
if (!id) {
|
||||||
|
return res.status(400).json({ success: false, message: "Link ID is required" });
|
||||||
|
}
|
||||||
|
const cacheKey = `resolved_${id}`;
|
||||||
|
const cached = getCache(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return res.json({ success: true, results: cached });
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: First get the embed URL from the link ID (with session establishment)
|
||||||
|
const streamInfo = await extractStreamInfo(id, slug);
|
||||||
|
if (!streamInfo.url) {
|
||||||
|
return res.status(404).json({ success: false, message: "Stream URL not found for this link ID" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: Resolve the actual video URL from the embed page
|
||||||
|
const resolved = await resolveStreamUrl(streamInfo.url);
|
||||||
|
const result = {
|
||||||
|
linkId: id,
|
||||||
|
embedUrl: streamInfo.url,
|
||||||
|
...resolved,
|
||||||
|
skipData: streamInfo.skipData || resolved.skipData,
|
||||||
|
};
|
||||||
|
|
||||||
|
setCache(cacheKey, result, TTL.stream);
|
||||||
|
res.json({ success: true, results: result });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// CONTROLLER: STREAM QUALITIES
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Available quality options for a stream URL ----
|
||||||
|
/**
|
||||||
|
* Handles GET /api/stream/qualities requests. Parses an M3U8
|
||||||
|
* playlist URL to return available quality options.
|
||||||
|
*
|
||||||
|
* @param {object} req - Express request object
|
||||||
|
* @param {string} req.query.url - M3U8 playlist URL
|
||||||
|
* @param {object} res - Express response object
|
||||||
|
* @param {Function} next - Express next middleware function
|
||||||
|
* @returns {void} Sends JSON response with quality options
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* GET /api/stream/qualities?url=https://example.com/master.m3u8
|
||||||
|
* Response: { success: true, results: [{ label: "720p", url: "..." }] }
|
||||||
|
*
|
||||||
|
* @throws {400} If 'url' query parameter is missing
|
||||||
|
*/
|
||||||
|
const getStreamQualities = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { url } = req.query;
|
||||||
|
if (!url) {
|
||||||
|
return res.status(400).json({ success: false, message: "M3U8 URL is required" });
|
||||||
|
}
|
||||||
|
const cacheKey = `qualities_${url}`;
|
||||||
|
const cached = getCache(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return res.json({ success: true, results: cached });
|
||||||
|
}
|
||||||
|
|
||||||
|
const qualities = await parseM3u8Qualities(url);
|
||||||
|
const result = {
|
||||||
|
url,
|
||||||
|
totalQualities: qualities.length,
|
||||||
|
qualities,
|
||||||
|
};
|
||||||
|
|
||||||
|
setCache(cacheKey, result, TTL.stream);
|
||||||
|
res.json({ success: true, results: result });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { getResolvedStream, getStreamQualities };
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: streamResolver.controller.js
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { extractSuggestions } from "../extractors/suggestion.extractor.js";
|
import { extractSuggestions } from "../extractors/suggestion.extractor.js";
|
||||||
import { getCache, setCache } from "../helper/cache.helper.js";
|
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// CONTROLLER: SUGGESTIONS
|
// CONTROLLER: SUGGESTIONS
|
||||||
@@ -52,7 +52,7 @@ const getSuggestions = async (req, res, next) => {
|
|||||||
return res.json({ success: true, results: cached });
|
return res.json({ success: true, results: cached });
|
||||||
}
|
}
|
||||||
const data = await extractSuggestions(keyword);
|
const data = await extractSuggestions(keyword);
|
||||||
setCache(cacheKey, data);
|
setCache(cacheKey, data, TTL.suggestions);
|
||||||
res.json({ success: true, results: data });
|
res.json({ success: true, results: data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* AniKotoAPI — topAnimeRankings.controller.js
|
||||||
|
* Repository: https://github.com/Shineii86/AniKotoAPI
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* Controller for top anime rankings. Returns ranked anime list
|
||||||
|
* with support for day, week, and month sort modes.
|
||||||
|
*
|
||||||
|
* @exports
|
||||||
|
* getTopAnimeRankings - Express route handler for GET /api/top-rankings
|
||||||
|
*
|
||||||
|
* @author Shinei Nouzen
|
||||||
|
* @license MIT
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { extractTopAnimeRankings } from "../extractors/topAnimeRankings.extractor.js";
|
||||||
|
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// CONTROLLER: TOP ANIME RANKINGS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Top anime rankings endpoint ----
|
||||||
|
/**
|
||||||
|
* Handles GET /api/top-rankings requests. Returns ranked anime list
|
||||||
|
* with support for top (all-time) and newest sort modes.
|
||||||
|
*
|
||||||
|
* @param {object} req - Express request object
|
||||||
|
* @param {string} [req.query.sort="day"] - Sort mode: "day", "week", or "month"
|
||||||
|
* @param {object} res - Express response object
|
||||||
|
* @param {Function} next - Express next middleware function
|
||||||
|
* @returns {void} Sends JSON response with ranked anime list
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* GET /api/top-rankings?sort=day
|
||||||
|
* Response: { success: true, results: [{ rank: 1, title: "...", ... }] }
|
||||||
|
*/
|
||||||
|
const getTopAnimeRankings = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { sort = "day" } = req.query;
|
||||||
|
const cacheKey = `top_rankings_${sort}`;
|
||||||
|
const cached = getCache(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return res.json({ success: true, results: cached });
|
||||||
|
}
|
||||||
|
const data = await extractTopAnimeRankings(sort);
|
||||||
|
setCache(cacheKey, data, TTL.trending);
|
||||||
|
res.json({ success: true, results: data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { getTopAnimeRankings };
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: topAnimeRankings.controller.js
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { extractTopTen } from "../extractors/topten.extractor.js";
|
import { extractTopTen } from "../extractors/topten.extractor.js";
|
||||||
import { getCache, setCache } from "../helper/cache.helper.js";
|
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// CONTROLLER: TOP TEN
|
// CONTROLLER: TOP TEN
|
||||||
@@ -44,7 +44,7 @@ const getTopTen = async (req, res, next) => {
|
|||||||
return res.json({ success: true, results: cached });
|
return res.json({ success: true, results: cached });
|
||||||
}
|
}
|
||||||
const data = await extractTopTen();
|
const data = await extractTopTen();
|
||||||
setCache(cacheKey, data);
|
setCache(cacheKey, data, TTL.default);
|
||||||
res.json({ success: true, results: data });
|
res.json({ success: true, results: data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { extractTrending } from "../extractors/trending.extractor.js";
|
import { extractTrending } from "../extractors/trending.extractor.js";
|
||||||
import { getCache, setCache } from "../helper/cache.helper.js";
|
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// CONTROLLER: TRENDING
|
// CONTROLLER: TRENDING
|
||||||
@@ -45,7 +45,7 @@ const getTrending = async (req, res, next) => {
|
|||||||
return res.json({ success: true, results: cached });
|
return res.json({ success: true, results: cached });
|
||||||
}
|
}
|
||||||
const data = await extractTrending();
|
const data = await extractTrending();
|
||||||
setCache(cacheKey, data);
|
setCache(cacheKey, data, TTL.trending);
|
||||||
res.json({ success: true, results: data });
|
res.json({ success: true, results: data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { extractTrendingSidebar } from "../extractors/trendingSidebar.extractor.js";
|
import { extractTrendingSidebar } from "../extractors/trendingSidebar.extractor.js";
|
||||||
import { getCache, setCache } from "../helper/cache.helper.js";
|
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// CONTROLLER: TRENDING SIDEBAR
|
// CONTROLLER: TRENDING SIDEBAR
|
||||||
@@ -44,7 +44,7 @@ const getTrendingSidebar = async (req, res, next) => {
|
|||||||
return res.json({ success: true, results: cached });
|
return res.json({ success: true, results: cached });
|
||||||
}
|
}
|
||||||
const data = await extractTrendingSidebar();
|
const data = await extractTrendingSidebar();
|
||||||
setCache(cacheKey, data);
|
setCache(cacheKey, data, TTL.trending);
|
||||||
res.json({ success: true, results: data });
|
res.json({ success: true, results: data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* AniKotoAPI — upcomingAnime.controller.js
|
||||||
|
* Repository: https://github.com/Shineii86/AniKotoAPI
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* Controller for upcoming anime section. Returns list of anime
|
||||||
|
* scheduled for future release from the homepage.
|
||||||
|
*
|
||||||
|
* @exports
|
||||||
|
* getUpcomingAnime - Express route handler for GET /api/upcoming
|
||||||
|
*
|
||||||
|
* @author Shinei Nouzen
|
||||||
|
* @license MIT
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { extractUpcomingAnime } from "../extractors/upcomingAnime.extractor.js";
|
||||||
|
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// CONTROLLER: UPCOMING ANIME
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Upcoming anime endpoint ----
|
||||||
|
/**
|
||||||
|
* Handles GET /api/upcoming requests. Returns list of anime
|
||||||
|
* scheduled for future release from the homepage section.
|
||||||
|
*
|
||||||
|
* @param {object} req - Express request object
|
||||||
|
* @param {object} res - Express response object
|
||||||
|
* @param {Function} next - Express next middleware function
|
||||||
|
* @returns {void} Sends JSON response with upcoming anime list
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* GET /api/upcoming
|
||||||
|
* Response: { success: true, results: [...], ... }
|
||||||
|
*/
|
||||||
|
const getUpcomingAnime = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const cacheKey = "upcoming";
|
||||||
|
const cached = getCache(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return res.json({ success: true, results: cached });
|
||||||
|
}
|
||||||
|
const data = await extractUpcomingAnime();
|
||||||
|
setCache(cacheKey, data, TTL.default);
|
||||||
|
res.json({ success: true, results: data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { getUpcomingAnime };
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: upcomingAnime.controller.js
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { extractWatchOrder } from "../extractors/watchOrder.extractor.js";
|
import { extractWatchOrder } from "../extractors/watchOrder.extractor.js";
|
||||||
import { getCache, setCache } from "../helper/cache.helper.js";
|
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// CONTROLLER: WATCH ORDER
|
// CONTROLLER: WATCH ORDER
|
||||||
@@ -53,7 +53,7 @@ const getWatchOrder = async (req, res, next) => {
|
|||||||
return res.json({ success: true, results: cached });
|
return res.json({ success: true, results: cached });
|
||||||
}
|
}
|
||||||
const data = await extractWatchOrder(id);
|
const data = await extractWatchOrder(id);
|
||||||
setCache(cacheKey, data);
|
setCache(cacheKey, data, TTL.default);
|
||||||
res.json({ success: true, results: data });
|
res.json({ success: true, results: data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { extractWatchPage } from "../extractors/watchPage.extractor.js";
|
import { extractWatchPage } from "../extractors/watchPage.extractor.js";
|
||||||
import { getCache, setCache } from "../helper/cache.helper.js";
|
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// CONTROLLER: WATCH PAGE
|
// CONTROLLER: WATCH PAGE
|
||||||
@@ -54,7 +54,7 @@ const getWatchPage = async (req, res, next) => {
|
|||||||
return res.json({ success: true, results: cached });
|
return res.json({ success: true, results: cached });
|
||||||
}
|
}
|
||||||
const data = await extractWatchPage(slug, ep);
|
const data = await extractWatchPage(slug, ep);
|
||||||
setCache(cacheKey, data);
|
setCache(cacheKey, data, TTL.stream);
|
||||||
res.json({ success: true, results: data });
|
res.json({ success: true, results: data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -17,9 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as cheerio from "cheerio";
|
import * as cheerio from "cheerio";
|
||||||
import axios from "axios";
|
import { fetchWithMirror } from "../helper/mirror.helper.js";
|
||||||
import { headers } from "../configs/header.config.js";
|
|
||||||
import { URLS } from "../configs/dataUrl.js";
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// ANIME INFO EXTRACTOR
|
// ANIME INFO EXTRACTOR
|
||||||
@@ -41,64 +39,48 @@ import { URLS } from "../configs/dataUrl.js";
|
|||||||
*/
|
*/
|
||||||
const extractAnimeInfo = async (slug) => {
|
const extractAnimeInfo = async (slug) => {
|
||||||
try {
|
try {
|
||||||
const url = URLS.watch(slug);
|
const path = `/watch/${slug}`;
|
||||||
const { data } = await axios.get(url, { headers });
|
const { data } = await fetchWithMirror(path);
|
||||||
const $ = cheerio.load(data);
|
const $ = cheerio.load(data);
|
||||||
|
|
||||||
// ---- FEATURE: Extract title and identification fields ----
|
|
||||||
// NOTE: itemprop='name' is used for schema.org microdata on the site
|
|
||||||
const title = $("h1[itemprop='name'].title.d-title").text().trim() || "";
|
const title = $("h1[itemprop='name'].title.d-title").text().trim() || "";
|
||||||
// NOTE: data-jp attribute holds the Japanese title variant
|
|
||||||
const japaneseTitle = $("h1[itemprop='name'].title.d-title").attr("data-jp") || "";
|
const japaneseTitle = $("h1[itemprop='name'].title.d-title").attr("data-jp") || "";
|
||||||
const altNames = $(".names.font-italic").text().trim() || "";
|
const altNames = $(".names.font-italic").text().trim() || "";
|
||||||
const poster = $("img[itemprop='image']").attr("src") || "";
|
const poster = $("img[itemprop='image']").attr("src") || "";
|
||||||
const synopsis = $(".synopsis .content").text().trim() || "";
|
const synopsis = $(".synopsis .content").text().trim() || "";
|
||||||
|
|
||||||
// ---- FEATURE: Extract rating and review metadata ----
|
|
||||||
// NOTE: Multiple rating selectors are checked — .score .value is the display score,
|
|
||||||
// itemprop='ratingValue' is the microdata fallback
|
|
||||||
const rating = $("#w-rating .score .value").text().trim() || "";
|
const rating = $("#w-rating .score .value").text().trim() || "";
|
||||||
const ratingValue = $("#w-rating span[itemprop='ratingValue']").text().trim() || "";
|
const ratingValue = $("#w-rating span[itemprop='ratingValue']").text().trim() || "";
|
||||||
const reviewCount = $("#w-rating span[itemprop='reviewCount']").text().trim() || "";
|
const reviewCount = $("#w-rating span[itemprop='reviewCount']").text().trim() || "";
|
||||||
// NOTE: data-id on #watch-main is the internal anime ID used for AJAX calls
|
|
||||||
const animeId = parseInt($("#watch-main").attr("data-id")) || 0;
|
const animeId = parseInt($("#watch-main").attr("data-id")) || 0;
|
||||||
|
|
||||||
// ---- FEATURE: Extract anime metadata (type, premiere, aired, status) ----
|
// NOTE: .bmeta .meta:first-child contains: type, premiered, aired, status, genres
|
||||||
// NOTE: .bmeta contains the structured metadata block — first child holds basic info
|
// NOTE: Positional selectors (nth-child) are fragile — match source site's exact HTML structure
|
||||||
const type = $(".bmeta .meta:first-child > div:nth-child(1) span").text().trim() || "";
|
const type = $(".bmeta .meta:first-child > div:nth-child(1) span").text().trim() || "";
|
||||||
const premiered = $(".bmeta .meta:first-child > div:nth-child(2) span").text().trim() || "";
|
const premiered = $(".bmeta .meta:first-child > div:nth-child(2) span").text().trim() || "";
|
||||||
const aired = $(".bmeta .meta:first-child > div:nth-child(3) span").text().trim() || "";
|
const aired = $(".bmeta .meta:first-child > div:nth-child(3) span").text().trim() || "";
|
||||||
const status = $(".bmeta .meta:first-child > div:nth-child(4) span a").text().trim() || "";
|
const status = $(".bmeta .meta:first-child > div:nth-child(4) span a").text().trim() || "";
|
||||||
|
|
||||||
// ---- FEATURE: Extract secondary metadata (MAL score, duration, episodes) ----
|
// NOTE: .bmeta .meta:nth-child(2) contains: MAL score, duration, episodes, studios, producers
|
||||||
// NOTE: Second .meta child holds MAL-linked and production metadata
|
|
||||||
const malScore = $(".bmeta .meta:nth-child(2) > div:nth-child(1) span").text().trim() || "";
|
const malScore = $(".bmeta .meta:nth-child(2) > div:nth-child(1) span").text().trim() || "";
|
||||||
const duration = $(".bmeta .meta:nth-child(2) > div:nth-child(2) span").text().trim() || "";
|
const duration = $(".bmeta .meta:nth-child(2) > div:nth-child(2) span").text().trim() || "";
|
||||||
const episodes = $(".bmeta .meta:nth-child(2) > div:nth-child(3) span").text().trim() || "";
|
const episodes = $(".bmeta .meta:nth-child(2) > div:nth-child(3) span").text().trim() || "";
|
||||||
|
|
||||||
// ---- FEATURE: Extract studios list ----
|
|
||||||
// NOTE: itemprop='director' links on studio spans — itemprop='name' inside holds the name
|
|
||||||
const studios = [];
|
const studios = [];
|
||||||
$(".bmeta .meta:nth-child(2) > div:nth-child(4) span a[itemprop='director'] span[itemprop='name']").each((i, el) => {
|
$(".bmeta .meta:nth-child(2) > div:nth-child(4) span a[itemprop='director'] span[itemprop='name']").each((i, el) => {
|
||||||
studios.push($(el).text().trim());
|
studios.push($(el).text().trim());
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- FEATURE: Extract producers list ----
|
|
||||||
// NOTE: Same selector pattern as studios but from the 5th child (producers row)
|
|
||||||
const producers = [];
|
const producers = [];
|
||||||
$(".bmeta .meta:nth-child(2) > div:nth-child(5) span a[itemprop='director'] span[itemprop='name']").each((i, el) => {
|
$(".bmeta .meta:nth-child(2) > div:nth-child(5) span a[itemprop='director'] span[itemprop='name']").each((i, el) => {
|
||||||
producers.push($(el).text().trim());
|
producers.push($(el).text().trim());
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- FEATURE: Extract genres ----
|
|
||||||
// NOTE: Genre links contain '/genre/' in their href — filters out non-genre links
|
|
||||||
const genres = [];
|
const genres = [];
|
||||||
$(".bmeta .meta:first-child > div:nth-child(5) span a[href*='/genre/']").each((i, el) => {
|
$(".bmeta .meta:first-child > div:nth-child(5) span a[href*='/genre/']").each((i, el) => {
|
||||||
genres.push($(el).text().trim());
|
genres.push($(el).text().trim());
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- FEATURE: Extract background artwork from player element ----
|
|
||||||
// NOTE: The #player div has a CSS background-image set via inline style
|
|
||||||
const backgroundImage = $("#player").css("background-image")?.match(/url\(['"]?(.+?)['"]?\)/)?.[1] || "";
|
const backgroundImage = $("#player").css("background-image")?.match(/url\(['"]?(.+?)['"]?\)/)?.[1] || "";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -15,12 +15,10 @@
|
|||||||
* ======= • ======= • ======= • ======= • =======• =======
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as cheerio from "cheerio";
|
|
||||||
import axios from "axios";
|
|
||||||
import { headers } from "../configs/header.config.js";
|
|
||||||
import { URLS } from "../configs/dataUrl.js";
|
import { URLS } from "../configs/dataUrl.js";
|
||||||
import { extractPages } from "../helper/extractPages.helper.js";
|
import { extractPages } from "../helper/extractPages.helper.js";
|
||||||
import { countPages } from "../helper/countPages.helper.js";
|
import { countPages } from "../helper/countPages.helper.js";
|
||||||
|
import { parseListItems } from "../helper/parseListItem.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// A-Z LIST EXTRACTION
|
// A-Z LIST EXTRACTION
|
||||||
@@ -54,40 +52,10 @@ import { countPages } from "../helper/countPages.helper.js";
|
|||||||
*/
|
*/
|
||||||
const extractAzList = async (letter = "all", page = 1) => {
|
const extractAzList = async (letter = "all", page = 1) => {
|
||||||
try {
|
try {
|
||||||
// NOTE: URLS.azList generates the correct endpoint for the given letter
|
|
||||||
const url = URLS.azList(letter);
|
const url = URLS.azList(letter);
|
||||||
const $ = await extractPages(url, page);
|
const $ = await extractPages(url, page);
|
||||||
const totalPages = countPages($);
|
const totalPages = countPages($);
|
||||||
|
const results = parseListItems($);
|
||||||
const results = [];
|
|
||||||
|
|
||||||
// NOTE: Multiple selectors to handle different page layouts
|
|
||||||
$(".film_list-wrap .flw-item, .film-detail, .item").each((i, el) => {
|
|
||||||
const slug = $(el).find("a").attr("href")?.split("/watch/").pop() || "";
|
|
||||||
const poster = $(el).find("img").attr("src") || "";
|
|
||||||
const title = $(el).find(".film-name a, .name.d-title").text().trim() || "";
|
|
||||||
const japaneseTitle = $(el).find(".name.d-title").attr("data-jp") || "";
|
|
||||||
const sub = parseInt($(el).find(".ep-status.sub span").text().trim()) || 0;
|
|
||||||
const dub = parseInt($(el).find(".ep-status.dub span").text().trim()) || 0;
|
|
||||||
const total = parseInt($(el).find(".ep-status.total span").text().trim()) || 0;
|
|
||||||
const type = $(el).find(".meta .inner .right, .fdi-item:nth-child(2)").text().trim() || "";
|
|
||||||
const rating = $(el).find(".rating, .fdi-item:nth-child(3)").text().trim() || "";
|
|
||||||
|
|
||||||
// NOTE: Only include items with valid slugs
|
|
||||||
if (slug) {
|
|
||||||
results.push({
|
|
||||||
slug,
|
|
||||||
poster,
|
|
||||||
title,
|
|
||||||
japaneseTitle,
|
|
||||||
sub,
|
|
||||||
dub,
|
|
||||||
total,
|
|
||||||
type,
|
|
||||||
rating
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return { totalPages, letter, data: results };
|
return { totalPages, letter, data: results };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -15,10 +15,10 @@
|
|||||||
* ======= • ======= • ======= • ======= • =======• =======
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { headers } from "../configs/header.config.js";
|
|
||||||
import { URLS } from "../configs/dataUrl.js";
|
import { URLS } from "../configs/dataUrl.js";
|
||||||
import { countPages } from "../helper/countPages.helper.js";
|
import { countPages } from "../helper/countPages.helper.js";
|
||||||
import { extractPages } from "../helper/extractPages.helper.js";
|
import { extractPages } from "../helper/extractPages.helper.js";
|
||||||
|
import { parseListItems } from "../helper/parseListItem.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// CATEGORY FILTER EXTRACTION
|
// CATEGORY FILTER EXTRACTION
|
||||||
@@ -54,53 +54,30 @@ import { extractPages } from "../helper/extractPages.helper.js";
|
|||||||
*/
|
*/
|
||||||
const extractCategory = async (category, page = 1) => {
|
const extractCategory = async (category, page = 1) => {
|
||||||
try {
|
try {
|
||||||
// NOTE: Category type is determined by prefix in the category string
|
|
||||||
let url;
|
let url;
|
||||||
if (category.startsWith("genre/")) {
|
if (category.startsWith("genre/")) {
|
||||||
url = URLS.genre(category.replace("genre/", ""));
|
url = URLS.genre(category.replace("genre/", ""));
|
||||||
} else if (category.startsWith("type/")) {
|
} else if (category.startsWith("type/")) {
|
||||||
url = URLS.type(category.replace("type/", ""));
|
url = URLS.type(category.replace("type/", ""));
|
||||||
} else if (category.startsWith("status/")) {
|
} else if (category.startsWith("status/")) {
|
||||||
url = URLS.status(category.replace("status/", ""));
|
// NOTE: Map short status names to full slugs used by the site
|
||||||
|
const statusSlug = category.replace("status/", "");
|
||||||
|
const statusMap = {
|
||||||
|
ongoing: "currently-airing",
|
||||||
|
airing: "currently-airing",
|
||||||
|
completed: "finished-airing",
|
||||||
|
finished: "finished-airing",
|
||||||
|
upcoming: "not-yet-aired"
|
||||||
|
};
|
||||||
|
const mapped = statusMap[statusSlug] || statusSlug;
|
||||||
|
url = URLS.status(mapped);
|
||||||
} else {
|
} else {
|
||||||
// NOTE: Fallback to direct URL construction for unknown category types
|
|
||||||
url = `${URLS.home}/${category}`;
|
url = `${URLS.home}/${category}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const $ = await extractPages(url, page);
|
const $ = await extractPages(url, page);
|
||||||
const totalPages = countPages($);
|
const totalPages = countPages($);
|
||||||
|
const results = parseListItems($, "#list-items > .item", { includeAnimeId: true });
|
||||||
const results = [];
|
|
||||||
|
|
||||||
// NOTE: The list items are contained in #list-items container
|
|
||||||
$("#list-items > .item").each((i, el) => {
|
|
||||||
const slug = $(el).find("a").attr("href")?.split("/watch/").pop() || "";
|
|
||||||
const poster = $(el).find(".ani.poster.tip > a > img").attr("src") || "";
|
|
||||||
const title = $(el).find(".info .b1 a.name.d-title").text().trim() || "";
|
|
||||||
const japaneseTitle = $(el).find(".info .b1 a.name.d-title").attr("data-jp") || "";
|
|
||||||
const animeId = $(el).find(".ani.poster.tip").attr("data-tip") || "";
|
|
||||||
const sub = parseInt($(el).find(".ep-status.sub span").text().trim()) || 0;
|
|
||||||
const dub = parseInt($(el).find(".ep-status.dub span").text().trim()) || 0;
|
|
||||||
const total = parseInt($(el).find(".ep-status.total span").text().trim()) || 0;
|
|
||||||
const type = $(el).find(".info .meta .m-item:nth-child(2) label").text().trim() || "";
|
|
||||||
const rating = $(el).find(".info .meta .m-item.rated span").text().trim() || "";
|
|
||||||
|
|
||||||
// NOTE: Only include items with valid slugs
|
|
||||||
if (slug) {
|
|
||||||
results.push({
|
|
||||||
slug,
|
|
||||||
animeId,
|
|
||||||
poster,
|
|
||||||
title,
|
|
||||||
japaneseTitle,
|
|
||||||
sub,
|
|
||||||
dub,
|
|
||||||
total,
|
|
||||||
type,
|
|
||||||
rating
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return { totalPages, data: results };
|
return { totalPages, data: results };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* AniKotoAPI — completedAnime.extractor.js
|
||||||
|
* Repository: https://github.com/Shineii86/AniKotoAPI
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* Extracts completed anime from the homepage section.
|
||||||
|
* Scrapes the completed tab/section for finished anime series.
|
||||||
|
*
|
||||||
|
* @exports
|
||||||
|
* extractCompletedAnime
|
||||||
|
*
|
||||||
|
* @author Shinei Nouzen
|
||||||
|
* @license MIT
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as cheerio from "cheerio";
|
||||||
|
import { fetchWithMirror } from "../helper/mirror.helper.js";
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// COMPLETED ANIME EXTRACTION
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Extract completed anime from homepage section ----
|
||||||
|
/**
|
||||||
|
* Fetches and parses the completed anime section from the homepage.
|
||||||
|
* Returns a list of anime that have finished airing.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Array<Object>>} Array of completed anime objects
|
||||||
|
* @returns {string} return[].slug - URL slug for the anime
|
||||||
|
* @returns {string} return[].poster - Poster image URL
|
||||||
|
* @returns {string} return[].title - English title
|
||||||
|
* @returns {string} return[].japaneseTitle - Japanese title
|
||||||
|
* @returns {number} return[].sub - Subbed episode count
|
||||||
|
* @returns {number} return[].dub - Dubbed episode count
|
||||||
|
* @returns {number} return[].total - Total episode count
|
||||||
|
* @returns {string} return[].type - Anime type
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const completed = await extractCompletedAnime();
|
||||||
|
* console.log(completed.length); // number of completed anime
|
||||||
|
* console.log(completed[0].title); // first completed anime title
|
||||||
|
*/
|
||||||
|
const extractCompletedAnime = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await fetchWithMirror("/home");
|
||||||
|
const $ = cheerio.load(data);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
// NOTE: completed section is inside .top-tables with data-name="completed"
|
||||||
|
// Items are <a class="item"> inside .scaff.items
|
||||||
|
$("section.top-table[data-name='completed'] .scaff.items a.item, .top-table[data-name='completed'] a.item").each((i, el) => {
|
||||||
|
const slug = $(el).attr("href")?.split("/watch/").pop() || "";
|
||||||
|
if (!slug) return;
|
||||||
|
|
||||||
|
const poster = $(el).find("img").attr("src") || "";
|
||||||
|
const title = $(el).find(".name").text().trim() || "";
|
||||||
|
const japaneseTitle = $(el).find(".name").attr("data-jp") || "";
|
||||||
|
const sub = parseInt($(el).find(".ep-status.sub span").text().trim()) || 0;
|
||||||
|
const dub = parseInt($(el).find(".ep-status.dub span").text().trim()) || 0;
|
||||||
|
const total = parseInt($(el).find(".ep-status.total span").text().trim()) || 0;
|
||||||
|
const type = $(el).find(".meta .dot:last-child").text().trim() || "";
|
||||||
|
|
||||||
|
results.push({ slug, poster, title, japaneseTitle, sub, dub, total, type });
|
||||||
|
});
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { extractCompletedAnime };
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: completedAnime.extractor.js
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* AniKotoAPI — download.extractor.js
|
||||||
|
* Repository: https://github.com/Shineii86/AniKotoAPI
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* Extracts download links for anime episodes from anikototv.to.
|
||||||
|
* Decodes base64 download-data attributes and follows redirects
|
||||||
|
* to get streaming server URLs.
|
||||||
|
*
|
||||||
|
* Download flow:
|
||||||
|
* 1. Watch page → base64 decode → nekostream.site URL
|
||||||
|
* 2. nekostream.site → Cloudflare Worker → kwik.cx URL
|
||||||
|
* 3. kwik.cx → actual download URL (requires browser)
|
||||||
|
*
|
||||||
|
* @exports
|
||||||
|
* extractDownloadLinks
|
||||||
|
*
|
||||||
|
* @author Shinei Nouzen
|
||||||
|
* @license MIT
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as cheerio from "cheerio";
|
||||||
|
import axios from "axios";
|
||||||
|
import { fetchWithMirror } from "../helper/mirror.helper.js";
|
||||||
|
import { headers } from "../configs/header.config.js";
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// BASE64 DECODER
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Base64 Download Data Decoder ----
|
||||||
|
/**
|
||||||
|
* Decodes base64 download data from the website.
|
||||||
|
* The source site encodes download server URLs as base64 JSON.
|
||||||
|
*
|
||||||
|
* @param {string} encoded - Base64 encoded string
|
||||||
|
* @returns {Object|null} Decoded download data, or null on failure
|
||||||
|
*/
|
||||||
|
function decodeDownloadData(encoded) {
|
||||||
|
try {
|
||||||
|
const decoded = Buffer.from(encoded, "base64").toString("utf-8");
|
||||||
|
return JSON.parse(decoded);
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// REDIRECT FOLLOWER
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Redirect Chain Resolver ----
|
||||||
|
/**
|
||||||
|
* Follows redirect chain to get the final URL.
|
||||||
|
* Uses axios maxRedirects to auto-follow up to 5 redirects.
|
||||||
|
*
|
||||||
|
* @param {string} url - Starting URL
|
||||||
|
* @returns {Promise<string>} Final URL after redirects
|
||||||
|
*/
|
||||||
|
async function followRedirects(url) {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(url, {
|
||||||
|
headers,
|
||||||
|
maxRedirects: 5,
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
// NOTE: response.request.res.responseUrl contains the final URL after all redirects
|
||||||
|
return response.request?.res?.responseUrl || response.config?.url || url;
|
||||||
|
} catch (error) {
|
||||||
|
// NOTE: On redirect errors, axios throws — extract Location header manually
|
||||||
|
if (error.response?.headers?.location) {
|
||||||
|
return error.response.headers.location;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// DOWNLOAD LINKS EXTRACTOR
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Download Links Extraction ----
|
||||||
|
/**
|
||||||
|
* Fetches and parses download links for a specific episode.
|
||||||
|
* Scrapes the watch page to extract download-data attributes
|
||||||
|
* and follows the redirect chain to get streaming server URLs.
|
||||||
|
*
|
||||||
|
* Note: Download data may be loaded dynamically via JavaScript.
|
||||||
|
* If no download links are found in the initial HTML, try using
|
||||||
|
* a browser or headless browser to get the full page content.
|
||||||
|
*
|
||||||
|
* @param {string} slug - The anime slug (e.g., "kill-blue-gcqj5")
|
||||||
|
* @param {number} ep - Episode number
|
||||||
|
* @returns {Promise<Object>} Object with slug, episode, and downloads array
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const downloads = await extractDownloadLinks("kill-blue-gcqj5", 1);
|
||||||
|
* console.log(downloads.downloads[0].server); // server name
|
||||||
|
* console.log(downloads.downloads[0].kwikUrl); // kwik.cx URL
|
||||||
|
*/
|
||||||
|
const extractDownloadLinks = async (slug, ep) => {
|
||||||
|
try {
|
||||||
|
const { data } = await fetchWithMirror(`/watch/${slug}/ep-${ep}`);
|
||||||
|
|
||||||
|
// NOTE: Response may be raw HTML string or JSON-wrapped HTML
|
||||||
|
let html = "";
|
||||||
|
if (typeof data === "string") {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(data);
|
||||||
|
html = json.result || data;
|
||||||
|
} catch (e) {
|
||||||
|
html = data;
|
||||||
|
}
|
||||||
|
} else if (data?.result) {
|
||||||
|
html = data.result;
|
||||||
|
} else if (data?.data) {
|
||||||
|
html = data.data;
|
||||||
|
} else {
|
||||||
|
html = String(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
|
const downloads = [];
|
||||||
|
|
||||||
|
// NOTE: Multiple selectors to handle different HTML structures across mirrors
|
||||||
|
const selectors = [
|
||||||
|
"[download-data]",
|
||||||
|
"li.download-icon",
|
||||||
|
".download-icon",
|
||||||
|
"[onclick*='openDownloadModalDownload']",
|
||||||
|
"[onclick*='openDownloadModal']"
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const selector of selectors) {
|
||||||
|
$(selector).each((_, el) => {
|
||||||
|
const encoded = $(el).attr("download-data") || "";
|
||||||
|
const serverType = $(el).closest(".type").attr("data-type") || "unknown";
|
||||||
|
const serverLabel = $(el).closest(".type").find("label").text().trim() || serverType;
|
||||||
|
|
||||||
|
if (encoded && encoded.length > 10) {
|
||||||
|
const decoded = decodeDownloadData(encoded);
|
||||||
|
if (decoded) {
|
||||||
|
// NOTE: Decoded data is a nested object: { serverGroup: { serverName: url } }
|
||||||
|
for (const [serverGroup, servers] of Object.entries(decoded)) {
|
||||||
|
for (const [serverName, url] of Object.entries(servers)) {
|
||||||
|
// Avoid duplicates by URL
|
||||||
|
const exists = downloads.some(d => d.url === url);
|
||||||
|
if (!exists) {
|
||||||
|
// NOTE: nekostream.site URLs need Cloudflare Worker proxy
|
||||||
|
const workerUrl = url.includes("nekostream.site")
|
||||||
|
? url.replace("https://pahe.nekostream.site/", "https://proud-dew-d754.download992.workers.dev/")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
downloads.push({
|
||||||
|
server: serverName,
|
||||||
|
serverGroup,
|
||||||
|
type: serverType,
|
||||||
|
typeLabel: serverLabel,
|
||||||
|
url, // nekostream.site URL
|
||||||
|
workerUrl,
|
||||||
|
kwikUrl: null, // Will be filled after redirect
|
||||||
|
note: "Visit kwikUrl in browser to get actual download link"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (downloads.length > 0) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: Follow redirects for each download URL to resolve kwik.cx links
|
||||||
|
for (const download of downloads) {
|
||||||
|
if (download.workerUrl) {
|
||||||
|
try {
|
||||||
|
const finalUrl = await followRedirects(download.workerUrl);
|
||||||
|
if (finalUrl.includes("kwik.cx")) {
|
||||||
|
download.kwikUrl = finalUrl;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// NOTE: Redirect may fail due to Cloudflare — construct placeholder
|
||||||
|
download.kwikUrl = "https://kwik.cx/f/[requires-browser]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
slug,
|
||||||
|
episode: ep,
|
||||||
|
totalDownloads: downloads.length,
|
||||||
|
downloads,
|
||||||
|
note: downloads.length > 0
|
||||||
|
? "kwikUrl requires browser to bypass Cloudflare protection"
|
||||||
|
: "Download links may be loaded dynamically via JavaScript. Try using a browser."
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { extractDownloadLinks };
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: download.extractor.js
|
||||||
@@ -17,9 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as cheerio from "cheerio";
|
import * as cheerio from "cheerio";
|
||||||
import axios from "axios";
|
import { fetchWithMirror } from "../helper/mirror.helper.js";
|
||||||
import { headers } from "../configs/header.config.js";
|
|
||||||
import { URLS, BASE_URL } from "../configs/dataUrl.js";
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// EPISODE LIST EXTRACTOR
|
// EPISODE LIST EXTRACTOR
|
||||||
@@ -31,7 +29,7 @@ import { URLS, BASE_URL } from "../configs/dataUrl.js";
|
|||||||
* queries the AJAX episode list endpoint to get all episodes with their
|
* queries the AJAX episode list endpoint to get all episodes with their
|
||||||
* metadata (ID, number, slug, title, active state, href).
|
* metadata (ID, number, slug, title, active state, href).
|
||||||
*
|
*
|
||||||
* @param {string} slug - The anime slug identifier (e.g. "naruto-shippuden")
|
* @param {string} slugOrId - The anime slug or numeric ID (e.g. "naruto-shippuden" or "12345")
|
||||||
* @returns {Promise<Object>} Object with animeId, slug, totalEpisodes, and episodes array
|
* @returns {Promise<Object>} Object with animeId, slug, totalEpisodes, and episodes array
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
@@ -44,37 +42,33 @@ const extractEpisodeList = async (slugOrId) => {
|
|||||||
let animeId = 0;
|
let animeId = 0;
|
||||||
let resolvedSlug = slugOrId;
|
let resolvedSlug = slugOrId;
|
||||||
|
|
||||||
// NOTE: Accept either numeric anime ID or slug — if numeric, skip watch page fetch
|
|
||||||
if (/^\d+$/.test(slugOrId)) {
|
if (/^\d+$/.test(slugOrId)) {
|
||||||
animeId = parseInt(slugOrId);
|
animeId = parseInt(slugOrId);
|
||||||
} else {
|
} else {
|
||||||
// NOTE: Fetch the watch page to extract the internal anime ID from the slug
|
const path = `/watch/${slugOrId}`;
|
||||||
const infoUrl = URLS.watch(slugOrId);
|
const { data: infoData } = await fetchWithMirror(path);
|
||||||
const { data: infoData } = await axios.get(infoUrl, { headers });
|
|
||||||
const $ = cheerio.load(infoData);
|
const $ = cheerio.load(infoData);
|
||||||
animeId = parseInt($("#watch-main").attr("data-id")) || 0;
|
animeId = parseInt($("#watch-main").attr("data-id")) || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!animeId) {
|
if (!animeId) {
|
||||||
return { animeId: 0, slug: resolvedSlug, totalEpisodes: 0, episodes: [] };
|
return { animeId: 0, slug: slugOrId, totalEpisodes: 0, episodes: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// NOTE: AJAX endpoint requires X-Requested-With header to return HTML fragment
|
const ajaxPath = `/ajax/episode/list/${animeId}`;
|
||||||
const ajaxUrl = `${BASE_URL}/ajax/episode/list/${animeId}`;
|
const { data: ajaxRaw } = await fetchWithMirror(ajaxPath, {
|
||||||
const { data: ajaxRaw } = await axios.get(ajaxUrl, {
|
headers: { "X-Requested-With": "XMLHttpRequest" }
|
||||||
headers: {
|
|
||||||
...headers,
|
|
||||||
"X-Requested-With": "XMLHttpRequest"
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// NOTE: AJAX response is JSON {status, result} where result is HTML
|
// NOTE: Parse JSON response first — fetchWithMirror returns raw text by default
|
||||||
const ajaxHtml = typeof ajaxRaw === "string" ? ajaxRaw : (ajaxRaw?.result || "");
|
let ajaxParsed = ajaxRaw;
|
||||||
|
if (typeof ajaxRaw === "string") {
|
||||||
|
try { ajaxParsed = JSON.parse(ajaxRaw); } catch { ajaxParsed = {}; }
|
||||||
|
}
|
||||||
|
const ajaxHtml = ajaxParsed?.result || "";
|
||||||
const $ep = cheerio.load(ajaxHtml);
|
const $ep = cheerio.load(ajaxHtml);
|
||||||
|
|
||||||
// ---- FEATURE: Parse episode list from AJAX response HTML ----
|
|
||||||
// NOTE: Episode links have either data-num or data-ep-id attributes
|
|
||||||
const episodes = [];
|
const episodes = [];
|
||||||
$ep("a[data-num], a[data-ep-id], a[data-id]").each((i, el) => {
|
$ep("a[data-num], a[data-ep-id], a[data-id]").each((i, el) => {
|
||||||
const epId = $ep(el).attr("data-ep-id") || $ep(el).attr("data-id") || "";
|
const epId = $ep(el).attr("data-ep-id") || $ep(el).attr("data-id") || "";
|
||||||
@@ -83,9 +77,7 @@ const extractEpisodeList = async (slugOrId) => {
|
|||||||
const href = $ep(el).attr("href") || "";
|
const href = $ep(el).attr("href") || "";
|
||||||
const epTitle = $ep(el).find(".ep-title, .ep-name").text().trim() || "";
|
const epTitle = $ep(el).find(".ep-title, .ep-name").text().trim() || "";
|
||||||
const isActive = $ep(el).hasClass("active") || false;
|
const isActive = $ep(el).hasClass("active") || false;
|
||||||
// NOTE: data-ids is the base64-encoded server ID string needed for /api/servers
|
const serverIds = ($ep(el).attr("data-ids") || "").replace(/^\\?["']|\\?["']$/g, "");
|
||||||
const serverIds = $ep(el).attr("data-ids") || "";
|
|
||||||
// NOTE: data-timestamp used for mapper API
|
|
||||||
const timestamp = $ep(el).attr("data-timestamp") || "";
|
const timestamp = $ep(el).attr("data-timestamp") || "";
|
||||||
const malId = $ep(el).attr("data-mal") || "";
|
const malId = $ep(el).attr("data-mal") || "";
|
||||||
|
|
||||||
@@ -104,15 +96,14 @@ const extractEpisodeList = async (slugOrId) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
animeId,
|
animeId,
|
||||||
slug: resolvedSlug,
|
slug: slugOrId,
|
||||||
totalEpisodes: episodes.length,
|
totalEpisodes: episodes.length,
|
||||||
episodes
|
episodes
|
||||||
};
|
};
|
||||||
} catch (ajaxError) {
|
} catch (ajaxError) {
|
||||||
// NOTE: Gracefully return empty list if AJAX call fails (e.g. network issue)
|
|
||||||
return {
|
return {
|
||||||
animeId,
|
animeId,
|
||||||
slug: resolvedSlug,
|
slug: slugOrId,
|
||||||
totalEpisodes: 0,
|
totalEpisodes: 0,
|
||||||
episodes: []
|
episodes: []
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,9 +17,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as cheerio from "cheerio";
|
import * as cheerio from "cheerio";
|
||||||
import axios from "axios";
|
|
||||||
import { headers } from "../configs/header.config.js";
|
import { headers } from "../configs/header.config.js";
|
||||||
import { BASE_URL } from "../configs/dataUrl.js";
|
import { fetchWithMirror } from "../helper/mirror.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// EPISODE LIST AJAX EXTRACTOR
|
// EPISODE LIST AJAX EXTRACTOR
|
||||||
@@ -42,13 +41,13 @@ import { BASE_URL } from "../configs/dataUrl.js";
|
|||||||
*/
|
*/
|
||||||
const extractEpisodeListAjax = async (animeId, style = "", vrf = "") => {
|
const extractEpisodeListAjax = async (animeId, style = "", vrf = "") => {
|
||||||
try {
|
try {
|
||||||
let url = `${BASE_URL}/ajax/episode/list/${animeId}`;
|
let path = `/ajax/episode/list/${animeId}`;
|
||||||
const params = [];
|
const params = [];
|
||||||
if (style) params.push(`style=${style}`);
|
if (style) params.push(`style=${style}`);
|
||||||
if (vrf) params.push(`vrf=${vrf}`);
|
if (vrf) params.push(`vrf=${vrf}`);
|
||||||
if (params.length) url += `?${params.join("&")}`;
|
if (params.length) path += `?${params.join("&")}`;
|
||||||
|
|
||||||
const { data: raw } = await axios.get(url, {
|
const { data: raw } = await fetchWithMirror(path, {
|
||||||
headers: {
|
headers: {
|
||||||
...headers,
|
...headers,
|
||||||
"X-Requested-With": "XMLHttpRequest"
|
"X-Requested-With": "XMLHttpRequest"
|
||||||
@@ -56,7 +55,11 @@ const extractEpisodeListAjax = async (animeId, style = "", vrf = "") => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// NOTE: Response is JSON {status, result} where result is HTML
|
// NOTE: Response is JSON {status, result} where result is HTML
|
||||||
const html = typeof raw === "string" ? raw : (raw?.result || "");
|
let parsed = raw;
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
try { parsed = JSON.parse(raw); } catch { parsed = {}; }
|
||||||
|
}
|
||||||
|
const html = parsed?.result || "";
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
// NOTE: Extract filter options (Sub/Dub, episode ranges)
|
// NOTE: Extract filter options (Sub/Dub, episode ranges)
|
||||||
@@ -98,7 +101,7 @@ const extractEpisodeListAjax = async (animeId, style = "", vrf = "") => {
|
|||||||
timestamp: a.attr("data-timestamp") || "",
|
timestamp: a.attr("data-timestamp") || "",
|
||||||
has_sub: a.attr("data-sub") === "1",
|
has_sub: a.attr("data-sub") === "1",
|
||||||
has_dub: a.attr("data-dub") === "1",
|
has_dub: a.attr("data-dub") === "1",
|
||||||
server_ids: a.attr("data-ids") || "",
|
server_ids: (a.attr("data-ids") || "").replace(/^\\?["']|\\?["']$/g, ""),
|
||||||
active: a.hasClass("active") || false
|
active: a.hasClass("active") || false
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* @description
|
* @description
|
||||||
* Provides advanced anime filtering on anikototv.to — supports filtering
|
* Provides advanced anime filtering on anikototv.to — supports filtering
|
||||||
* by genre, type, status, language, rating, sort order, season, year,
|
* by genre, type, status, language, rating, sort order, season, year,
|
||||||
* and keyword with full pagination support.
|
* source, episode range, and keyword with full pagination support.
|
||||||
*
|
*
|
||||||
* @exports
|
* @exports
|
||||||
* extractFilter
|
* extractFilter
|
||||||
@@ -16,10 +16,8 @@
|
|||||||
* ======= • ======= • ======= • ======= • =======• =======
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as cheerio from "cheerio";
|
|
||||||
import { headers } from "../configs/header.config.js";
|
|
||||||
import { URLS } from "../configs/dataUrl.js";
|
import { URLS } from "../configs/dataUrl.js";
|
||||||
import { GENRE_IDS, TYPE_IDS, STATUS_IDS, RATING_IDS, SORT_IDS } from "../configs/ids.config.js";
|
import { GENRE_IDS, TYPE_IDS, STATUS_IDS, RATING_IDS, SORT_IDS, SOURCE_IDS, SEASON_IDS } from "../configs/ids.config.js";
|
||||||
import { countPages } from "../helper/countPages.helper.js";
|
import { countPages } from "../helper/countPages.helper.js";
|
||||||
import { extractPages } from "../helper/extractPages.helper.js";
|
import { extractPages } from "../helper/extractPages.helper.js";
|
||||||
|
|
||||||
@@ -31,7 +29,7 @@ import { extractPages } from "../helper/extractPages.helper.js";
|
|||||||
/**
|
/**
|
||||||
* Builds a filter URL from the given parameters and fetches matching anime
|
* Builds a filter URL from the given parameters and fetches matching anime
|
||||||
* from anikototv.to. Supports keyword, genre, type, status, language,
|
* from anikototv.to. Supports keyword, genre, type, status, language,
|
||||||
* rating, sort, season, and year filters — all combinable.
|
* rating, sort, season, year, source, episode range, and exclude watchlist.
|
||||||
*
|
*
|
||||||
* @param {Object} params - Filter parameters object
|
* @param {Object} params - Filter parameters object
|
||||||
* @param {string} [params.keyword] - Search keyword to filter by name
|
* @param {string} [params.keyword] - Search keyword to filter by name
|
||||||
@@ -40,9 +38,13 @@ import { extractPages } from "../helper/extractPages.helper.js";
|
|||||||
* @param {string} [params.status] - Comma-separated statuses (e.g. "airing,completed")
|
* @param {string} [params.status] - Comma-separated statuses (e.g. "airing,completed")
|
||||||
* @param {string} [params.language] - Comma-separated languages (e.g. "sub,dub")
|
* @param {string} [params.language] - Comma-separated languages (e.g. "sub,dub")
|
||||||
* @param {string} [params.rating] - Comma-separated ratings (e.g. "pg-13,r")
|
* @param {string} [params.rating] - Comma-separated ratings (e.g. "pg-13,r")
|
||||||
* @param {string} [params.sort] - Sort order key (e.g. "score", "name")
|
* @param {string} [params.sort] - Sort order key (e.g. "score", "name-az")
|
||||||
* @param {string} [params.season] - Comma-separated seasons (e.g. "winter,spring")
|
* @param {string} [params.season] - Comma-separated seasons (e.g. "winter,spring")
|
||||||
* @param {string} [params.year] - Comma-separated years (e.g. "2024,2025")
|
* @param {string} [params.year] - Comma-separated years (e.g. "2024,2025")
|
||||||
|
* @param {string} [params.source] - Comma-separated sources (e.g. "manga,light-novel")
|
||||||
|
* @param {number} [params.epMin] - Minimum episode count filter
|
||||||
|
* @param {number} [params.epMax] - Maximum episode count filter
|
||||||
|
* @param {boolean} [params.excludeWatchlist=false] - Exclude watchlisted anime
|
||||||
* @param {number} [params.page=1] - Page number for pagination
|
* @param {number} [params.page=1] - Page number for pagination
|
||||||
* @returns {Promise<Object>} Object with totalPages count and data array of results
|
* @returns {Promise<Object>} Object with totalPages count and data array of results
|
||||||
*
|
*
|
||||||
@@ -50,6 +52,17 @@ import { extractPages } from "../helper/extractPages.helper.js";
|
|||||||
* const filtered = await extractFilter({ genre: "action", type: "tv", page: 1 });
|
* const filtered = await extractFilter({ genre: "action", type: "tv", page: 1 });
|
||||||
* console.log(filtered.totalPages);
|
* console.log(filtered.totalPages);
|
||||||
* console.log(filtered.data[0].title);
|
* console.log(filtered.data[0].title);
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Advanced filter with episode range
|
||||||
|
* const filtered = await extractFilter({
|
||||||
|
* genre: "comedy,slice-of-life",
|
||||||
|
* type: "tv",
|
||||||
|
* year: "2024",
|
||||||
|
* epMin: 12,
|
||||||
|
* epMax: 24,
|
||||||
|
* sort: "score"
|
||||||
|
* });
|
||||||
*/
|
*/
|
||||||
const extractFilter = async (params) => {
|
const extractFilter = async (params) => {
|
||||||
try {
|
try {
|
||||||
@@ -103,8 +116,8 @@ const extractFilter = async (params) => {
|
|||||||
|
|
||||||
// ---- FEATURE: Append season filter ----
|
// ---- FEATURE: Append season filter ----
|
||||||
if (params.season) {
|
if (params.season) {
|
||||||
const seasons = params.season.split(",");
|
const seasons = params.season.split(",").map(s => SEASON_IDS[s.trim().toLowerCase()] || s.trim());
|
||||||
seasons.forEach(s => queryParams.append("season[]", s.trim()));
|
seasons.forEach(s => queryParams.append("season[]", s));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- FEATURE: Append year filter ----
|
// ---- FEATURE: Append year filter ----
|
||||||
@@ -113,6 +126,28 @@ const extractFilter = async (params) => {
|
|||||||
years.forEach(y => queryParams.append("year[]", y.trim()));
|
years.forEach(y => queryParams.append("year[]", y.trim()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- FEATURE: Append source filter ----
|
||||||
|
// NOTE: SOURCE_IDS maps source material types to numeric IDs
|
||||||
|
if (params.source) {
|
||||||
|
const sources = params.source.split(",").map(s => SOURCE_IDS[s.trim().toLowerCase()] || s.trim());
|
||||||
|
sources.forEach(s => queryParams.append("source[]", s));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- FEATURE: Append episode range filter ----
|
||||||
|
// NOTE: epMin and epMax filter by total episode count range
|
||||||
|
if (params.epMin) {
|
||||||
|
queryParams.set("ep_min", params.epMin);
|
||||||
|
}
|
||||||
|
if (params.epMax) {
|
||||||
|
queryParams.set("ep_max", params.epMax);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- FEATURE: Append exclude watchlist filter ----
|
||||||
|
// NOTE: Only works for logged-in users — ignores for guests
|
||||||
|
if (params.excludeWatchlist) {
|
||||||
|
queryParams.set("exclude_watchlist", "1");
|
||||||
|
}
|
||||||
|
|
||||||
// NOTE: The filter URL is built by appending query params to the search base URL
|
// NOTE: The filter URL is built by appending query params to the search base URL
|
||||||
const filterUrl = `${URLS.search}?${queryParams.toString()}`;
|
const filterUrl = `${URLS.search}?${queryParams.toString()}`;
|
||||||
const $ = await extractPages(filterUrl, params.page || 1);
|
const $ = await extractPages(filterUrl, params.page || 1);
|
||||||
|
|||||||
@@ -16,9 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as cheerio from "cheerio";
|
import * as cheerio from "cheerio";
|
||||||
import axios from "axios";
|
import { fetchWithMirror } from "../helper/mirror.helper.js";
|
||||||
import { headers } from "../configs/header.config.js";
|
|
||||||
import { URLS } from "../configs/dataUrl.js";
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// HOME INFO EXTRACTOR
|
// HOME INFO EXTRACTOR
|
||||||
@@ -37,24 +35,19 @@ import { URLS } from "../configs/dataUrl.js";
|
|||||||
*/
|
*/
|
||||||
const extractHomeInfo = async () => {
|
const extractHomeInfo = async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get(URLS.home, { headers });
|
const { data } = await fetchWithMirror("/home");
|
||||||
const $ = cheerio.load(data);
|
const $ = cheerio.load(data);
|
||||||
|
|
||||||
// ---- FEATURE: Extract spotlight carousel items from #hotest section ----
|
|
||||||
// NOTE: Swiper slides contain the hero carousel on the homepage
|
|
||||||
const spotlights = [];
|
const spotlights = [];
|
||||||
|
// NOTE: #hotest .swiper-slide.item = spotlight carousel on homepage
|
||||||
$("#hotest .swiper-slide.item").each((i, el) => {
|
$("#hotest .swiper-slide.item").each((i, el) => {
|
||||||
// NOTE: The play button's href contains the /watch/{slug} path
|
|
||||||
const slug = $(el).find("a.play").attr("href")?.split("/watch/").pop() || "";
|
const slug = $(el).find("a.play").attr("href")?.split("/watch/").pop() || "";
|
||||||
// NOTE: Poster is set as inline CSS background-image on a div inside .image
|
|
||||||
const poster = $(el).find(".image div").attr("style")?.match(/url\(['"]?(.+?)['"]?\)/)?.[1] || "";
|
const poster = $(el).find(".image div").attr("style")?.match(/url\(['"]?(.+?)['"]?\)/)?.[1] || "";
|
||||||
const title = $(el).find("h2.title.d-title").text().trim() || "";
|
const title = $(el).find("h2.title.d-title").text().trim() || "";
|
||||||
// NOTE: data-jp attribute holds the Japanese/romaji title variant
|
|
||||||
const japaneseTitle = $(el).find("h2.title.d-title").attr("data-jp") || "";
|
const japaneseTitle = $(el).find("h2.title.d-title").attr("data-jp") || "";
|
||||||
const description = $(el).find(".desc").text().trim() || "";
|
const description = $(el).find(".desc").text().trim() || "";
|
||||||
const rating = $(el).find("i.rating").text().trim() || "";
|
const rating = $(el).find("i.rating").text().trim() || "";
|
||||||
const quality = $(el).find("i.quality").text().trim() || "";
|
const quality = $(el).find("i.quality").text().trim() || "";
|
||||||
// NOTE: sub/dub counts are extracted as integers, defaulting to 0
|
|
||||||
const sub = parseInt($(el).find("i.sub").text().trim()) || 0;
|
const sub = parseInt($(el).find("i.sub").text().trim()) || 0;
|
||||||
const dub = parseInt($(el).find("i.dub").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() || "";
|
||||||
@@ -75,15 +68,13 @@ const extractHomeInfo = async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- FEATURE: Extract trending/recently-updated anime list ----
|
|
||||||
// NOTE: Combines both .section-updated and #recent-update containers for coverage
|
|
||||||
const trending = [];
|
const trending = [];
|
||||||
|
// NOTE: Dual selector — .section-updated = new layout, #recent-update = old layout
|
||||||
$(".section-updated .item, #recent-update .item").each((i, el) => {
|
$(".section-updated .item, #recent-update .item").each((i, el) => {
|
||||||
const slug = $(el).find("a.name.d-title").attr("href")?.split("/watch/").pop() || "";
|
const slug = $(el).find("a.name.d-title").attr("href")?.split("/watch/").pop() || "";
|
||||||
const poster = $(el).find(".poster img").attr("src") || "";
|
const poster = $(el).find(".poster img").attr("src") || "";
|
||||||
const title = $(el).find("a.name.d-title").text().trim() || "";
|
const title = $(el).find("a.name.d-title").text().trim() || "";
|
||||||
const japaneseTitle = $(el).find("a.name.d-title").attr("data-jp") || "";
|
const japaneseTitle = $(el).find("a.name.d-title").attr("data-jp") || "";
|
||||||
// NOTE: Episode counts are in .ep-status spans — sub, dub, and total
|
|
||||||
const sub = parseInt($(el).find(".ep-status.sub span").text().trim()) || 0;
|
const sub = parseInt($(el).find(".ep-status.sub span").text().trim()) || 0;
|
||||||
const dub = parseInt($(el).find(".ep-status.dub span").text().trim()) || 0;
|
const dub = parseInt($(el).find(".ep-status.dub span").text().trim()) || 0;
|
||||||
const total = parseInt($(el).find(".ep-status.total span").text().trim()) || 0;
|
const total = parseInt($(el).find(".ep-status.total span").text().trim()) || 0;
|
||||||
@@ -103,8 +94,6 @@ const extractHomeInfo = async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- FEATURE: Extract top airing anime of the day ----
|
|
||||||
// NOTE: Only the 'day' tab content is scraped from #top-anime
|
|
||||||
const topAiring = [];
|
const topAiring = [];
|
||||||
$("#top-anime .tab-content[data-name='day'] a.item").each((i, el) => {
|
$("#top-anime .tab-content[data-name='day'] a.item").each((i, el) => {
|
||||||
const slug = $(el).attr("href")?.split("/watch/").pop() || "";
|
const slug = $(el).attr("href")?.split("/watch/").pop() || "";
|
||||||
@@ -119,8 +108,6 @@ const extractHomeInfo = async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- FEATURE: Extract genre filter list from navigation menu ----
|
|
||||||
// NOTE: Only links containing /genre/ in href are considered valid genre entries
|
|
||||||
const genres = [];
|
const genres = [];
|
||||||
$("#menu ul.c4 li a").each((i, el) => {
|
$("#menu ul.c4 li a").each((i, el) => {
|
||||||
const genre = $(el).text().trim();
|
const genre = $(el).text().trim();
|
||||||
|
|||||||
@@ -10,18 +10,17 @@
|
|||||||
* @exports
|
* @exports
|
||||||
* extractNewRelease
|
* extractNewRelease
|
||||||
* extractNewlyAdded
|
* extractNewlyAdded
|
||||||
|
* extractLatestUpdated
|
||||||
*
|
*
|
||||||
* @author Shinei Nouzen
|
* @author Shinei Nouzen
|
||||||
* @license MIT
|
* @license MIT
|
||||||
* ======= • ======= • ======= • ======= • =======• =======
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as cheerio from "cheerio";
|
|
||||||
import axios from "axios";
|
|
||||||
import { headers } from "../configs/header.config.js";
|
|
||||||
import { URLS } from "../configs/dataUrl.js";
|
import { URLS } from "../configs/dataUrl.js";
|
||||||
import { extractPages } from "../helper/extractPages.helper.js";
|
import { extractPages } from "../helper/extractPages.helper.js";
|
||||||
import { countPages } from "../helper/countPages.helper.js";
|
import { countPages } from "../helper/countPages.helper.js";
|
||||||
|
import { parseListItems } from "../helper/parseListItem.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// NEW RELEASE EXTRACTION
|
// NEW RELEASE EXTRACTION
|
||||||
@@ -53,39 +52,9 @@ import { countPages } from "../helper/countPages.helper.js";
|
|||||||
*/
|
*/
|
||||||
const extractNewRelease = async (page = 1) => {
|
const extractNewRelease = async (page = 1) => {
|
||||||
try {
|
try {
|
||||||
// NOTE: extractPages handles pagination and returns parsed Cheerio object
|
|
||||||
const $ = await extractPages(URLS.newRelease, page);
|
const $ = await extractPages(URLS.newRelease, page);
|
||||||
const totalPages = countPages($);
|
const totalPages = countPages($);
|
||||||
|
const results = parseListItems($);
|
||||||
const results = [];
|
|
||||||
|
|
||||||
// NOTE: Multiple selectors to handle different page layouts
|
|
||||||
$(".film_list-wrap .flw-item, .film-detail, .item").each((i, el) => {
|
|
||||||
const slug = $(el).find("a").attr("href")?.split("/watch/").pop() || "";
|
|
||||||
const poster = $(el).find("img").attr("src") || "";
|
|
||||||
const title = $(el).find(".film-name a, .name.d-title").text().trim() || "";
|
|
||||||
const japaneseTitle = $(el).find(".name.d-title").attr("data-jp") || "";
|
|
||||||
const sub = parseInt($(el).find(".ep-status.sub span").text().trim()) || 0;
|
|
||||||
const dub = parseInt($(el).find(".ep-status.dub span").text().trim()) || 0;
|
|
||||||
const total = parseInt($(el).find(".ep-status.total span").text().trim()) || 0;
|
|
||||||
const type = $(el).find(".meta .inner .right, .fdi-item:nth-child(2)").text().trim() || "";
|
|
||||||
const rating = $(el).find(".rating, .fdi-item:nth-child(3)").text().trim() || "";
|
|
||||||
|
|
||||||
// NOTE: Only include items with valid slugs
|
|
||||||
if (slug) {
|
|
||||||
results.push({
|
|
||||||
slug,
|
|
||||||
poster,
|
|
||||||
title,
|
|
||||||
japaneseTitle,
|
|
||||||
sub,
|
|
||||||
dub,
|
|
||||||
total,
|
|
||||||
type,
|
|
||||||
rating
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return { totalPages, data: results };
|
return { totalPages, data: results };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -122,37 +91,9 @@ const extractNewRelease = async (page = 1) => {
|
|||||||
*/
|
*/
|
||||||
const extractNewlyAdded = async (page = 1) => {
|
const extractNewlyAdded = async (page = 1) => {
|
||||||
try {
|
try {
|
||||||
// NOTE: The latestUpdated endpoint with sort=added returns recently added anime
|
|
||||||
const $ = await extractPages(`${URLS.latestUpdated}?sort=added`, page);
|
const $ = await extractPages(`${URLS.latestUpdated}?sort=added`, page);
|
||||||
const totalPages = countPages($);
|
const totalPages = countPages($);
|
||||||
|
const results = parseListItems($);
|
||||||
const results = [];
|
|
||||||
|
|
||||||
// NOTE: Multiple selectors to handle different page layouts
|
|
||||||
$(".film_list-wrap .flw-item, .film-detail, .item").each((i, el) => {
|
|
||||||
const slug = $(el).find("a").attr("href")?.split("/watch/").pop() || "";
|
|
||||||
const poster = $(el).find("img").attr("src") || "";
|
|
||||||
const title = $(el).find(".film-name a, .name.d-title").text().trim() || "";
|
|
||||||
const japaneseTitle = $(el).find(".name.d-title").attr("data-jp") || "";
|
|
||||||
const sub = parseInt($(el).find(".ep-status.sub span").text().trim()) || 0;
|
|
||||||
const dub = parseInt($(el).find(".ep-status.dub span").text().trim()) || 0;
|
|
||||||
const total = parseInt($(el).find(".ep-status.total span").text().trim()) || 0;
|
|
||||||
const type = $(el).find(".meta .inner .right, .fdi-item:nth-child(2)").text().trim() || "";
|
|
||||||
|
|
||||||
// NOTE: Only include items with valid slugs
|
|
||||||
if (slug) {
|
|
||||||
results.push({
|
|
||||||
slug,
|
|
||||||
poster,
|
|
||||||
title,
|
|
||||||
japaneseTitle,
|
|
||||||
sub,
|
|
||||||
dub,
|
|
||||||
total,
|
|
||||||
type
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return { totalPages, data: results };
|
return { totalPages, data: results };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -160,6 +101,35 @@ const extractNewlyAdded = async (page = 1) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export { extractNewRelease, extractNewlyAdded };
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// LATEST UPDATED EXTRACTION
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Latest Updated Extraction ----
|
||||||
|
/**
|
||||||
|
* Fetches and parses recently updated anime with pagination.
|
||||||
|
* Uses the /latest-updated page which shows anime sorted by last update time.
|
||||||
|
*
|
||||||
|
* @param {number} page - Page number to fetch (default: 1)
|
||||||
|
* @returns {Promise<Object>} Object containing totalPages and data array
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const latest = await extractLatestUpdated(1);
|
||||||
|
* console.log(latest.totalPages);
|
||||||
|
* console.log(latest.data[0].title);
|
||||||
|
*/
|
||||||
|
const extractLatestUpdated = async (page = 1) => {
|
||||||
|
try {
|
||||||
|
const $ = await extractPages("/latest-updated", page);
|
||||||
|
const totalPages = countPages($);
|
||||||
|
const results = parseListItems($, ".item");
|
||||||
|
|
||||||
|
return { totalPages, data: results };
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { extractNewRelease, extractNewlyAdded, extractLatestUpdated };
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════ END: newRelease.extractor.js
|
// ══════════════════════════════════════════════════════════════ END: newRelease.extractor.js
|
||||||
|
|||||||
@@ -15,11 +15,10 @@
|
|||||||
* ======= • ======= • ======= • ======= • =======• =======
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as cheerio from "cheerio";
|
|
||||||
import { headers } from "../configs/header.config.js";
|
|
||||||
import { URLS } from "../configs/dataUrl.js";
|
import { URLS } from "../configs/dataUrl.js";
|
||||||
import { countPages } from "../helper/countPages.helper.js";
|
import { countPages } from "../helper/countPages.helper.js";
|
||||||
import { extractPages } from "../helper/extractPages.helper.js";
|
import { extractPages } from "../helper/extractPages.helper.js";
|
||||||
|
import { parseListItems } from "../helper/parseListItem.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// POPULAR EXTRACTION
|
// POPULAR EXTRACTION
|
||||||
@@ -52,42 +51,9 @@ import { extractPages } from "../helper/extractPages.helper.js";
|
|||||||
*/
|
*/
|
||||||
const extractPopular = async (page = 1) => {
|
const extractPopular = async (page = 1) => {
|
||||||
try {
|
try {
|
||||||
// NOTE: extractPages handles pagination and returns parsed Cheerio object
|
|
||||||
const $ = await extractPages(URLS.mostViewed, page);
|
const $ = await extractPages(URLS.mostViewed, page);
|
||||||
const totalPages = countPages($);
|
const totalPages = countPages($);
|
||||||
|
const results = parseListItems($, "#list-items > .item", { includeAnimeId: true });
|
||||||
const results = [];
|
|
||||||
|
|
||||||
// NOTE: The list items are contained in #list-items container
|
|
||||||
$("#list-items > .item").each((i, el) => {
|
|
||||||
const slug = $(el).find("a").attr("href")?.split("/watch/").pop() || "";
|
|
||||||
const poster = $(el).find(".ani.poster.tip > a > img").attr("src") || "";
|
|
||||||
const title = $(el).find(".info .b1 a.name.d-title").text().trim() || "";
|
|
||||||
const japaneseTitle = $(el).find(".info .b1 a.name.d-title").attr("data-jp") || "";
|
|
||||||
const animeId = $(el).find(".ani.poster.tip").attr("data-tip") || "";
|
|
||||||
const sub = parseInt($(el).find(".ep-status.sub span").text().trim()) || 0;
|
|
||||||
const dub = parseInt($(el).find(".ep-status.dub span").text().trim()) || 0;
|
|
||||||
const total = parseInt($(el).find(".ep-status.total span").text().trim()) || 0;
|
|
||||||
const type = $(el).find(".info .meta .m-item:nth-child(2) label").text().trim() || "";
|
|
||||||
const rating = $(el).find(".info .meta .m-item.rated span").text().trim() || "";
|
|
||||||
|
|
||||||
// NOTE: Only include items with valid slugs
|
|
||||||
if (slug) {
|
|
||||||
results.push({
|
|
||||||
slug,
|
|
||||||
animeId,
|
|
||||||
poster,
|
|
||||||
title,
|
|
||||||
japaneseTitle,
|
|
||||||
sub,
|
|
||||||
dub,
|
|
||||||
total,
|
|
||||||
type,
|
|
||||||
rating
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return { totalPages, data: results };
|
return { totalPages, data: results };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import * as cheerio from "cheerio";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { headers } from "../configs/header.config.js";
|
import { headers } from "../configs/header.config.js";
|
||||||
import { URLS } from "../configs/dataUrl.js";
|
import { URLS } from "../configs/dataUrl.js";
|
||||||
|
import { fetchWithMirror } from "../helper/mirror.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// RANDOM ANIME EXTRACTOR
|
// RANDOM ANIME EXTRACTOR
|
||||||
@@ -40,34 +41,29 @@ import { URLS } from "../configs/dataUrl.js";
|
|||||||
*/
|
*/
|
||||||
const extractRandom = async () => {
|
const extractRandom = async () => {
|
||||||
try {
|
try {
|
||||||
// NOTE: maxRedirects=5 ensures we follow the random redirect chain
|
const { data, request } = await axios.get(URLS.random, {
|
||||||
// NOTE: validateStatus allows any 2xx/3xx response without throwing
|
|
||||||
const { data, request, response } = await axios.get(URLS.random, {
|
|
||||||
headers,
|
headers,
|
||||||
|
// NOTE: maxRedirects=5 allows following the redirect chain from /random to the final anime page
|
||||||
maxRedirects: 5,
|
maxRedirects: 5,
|
||||||
|
// NOTE: validateStatus < 400 prevents axios from throwing on redirects (3xx)
|
||||||
validateStatus: (status) => status < 400
|
validateStatus: (status) => status < 400
|
||||||
});
|
});
|
||||||
|
|
||||||
// NOTE: responseURL on the request object gives us the final resolved URL after redirects
|
// NOTE: request.responseURL contains the final URL after all redirects
|
||||||
const finalUrl = request?.responseURL || URLS.random;
|
const finalUrl = request?.responseURL || URLS.random;
|
||||||
// NOTE: The slug is extracted from the /watch/{slug} path in the final URL
|
|
||||||
const slug = finalUrl.split("/watch/").pop() || "";
|
const slug = finalUrl.split("/watch/").pop() || "";
|
||||||
|
|
||||||
const $ = cheerio.load(data);
|
const $ = cheerio.load(data);
|
||||||
|
|
||||||
// ---- FEATURE: Extract anime title and identification ----
|
|
||||||
const title = $("h1[itemprop='name'].title.d-title").text().trim() || "";
|
const title = $("h1[itemprop='name'].title.d-title").text().trim() || "";
|
||||||
const japaneseTitle = $("h1[itemprop='name'].title.d-title").attr("data-jp") || "";
|
const japaneseTitle = $("h1[itemprop='name'].title.d-title").attr("data-jp") || "";
|
||||||
const poster = $("img[itemprop='image']").attr("src") || "";
|
const poster = $("img[itemprop='image']").attr("src") || "";
|
||||||
|
|
||||||
// ---- FEATURE: Extract type, synopsis, rating, and anime ID ----
|
|
||||||
const type = $(".bmeta .meta:first-child > div:nth-child(1) span").text().trim() || "";
|
const type = $(".bmeta .meta:first-child > div:nth-child(1) span").text().trim() || "";
|
||||||
const synopsis = $(".synopsis .content").text().trim() || "";
|
const synopsis = $(".synopsis .content").text().trim() || "";
|
||||||
const rating = $("#w-rating .score .value").text().trim() || "";
|
const rating = $("#w-rating .score .value").text().trim() || "";
|
||||||
const animeId = parseInt($("#watch-main").attr("data-id")) || 0;
|
const animeId = parseInt($("#watch-main").attr("data-id")) || 0;
|
||||||
|
|
||||||
// ---- FEATURE: Extract genres ----
|
|
||||||
// NOTE: Genre links are identified by '/genre/' in their href attribute
|
|
||||||
const genres = [];
|
const genres = [];
|
||||||
$(".bmeta .meta:first-child > div:nth-child(5) span a[href*='/genre/']").each((i, el) => {
|
$(".bmeta .meta:first-child > div:nth-child(5) span a[href*='/genre/']").each((i, el) => {
|
||||||
genres.push($(el).text().trim());
|
genres.push($(el).text().trim());
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* AniKotoAPI — recentlyUpdatedTabs.extractor.js
|
||||||
|
* Repository: https://github.com/Shineii86/AniKotoAPI
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* Extracts recently updated anime from homepage with tab filtering.
|
||||||
|
* Supports three tabs: all, dub, and sub for different update types.
|
||||||
|
*
|
||||||
|
* @exports
|
||||||
|
* extractRecentlyUpdatedTabs
|
||||||
|
*
|
||||||
|
* @author Shinei Nouzen
|
||||||
|
* @license MIT
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as cheerio from "cheerio";
|
||||||
|
import { fetchWithMirror } from "../helper/mirror.helper.js";
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// RECENTLY UPDATED TABS EXTRACTION
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Extract recently updated anime with tab filtering ----
|
||||||
|
/**
|
||||||
|
* Fetches and parses the recently updated anime section from the homepage.
|
||||||
|
* Supports filtering by update type: all, dub, or sub.
|
||||||
|
*
|
||||||
|
* @param {string} tab - Tab filter: "all", "dub", or "sub" (default: "all")
|
||||||
|
* @returns {Promise<Array<Object>>} Array of recently updated anime objects
|
||||||
|
* @returns {string} return[].slug - URL slug for the anime
|
||||||
|
* @returns {string} return[].poster - Poster image URL
|
||||||
|
* @returns {string} return[].title - English title
|
||||||
|
* @returns {string} return[].japaneseTitle - Japanese title
|
||||||
|
* @returns {number} return[].sub - Subbed episode count
|
||||||
|
* @returns {number} return[].dub - Dubbed episode count
|
||||||
|
* @returns {number} return[].total - Total episode count
|
||||||
|
* @returns {string} return[].type - Anime type
|
||||||
|
* @returns {string} return[].episodeInfo - Latest episode info
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const all = await extractRecentlyUpdatedTabs("all");
|
||||||
|
* console.log(all.length); // number of updated anime
|
||||||
|
*
|
||||||
|
* const dubOnly = await extractRecentlyUpdatedTabs("dub");
|
||||||
|
* console.log(dubOnly[0].title); // first dubbed updated anime
|
||||||
|
*/
|
||||||
|
const extractRecentlyUpdatedTabs = async (tab = "all") => {
|
||||||
|
try {
|
||||||
|
const { data } = await fetchWithMirror("/home");
|
||||||
|
const $ = cheerio.load(data);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
// NOTE: #recent-update has tabs (all/dub/sub/trending/random) but filtering is client-side
|
||||||
|
// Items are <div class="item"> inside #recent-update .ani.items
|
||||||
|
// Each item has .ani.poster with <a> link and .info .name.d-title
|
||||||
|
const allItems = $("#recent-update .ani.items .item");
|
||||||
|
|
||||||
|
allItems.each((i, el) => {
|
||||||
|
// NOTE: The item itself is a div, the link is inside .ani.poster a or .info a.name
|
||||||
|
const link = $(el).find(".ani.poster a").attr("href") || $(el).find(".info a.name").attr("href") || "";
|
||||||
|
const slug = link.split("/watch/").pop()?.split("/ep-")[0] || "";
|
||||||
|
if (!slug) return;
|
||||||
|
|
||||||
|
// NOTE: Check sub/dub status for tab filtering
|
||||||
|
const hasSub = $(el).find(".ep-status.sub").length > 0;
|
||||||
|
const hasDub = $(el).find(".ep-status.dub").length > 0;
|
||||||
|
|
||||||
|
// NOTE: Filter by tab if not "all" — since server-side rendering shows all items
|
||||||
|
if (tab === "dub" && !hasDub) return;
|
||||||
|
if (tab === "sub" && !hasSub) return;
|
||||||
|
|
||||||
|
const poster = $(el).find("img").attr("src") || "";
|
||||||
|
const title = $(el).find(".name").text().trim() || "";
|
||||||
|
const japaneseTitle = $(el).find(".name").attr("data-jp") || "";
|
||||||
|
const sub = parseInt($(el).find(".ep-status.sub span").text().trim()) || 0;
|
||||||
|
const dub = parseInt($(el).find(".ep-status.dub span").text().trim()) || 0;
|
||||||
|
const total = parseInt($(el).find(".ep-status.total span").text().trim()) || 0;
|
||||||
|
const type = $(el).find(".meta .right, .type").text().trim() || "";
|
||||||
|
|
||||||
|
results.push({ slug, poster, title, japaneseTitle, sub, dub, total, type });
|
||||||
|
});
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { extractRecentlyUpdatedTabs };
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: recentlyUpdatedTabs.extractor.js
|
||||||
@@ -16,9 +16,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as cheerio from "cheerio";
|
import * as cheerio from "cheerio";
|
||||||
import axios from "axios";
|
|
||||||
import { headers } from "../configs/header.config.js";
|
|
||||||
import { URLS } from "../configs/dataUrl.js";
|
import { URLS } from "../configs/dataUrl.js";
|
||||||
|
import { fetchWithMirror } from "../helper/mirror.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// SCHEDULE EXTRACTION
|
// SCHEDULE EXTRACTION
|
||||||
@@ -43,25 +42,37 @@ import { URLS } from "../configs/dataUrl.js";
|
|||||||
*/
|
*/
|
||||||
const extractSchedule = async (date) => {
|
const extractSchedule = async (date) => {
|
||||||
try {
|
try {
|
||||||
// NOTE: The schedule is fetched by appending date parameter to homepage
|
// NOTE: The schedule data is loaded via AJAX endpoint, not a static page
|
||||||
const url = `${URLS.home}?date=${date}`;
|
const path = `/ajax/schedule?date=${date}`;
|
||||||
const { data } = await axios.get(url, { headers });
|
const { data: raw } = await fetchWithMirror(path, {
|
||||||
const $ = cheerio.load(data);
|
headers: { "X-Requested-With": "XMLHttpRequest" }
|
||||||
|
});
|
||||||
|
|
||||||
|
// NOTE: Parse JSON response first — fetchWithMirror returns raw text by default
|
||||||
|
let parsed = raw;
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
try { parsed = JSON.parse(raw); } catch { parsed = {}; }
|
||||||
|
}
|
||||||
|
const html = parsed?.result || "";
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
const schedule = [];
|
const schedule = [];
|
||||||
|
|
||||||
// NOTE: Schedule items use different selectors depending on page structure
|
// NOTE: Schedule items live inside .item with .time, .ep, .title structure
|
||||||
$(".schedule-item, .anime-schedule .item").each((i, el) => {
|
$(".item").each((i, el) => {
|
||||||
const slug = $(el).find("a").attr("href")?.split("/watch/").pop() || "";
|
const title = $(el).find(".title").text().trim() || "";
|
||||||
const title = $(el).find(".film-name a, .name").text().trim() || "";
|
const jpTitle = $(el).find(".title").attr("data-jp") || "";
|
||||||
const time = $(el).find(".time, .schedule-time").text().trim() || "";
|
const time = $(el).find(".time").text().trim() || "";
|
||||||
const episodeNo = parseInt($(el).find(".episode-no, .ep-num").text().trim()) || 0;
|
const episodeText = $(el).find(".ep span").text().trim() || "";
|
||||||
|
const episodeNo = parseInt(episodeText.replace(/\D/g, "")) || 0;
|
||||||
|
// NOTE: Items may not have direct links — use title as identifier
|
||||||
|
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "";
|
||||||
|
|
||||||
// NOTE: Only include items with valid slugs
|
if (title) {
|
||||||
if (slug) {
|
|
||||||
schedule.push({
|
schedule.push({
|
||||||
slug,
|
slug,
|
||||||
title,
|
title,
|
||||||
|
japaneseTitle: jpTitle,
|
||||||
time,
|
time,
|
||||||
episode_no: episodeNo
|
episode_no: episodeNo
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,11 +16,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as cheerio from "cheerio";
|
import * as cheerio from "cheerio";
|
||||||
import axios from "axios";
|
|
||||||
import { headers } from "../configs/header.config.js";
|
|
||||||
import { URLS } from "../configs/dataUrl.js";
|
import { URLS } from "../configs/dataUrl.js";
|
||||||
import { extractPages } from "../helper/extractPages.helper.js";
|
import { extractPages } from "../helper/extractPages.helper.js";
|
||||||
import { countPages } from "../helper/countPages.helper.js";
|
import { countPages } from "../helper/countPages.helper.js";
|
||||||
|
import { fetchWithMirror } from "../helper/mirror.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// SEARCH RESULTS EXTRACTOR
|
// SEARCH RESULTS EXTRACTOR
|
||||||
@@ -42,27 +41,21 @@ import { countPages } from "../helper/countPages.helper.js";
|
|||||||
*/
|
*/
|
||||||
const extractSearchResults = async (keyword, page = 1) => {
|
const extractSearchResults = async (keyword, page = 1) => {
|
||||||
try {
|
try {
|
||||||
// WARNING: keyword param is required — omitting it may cause a 500 error from the source
|
|
||||||
const url = `${URLS.search}?keyword=${encodeURIComponent(keyword)}`;
|
const url = `${URLS.search}?keyword=${encodeURIComponent(keyword)}`;
|
||||||
const $ = await extractPages(url, page);
|
const $ = await extractPages(url, page);
|
||||||
const totalPages = countPages($);
|
const totalPages = countPages($);
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
// NOTE: #list-items > .item is the standard result container on anikototv.to search pages
|
|
||||||
$("#list-items > .item").each((i, el) => {
|
$("#list-items > .item").each((i, el) => {
|
||||||
const slug = $(el).find("a").attr("href")?.split("/watch/").pop() || "";
|
const slug = $(el).find("a").attr("href")?.split("/watch/").pop() || "";
|
||||||
// NOTE: .ani.poster.tip is the poster container with tooltip data
|
|
||||||
const poster = $(el).find(".ani.poster.tip > a > img").attr("src") || "";
|
const poster = $(el).find(".ani.poster.tip > a > img").attr("src") || "";
|
||||||
const title = $(el).find(".info .b1 a.name.d-title").text().trim() || "";
|
const title = $(el).find(".info .b1 a.name.d-title").text().trim() || "";
|
||||||
const japaneseTitle = $(el).find(".info .b1 a.name.d-title").attr("data-jp") || "";
|
const japaneseTitle = $(el).find(".info .b1 a.name.d-title").attr("data-jp") || "";
|
||||||
// NOTE: data-tip attribute on the poster container holds the animeId
|
|
||||||
const animeId = $(el).find(".ani.poster.tip").attr("data-tip") || "";
|
const animeId = $(el).find(".ani.poster.tip").attr("data-tip") || "";
|
||||||
const sub = parseInt($(el).find(".ep-status.sub span").text().trim()) || 0;
|
const sub = parseInt($(el).find(".ep-status.sub span").text().trim()) || 0;
|
||||||
const dub = parseInt($(el).find(".ep-status.dub span").text().trim()) || 0;
|
const dub = parseInt($(el).find(".ep-status.dub span").text().trim()) || 0;
|
||||||
const total = parseInt($(el).find(".ep-status.total span").text().trim()) || 0;
|
const total = parseInt($(el).find(".ep-status.total span").text().trim()) || 0;
|
||||||
// NOTE: Type is in the second .m-item label (e.g. TV, Movie, OVA)
|
|
||||||
const type = $(el).find(".info .meta .m-item:nth-child(2) label").text().trim() || "";
|
const type = $(el).find(".info .meta .m-item:nth-child(2) label").text().trim() || "";
|
||||||
// NOTE: Rating is in a dedicated .m-item.rated span
|
|
||||||
const rating = $(el).find(".info .meta .m-item.rated span").text().trim() || "";
|
const rating = $(el).find(".info .meta .m-item.rated span").text().trim() || "";
|
||||||
const genres = [];
|
const genres = [];
|
||||||
$(el).find(".info .b1 .genre a").each((j, g) => {
|
$(el).find(".info .b1 .genre a").each((j, g) => {
|
||||||
@@ -110,8 +103,8 @@ const extractSearchResults = async (keyword, page = 1) => {
|
|||||||
*/
|
*/
|
||||||
const extractSearchSuggestions = async (keyword) => {
|
const extractSearchSuggestions = async (keyword) => {
|
||||||
try {
|
try {
|
||||||
const url = `${URLS.search}?keyword=${encodeURIComponent(keyword)}`;
|
const path = `/search?keyword=${encodeURIComponent(keyword)}`;
|
||||||
const { data } = await axios.get(url, { headers });
|
const { data } = await fetchWithMirror(path);
|
||||||
const $ = cheerio.load(data);
|
const $ = cheerio.load(data);
|
||||||
|
|
||||||
const suggestions = [];
|
const suggestions = [];
|
||||||
@@ -126,7 +119,6 @@ const extractSearchSuggestions = async (keyword) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// NOTE: Limit to 10 suggestions to keep autocomplete payloads small
|
|
||||||
return suggestions.slice(0, 10);
|
return suggestions.slice(0, 10);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
* Repository: https://github.com/Shineii86/AniKotoAPI
|
* Repository: https://github.com/Shineii86/AniKotoAPI
|
||||||
*
|
*
|
||||||
* @description
|
* @description
|
||||||
* Extracts season information for a specific anime via API.
|
* Extracts season information for a specific anime from the
|
||||||
* Returns season data from the internal API endpoint.
|
* watch page HTML. Returns available seasons from the page
|
||||||
|
* sidebar and metadata.
|
||||||
*
|
*
|
||||||
* @exports
|
* @exports
|
||||||
* extractSeasons
|
* extractSeasons
|
||||||
@@ -15,44 +16,86 @@
|
|||||||
* ======= • ======= • ======= • ======= • =======• =======
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import axios from "axios";
|
import * as cheerio from "cheerio";
|
||||||
import { headers } from "../configs/header.config.js";
|
import { fetchWithMirror } from "../helper/mirror.helper.js";
|
||||||
import { BASE_URL } from "../configs/dataUrl.js";
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// SEASONS API EXTRACTION
|
// SEASONS EXTRACTOR
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// ---- FEATURE: Extract season data from internal API ----
|
// ---- FEATURE: Season Information Extraction ----
|
||||||
/**
|
/**
|
||||||
* Fetches season information for a specific anime from the internal API.
|
* Fetches and parses season information for an anime.
|
||||||
* Returns an array of season objects or empty array on error.
|
* Extracts season data from the watch page sidebar.
|
||||||
*
|
*
|
||||||
* @param {string} animeId - The anime ID to fetch seasons for
|
* NOTE: The source site's AJAX endpoints (/ajax/seasons/) don't exist,
|
||||||
* @returns {Promise<Array<Object>>} Array of season objects or empty array
|
* so we extract from the watch page HTML sidebar instead.
|
||||||
|
*
|
||||||
|
* @param {string|number} slugOrId - The anime slug or numeric ID
|
||||||
|
* @returns {Promise<Object>} Object with animeId, totalSeasons, and seasons array
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const seasons = await extractSeasons("12345");
|
* const seasons = await extractSeasons("one-piece-odmau");
|
||||||
* console.log(seasons.length); // Number of seasons
|
* console.log(seasons.totalSeasons);
|
||||||
*/
|
*/
|
||||||
const extractSeasons = async (animeId) => {
|
const extractSeasons = async (slugOrId) => {
|
||||||
try {
|
try {
|
||||||
// NOTE: This is an internal API endpoint requiring X-Requested-With header
|
const path = `/watch/${slugOrId}`;
|
||||||
const url = `${BASE_URL}/api/seasons/${animeId}`;
|
const { data } = await fetchWithMirror(path);
|
||||||
const { data } = await axios.get(url, {
|
|
||||||
headers: {
|
const html = typeof data === "string" ? data : String(data);
|
||||||
...headers,
|
const $ = cheerio.load(html);
|
||||||
"X-Requested-With": "XMLHttpRequest"
|
|
||||||
|
const animeId = parseInt($("#watch-main").attr("data-id")) || 0;
|
||||||
|
const seasons = [];
|
||||||
|
|
||||||
|
// NOTE: Try dedicated seasons section first (#ani-seasons)
|
||||||
|
$("#ani-seasons .swiper-slide, .seasons .item, #w-seasons .item").each((_, el) => {
|
||||||
|
const link = $(el).find("a").first();
|
||||||
|
const href = link.attr("href") || "";
|
||||||
|
const poster = $(el).find("img").attr("src") || "";
|
||||||
|
const title = $(el).find(".name, .title").text().trim() || link.text().trim() || "";
|
||||||
|
let slug = href.split("/watch/").pop() || "";
|
||||||
|
if (!slug) {
|
||||||
|
slug = $(el).find(".poster").attr("data-tip") || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slug || title) {
|
||||||
|
seasons.push({ title, slug, poster, url: href });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return data || [];
|
// NOTE: Fallback — extract from episode list sidebar if no dedicated section
|
||||||
|
if (seasons.length === 0) {
|
||||||
|
$(".w-side-section .item").each((_, el) => {
|
||||||
|
const link = $(el).find("a").first();
|
||||||
|
const href = link.attr("href") || "";
|
||||||
|
const poster = $(el).find("img").attr("src") || "";
|
||||||
|
const title = $(el).find(".name").text().trim() || "";
|
||||||
|
let slug = href.split("/watch/").pop() || "";
|
||||||
|
// NOTE: Items may not have <a> tags — fall back to data-tip attribute
|
||||||
|
if (!slug) {
|
||||||
|
slug = $(el).find(".poster").attr("data-tip") || "";
|
||||||
|
}
|
||||||
|
const score = $(el).find(".score").text().trim() || "";
|
||||||
|
const episodes = $(el).find(".meta .dot:last-child").text().trim() || "";
|
||||||
|
|
||||||
|
if (slug && slug !== slugOrId) {
|
||||||
|
seasons.push({ title, slug, poster, url: href, score, episodes });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
animeId,
|
||||||
|
slug: slugOrId,
|
||||||
|
totalSeasons: seasons.length,
|
||||||
|
seasons,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// NOTE: Return empty array on error to maintain consistent API
|
throw error;
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export { extractSeasons };
|
export { extractSeasons };
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════ END: seasons.extractor.js
|
// ══════════════════════════════════════════════════════════════ END: seasons.extractor.js
|
||||||
|
|||||||
@@ -16,9 +16,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as cheerio from "cheerio";
|
import * as cheerio from "cheerio";
|
||||||
import axios from "axios";
|
|
||||||
import { headers } from "../configs/header.config.js";
|
|
||||||
import { URLS } from "../configs/dataUrl.js";
|
import { URLS } from "../configs/dataUrl.js";
|
||||||
|
import { fetchWithMirror } from "../helper/mirror.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// SPOTLIGHT EXTRACTION
|
// SPOTLIGHT EXTRACTION
|
||||||
@@ -48,13 +47,11 @@ import { URLS } from "../configs/dataUrl.js";
|
|||||||
*/
|
*/
|
||||||
const extractSpotlight = async () => {
|
const extractSpotlight = async () => {
|
||||||
try {
|
try {
|
||||||
// NOTE: The homepage contains the spotlight carousel in #hotest section
|
const { data } = await fetchWithMirror("/home");
|
||||||
const { data } = await axios.get(URLS.home, { headers });
|
|
||||||
const $ = cheerio.load(data);
|
const $ = cheerio.load(data);
|
||||||
|
|
||||||
const spotlights = [];
|
const spotlights = [];
|
||||||
|
|
||||||
// NOTE: Each slide in the swiper carousel represents a featured anime
|
|
||||||
$("#hotest .swiper-slide.item").each((i, el) => {
|
$("#hotest .swiper-slide.item").each((i, el) => {
|
||||||
const slug = $(el).find("a.play").attr("href")?.split("/watch/").pop() || "";
|
const slug = $(el).find("a.play").attr("href")?.split("/watch/").pop() || "";
|
||||||
const poster = $(el).find(".image div").attr("style")?.match(/url\(['"]?(.+?)['"]?\)/)?.[1] || "";
|
const poster = $(el).find(".image div").attr("style")?.match(/url\(['"]?(.+?)['"]?\)/)?.[1] || "";
|
||||||
@@ -67,7 +64,6 @@ const extractSpotlight = async () => {
|
|||||||
const dub = parseInt($(el).find("i.dub").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() || "";
|
||||||
|
|
||||||
// NOTE: Only include items with valid slugs
|
|
||||||
if (slug) {
|
if (slug) {
|
||||||
spotlights.push({
|
spotlights.push({
|
||||||
slug,
|
slug,
|
||||||
|
|||||||
@@ -15,12 +15,10 @@
|
|||||||
* ======= • ======= • ======= • ======= • =======• =======
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as cheerio from "cheerio";
|
|
||||||
import axios from "axios";
|
|
||||||
import { headers } from "../configs/header.config.js";
|
|
||||||
import { URLS } from "../configs/dataUrl.js";
|
import { URLS } from "../configs/dataUrl.js";
|
||||||
import { extractPages } from "../helper/extractPages.helper.js";
|
import { extractPages } from "../helper/extractPages.helper.js";
|
||||||
import { countPages } from "../helper/countPages.helper.js";
|
import { countPages } from "../helper/countPages.helper.js";
|
||||||
|
import { parseListItems } from "../helper/parseListItem.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// STATUS FILTER EXTRACTION
|
// STATUS FILTER EXTRACTION
|
||||||
@@ -54,40 +52,10 @@ import { countPages } from "../helper/countPages.helper.js";
|
|||||||
*/
|
*/
|
||||||
const extractStatus = async (status, page = 1) => {
|
const extractStatus = async (status, page = 1) => {
|
||||||
try {
|
try {
|
||||||
// NOTE: URLS.status generates the correct endpoint for the given status
|
|
||||||
const url = URLS.status(status);
|
const url = URLS.status(status);
|
||||||
const $ = await extractPages(url, page);
|
const $ = await extractPages(url, page);
|
||||||
const totalPages = countPages($);
|
const totalPages = countPages($);
|
||||||
|
const results = parseListItems($);
|
||||||
const results = [];
|
|
||||||
|
|
||||||
// NOTE: Multiple selectors to handle different page layouts
|
|
||||||
$(".film_list-wrap .flw-item, .film-detail, .item").each((i, el) => {
|
|
||||||
const slug = $(el).find("a").attr("href")?.split("/watch/").pop() || "";
|
|
||||||
const poster = $(el).find("img").attr("src") || "";
|
|
||||||
const title = $(el).find(".film-name a, .name.d-title").text().trim() || "";
|
|
||||||
const japaneseTitle = $(el).find(".name.d-title").attr("data-jp") || "";
|
|
||||||
const sub = parseInt($(el).find(".ep-status.sub span").text().trim()) || 0;
|
|
||||||
const dub = parseInt($(el).find(".ep-status.dub span").text().trim()) || 0;
|
|
||||||
const total = parseInt($(el).find(".ep-status.total span").text().trim()) || 0;
|
|
||||||
const type = $(el).find(".meta .inner .right, .fdi-item:nth-child(2)").text().trim() || "";
|
|
||||||
const rating = $(el).find(".rating, .fdi-item:nth-child(3)").text().trim() || "";
|
|
||||||
|
|
||||||
// NOTE: Only include items with valid slugs
|
|
||||||
if (slug) {
|
|
||||||
results.push({
|
|
||||||
slug,
|
|
||||||
poster,
|
|
||||||
title,
|
|
||||||
japaneseTitle,
|
|
||||||
sub,
|
|
||||||
dub,
|
|
||||||
total,
|
|
||||||
type,
|
|
||||||
rating
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return { totalPages, status, data: results };
|
return { totalPages, status, data: results };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -16,10 +16,12 @@
|
|||||||
* ======= • ======= • ======= • ======= • =======• =======
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as cheerio from "cheerio";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import * as cheerio from "cheerio";
|
||||||
import { headers } from "../configs/header.config.js";
|
import { headers } from "../configs/header.config.js";
|
||||||
import { BASE_URL } from "../configs/dataUrl.js";
|
import { BASE_URL } from "../configs/dataUrl.js";
|
||||||
|
import { fetchWithMirror, getWorkingMirror } from "../helper/mirror.helper.js";
|
||||||
|
import { normalizeServerName } from "./streamResolver.extractor.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// STREAM INFO EXTRACTOR
|
// STREAM INFO EXTRACTOR
|
||||||
@@ -28,37 +30,101 @@ import { BASE_URL } from "../configs/dataUrl.js";
|
|||||||
// ---- FEATURE: Resolve a single stream URL from a linkId ----
|
// ---- FEATURE: Resolve a single stream URL from a linkId ----
|
||||||
/**
|
/**
|
||||||
* Fetches the actual stream URL and skip data for a given server link ID.
|
* Fetches the actual stream URL and skip data for a given server link ID.
|
||||||
* This is the primary method for resolving playable video URLs.
|
* First establishes a session by fetching the watch page, then queries
|
||||||
|
* the AJAX stream endpoint with proper session context.
|
||||||
*
|
*
|
||||||
* @param {string} linkId - The server link ID to resolve
|
* @param {string} linkId - The server link ID to resolve
|
||||||
* @returns {Promise<Object>} Object with linkId, url, and skipData (intro/outro timestamps)
|
* @param {string} [watchSlug] - Optional watch page slug to establish session
|
||||||
|
* @returns {Promise<Object>} Object with linkId, url, type, skipData (intro/outro timestamps)
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const stream = await extractStreamInfo("abc123");
|
* const stream = await extractStreamInfo("abc123", "one-piece-odmau");
|
||||||
* console.log(stream.url); // direct video URL
|
* console.log(stream.url); // direct video URL
|
||||||
* console.log(stream.skipData); // intro/outro skip ranges
|
* console.log(stream.skipData); // intro/outro skip ranges
|
||||||
*/
|
*/
|
||||||
const extractStreamInfo = async (linkId) => {
|
const extractStreamInfo = async (linkId, watchSlug = null) => {
|
||||||
try {
|
try {
|
||||||
const url = `${BASE_URL}/ajax/server?get=${linkId}`;
|
// NOTE: Establish session with cookies — AJAX endpoints require session cookies
|
||||||
const { data } = await axios.get(url, {
|
const mirror = await getWorkingMirror();
|
||||||
headers: {
|
let cookieHeader = "";
|
||||||
...headers,
|
|
||||||
"X-Requested-With": "XMLHttpRequest"
|
if (watchSlug) {
|
||||||
|
try {
|
||||||
|
const sessionRes = await axios.get(`${mirror}/watch/${watchSlug}`, {
|
||||||
|
headers,
|
||||||
|
timeout: 10000,
|
||||||
|
maxRedirects: 5,
|
||||||
|
});
|
||||||
|
const setCookies = sessionRes.headers["set-cookie"];
|
||||||
|
if (Array.isArray(setCookies)) {
|
||||||
|
cookieHeader = setCookies.map(c => c.split(";")[0]).join("; ");
|
||||||
}
|
}
|
||||||
|
} catch { /* session establishment is best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: Fallback — if no slug provided, try fetching any page to get session cookies
|
||||||
|
if (!cookieHeader) {
|
||||||
|
try {
|
||||||
|
const fallbackRes = await axios.get(`${mirror}/home`, {
|
||||||
|
headers,
|
||||||
|
timeout: 10000,
|
||||||
|
maxRedirects: 5,
|
||||||
|
});
|
||||||
|
const setCookies = fallbackRes.headers["set-cookie"];
|
||||||
|
if (Array.isArray(setCookies)) {
|
||||||
|
cookieHeader = setCookies.map(c => c.split(";")[0]).join("; ");
|
||||||
|
}
|
||||||
|
} catch { /* fallback is best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = `/ajax/server?get=${linkId}`;
|
||||||
|
const requestHeaders = {
|
||||||
|
...headers,
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
...(cookieHeader ? { "Cookie": cookieHeader } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: raw } = await fetchWithMirror(path, {
|
||||||
|
headers: requestHeaders
|
||||||
});
|
});
|
||||||
|
|
||||||
// NOTE: Response is JSON {status, result: {url, skip_data}}
|
// NOTE: Handle both string JSON and parsed object responses
|
||||||
if (!data || !data.result) {
|
let data = raw;
|
||||||
return { linkId, url: null, skipData: null };
|
if (typeof raw === "string") {
|
||||||
|
try { data = JSON.parse(raw); } catch { data = {}; }
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = typeof data.result === "string" ? (() => { try { return JSON.parse(data.result); } catch { return {}; } })() : data.result;
|
// NOTE: Detect upstream API error responses (e.g. {status: 500, result: "Bad request"})
|
||||||
|
if (data && typeof data === "object" && data.status && Number(data.status) >= 400) {
|
||||||
|
throw new Error(`Upstream API error (${data.status}): ${data.result || "Unknown error"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || !data.result) {
|
||||||
|
return { linkId, url: null, type: null, skipData: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: If result is a non-JSON string (error message), treat as upstream failure
|
||||||
|
const result = typeof data.result === "string"
|
||||||
|
? (() => {
|
||||||
|
try { return JSON.parse(data.result); }
|
||||||
|
catch {
|
||||||
|
if (!data.result.startsWith("{") && !data.result.startsWith("[")) {
|
||||||
|
throw new Error(`Upstream returned invalid data: ${data.result}`);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
: data.result;
|
||||||
|
|
||||||
|
const streamUrl = result.url || null;
|
||||||
|
const streamType = streamUrl?.includes(".m3u8") ? "hls" : streamUrl ? "mp4" : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
linkId,
|
linkId,
|
||||||
url: result.url || null,
|
url: streamUrl,
|
||||||
skipData: result.skip_data || null
|
type: streamType,
|
||||||
|
skipData: result.skip_data || null,
|
||||||
|
backup: result.backup || null,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -72,41 +138,91 @@ const extractStreamInfo = async (linkId) => {
|
|||||||
// ---- FEATURE: Fetch available servers for a set of episode IDs ----
|
// ---- FEATURE: Fetch available servers for a set of episode IDs ----
|
||||||
/**
|
/**
|
||||||
* Retrieves the server list for the given episode IDs from the AJAX endpoint.
|
* Retrieves the server list for the given episode IDs from the AJAX endpoint.
|
||||||
* Returns raw server data as provided by the source site.
|
* First establishes a session by fetching the watch page, then queries
|
||||||
|
* the AJAX server list endpoint with proper session context.
|
||||||
*
|
*
|
||||||
* @param {string} episodeIds - Comma-separated or single episode ID string
|
* @param {string} episodeIds - Comma-separated or single episode ID string
|
||||||
* @returns {Promise<Object>} Raw server list data from the AJAX response
|
* @param {string} [watchSlug] - Optional watch page slug to establish session
|
||||||
|
* @returns {Promise<Array>} Array of server objects with type, link_id, ep_id, etc.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const servers = await extractServerList("12345");
|
* const servers = await extractServerList("12345", "one-piece-odmau");
|
||||||
* // servers contains the server configuration for that episode
|
* console.log(servers[0].link_id); // first server's link ID
|
||||||
*/
|
*/
|
||||||
const extractServerList = async (episodeIds) => {
|
const extractServerList = async (episodeIds, watchSlug = null) => {
|
||||||
try {
|
try {
|
||||||
const url = `${BASE_URL}/ajax/server/list?servers=${episodeIds}`;
|
// NOTE: Establish session with cookies — AJAX endpoints require session cookies
|
||||||
const { data: raw } = await axios.get(url, {
|
const mirror = await getWorkingMirror();
|
||||||
headers: {
|
let cookieHeader = "";
|
||||||
...headers,
|
|
||||||
"X-Requested-With": "XMLHttpRequest"
|
if (watchSlug) {
|
||||||
|
try {
|
||||||
|
const sessionRes = await axios.get(`${mirror}/watch/${watchSlug}`, {
|
||||||
|
headers,
|
||||||
|
timeout: 10000,
|
||||||
|
maxRedirects: 5,
|
||||||
|
});
|
||||||
|
const setCookies = sessionRes.headers["set-cookie"];
|
||||||
|
if (Array.isArray(setCookies)) {
|
||||||
|
cookieHeader = setCookies.map(c => c.split(";")[0]).join("; ");
|
||||||
}
|
}
|
||||||
|
} catch { /* session establishment is best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cookieHeader) {
|
||||||
|
try {
|
||||||
|
const fallbackRes = await axios.get(`${mirror}/home`, {
|
||||||
|
headers,
|
||||||
|
timeout: 10000,
|
||||||
|
maxRedirects: 5,
|
||||||
|
});
|
||||||
|
const setCookies = fallbackRes.headers["set-cookie"];
|
||||||
|
if (Array.isArray(setCookies)) {
|
||||||
|
cookieHeader = setCookies.map(c => c.split(";")[0]).join("; ");
|
||||||
|
}
|
||||||
|
} catch { /* fallback is best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = `/ajax/server/list?servers=${episodeIds}`;
|
||||||
|
const requestHeaders = {
|
||||||
|
...headers,
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
...(cookieHeader ? { "Cookie": cookieHeader } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: raw } = await fetchWithMirror(path, {
|
||||||
|
headers: requestHeaders
|
||||||
});
|
});
|
||||||
|
|
||||||
// NOTE: Response is JSON {status, result} where result is HTML
|
// NOTE: Parse JSON response first — fetchWithMirror returns raw text by default
|
||||||
const html = typeof raw === "string" ? raw : (raw?.result || "");
|
let parsed = raw;
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
try { parsed = JSON.parse(raw); } catch { parsed = {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: Detect upstream API error responses
|
||||||
|
if (parsed && typeof parsed === "object" && parsed.status && Number(parsed.status) >= 400) {
|
||||||
|
throw new Error(`Upstream API error (${parsed.status}): ${parsed.result || "Unknown error"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = parsed?.result || "";
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
const servers = [];
|
const servers = [];
|
||||||
// NOTE: Servers are in .servers div, grouped by type (sub/dub)
|
|
||||||
$(".servers .type").each((_, typeEl) => {
|
$(".servers .type").each((_, typeEl) => {
|
||||||
const type = $(typeEl).attr("data-type") || "sub";
|
const type = $(typeEl).attr("data-type") || "sub";
|
||||||
|
const label = $(typeEl).find("label").text().trim() || type.toUpperCase();
|
||||||
$(typeEl).find("li[data-link-id]").each((__, li) => {
|
$(typeEl).find("li[data-link-id]").each((__, li) => {
|
||||||
|
const rawName = $(li).text().trim() || "";
|
||||||
servers.push({
|
servers.push({
|
||||||
type,
|
type,
|
||||||
|
typeLabel: label,
|
||||||
ep_id: $(li).attr("data-ep-id") || "",
|
ep_id: $(li).attr("data-ep-id") || "",
|
||||||
link_id: $(li).attr("data-link-id") || "",
|
link_id: $(li).attr("data-link-id") || "",
|
||||||
cmid: $(li).attr("data-cmid") || "",
|
cmid: $(li).attr("data-cmid") || "",
|
||||||
sv_id: $(li).attr("data-sv-id") || "",
|
sv_id: $(li).attr("data-sv-id") || "",
|
||||||
name: $(li).text().trim() || ""
|
name: rawName,
|
||||||
|
normalizedName: normalizeServerName(rawName),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -139,14 +255,23 @@ const extractServerList = async (episodeIds) => {
|
|||||||
*/
|
*/
|
||||||
const extractMapperServers = async (malId, slug, timestamp) => {
|
const extractMapperServers = async (malId, slug, timestamp) => {
|
||||||
try {
|
try {
|
||||||
const url = `https://mapper.nekostream.site/api/mal/${malId}/${slug}/${timestamp}`;
|
if (!Number.isFinite(Number(malId))) throw new Error("Invalid malId");
|
||||||
const { data } = await axios.get(url, { headers });
|
if (!/^[a-zA-Z0-9-]+$/.test(slug)) throw new Error("Invalid slug");
|
||||||
|
if (!Number.isFinite(Number(timestamp))) throw new Error("Invalid timestamp");
|
||||||
|
|
||||||
|
const path = `/ajax/mapper/${encodeURIComponent(malId)}/${encodeURIComponent(slug)}/${encodeURIComponent(timestamp)}`;
|
||||||
|
const { data: rawData } = await fetchWithMirror(path);
|
||||||
|
|
||||||
|
// NOTE: Parse JSON response first — fetchWithMirror returns raw text by default
|
||||||
|
let data = rawData;
|
||||||
|
if (typeof rawData === "string") {
|
||||||
|
try { data = JSON.parse(rawData); } catch { data = {}; }
|
||||||
|
}
|
||||||
|
|
||||||
const servers = [];
|
const servers = [];
|
||||||
|
|
||||||
if (data && typeof data === "object") {
|
if (data && typeof data === "object") {
|
||||||
for (const [provider, sources] of Object.entries(data)) {
|
for (const [provider, sources] of Object.entries(data)) {
|
||||||
// NOTE: Each provider may have sub, dub, or both source types
|
|
||||||
if (sources && sources.sub) {
|
if (sources && sources.sub) {
|
||||||
servers.push({
|
servers.push({
|
||||||
provider,
|
provider,
|
||||||
@@ -168,7 +293,6 @@ const extractMapperServers = async (malId, slug, timestamp) => {
|
|||||||
|
|
||||||
return servers;
|
return servers;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// NOTE: Returns empty array on failure — mapper API may be unavailable
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* AniKotoAPI — streamResolver.extractor.js
|
||||||
|
* Repository: https://github.com/Shineii86/AniKotoAPI
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* Resolves actual streaming video URLs (m3u8/mp4) from embed player URLs.
|
||||||
|
* Follows the chain: vidtube.site embed → megaplay API → m3u8 playlist.
|
||||||
|
* Supports quality selection and subtitle extraction.
|
||||||
|
*
|
||||||
|
* @exports
|
||||||
|
* resolveStreamUrl, resolveStreamUrls, parseM3u8Qualities
|
||||||
|
*
|
||||||
|
* @author Shinei Nouzen
|
||||||
|
* @license MIT
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
*/
|
||||||
|
|
||||||
|
import axios from "axios";
|
||||||
|
import { headers } from "../configs/header.config.js";
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// STREAM RESOLVER
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// Server name normalization map
|
||||||
|
const SERVER_NAME_MAP = {
|
||||||
|
"VidPlay-1": "vidplay",
|
||||||
|
"VidPlay-2": "vidplay",
|
||||||
|
"HD-1": "hd",
|
||||||
|
"HD-2": "hd",
|
||||||
|
"Vidstream-1": "vidstream",
|
||||||
|
"Vidstream-2": "vidstream",
|
||||||
|
"VidCloud-1": "vidcloud",
|
||||||
|
"VidCloud-2": "vidcloud",
|
||||||
|
"StreamTape-1": "streamtape",
|
||||||
|
"StreamTape-2": "streamtape",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Embed domain to API domain mapping
|
||||||
|
const EMBED_API_MAP = {
|
||||||
|
"vidtube.site": "megaplay-1.buzz",
|
||||||
|
"vidplay.site": "megaplay-1.buzz",
|
||||||
|
"megaplay.buzz": "megaplay-1.buzz",
|
||||||
|
"embed.bunkrerrer.com": "megaplay-1.buzz",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- FEATURE: Server Name Normalization ----
|
||||||
|
/**
|
||||||
|
* Normalizes server display names to clean identifiers
|
||||||
|
* @param {string} name - Raw server name (e.g., "VidPlay-1")
|
||||||
|
* @returns {string} Normalized name (e.g., "vidplay")
|
||||||
|
*/
|
||||||
|
const normalizeServerName = (name) => {
|
||||||
|
if (!name) return "unknown";
|
||||||
|
return SERVER_NAME_MAP[name] || name.toLowerCase().replace(/[-\s]+\d+$/, "").trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- FEATURE: Stream URL Resolution ----
|
||||||
|
/**
|
||||||
|
* Resolves the actual video stream URL from an embed player page.
|
||||||
|
* Extracts data-id from the embed HTML, then queries the megaplay API.
|
||||||
|
*
|
||||||
|
* @param {string} embedUrl - The embed player URL (e.g., vidtube.site/stream/...)
|
||||||
|
* @param {object} [options] - Resolution options
|
||||||
|
* @param {boolean} [options.followRedirects=true] - Follow HTTP redirects
|
||||||
|
* @param {number} [options.timeout=10000] - Request timeout in ms
|
||||||
|
* @returns {Promise<Object>} Object with url, qualities, skipData, type
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const resolved = await resolveStreamUrl("https://vidtube.site/stream/...");
|
||||||
|
* console.log(resolved.url); // m3u8 URL
|
||||||
|
* console.log(resolved.qualities); // [{ label: "720p", url: "..." }]
|
||||||
|
*/
|
||||||
|
const resolveStreamUrl = async (embedUrl, options = {}) => {
|
||||||
|
const { followRedirects = true, timeout = 10000 } = options;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// NOTE: Extract the embed page to find data-id and API domain
|
||||||
|
const embedResponse = await axios.get(embedUrl, {
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
"Referer": "https://anikototv.to/",
|
||||||
|
"Origin": "https://anikototv.to",
|
||||||
|
},
|
||||||
|
timeout,
|
||||||
|
maxRedirects: followRedirects ? 5 : 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const embedHtml = typeof embedResponse.data === "string"
|
||||||
|
? embedResponse.data
|
||||||
|
: String(embedResponse.data);
|
||||||
|
|
||||||
|
// NOTE: Extract data attributes from the player div
|
||||||
|
const dataId = embedHtml.match(/data-id="(\d+)"/)?.[1];
|
||||||
|
const realId = embedHtml.match(/data-realid="([^"]+)"/)?.[1];
|
||||||
|
const mediaId = embedHtml.match(/data-mediaid="([^"]+)"/)?.[1];
|
||||||
|
|
||||||
|
if (!dataId) {
|
||||||
|
return {
|
||||||
|
url: null,
|
||||||
|
qualities: [],
|
||||||
|
skipData: null,
|
||||||
|
type: null,
|
||||||
|
error: "Could not extract player data-id from embed page",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: Determine API domain from embed URL or extract from page
|
||||||
|
const embedDomain = new URL(embedUrl).hostname;
|
||||||
|
const apiDomain = EMBED_API_MAP[embedDomain] || embedDomain;
|
||||||
|
|
||||||
|
// NOTE: Query the megaplay API for the actual stream URL
|
||||||
|
const apiResponse = await axios.get(
|
||||||
|
`https://${apiDomain}/ajax/sources/${dataId}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
"Referer": embedUrl,
|
||||||
|
"Origin": `https://${embedDomain}`,
|
||||||
|
},
|
||||||
|
timeout,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const apiData = typeof apiResponse.data === "string"
|
||||||
|
? (() => { try { return JSON.parse(apiResponse.data); } catch { return {}; } })()
|
||||||
|
: apiResponse.data;
|
||||||
|
|
||||||
|
// NOTE: Extract stream URL from API response
|
||||||
|
const streamUrl = apiData?.source || apiData?.url || apiData?.file || null;
|
||||||
|
const streamType = apiData?.type || (streamUrl?.includes(".m3u8") ? "hls" : "mp4");
|
||||||
|
|
||||||
|
// NOTE: Parse m3u8 qualities if it's an HLS stream
|
||||||
|
let qualities = [];
|
||||||
|
if (streamUrl && streamType === "hls") {
|
||||||
|
qualities = await parseM3u8Qualities(streamUrl, {
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
"Referer": embedUrl,
|
||||||
|
},
|
||||||
|
timeout,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: Extract subtitles if available
|
||||||
|
const subtitles = extractSubtitles(apiData);
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: streamUrl,
|
||||||
|
type: streamType,
|
||||||
|
qualities,
|
||||||
|
subtitles,
|
||||||
|
skipData: apiData?.skip_data || null,
|
||||||
|
dataId,
|
||||||
|
realId,
|
||||||
|
mediaId,
|
||||||
|
backup: apiData?.backup || null,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
url: null,
|
||||||
|
qualities: [],
|
||||||
|
skipData: null,
|
||||||
|
type: null,
|
||||||
|
error: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- FEATURE: Batch Stream URL Resolution ----
|
||||||
|
/**
|
||||||
|
* Resolves stream URLs for multiple episodes in batch
|
||||||
|
* @param {string} embedUrl - The embed player URL
|
||||||
|
* @param {object} [options] - Resolution options
|
||||||
|
* @returns {Promise<Object>} Batch resolution result
|
||||||
|
*/
|
||||||
|
const resolveStreamUrls = async (embedUrl, options = {}) => {
|
||||||
|
try {
|
||||||
|
const resolved = await resolveStreamUrl(embedUrl, options);
|
||||||
|
return resolved;
|
||||||
|
} catch (error) {
|
||||||
|
return { url: null, error: error.message };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// M3U8 PARSER
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: M3U8 Quality Parser ----
|
||||||
|
/**
|
||||||
|
* Parses an M3U8 playlist to extract available quality options.
|
||||||
|
* Supports both master playlists (multiple qualities) and media playlists.
|
||||||
|
*
|
||||||
|
* @param {string} m3u8Url - URL of the M3U8 playlist
|
||||||
|
* @param {object} [options] - Fetch options
|
||||||
|
* @param {object} [options.headers] - Custom headers
|
||||||
|
* @param {number} [options.timeout=10000] - Request timeout
|
||||||
|
* @returns {Promise<Array<Object>>} Array of quality objects
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const qualities = await parseM3u8Qualities("https://example.com/master.m3u8");
|
||||||
|
* qualities.forEach(q => console.log(`${q.label}: ${q.url}`));
|
||||||
|
*/
|
||||||
|
const parseM3u8Qualities = async (m3u8Url, options = {}) => {
|
||||||
|
const { headers: customHeaders = {}, timeout = 10000 } = options;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(m3u8Url, {
|
||||||
|
headers: { ...headers, ...customHeaders },
|
||||||
|
timeout,
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = typeof response.data === "string"
|
||||||
|
? response.data
|
||||||
|
: String(response.data);
|
||||||
|
|
||||||
|
const qualities = [];
|
||||||
|
const lines = content.split("\n").map((l) => l.trim());
|
||||||
|
|
||||||
|
// NOTE: Parse master playlist (contains #EXT-X-STREAM-INF)
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (lines[i].startsWith("#EXT-X-STREAM-INF:")) {
|
||||||
|
const attrs = lines[i].substring("#EXT-X-STREAM-INF:".length);
|
||||||
|
const bandwidth = parseInt(attrs.match(/BANDWIDTH=(\d+)/)?.[1] || "0");
|
||||||
|
const resolution = attrs.match(/RESOLUTION=(\d+x\d+)/)?.[1] || "";
|
||||||
|
const [width, height] = resolution.split("x").map(Number);
|
||||||
|
const nextLine = lines[i + 1];
|
||||||
|
|
||||||
|
if (nextLine && !nextLine.startsWith("#")) {
|
||||||
|
// NOTE: Resolve relative URLs against base M3U8 URL
|
||||||
|
const qualityUrl = nextLine.startsWith("http")
|
||||||
|
? nextLine
|
||||||
|
: new URL(nextLine, m3u8Url).href;
|
||||||
|
|
||||||
|
qualities.push({
|
||||||
|
label: height ? `${height}p` : `${bandwidth}bps`,
|
||||||
|
width: width || 0,
|
||||||
|
height: height || 0,
|
||||||
|
bandwidth,
|
||||||
|
url: qualityUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: If no qualities found, it's likely a single-quality stream
|
||||||
|
if (qualities.length === 0 && content.includes("#EXTINF")) {
|
||||||
|
qualities.push({
|
||||||
|
label: "default",
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
bandwidth: 0,
|
||||||
|
url: m3u8Url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: Sort by resolution/bandwidth descending (highest first)
|
||||||
|
qualities.sort((a, b) => (b.height || b.bandwidth) - (a.height || a.bandwidth));
|
||||||
|
|
||||||
|
return qualities;
|
||||||
|
} catch (error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// SUBTITLE EXTRACTOR
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Subtitle Track Extractor ----
|
||||||
|
/**
|
||||||
|
* Extracts subtitle information from API response data
|
||||||
|
* @param {object} apiData - The megaplay API response
|
||||||
|
* @returns {Array<Object>} Array of subtitle objects
|
||||||
|
*/
|
||||||
|
const extractSubtitles = (apiData) => {
|
||||||
|
const subtitles = [];
|
||||||
|
|
||||||
|
// NOTE: Subtitles may be in different formats depending on the source
|
||||||
|
if (apiData?.subtitles && Array.isArray(apiData.subtitles)) {
|
||||||
|
for (const sub of apiData.subtitles) {
|
||||||
|
subtitles.push({
|
||||||
|
label: sub.label || sub.language || "Unknown",
|
||||||
|
language: sub.code || sub.language || "unknown",
|
||||||
|
url: sub.url || sub.file || null,
|
||||||
|
format: sub.format || "srt",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: Some sources embed subtitles in tracks array
|
||||||
|
if (apiData?.tracks && Array.isArray(apiData.tracks)) {
|
||||||
|
for (const track of apiData.tracks) {
|
||||||
|
if (track.kind === "subtitles" || track.type === "subtitles") {
|
||||||
|
subtitles.push({
|
||||||
|
label: track.label || track.language || "Unknown",
|
||||||
|
language: track.srclang || track.language || "unknown",
|
||||||
|
url: track.file || track.src || null,
|
||||||
|
format: track.format || "srt",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return subtitles;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { resolveStreamUrl, resolveStreamUrls, parseM3u8Qualities, normalizeServerName, extractSubtitles };
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: streamResolver.extractor.js
|
||||||
@@ -16,9 +16,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as cheerio from "cheerio";
|
import * as cheerio from "cheerio";
|
||||||
import axios from "axios";
|
|
||||||
import { headers } from "../configs/header.config.js";
|
|
||||||
import { URLS } from "../configs/dataUrl.js";
|
import { URLS } from "../configs/dataUrl.js";
|
||||||
|
import { fetchWithMirror } from "../helper/mirror.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// SUGGESTION EXTRACTION
|
// SUGGESTION EXTRACTION
|
||||||
@@ -46,14 +45,12 @@ import { URLS } from "../configs/dataUrl.js";
|
|||||||
*/
|
*/
|
||||||
const extractSuggestions = async (keyword) => {
|
const extractSuggestions = async (keyword) => {
|
||||||
try {
|
try {
|
||||||
// NOTE: The search endpoint requires the 'keyword' parameter
|
const path = `/search?keyword=${encodeURIComponent(keyword)}`;
|
||||||
const url = `${URLS.search}?keyword=${encodeURIComponent(keyword)}`;
|
const { data } = await fetchWithMirror(path);
|
||||||
const { data } = await axios.get(url, { headers });
|
|
||||||
const $ = cheerio.load(data);
|
const $ = cheerio.load(data);
|
||||||
|
|
||||||
const suggestions = [];
|
const suggestions = [];
|
||||||
|
|
||||||
// NOTE: Search results use the same list structure as other pages
|
|
||||||
$("#list-items > .item").each((i, el) => {
|
$("#list-items > .item").each((i, el) => {
|
||||||
const slug = $(el).find("a").attr("href")?.split("/watch/").pop() || "";
|
const slug = $(el).find("a").attr("href")?.split("/watch/").pop() || "";
|
||||||
const poster = $(el).find(".ani.poster.tip > a > img").attr("src") || "";
|
const poster = $(el).find(".ani.poster.tip > a > img").attr("src") || "";
|
||||||
@@ -63,13 +60,11 @@ const extractSuggestions = async (keyword) => {
|
|||||||
const sub = parseInt($(el).find(".ep-status.sub span").text().trim()) || 0;
|
const sub = parseInt($(el).find(".ep-status.sub span").text().trim()) || 0;
|
||||||
const dub = parseInt($(el).find(".ep-status.dub span").text().trim()) || 0;
|
const dub = parseInt($(el).find(".ep-status.dub span").text().trim()) || 0;
|
||||||
|
|
||||||
// NOTE: Only include items with valid slugs
|
|
||||||
if (slug) {
|
if (slug) {
|
||||||
suggestions.push({ slug, poster, title, japaneseTitle, type, sub, dub });
|
suggestions.push({ slug, poster, title, japaneseTitle, type, sub, dub });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// NOTE: Limit to 10 suggestions for autocomplete-style responses
|
|
||||||
return suggestions.slice(0, 10);
|
return suggestions.slice(0, 10);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* AniKotoAPI — topAnimeRankings.extractor.js
|
||||||
|
* Repository: https://github.com/Shineii86/AniKotoAPI
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* Extracts top anime rankings from the homepage with sort options.
|
||||||
|
* Supports both "top" (all-time) and "newest" ranking modes.
|
||||||
|
* Parses ranked items with position numbers 1-9.
|
||||||
|
*
|
||||||
|
* @exports
|
||||||
|
* extractTopAnimeRankings
|
||||||
|
*
|
||||||
|
* @author Shinei Nouzen
|
||||||
|
* @license MIT
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as cheerio from "cheerio";
|
||||||
|
import { fetchWithMirror } from "../helper/mirror.helper.js";
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// TOP ANIME RANKINGS EXTRACTION
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Extract top anime rankings with sort option ----
|
||||||
|
/**
|
||||||
|
* Fetches and parses the top anime rankings from the homepage.
|
||||||
|
* Supports switching between "top" (all-time) and "newest" rankings.
|
||||||
|
*
|
||||||
|
* @param {string} sort - Ranking sort mode: "day", "week", or "month" (default: "day")
|
||||||
|
* @returns {Promise<Array<Object>>} Array of ranked anime objects
|
||||||
|
* @returns {number} return[].rank - Ranking position (1-9)
|
||||||
|
* @returns {string} return[].slug - URL slug for the anime
|
||||||
|
* @returns {string} return[].poster - Poster image URL
|
||||||
|
* @returns {string} return[].title - Anime title
|
||||||
|
* @returns {string} return[].japaneseTitle - Japanese title
|
||||||
|
* @returns {number} return[].sub - Subbed episode count
|
||||||
|
* @returns {number} return[].dub - Dubbed episode count
|
||||||
|
* @returns {string} return[].type - Anime type
|
||||||
|
* @returns {string} return[].views - View count
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const topAnime = await extractTopAnimeRankings("top");
|
||||||
|
* console.log(topAnime[0].rank); // 1
|
||||||
|
* console.log(topAnime[0].title); // #1 anime
|
||||||
|
*
|
||||||
|
* const newest = await extractTopAnimeRankings("newest");
|
||||||
|
* console.log(newest[0].title); // #1 newest anime
|
||||||
|
*/
|
||||||
|
const extractTopAnimeRankings = async (sort = "top") => {
|
||||||
|
try {
|
||||||
|
// NOTE: Homepage contains both top and newest rankings in tabbed sections
|
||||||
|
const { data } = await fetchWithMirror("/home");
|
||||||
|
const $ = cheerio.load(data);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
// NOTE: Ranking items live inside #top-anime .tab-content .scaff.side.items
|
||||||
|
// Each item is an <a class="item rankN"> with rank classes rank1..rank9
|
||||||
|
// The data-name on .tab-content controls which tab: day/week/month
|
||||||
|
const tabSelector = sort === "week" ? "[data-name='week']"
|
||||||
|
: sort === "month" ? "[data-name='month']"
|
||||||
|
: "[data-name='day']";
|
||||||
|
|
||||||
|
$(`#top-anime .tab-content${tabSelector} a.item, #top-anime .scaff.side.items a.item`).each((i, el) => {
|
||||||
|
const slug = $(el).attr("href")?.split("/watch/").pop() || "";
|
||||||
|
if (!slug) return;
|
||||||
|
|
||||||
|
const poster = $(el).find("img").attr("src") || "";
|
||||||
|
const title = $(el).find(".name").text().trim() || "";
|
||||||
|
const japaneseTitle = $(el).find(".name").attr("data-jp") || "";
|
||||||
|
const sub = parseInt($(el).find(".ep-status.sub span").text().trim()) || 0;
|
||||||
|
const dub = parseInt($(el).find(".ep-status.dub span").text().trim()) || 0;
|
||||||
|
const type = $(el).find(".meta .dot:last-child").text().trim() || "";
|
||||||
|
|
||||||
|
// NOTE: Rank is extracted from the rankN class on the <a> element
|
||||||
|
const rankClass = [...$(el).attr("class") || ""].join(" ").match(/rank(\d+)/);
|
||||||
|
const rank = rankClass ? parseInt(rankClass[1]) : i + 1;
|
||||||
|
|
||||||
|
results.push({ rank, slug, poster, title, japaneseTitle, sub, dub, type });
|
||||||
|
});
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { extractTopAnimeRankings };
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: topAnimeRankings.extractor.js
|
||||||
@@ -16,9 +16,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as cheerio from "cheerio";
|
import * as cheerio from "cheerio";
|
||||||
import axios from "axios";
|
|
||||||
import { headers } from "../configs/header.config.js";
|
|
||||||
import { URLS } from "../configs/dataUrl.js";
|
import { URLS } from "../configs/dataUrl.js";
|
||||||
|
import { fetchWithMirror } from "../helper/mirror.helper.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// TOP TEN EXTRACTION
|
// TOP TEN EXTRACTION
|
||||||
@@ -47,15 +46,13 @@ import { URLS } from "../configs/dataUrl.js";
|
|||||||
*/
|
*/
|
||||||
const extractTopTen = async () => {
|
const extractTopTen = async () => {
|
||||||
try {
|
try {
|
||||||
// NOTE: The top 10 section uses tabbed content with data-name attributes
|
const { data } = await fetchWithMirror("/home");
|
||||||
const { data } = await axios.get(URLS.home, { headers });
|
|
||||||
const $ = cheerio.load(data);
|
const $ = cheerio.load(data);
|
||||||
|
|
||||||
const today = [];
|
const today = [];
|
||||||
const week = [];
|
const week = [];
|
||||||
const month = [];
|
const month = [];
|
||||||
|
|
||||||
// NOTE: Each tab contains a list of anime items with ranking data
|
|
||||||
$("#top-anime .tab-content[data-name='day'] a.item").each((i, el) => {
|
$("#top-anime .tab-content[data-name='day'] a.item").each((i, el) => {
|
||||||
const slug = $(el).attr("href")?.split("/watch/").pop() || "";
|
const slug = $(el).attr("href")?.split("/watch/").pop() || "";
|
||||||
const poster = $(el).find(".poster img").attr("src") || "";
|
const poster = $(el).find(".poster img").attr("src") || "";
|
||||||
@@ -65,13 +62,11 @@ const extractTopTen = async () => {
|
|||||||
const type = $(el).find(".type").text().trim() || "";
|
const type = $(el).find(".type").text().trim() || "";
|
||||||
const rank = i + 1;
|
const rank = i + 1;
|
||||||
|
|
||||||
// NOTE: Only include items with valid slugs
|
|
||||||
if (slug) {
|
if (slug) {
|
||||||
today.push({ slug, rank, name, poster, sub, dub, type });
|
today.push({ slug, rank, name, poster, sub, dub, type });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// NOTE: Weekly rankings follow the same structure as daily
|
|
||||||
$("#top-anime .tab-content[data-name='week'] a.item").each((i, el) => {
|
$("#top-anime .tab-content[data-name='week'] a.item").each((i, el) => {
|
||||||
const slug = $(el).attr("href")?.split("/watch/").pop() || "";
|
const slug = $(el).attr("href")?.split("/watch/").pop() || "";
|
||||||
const poster = $(el).find(".poster img").attr("src") || "";
|
const poster = $(el).find(".poster img").attr("src") || "";
|
||||||
@@ -86,7 +81,6 @@ const extractTopTen = async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// NOTE: Monthly rankings follow the same structure
|
|
||||||
$("#top-anime .tab-content[data-name='month'] a.item").each((i, el) => {
|
$("#top-anime .tab-content[data-name='month'] a.item").each((i, el) => {
|
||||||
const slug = $(el).attr("href")?.split("/watch/").pop() || "";
|
const slug = $(el).attr("href")?.split("/watch/").pop() || "";
|
||||||
const poster = $(el).find(".poster img").attr("src") || "";
|
const poster = $(el).find(".poster img").attr("src") || "";
|
||||||
|
|||||||
@@ -16,9 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as cheerio from "cheerio";
|
import * as cheerio from "cheerio";
|
||||||
import axios from "axios";
|
import { fetchWithMirror } from "../helper/mirror.helper.js";
|
||||||
import { headers } from "../configs/header.config.js";
|
|
||||||
import { URLS } from "../configs/dataUrl.js";
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// TRENDING EXTRACTION
|
// TRENDING EXTRACTION
|
||||||
@@ -45,13 +43,11 @@ import { URLS } from "../configs/dataUrl.js";
|
|||||||
*/
|
*/
|
||||||
const extractTrending = async () => {
|
const extractTrending = async () => {
|
||||||
try {
|
try {
|
||||||
// NOTE: Trending data is split across multiple sections on the homepage
|
const { data } = await fetchWithMirror("/home");
|
||||||
const { data } = await axios.get(URLS.home, { headers });
|
|
||||||
const $ = cheerio.load(data);
|
const $ = cheerio.load(data);
|
||||||
|
|
||||||
const trending = [];
|
const trending = [];
|
||||||
|
|
||||||
// NOTE: This selector targets both the updated section and recent updates
|
|
||||||
$(".section-updated .item, #recent-update .item").each((i, el) => {
|
$(".section-updated .item, #recent-update .item").each((i, el) => {
|
||||||
const slug = $(el).find("a.name.d-title").attr("href")?.split("/watch/").pop() || "";
|
const slug = $(el).find("a.name.d-title").attr("href")?.split("/watch/").pop() || "";
|
||||||
const poster = $(el).find(".poster img").attr("src") || "";
|
const poster = $(el).find(".poster img").attr("src") || "";
|
||||||
@@ -62,7 +58,6 @@ const extractTrending = async () => {
|
|||||||
const total = parseInt($(el).find(".ep-status.total span").text().trim()) || 0;
|
const total = parseInt($(el).find(".ep-status.total span").text().trim()) || 0;
|
||||||
const type = $(el).find(".meta .inner .right").text().trim() || "";
|
const type = $(el).find(".meta .inner .right").text().trim() || "";
|
||||||
|
|
||||||
// NOTE: Only include items with valid slugs
|
|
||||||
if (slug) {
|
if (slug) {
|
||||||
trending.push({
|
trending.push({
|
||||||
slug,
|
slug,
|
||||||
|
|||||||
@@ -16,9 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as cheerio from "cheerio";
|
import * as cheerio from "cheerio";
|
||||||
import axios from "axios";
|
import { fetchWithMirror } from "../helper/mirror.helper.js";
|
||||||
import { headers } from "../configs/header.config.js";
|
|
||||||
import { URLS } from "../configs/dataUrl.js";
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// TRENDING SIDEBAR EXTRACTION
|
// TRENDING SIDEBAR EXTRACTION
|
||||||
@@ -49,15 +47,13 @@ import { URLS } from "../configs/dataUrl.js";
|
|||||||
*/
|
*/
|
||||||
const extractTrendingSidebar = async () => {
|
const extractTrendingSidebar = async () => {
|
||||||
try {
|
try {
|
||||||
// NOTE: The sidebar content is embedded in the homepage HTML
|
const { data } = await fetchWithMirror("/home");
|
||||||
const { data } = await axios.get(URLS.home, { headers });
|
|
||||||
const $ = cheerio.load(data);
|
const $ = cheerio.load(data);
|
||||||
|
|
||||||
const day = [];
|
const day = [];
|
||||||
const week = [];
|
const week = [];
|
||||||
const month = [];
|
const month = [];
|
||||||
|
|
||||||
// NOTE: Daily rankings use the 'day' tab content
|
|
||||||
$("#top-anime .tab-content[data-name='day'] a.item").each((i, el) => {
|
$("#top-anime .tab-content[data-name='day'] a.item").each((i, el) => {
|
||||||
const slug = $(el).attr("href")?.split("/watch/").pop() || "";
|
const slug = $(el).attr("href")?.split("/watch/").pop() || "";
|
||||||
const poster = $(el).find(".poster img").attr("src") || "";
|
const poster = $(el).find(".poster img").attr("src") || "";
|
||||||
@@ -67,13 +63,11 @@ const extractTrendingSidebar = async () => {
|
|||||||
const type = $(el).find(".type").text().trim() || "";
|
const type = $(el).find(".type").text().trim() || "";
|
||||||
const rank = i + 1;
|
const rank = i + 1;
|
||||||
|
|
||||||
// NOTE: Only include items with valid slugs
|
|
||||||
if (slug) {
|
if (slug) {
|
||||||
day.push({ slug, rank, name, poster, sub, dub, type });
|
day.push({ slug, rank, name, poster, sub, dub, type });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// NOTE: Weekly rankings use the 'week' tab content
|
|
||||||
$("#top-anime .tab-content[data-name='week'] a.item").each((i, el) => {
|
$("#top-anime .tab-content[data-name='week'] a.item").each((i, el) => {
|
||||||
const slug = $(el).attr("href")?.split("/watch/").pop() || "";
|
const slug = $(el).attr("href")?.split("/watch/").pop() || "";
|
||||||
const poster = $(el).find(".poster img").attr("src") || "";
|
const poster = $(el).find(".poster img").attr("src") || "";
|
||||||
@@ -88,7 +82,6 @@ const extractTrendingSidebar = async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// NOTE: Monthly rankings use the 'month' tab content
|
|
||||||
$("#top-anime .tab-content[data-name='month'] a.item").each((i, el) => {
|
$("#top-anime .tab-content[data-name='month'] a.item").each((i, el) => {
|
||||||
const slug = $(el).attr("href")?.split("/watch/").pop() || "";
|
const slug = $(el).attr("href")?.split("/watch/").pop() || "";
|
||||||
const poster = $(el).find(".poster img").attr("src") || "";
|
const poster = $(el).find(".poster img").attr("src") || "";
|
||||||
@@ -103,7 +96,6 @@ const extractTrendingSidebar = async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// NOTE: Latest episodes section provides recently updated content
|
|
||||||
const latestEpisodes = [];
|
const latestEpisodes = [];
|
||||||
$("#recent-update .item, .section-updated .item").each((i, el) => {
|
$("#recent-update .item, .section-updated .item").each((i, el) => {
|
||||||
const slug = $(el).find("a.name.d-title").attr("href")?.split("/watch/").pop() || "";
|
const slug = $(el).find("a.name.d-title").attr("href")?.split("/watch/").pop() || "";
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* AniKotoAPI — upcomingAnime.extractor.js
|
||||||
|
* Repository: https://github.com/Shineii86/AniKotoAPI
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* Extracts upcoming anime from the homepage section.
|
||||||
|
* Scrapes the #upcoming-anime section for future releases.
|
||||||
|
*
|
||||||
|
* @exports
|
||||||
|
* extractUpcomingAnime
|
||||||
|
*
|
||||||
|
* @author Shinei Nouzen
|
||||||
|
* @license MIT
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as cheerio from "cheerio";
|
||||||
|
import { fetchWithMirror } from "../helper/mirror.helper.js";
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// UPCOMING ANIME EXTRACTION
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Extract upcoming anime from homepage section ----
|
||||||
|
/**
|
||||||
|
* Fetches and parses the upcoming anime section from the homepage.
|
||||||
|
* Returns a list of anime that are scheduled for future release.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Array<Object>>} Array of upcoming anime objects
|
||||||
|
* @returns {string} return[].slug - URL slug for the anime
|
||||||
|
* @returns {string} return[].poster - Poster image URL
|
||||||
|
* @returns {string} return[].title - English title
|
||||||
|
* @returns {string} return[].japaneseTitle - Japanese title
|
||||||
|
* @returns {string} return[].type - Anime type (TV, Movie, etc.)
|
||||||
|
* @returns {string} return[].releaseDate - Expected release date
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const upcoming = await extractUpcomingAnime();
|
||||||
|
* console.log(upcoming.length); // number of upcoming anime
|
||||||
|
* console.log(upcoming[0].title); // first upcoming anime title
|
||||||
|
*/
|
||||||
|
const extractUpcomingAnime = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await fetchWithMirror("/home");
|
||||||
|
const $ = cheerio.load(data);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
// NOTE: upcoming-anime section contains future releases
|
||||||
|
$("#upcoming-anime .item, .upcoming .item").each((i, el) => {
|
||||||
|
const slug = $(el).find("a").attr("href")?.split("/watch/").pop() || "";
|
||||||
|
if (!slug) return;
|
||||||
|
|
||||||
|
const poster = $(el).find("img").attr("src") || "";
|
||||||
|
const title = $(el).find(".film-name a, .name.d-title, a.name").text().trim() || "";
|
||||||
|
const japaneseTitle = $(el).find(".name.d-title, a.name").attr("data-jp") || "";
|
||||||
|
const type = $(el).find(".type, .fdi-item:nth-child(2)").text().trim() || "";
|
||||||
|
const releaseDate = $(el).find(".date, .release-date").text().trim() || "";
|
||||||
|
|
||||||
|
results.push({ slug, poster, title, japaneseTitle, type, releaseDate });
|
||||||
|
});
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { extractUpcomingAnime };
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: upcomingAnime.extractor.js
|
||||||
@@ -4,8 +4,9 @@
|
|||||||
* Repository: https://github.com/Shineii86/AniKotoAPI
|
* Repository: https://github.com/Shineii86/AniKotoAPI
|
||||||
*
|
*
|
||||||
* @description
|
* @description
|
||||||
* Extracts watch order information for a specific anime via API.
|
* Extracts watch order and related anime information from the
|
||||||
* Returns recommended viewing order from the internal API endpoint.
|
* watch page sidebar. Returns trending/related anime with their
|
||||||
|
* metadata for continuous viewing.
|
||||||
*
|
*
|
||||||
* @exports
|
* @exports
|
||||||
* extractWatchOrder
|
* extractWatchOrder
|
||||||
@@ -15,44 +16,88 @@
|
|||||||
* ======= • ======= • ======= • ======= • =======• =======
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import axios from "axios";
|
import * as cheerio from "cheerio";
|
||||||
import { headers } from "../configs/header.config.js";
|
import { fetchWithMirror } from "../helper/mirror.helper.js";
|
||||||
import { BASE_URL } from "../configs/dataUrl.js";
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// WATCH ORDER API EXTRACTION
|
// WATCH ORDER EXTRACTOR
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// ---- FEATURE: Extract watch order data from internal API ----
|
// ---- FEATURE: Watch Order / Related Anime Extraction ----
|
||||||
/**
|
/**
|
||||||
* Fetches watch order information for a specific anime from the internal API.
|
* Fetches and parses watch order / related anime for an anime.
|
||||||
* Returns an array of watch order objects or empty array on error.
|
* Extracts related anime from the watch page sidebar.
|
||||||
*
|
*
|
||||||
* @param {string} animeId - The anime ID to fetch watch order for
|
* NOTE: The source site's AJAX endpoints (/ajax/related/) don't exist,
|
||||||
* @returns {Promise<Array<Object>>} Array of watch order objects or empty array
|
* so we extract from the watch page HTML sidebar instead.
|
||||||
|
*
|
||||||
|
* @param {string|number} slugOrId - The anime slug or numeric ID
|
||||||
|
* @returns {Promise<Object>} Object with animeId, totalRelated, related array
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const watchOrder = await extractWatchOrder("12345");
|
* const data = await extractWatchOrder("one-piece-odmau");
|
||||||
* console.log(watchOrder[0].title); // First anime in watch order
|
* console.log(data.totalRelated);
|
||||||
*/
|
*/
|
||||||
const extractWatchOrder = async (animeId) => {
|
const extractWatchOrder = async (slugOrId) => {
|
||||||
try {
|
try {
|
||||||
// NOTE: This is an internal API endpoint requiring X-Requested-With header
|
const path = `/watch/${slugOrId}`;
|
||||||
const url = `${BASE_URL}/api/watch-order/${animeId}`;
|
const { data } = await fetchWithMirror(path);
|
||||||
const { data } = await axios.get(url, {
|
|
||||||
headers: {
|
const html = typeof data === "string" ? data : String(data);
|
||||||
...headers,
|
const $ = cheerio.load(html);
|
||||||
"X-Requested-With": "XMLHttpRequest"
|
|
||||||
|
const animeId = parseInt($("#watch-main").attr("data-id")) || 0;
|
||||||
|
const related = [];
|
||||||
|
|
||||||
|
// NOTE: Try dedicated related section first (#w-related)
|
||||||
|
$("#w-related .item, .w-side-section:has(.title:contains('Related')) .item").each((_, el) => {
|
||||||
|
const link = $(el).find("a").first();
|
||||||
|
const href = link.attr("href") || "";
|
||||||
|
const title = $(el).find(".name").text().trim() || "";
|
||||||
|
const poster = $(el).find("img").attr("src") || "";
|
||||||
|
const slug = href.split("/watch/").pop() || $(el).find(".poster").attr("data-tip") || "";
|
||||||
|
const relation = $(el).find(".relation, .serieslabelitem").text().trim() || "related";
|
||||||
|
|
||||||
|
if (slug) {
|
||||||
|
related.push({ title, slug, poster, url: href, relation });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return data || [];
|
// NOTE: Fallback — extract from #watch-order section (trending sidebar items as suggested watch order)
|
||||||
|
if (related.length === 0) {
|
||||||
|
$("#watch-order .item, .w-side-section:has(.title:contains('Trending')) .item, .w-side-section:first .item").each((_, el) => {
|
||||||
|
const link = $(el).find("a").first();
|
||||||
|
const href = link.attr("href") || "";
|
||||||
|
const title = $(el).find(".name").text().trim() || "";
|
||||||
|
const poster = $(el).find("img").attr("src") || "";
|
||||||
|
// NOTE: Items may not have <a> tags — fall back to data-tip attribute for slug
|
||||||
|
let slug = href.split("/watch/").pop() || "";
|
||||||
|
if (!slug) {
|
||||||
|
const dataTip = $(el).find(".poster").attr("data-tip") || "";
|
||||||
|
if (dataTip) slug = dataTip;
|
||||||
|
}
|
||||||
|
const score = $(el).find(".score").text().trim() || "";
|
||||||
|
// NOTE: .meta .dot elements: [0]=score, [1]=type, [2]=episodes
|
||||||
|
const metaDots = $(el).find(".meta .dot");
|
||||||
|
const type = metaDots.eq(1).text().trim() || "";
|
||||||
|
const episodes = metaDots.eq(2).text().trim() || "";
|
||||||
|
|
||||||
|
if (slug && slug !== slugOrId) {
|
||||||
|
related.push({ title, slug, poster, url: href, relation: "trending", score, type, episodes });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
animeId,
|
||||||
|
slug: slugOrId,
|
||||||
|
totalRelated: related.length,
|
||||||
|
related,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// NOTE: Return empty array on error to maintain consistent API
|
throw error;
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export { extractWatchOrder };
|
export { extractWatchOrder };
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════ END: watchOrder.extractor.js
|
// ══════════════════════════════════════════════════════════════ END: watchOrder.extractor.js
|
||||||
|
|||||||
@@ -16,9 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as cheerio from "cheerio";
|
import * as cheerio from "cheerio";
|
||||||
import axios from "axios";
|
import { fetchWithMirror } from "../helper/mirror.helper.js";
|
||||||
import { headers } from "../configs/header.config.js";
|
|
||||||
import { URLS, BASE_URL } from "../configs/dataUrl.js";
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// WATCH PAGE EXTRACTOR
|
// WATCH PAGE EXTRACTOR
|
||||||
@@ -41,54 +39,40 @@ import { URLS, BASE_URL } from "../configs/dataUrl.js";
|
|||||||
*/
|
*/
|
||||||
const extractWatchPage = async (slug, ep) => {
|
const extractWatchPage = async (slug, ep) => {
|
||||||
try {
|
try {
|
||||||
const url = URLS.episode(slug, ep);
|
const path = `/watch/${slug}/ep-${ep}`;
|
||||||
const { data } = await axios.get(url, { headers });
|
const { data } = await fetchWithMirror(path);
|
||||||
const $ = cheerio.load(data);
|
const $ = cheerio.load(data);
|
||||||
|
|
||||||
// ---- FEATURE: Extract anime identification and title ----
|
|
||||||
// NOTE: data-id on #watch-main is the internal anime ID for AJAX calls
|
|
||||||
const animeId = parseInt($("#watch-main").attr("data-id")) || 0;
|
const animeId = parseInt($("#watch-main").attr("data-id")) || 0;
|
||||||
const animeUrl = $("#watch-main").attr("data-url") || "";
|
const animeUrl = $("#watch-main").attr("data-url") || "";
|
||||||
const title = $("h1[itemprop='name'].title.d-title").text().trim() || slug;
|
const title = $("h1[itemprop='name'].title.d-title").text().trim() || slug;
|
||||||
const japaneseTitle = $("h1[itemprop='name'].title.d-title").attr("data-jp") || "";
|
const japaneseTitle = $("h1[itemprop='name'].title.d-title").attr("data-jp") || "";
|
||||||
const episodeNumber = parseInt(ep) || 0;
|
const episodeNumber = parseInt(ep) || 0;
|
||||||
|
|
||||||
// ---- FEATURE: Extract synopsis and rating ----
|
|
||||||
const synopsis = $(".synopsis .content").text().trim() || "";
|
const synopsis = $(".synopsis .content").text().trim() || "";
|
||||||
const rating = $("#w-rating .score .value").text().trim() || "";
|
const rating = $("#w-rating .score .value").text().trim() || "";
|
||||||
const poster = $("img[itemprop='image']").attr("src") || "";
|
const poster = $("img[itemprop='image']").attr("src") || "";
|
||||||
// NOTE: Background image is extracted from CSS background-image property on #player
|
|
||||||
const backgroundImage = $("#player").css("background-image")?.match(/url\(['"]?(.+?)['"]?\)/)?.[1] || "";
|
const backgroundImage = $("#player").css("background-image")?.match(/url\(['"]?(.+?)['"]?\)/)?.[1] || "";
|
||||||
|
|
||||||
// ---- FEATURE: Extract anime metadata (type, status, MAL score, duration, episodes) ----
|
|
||||||
const type = $(".bmeta .meta:first-child > div:nth-child(1) span").text().trim() || "";
|
const type = $(".bmeta .meta:first-child > div:nth-child(1) span").text().trim() || "";
|
||||||
const status = $(".bmeta .meta:first-child > div:nth-child(4) span a").text().trim() || "";
|
const status = $(".bmeta .meta:first-child > div:nth-child(4) span a").text().trim() || "";
|
||||||
const malScore = $(".bmeta .meta:nth-child(2) > div:nth-child(1) span").text().trim() || "";
|
const malScore = $(".bmeta .meta:nth-child(2) > div:nth-child(1) span").text().trim() || "";
|
||||||
const duration = $(".bmeta .meta:nth-child(2) > div:nth-child(2) span").text().trim() || "";
|
const duration = $(".bmeta .meta:nth-child(2) > div:nth-child(2) span").text().trim() || "";
|
||||||
const episodes = $(".bmeta .meta:nth-child(2) > div:nth-child(3) span").text().trim() || "";
|
const episodes = $(".bmeta .meta:nth-child(2) > div:nth-child(3) span").text().trim() || "";
|
||||||
|
|
||||||
// ---- FEATURE: Extract genres ----
|
|
||||||
// NOTE: Genre links contain '/genre/' in href to distinguish from other links
|
|
||||||
const genres = [];
|
const genres = [];
|
||||||
$(".bmeta .meta:first-child > div:nth-child(5) span a[href*='/genre/']").each((i, el) => {
|
$(".bmeta .meta:first-child > div:nth-child(5) span a[href*='/genre/']").each((i, el) => {
|
||||||
genres.push($(el).text().trim());
|
genres.push($(el).text().trim());
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- FEATURE: Extract studios ----
|
|
||||||
// NOTE: Uses itemprop='director' links with itemprop='name' spans inside
|
|
||||||
const studios = [];
|
const studios = [];
|
||||||
$(".bmeta .meta:nth-child(2) > div:nth-child(4) span a[itemprop='director'] span[itemprop='name']").each((i, el) => {
|
$(".bmeta .meta:nth-child(2) > div:nth-child(4) span a[itemprop='director'] span[itemprop='name']").each((i, el) => {
|
||||||
studios.push($(el).text().trim());
|
studios.push($(el).text().trim());
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- FEATURE: Extract next episode release info ----
|
|
||||||
// NOTE: .next-episode shows the countdown text, .count-down has data-target timestamp
|
|
||||||
const nextEpisodeDate = $(".next-episode, .alert.next-episode").text().trim() || "";
|
const nextEpisodeDate = $(".next-episode, .alert.next-episode").text().trim() || "";
|
||||||
const nextEpisodeTimestamp = parseInt($(".count-down").attr("data-target")) || 0;
|
const nextEpisodeTimestamp = parseInt($(".count-down").attr("data-target")) || 0;
|
||||||
|
|
||||||
// ---- FEATURE: Extract available streaming servers ----
|
|
||||||
// NOTE: Each server li has data-link-id (unique), data-ep-id, data-cmid, data-sv-id
|
|
||||||
// NOTE: Server type (sub/dub) is derived from closest .type container's data-type attribute
|
|
||||||
const servers = [];
|
const servers = [];
|
||||||
$("#w-servers .servers .type li, #w-servers li[data-link-id]").each((i, el) => {
|
$("#w-servers .servers .type li, #w-servers li[data-link-id]").each((i, el) => {
|
||||||
const linkId = $(el).attr("data-link-id") || "";
|
const linkId = $(el).attr("data-link-id") || "";
|
||||||
@@ -110,14 +94,11 @@ const extractWatchPage = async (slug, ep) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- FEATURE: Extract trending/watch-order sidebar items ----
|
|
||||||
// NOTE: Grabs from both the first .w-side-section and #watch-order containers
|
|
||||||
const trending = [];
|
const trending = [];
|
||||||
$(".w-side-section:first .scaff.side.items a.item, #watch-order a.item").each((i, el) => {
|
$(".w-side-section:first .scaff.side.items a.item, #watch-order a.item").each((i, el) => {
|
||||||
const trendSlug = $(el).attr("href")?.split("/watch/").pop() || "";
|
const trendSlug = $(el).attr("href")?.split("/watch/").pop() || "";
|
||||||
const trendPoster = $(el).find(".poster img").attr("src") || "";
|
const trendPoster = $(el).find(".poster img").attr("src") || "";
|
||||||
const trendTitle = $(el).find(".name").text().trim() || "";
|
const trendTitle = $(el).find(".name").text().trim() || "";
|
||||||
// NOTE: .dot:last-child typically holds the type badge text
|
|
||||||
const trendType = $(el).find(".dot:last-child").text().trim() || "";
|
const trendType = $(el).find(".dot:last-child").text().trim() || "";
|
||||||
const trendScore = $(el).find(".score").text().trim() || "";
|
const trendScore = $(el).find(".score").text().trim() || "";
|
||||||
|
|
||||||
@@ -132,8 +113,6 @@ const extractWatchPage = async (slug, ep) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- FEATURE: Extract recommended anime sidebar items ----
|
|
||||||
// NOTE: Targets the section with a title containing "Recommended", or the last sidebar
|
|
||||||
const recommended = [];
|
const recommended = [];
|
||||||
$(".w-side-section:has(.title:contains('Recommended')) a.item, aside.sidebar:last .w-side-section a.item").each((i, el) => {
|
$(".w-side-section:has(.title:contains('Recommended')) a.item, aside.sidebar:last .w-side-section a.item").each((i, el) => {
|
||||||
const recSlug = $(el).attr("href")?.split("/watch/").pop() || "";
|
const recSlug = $(el).attr("href")?.split("/watch/").pop() || "";
|
||||||
|
|||||||
+183
-77
@@ -4,12 +4,12 @@
|
|||||||
* Repository: https://github.com/Shineii86/AniKotoAPI
|
* Repository: https://github.com/Shineii86/AniKotoAPI
|
||||||
*
|
*
|
||||||
* @description
|
* @description
|
||||||
* In-memory caching utility for storing and retrieving API responses.
|
* In-memory LRU cache with configurable TTL per endpoint.
|
||||||
* Implements TTL-based cache invalidation to prevent stale data
|
* Evicts least recently used entries when max size reached.
|
||||||
* while reducing unnecessary requests to the source site.
|
* Supports cache statistics and manual invalidation.
|
||||||
*
|
*
|
||||||
* @exports
|
* @exports
|
||||||
* getCache, setCache, clearCache
|
* getCache, setCache, clearCache, getCacheStats, LRUCache
|
||||||
*
|
*
|
||||||
* @author Shinei Nouzen
|
* @author Shinei Nouzen
|
||||||
* @license MIT
|
* @license MIT
|
||||||
@@ -17,91 +17,197 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// CACHE STORAGE & CONFIGURATION
|
// LRU CACHE CLASS
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// ---- FEATURE: In-memory Map-based cache storage ----
|
// ---- FEATURE: LRU Cache ----
|
||||||
/**
|
/**
|
||||||
* Internal cache storage using JavaScript Map object.
|
* Least Recently Used (LRU) cache implementation.
|
||||||
* Stores data with timestamps for TTL-based expiration.
|
* Maintains access order by deleting and re-inserting on read.
|
||||||
*
|
* Auto-evicts oldest entry when maxSize is reached.
|
||||||
* @type {Map<string, {data: any, timestamp: number}>}
|
|
||||||
*/
|
*/
|
||||||
const cache = new Map();
|
class LRUCache {
|
||||||
|
/**
|
||||||
// ---- FEATURE: Cache time-to-live configuration (5 minutes) ----
|
* @param {number} maxSize - Maximum number of entries (default: 100)
|
||||||
/**
|
* @param {number} defaultTTL - Default time-to-live in ms (default: 300000 = 5min)
|
||||||
* Cache TTL (Time-To-Live) in milliseconds.
|
|
||||||
* Default: 5 minutes (300,000 ms).
|
|
||||||
* After this duration, cached items are considered stale
|
|
||||||
* and will be removed on next access.
|
|
||||||
*
|
|
||||||
* @type {number}
|
|
||||||
* @default 300000
|
|
||||||
*/
|
*/
|
||||||
const CACHE_TTL = 5 * 60 * 1000;
|
constructor(maxSize = 100, defaultTTL = 300000) {
|
||||||
|
this.cache = new Map();
|
||||||
// ══════════════════════════════════════════════════════════════
|
this.maxSize = maxSize;
|
||||||
// CACHE OPERATIONS
|
this.defaultTTL = defaultTTL;
|
||||||
// ══════════════════════════════════════════════════════════════
|
this.stats = { hits: 0, misses: 0, sets: 0, deletes: 0 };
|
||||||
|
|
||||||
// ---- FEATURE: Retrieve cached data by key with TTL validation ----
|
|
||||||
/**
|
|
||||||
* Retrieves data from cache if it exists and hasn't expired.
|
|
||||||
* Automatically removes stale entries on access.
|
|
||||||
*
|
|
||||||
* @param {string} key - The cache key to lookup
|
|
||||||
* @returns {any|null} Cached data if valid, null if expired or missing
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // Get cached anime list
|
|
||||||
* const data = getCache("anime-list-action");
|
|
||||||
* if (data) {
|
|
||||||
* // Use cached data
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
const getCache = (key) => {
|
|
||||||
const item = cache.get(key);
|
|
||||||
if (item && Date.now() - item.timestamp < CACHE_TTL) {
|
|
||||||
return item.data;
|
|
||||||
}
|
}
|
||||||
cache.delete(key);
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- FEATURE: Store data in cache with current timestamp ----
|
/**
|
||||||
/**
|
* Get a value from the cache. Moves the entry to the end (most recent) on access.
|
||||||
* Stores data in cache with current timestamp for TTL tracking.
|
* Returns undefined if not found or expired.
|
||||||
* Overwrites any existing data for the same key.
|
|
||||||
*
|
*
|
||||||
* @param {string} key - The cache key to store under
|
* @param {string} key - Cache key
|
||||||
* @param {any} data - The data to cache (any serializable type)
|
* @returns {*} Cached value or undefined
|
||||||
* @returns {void}
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // Cache anime search results
|
|
||||||
* setCache("search-one-piece", animeResults);
|
|
||||||
*/
|
*/
|
||||||
const setCache = (key, data) => {
|
get(key) {
|
||||||
cache.set(key, { data, timestamp: Date.now() });
|
if (!this.cache.has(key)) {
|
||||||
};
|
this.stats.misses++;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- FEATURE: Clear all cached entries from memory ----
|
const entry = this.cache.get(key);
|
||||||
/**
|
// NOTE: Lazy expiration — check expiry on access, not background timer
|
||||||
* Clears all entries from the cache. Use when:
|
if (Date.now() > entry.expiresAt) {
|
||||||
* - Memory cleanup is needed
|
this.cache.delete(key);
|
||||||
* - Force-refreshing all data
|
this.stats.misses++;
|
||||||
* - Application restart/reset
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: Re-insert to move to end (most recently used position)
|
||||||
|
this.stats.hits++;
|
||||||
|
this.cache.delete(key);
|
||||||
|
this.cache.set(key, entry);
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a value in the cache with optional TTL.
|
||||||
|
* Evicts the oldest entry if at capacity.
|
||||||
*
|
*
|
||||||
* @returns {void}
|
* @param {string} key - Cache key
|
||||||
*
|
* @param {*} value - Value to store
|
||||||
* @example
|
* @param {number} [ttl] - Time-to-live in ms (uses defaultTTL if not provided)
|
||||||
* // Force refresh all cached data
|
|
||||||
* clearCache();
|
|
||||||
*/
|
*/
|
||||||
const clearCache = () => {
|
set(key, value, ttl) {
|
||||||
cache.clear();
|
if (this.cache.has(key)) {
|
||||||
|
this.cache.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: Evict oldest (first) entry when cache is full
|
||||||
|
if (this.cache.size >= this.maxSize) {
|
||||||
|
const firstKey = this.cache.keys().next().value;
|
||||||
|
this.cache.delete(firstKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cache.set(key, {
|
||||||
|
value,
|
||||||
|
expiresAt: Date.now() + (ttl || this.defaultTTL)
|
||||||
|
});
|
||||||
|
this.stats.sets++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a specific key from the cache.
|
||||||
|
*
|
||||||
|
* @param {string} key - Cache key to delete
|
||||||
|
* @returns {boolean} True if the key existed before deletion
|
||||||
|
*/
|
||||||
|
delete(key) {
|
||||||
|
const existed = this.cache.delete(key);
|
||||||
|
if (existed) this.stats.deletes++;
|
||||||
|
return existed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all entries from the cache.
|
||||||
|
*
|
||||||
|
* @returns {number} Number of entries cleared
|
||||||
|
*/
|
||||||
|
clear() {
|
||||||
|
const size = this.cache.size;
|
||||||
|
this.cache.clear();
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache statistics including hit rate.
|
||||||
|
*
|
||||||
|
* @returns {Object} Stats object with hits, misses, sets, deletes, size, hitRate
|
||||||
|
*/
|
||||||
|
getStats() {
|
||||||
|
const total = this.stats.hits + this.stats.misses;
|
||||||
|
return {
|
||||||
|
...this.stats,
|
||||||
|
size: this.cache.size,
|
||||||
|
hitRate: total > 0 ? ((this.stats.hits / total) * 100).toFixed(2) + '%' : '0%'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a key exists and is not expired.
|
||||||
|
*
|
||||||
|
* @param {string} key - Cache key
|
||||||
|
* @returns {boolean} True if key exists and is valid
|
||||||
|
*/
|
||||||
|
has(key) {
|
||||||
|
if (!this.cache.has(key)) return false;
|
||||||
|
const entry = this.cache.get(key);
|
||||||
|
if (Date.now() > entry.expiresAt) {
|
||||||
|
this.cache.delete(key);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// TTL CONFIGURATION
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Per-Endpoint TTL ----
|
||||||
|
// NOTE: TTL values tuned per endpoint — stale data tolerance vs server load
|
||||||
|
const TTL = {
|
||||||
|
home: 600000, // 10min — homepage changes infrequently
|
||||||
|
search: 300000, // 5min — search results update moderate
|
||||||
|
info: 600000, // 10min — anime info is relatively stable
|
||||||
|
episodes: 300000, // 5min — episode lists update weekly
|
||||||
|
servers: 600000, // 10min — server URLs rarely change
|
||||||
|
stream: 180000, // 3min — stream URLs rotate frequently
|
||||||
|
spotlight: 600000, // 10min — spotlight rotates daily
|
||||||
|
trending: 600000, // 10min — trending updates hourly
|
||||||
|
schedule: 1800000, // 30min — schedule changes daily
|
||||||
|
genres: 3600000, // 60min — genre list is static
|
||||||
|
suggestions: 300000, // 5min — suggestions update moderate
|
||||||
|
default: 300000 // 5min — safe default for unknown endpoints
|
||||||
};
|
};
|
||||||
|
|
||||||
export { getCache, setCache, clearCache };
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// SINGLETON CACHE INSTANCE
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// NOTE: Singleton pattern — single cache instance shared across all requests
|
||||||
|
const cache = new LRUCache(
|
||||||
|
parseInt(process.env.CACHE_MAX_SIZE) || 200,
|
||||||
|
parseInt(process.env.CACHE_DEFAULT_TTL) || 300000
|
||||||
|
);
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// EXPORTED HELPERS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Cache Get/Set/Clear ----
|
||||||
|
/**
|
||||||
|
* Get a value from the global cache.
|
||||||
|
* @param {string} key - Cache key
|
||||||
|
* @returns {*} Cached value or undefined
|
||||||
|
*/
|
||||||
|
const getCache = (key) => cache.get(key);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a value in the global cache with optional TTL.
|
||||||
|
* @param {string} key - Cache key
|
||||||
|
* @param {*} data - Value to store
|
||||||
|
* @param {number} [ttl] - Time-to-live in ms
|
||||||
|
*/
|
||||||
|
const setCache = (key, data, ttl) => cache.set(key, data, ttl);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all entries from the global cache.
|
||||||
|
* @returns {number} Number of entries cleared
|
||||||
|
*/
|
||||||
|
const clearCache = () => cache.clear();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache statistics (hits, misses, hit rate, size).
|
||||||
|
* @returns {Object} Cache stats
|
||||||
|
*/
|
||||||
|
const getCacheStats = () => cache.getStats();
|
||||||
|
|
||||||
|
export { getCache, setCache, clearCache, getCacheStats, LRUCache, TTL };
|
||||||
// ══════════════════════════════════════════════════════════════ END: cache.helper.js
|
// ══════════════════════════════════════════════════════════════ END: cache.helper.js
|
||||||
@@ -5,8 +5,8 @@
|
|||||||
*
|
*
|
||||||
* @description
|
* @description
|
||||||
* HTTP client utility for fetching and parsing HTML content from
|
* HTTP client utility for fetching and parsing HTML content from
|
||||||
* paginated URLs. Uses axios for HTTP requests and Cheerio for
|
* paginated URLs. Uses mirror fallback for resilience and Cheerio
|
||||||
* DOM parsing, with support for page-based pagination.
|
* for DOM parsing, with support for page-based pagination.
|
||||||
*
|
*
|
||||||
* @exports
|
* @exports
|
||||||
* extractPages
|
* extractPages
|
||||||
@@ -16,73 +16,44 @@
|
|||||||
* ======= • ======= • ======= • ======= • =======• =======
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
|
||||||
// DEPENDENCIES
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
// ---- FEATURE: Cheerio HTML parser import ----
|
|
||||||
/**
|
|
||||||
* Cheerio library for server-side DOM manipulation.
|
|
||||||
* Used to parse and query HTML content from fetched pages.
|
|
||||||
*
|
|
||||||
* @type {module:cheerio}
|
|
||||||
*/
|
|
||||||
import * as cheerio from "cheerio";
|
import * as cheerio from "cheerio";
|
||||||
|
import { fetchWithMirror } from "./mirror.helper.js";
|
||||||
// ---- FEATURE: Axios HTTP client import ----
|
|
||||||
/**
|
|
||||||
* Axios HTTP client for making requests to the source site.
|
|
||||||
* Handles redirects, timeouts, and response parsing.
|
|
||||||
*
|
|
||||||
* @type {module:axios}
|
|
||||||
*/
|
|
||||||
import axios from "axios";
|
|
||||||
|
|
||||||
// ---- FEATURE: Browser-mimicking headers import ----
|
|
||||||
/**
|
|
||||||
* Pre-configured HTTP headers to mimic browser requests.
|
|
||||||
* Imported from header.config.js for consistent usage.
|
|
||||||
*
|
|
||||||
* @type {Object}
|
|
||||||
*/
|
|
||||||
import { headers } from "../configs/header.config.js";
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// PAGE EXTRACTION FUNCTION
|
// PAGE EXTRACTOR
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// ---- FEATURE: Fetch and parse paginated URL content ----
|
// ---- FEATURE: Paginated Page Fetcher ----
|
||||||
/**
|
/**
|
||||||
* Fetches HTML content from a URL with optional pagination support.
|
* Fetches HTML content from a URL with pagination and mirror fallback.
|
||||||
* Appends page query parameter for pages > 1 and returns parsed
|
* Automatically tries alternative domains if primary fails.
|
||||||
* Cheerio instance for DOM querying.
|
|
||||||
*
|
*
|
||||||
* @param {string} url - Base URL to fetch (without page parameter)
|
* @param {string} url - Base URL path to fetch (e.g., "/filter?keyword=naruto")
|
||||||
* @param {number} [page=1] - Page number to fetch (1-indexed)
|
* @param {number} [page=1] - Page number to fetch (1-indexed)
|
||||||
* @returns {Promise<CheerioAPI>} Parsed Cheerio instance of the page
|
* @returns {Promise<CheerioAPI>} Parsed Cheerio instance of the page
|
||||||
* @throws {Error} If HTTP request fails or returns non-2xx status
|
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* // Fetch first page of search results
|
* const $ = await extractPages("/filter?keyword=one-piece");
|
||||||
* const $ = await extractPages("https://anikototv.to/filter?keyword=one-piece");
|
* const $page2 = await extractPages("/filter?keyword=one-piece", 2);
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // Fetch second page of genre listing
|
|
||||||
* const $ = await extractPages(genreUrl, 2);
|
|
||||||
*
|
|
||||||
* @note
|
|
||||||
* WARNING: The site may block requests without proper headers.
|
|
||||||
* Always use the imported headers configuration.
|
|
||||||
*
|
|
||||||
* @note
|
|
||||||
* TIP: Consider implementing retry logic for failed requests
|
|
||||||
* in production environments.
|
|
||||||
*/
|
*/
|
||||||
const extractPages = async (url, page = 1) => {
|
const extractPages = async (url, page = 1) => {
|
||||||
try {
|
try {
|
||||||
const separator = url.includes("?") ? "&" : "?";
|
// NOTE: Extract path from full URL if needed (e.g., "https://anikototv.to/filter" -> "/filter")
|
||||||
const finalUrl = page > 1 ? `${url}${separator}page=${page}` : url;
|
let path = url;
|
||||||
const { data } = await axios.get(finalUrl, { headers });
|
if (url.startsWith("http")) {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
path = urlObj.pathname + urlObj.search;
|
||||||
|
} catch (e) {
|
||||||
|
// NOTE: If URL parsing fails, try regex fallback
|
||||||
|
const match = url.match(/https?:\/\/[^/]+(\/.*)/);
|
||||||
|
if (match) path = match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = path.includes("?") ? "&" : "?";
|
||||||
|
const fullPath = page > 1 ? `${path}${separator}page=${page}` : path;
|
||||||
|
const { data } = await fetchWithMirror(fullPath);
|
||||||
const $ = cheerio.load(data);
|
const $ = cheerio.load(data);
|
||||||
return $;
|
return $;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* AniKotoAPI — mirror.helper.js
|
||||||
|
* Repository: https://github.com/Shineii86/AniKotoAPI
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* Multi-mirror fallback helper for resilient scraping.
|
||||||
|
* Automatically tries alternative domains if primary is blocked/down.
|
||||||
|
* Caches working mirror per session for faster subsequent requests.
|
||||||
|
*
|
||||||
|
* @exports
|
||||||
|
* fetchWithMirror, getWorkingMirror, resetMirrorCache
|
||||||
|
*
|
||||||
|
* @author Shinei Nouzen
|
||||||
|
* @license MIT
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
*/
|
||||||
|
|
||||||
|
import axios from "axios";
|
||||||
|
import { headers } from "../configs/header.config.js";
|
||||||
|
import { getCache, setCache } from "./cache.helper.js";
|
||||||
|
import { fetchWithProxy, getProxyStatus } from "./proxy.helper.js";
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// MIRROR CONFIGURATION
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Mirror Domain Configuration ----
|
||||||
|
// Default mirrors with names and priority
|
||||||
|
const DEFAULT_MIRRORS = [
|
||||||
|
{ url: "https://anikototv.to", name: "Primary", priority: 1 },
|
||||||
|
{ url: "https://anikoto.cz", name: "Regional CZ", priority: 2 },
|
||||||
|
{ url: "https://anikoto.me", name: "Short TLD", priority: 3 },
|
||||||
|
{ url: "https://anikoto.net", name: "Network", priority: 4 },
|
||||||
|
{ url: "https://anikototv.se", name: "Nordic .se", priority: 5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Parse custom mirrors from env or use defaults
|
||||||
|
function parseMirrors() {
|
||||||
|
const envMirrors = process.env.MIRROR_DOMAINS;
|
||||||
|
if (!envMirrors) return DEFAULT_MIRRORS;
|
||||||
|
|
||||||
|
return envMirrors.split(",").map((url, index) => ({
|
||||||
|
url: url.trim(),
|
||||||
|
name: `Mirror ${index + 1}`,
|
||||||
|
priority: index + 1
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_MIRRORS = parseMirrors();
|
||||||
|
ALL_MIRRORS.sort((a, b) => a.priority - b.priority);
|
||||||
|
|
||||||
|
// Cache key for working mirror
|
||||||
|
const MIRROR_CACHE_KEY = "working_mirror";
|
||||||
|
const MIRROR_CACHE_TTL = parseInt(process.env.MIRROR_CACHE_TTL) || 3600000;
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// MIRROR STATE
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
let workingMirror = null;
|
||||||
|
let failedMirrors = new Set();
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// HELPER FUNCTIONS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Mirror Health Check ----
|
||||||
|
/**
|
||||||
|
* Check if a mirror is accessible
|
||||||
|
* @param {string} baseUrl - Mirror base URL
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async function checkMirror(baseUrl) {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${baseUrl}/home`, {
|
||||||
|
headers,
|
||||||
|
timeout: 10000,
|
||||||
|
maxRedirects: 5,
|
||||||
|
});
|
||||||
|
return response.status === 200;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- FEATURE: Working Mirror Discovery ----
|
||||||
|
/**
|
||||||
|
* Get the best available mirror
|
||||||
|
* @returns {Promise<string>} Working mirror base URL
|
||||||
|
*/
|
||||||
|
async function getWorkingMirror() {
|
||||||
|
// Check cache first
|
||||||
|
const cached = getCache(MIRROR_CACHE_KEY);
|
||||||
|
if (cached && !failedMirrors.has(cached)) {
|
||||||
|
workingMirror = cached;
|
||||||
|
return workingMirror;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try mirrors in priority order
|
||||||
|
for (const mirror of ALL_MIRRORS) {
|
||||||
|
if (failedMirrors.has(mirror.url)) continue;
|
||||||
|
|
||||||
|
const isWorking = await checkMirror(mirror.url);
|
||||||
|
if (isWorking) {
|
||||||
|
workingMirror = mirror.url;
|
||||||
|
setCache(MIRROR_CACHE_KEY, mirror.url, MIRROR_CACHE_TTL);
|
||||||
|
console.log(`[MIRROR] Using: ${mirror.name} (${mirror.url})`);
|
||||||
|
return mirror.url;
|
||||||
|
} else {
|
||||||
|
failedMirrors.add(mirror.url);
|
||||||
|
console.log(`[MIRROR] Failed: ${mirror.name} (${mirror.url})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All mirrors failed, reset and try again
|
||||||
|
failedMirrors.clear();
|
||||||
|
workingMirror = ALL_MIRRORS[0].url;
|
||||||
|
console.log(`[MIRROR] All failed, resetting to primary: ${workingMirror}`);
|
||||||
|
return workingMirror;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- FEATURE: Mirror Cache Reset ----
|
||||||
|
/**
|
||||||
|
* Reset mirror cache and failed state
|
||||||
|
*/
|
||||||
|
function resetMirrorCache() {
|
||||||
|
workingMirror = null;
|
||||||
|
failedMirrors.clear();
|
||||||
|
setCache(MIRROR_CACHE_KEY, null, 0);
|
||||||
|
console.log("[MIRROR] Cache reset");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- FEATURE: URL Builder ----
|
||||||
|
/**
|
||||||
|
* Build URL with current mirror
|
||||||
|
* @param {string} path - URL path
|
||||||
|
* @param {string} baseUrl - Optional custom base URL
|
||||||
|
* @returns {string} Full URL
|
||||||
|
*/
|
||||||
|
function buildUrl(path, baseUrl = workingMirror) {
|
||||||
|
const base = baseUrl || ALL_MIRRORS[0].url;
|
||||||
|
return `${base}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- FEATURE: Resilient Fetch with Mirror Fallback ----
|
||||||
|
/**
|
||||||
|
* Fetch with automatic mirror fallback and proxy support
|
||||||
|
* @param {string} path - URL path to fetch
|
||||||
|
* @param {object} options - Additional options
|
||||||
|
* @returns {Promise<{data: string, mirror: string, proxy: string}>}
|
||||||
|
*/
|
||||||
|
async function fetchWithMirror(path, options = {}) {
|
||||||
|
const {
|
||||||
|
timeout = 15000,
|
||||||
|
retries = 2,
|
||||||
|
returnType = "text",
|
||||||
|
headers: customHeaders = {},
|
||||||
|
useProxy = true,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Merge default headers with custom headers
|
||||||
|
const requestHeaders = { ...headers, ...customHeaders };
|
||||||
|
|
||||||
|
let lastError = null;
|
||||||
|
|
||||||
|
// Try cached working mirror first
|
||||||
|
const mirrorsToTry = [];
|
||||||
|
|
||||||
|
if (workingMirror && !failedMirrors.has(workingMirror)) {
|
||||||
|
mirrorsToTry.push(workingMirror);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add remaining mirrors
|
||||||
|
for (const mirror of ALL_MIRRORS) {
|
||||||
|
if (!mirrorsToTry.includes(mirror.url) && !failedMirrors.has(mirror.url)) {
|
||||||
|
mirrorsToTry.push(mirror.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If all failed, reset and try all
|
||||||
|
if (mirrorsToTry.length === 0) {
|
||||||
|
failedMirrors.clear();
|
||||||
|
mirrorsToTry.push(...ALL_MIRRORS.map(m => m.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try direct requests first
|
||||||
|
for (const mirror of mirrorsToTry) {
|
||||||
|
const url = buildUrl(path, mirror);
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(url, {
|
||||||
|
headers: requestHeaders,
|
||||||
|
timeout,
|
||||||
|
responseType: returnType === "text" ? "text" : "json",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
// Check for Cloudflare challenge
|
||||||
|
const data = response.data;
|
||||||
|
if (typeof data === "string" && (
|
||||||
|
data.includes("Just a moment...") ||
|
||||||
|
data.includes("Checking your browser") ||
|
||||||
|
data.includes("Enable JavaScript")
|
||||||
|
)) {
|
||||||
|
throw new Error("Cloudflare challenge detected");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for upstream error
|
||||||
|
let parsed = data;
|
||||||
|
if (typeof data === "string") {
|
||||||
|
try { parsed = JSON.parse(data); } catch { /* not JSON */ }
|
||||||
|
}
|
||||||
|
if (parsed && typeof parsed === "object" && parsed.status && Number(parsed.status) >= 400) {
|
||||||
|
throw new Error(`Upstream error (${parsed.status}): ${parsed.result || "Bad request"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update working mirror
|
||||||
|
if (mirror !== workingMirror) {
|
||||||
|
workingMirror = mirror;
|
||||||
|
setCache(MIRROR_CACHE_KEY, mirror, MIRROR_CACHE_TTL);
|
||||||
|
console.log(`[MIRROR] Switched to: ${mirror}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
data: response.data,
|
||||||
|
mirror,
|
||||||
|
proxy: "direct"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: 404 means endpoint doesn't exist, not mirror failure — don't retry
|
||||||
|
if (response.status === 404) {
|
||||||
|
throw new Error(`Endpoint not found: ${path}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
// NOTE: Don't retry on 404 — the endpoint simply doesn't exist on this mirror
|
||||||
|
if (error.message?.includes("Endpoint not found")) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (attempt < retries) {
|
||||||
|
// Wait before retry
|
||||||
|
await new Promise(r => setTimeout(r, 500 * (attempt + 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: Only mark mirror as failed for connection errors, not endpoint 404s
|
||||||
|
if (!lastError.message?.includes("Endpoint not found")) {
|
||||||
|
failedMirrors.add(mirror);
|
||||||
|
console.log(`[MIRROR] Marked as failed: ${mirror}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All direct requests failed — try proxy if configured
|
||||||
|
if (useProxy) {
|
||||||
|
const proxyStatus = getProxyStatus();
|
||||||
|
if (proxyStatus.anyEnabled) {
|
||||||
|
console.log(`[MIRROR] All mirrors failed, trying proxy...`);
|
||||||
|
try {
|
||||||
|
const proxyResult = await fetchWithProxy(
|
||||||
|
buildUrl(path, mirrorsToTry[0]),
|
||||||
|
{ requestHeaders, timeout: timeout * 2, returnType }
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
data: proxyResult.data,
|
||||||
|
mirror: mirrorsToTry[0],
|
||||||
|
proxy: proxyResult.proxy,
|
||||||
|
};
|
||||||
|
} catch (proxyError) {
|
||||||
|
console.log(`[MIRROR] Proxy also failed: ${proxyError.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`All mirrors failed. Last error: ${lastError?.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- FEATURE: Mirror Status Reporter ----
|
||||||
|
/**
|
||||||
|
* Get mirror status for all domains
|
||||||
|
* @returns {Promise<Array>}
|
||||||
|
*/
|
||||||
|
async function getMirrorStatus() {
|
||||||
|
const status = [];
|
||||||
|
|
||||||
|
for (const mirror of ALL_MIRRORS) {
|
||||||
|
const start = Date.now();
|
||||||
|
const isWorking = await checkMirror(mirror.url);
|
||||||
|
const latency = Date.now() - start;
|
||||||
|
|
||||||
|
status.push({
|
||||||
|
name: mirror.name,
|
||||||
|
url: mirror.url,
|
||||||
|
priority: mirror.priority,
|
||||||
|
status: isWorking ? "online" : "offline",
|
||||||
|
latency: `${latency}ms`,
|
||||||
|
isCurrent: mirror.url === workingMirror,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
fetchWithMirror,
|
||||||
|
getWorkingMirror,
|
||||||
|
resetMirrorCache,
|
||||||
|
buildUrl,
|
||||||
|
getMirrorStatus,
|
||||||
|
getProxyStatus,
|
||||||
|
ALL_MIRRORS
|
||||||
|
};
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: mirror.helper.js
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* AniKotoAPI — pagination.helper.js
|
||||||
|
* Repository: https://github.com/Shineii86/AniKotoAPI
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* Utility function to add pagination metadata to API responses.
|
||||||
|
* Provides standardized pagination info for all paginated endpoints.
|
||||||
|
*
|
||||||
|
* @exports
|
||||||
|
* addPaginationMeta
|
||||||
|
*
|
||||||
|
* @author Shinei Nouzen
|
||||||
|
* @license MIT
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// PAGINATION METADATA
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Pagination Metadata Generator ----
|
||||||
|
/**
|
||||||
|
* Adds pagination metadata to a response object.
|
||||||
|
*
|
||||||
|
* @param {object} data - The data object containing `data` array and optionally `totalPages`
|
||||||
|
* @param {number} currentPage - Current page number (1-indexed)
|
||||||
|
* @param {number} [itemsPerPage=30] - Number of items per page
|
||||||
|
* @returns {object} Object with data and pagination metadata
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const response = addPaginationMeta({ data: [...], totalPages: 5 }, 1, 30);
|
||||||
|
* // response = {
|
||||||
|
* // data: [...],
|
||||||
|
* // pagination: {
|
||||||
|
* // currentPage: 1,
|
||||||
|
* // totalPages: 5,
|
||||||
|
* // totalItems: 150,
|
||||||
|
* // itemsPerPage: 30,
|
||||||
|
* // hasNext: true,
|
||||||
|
* // hasPrev: false
|
||||||
|
* // }
|
||||||
|
* // }
|
||||||
|
*/
|
||||||
|
const addPaginationMeta = (data, currentPage, itemsPerPage = 30) => {
|
||||||
|
const totalPages = data.totalPages || 1;
|
||||||
|
const page = parseInt(currentPage) || 1;
|
||||||
|
const totalItems = data.data ? data.data.length + (totalPages - 1) * itemsPerPage : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
pagination: {
|
||||||
|
currentPage: page,
|
||||||
|
totalPages: totalPages,
|
||||||
|
totalItems: totalItems,
|
||||||
|
itemsPerPage: itemsPerPage,
|
||||||
|
hasNext: page < totalPages,
|
||||||
|
hasPrev: page > 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export { addPaginationMeta };
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: pagination.helper.js
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* AniKotoAPI — parseListItem.helper.js
|
||||||
|
* Repository: https://github.com/Shineii86/AniKotoAPI
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* Shared helper for parsing anime list items from Cheerio DOM.
|
||||||
|
* Eliminates code duplication across multiple extractors by
|
||||||
|
* providing a unified parsing function for standard list items.
|
||||||
|
*
|
||||||
|
* @exports
|
||||||
|
* parseListItems, parseListItem
|
||||||
|
*
|
||||||
|
* @author Shinei Nouzen
|
||||||
|
* @license MIT
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// SINGLE ITEM PARSER
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Parse a single anime list item from a Cheerio element ----
|
||||||
|
/**
|
||||||
|
* Parses a single anime list item from a Cheerio element.
|
||||||
|
* Handles multiple CSS selector patterns for different page layouts
|
||||||
|
* by falling through comma-separated selectors until one matches.
|
||||||
|
*
|
||||||
|
* @param {CheerioAPI} $ - Cheerio instance with loaded HTML
|
||||||
|
* @param {CheerioElement} el - DOM element to parse
|
||||||
|
* @param {object} [options] - Parsing options
|
||||||
|
* @param {boolean} [options.includeAnimeId=false] - Include animeId field from data-tip attribute
|
||||||
|
* @param {boolean} [options.includeRating=true] - Include rating field from .rated span
|
||||||
|
* @returns {object|null} Parsed anime object or null if slug is missing (invalid item)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const item = parseListItem($, el, { includeAnimeId: true });
|
||||||
|
* if (item) results.push(item);
|
||||||
|
*/
|
||||||
|
const parseListItem = ($, el, options = {}) => {
|
||||||
|
const { includeAnimeId = false, includeRating = true } = options;
|
||||||
|
|
||||||
|
// NOTE: slug is the primary key — skip items without a valid watch link
|
||||||
|
// NOTE: item may BE the <a> tag itself, so check both self and children
|
||||||
|
let slug = "";
|
||||||
|
if ($(el).is("a")) {
|
||||||
|
slug = $(el).attr("href")?.split("/watch/").pop() || "";
|
||||||
|
}
|
||||||
|
if (!slug) {
|
||||||
|
slug = $(el).find("a").attr("href")?.split("/watch/").pop() || "";
|
||||||
|
}
|
||||||
|
if (!slug) return null;
|
||||||
|
|
||||||
|
// NOTE: Multiple selectors handle different page layouts (#list-items vs .film_list-wrap vs .item)
|
||||||
|
const poster = $(el).find("img").attr("src") || "";
|
||||||
|
const title = $(el).find(".name").text().trim() || $(el).children(".info").find(".name").text().trim() || "";
|
||||||
|
const japaneseTitle = $(el).find(".name").attr("data-jp") || "";
|
||||||
|
const sub = parseInt($(el).find(".ep-status.sub span").text().trim()) || 0;
|
||||||
|
const dub = parseInt($(el).find(".ep-status.dub span").text().trim()) || 0;
|
||||||
|
const total = parseInt($(el).find(".ep-status.total span").text().trim()) || 0;
|
||||||
|
const type = $(el).find(".meta .dot:last-child, .fdi-item:nth-child(2)").text().trim() || "";
|
||||||
|
|
||||||
|
const item = { slug, poster, title, japaneseTitle, sub, dub, total, type };
|
||||||
|
|
||||||
|
// NOTE: animeId comes from data-tip on .ani.poster.tip — only present on some page layouts
|
||||||
|
if (includeAnimeId) {
|
||||||
|
item.animeId = $(el).find(".ani.poster.tip").attr("data-tip") || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: Rating uses different selectors depending on whether it's a list page or detail page
|
||||||
|
if (includeRating) {
|
||||||
|
item.rating = $(el).find(".rated span, .rating, .fdi-item:nth-child(3)").text().trim() || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// MULTI-ITEM PARSER
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Parse multiple anime list items from a Cheerio instance ----
|
||||||
|
/**
|
||||||
|
* Parses multiple anime list items from a Cheerio instance.
|
||||||
|
* Uses multiple CSS selectors to handle different page layouts,
|
||||||
|
* returning only items with valid slugs.
|
||||||
|
*
|
||||||
|
* @param {CheerioAPI} $ - Cheerio instance with loaded HTML
|
||||||
|
* @param {string} [selector] - CSS selector for list items (default: auto-detect all layouts)
|
||||||
|
* @param {object} [options] - Parsing options (passed to parseListItem)
|
||||||
|
* @returns {Array<object>} Array of parsed anime objects (empty if none found)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const $ = await extractPages("/most-popular");
|
||||||
|
* const items = parseListItems($);
|
||||||
|
* console.log(items.length); // Number of parsed items
|
||||||
|
*/
|
||||||
|
const parseListItems = ($, selector = "#list-items > .item, .film_list-wrap .flw-item, .film-detail, .item", options = {}) => {
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
$(selector).each((i, el) => {
|
||||||
|
const item = parseListItem($, el, options);
|
||||||
|
if (item) results.push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { parseListItems, parseListItem };
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: parseListItem.helper.js
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* AniKotoAPI — proxy.helper.js
|
||||||
|
* Repository: https://github.com/Shineii86/AniKotoAPI
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* Proxy helper for bypassing Cloudflare bot protection.
|
||||||
|
* Supports ScraperAPI (premium) and FlareSolverr (self-hosted).
|
||||||
|
* Automatically routes requests through proxy when direct requests fail.
|
||||||
|
*
|
||||||
|
* @exports
|
||||||
|
* fetchWithProxy, getProxyStatus
|
||||||
|
*
|
||||||
|
* @author Shinei Nouzen
|
||||||
|
* @license MIT
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
*/
|
||||||
|
|
||||||
|
import axios from "axios";
|
||||||
|
import { headers } from "../configs/header.config.js";
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// PROXY CONFIGURATION
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const SCRAPER_API_KEY = process.env.SCRAPER_API_KEY;
|
||||||
|
const FLARESOLVERR_URL = process.env.FLARESOLVERR_URL;
|
||||||
|
|
||||||
|
// ScraperAPI endpoint
|
||||||
|
const SCRAPER_API_URL = "http://api.scraperapi.com";
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// PROXY STATUS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Get proxy configuration status ----
|
||||||
|
/**
|
||||||
|
* Returns current proxy configuration status
|
||||||
|
* @returns {Object} Proxy status with enabled/disabled and available methods
|
||||||
|
*/
|
||||||
|
function getProxyStatus() {
|
||||||
|
return {
|
||||||
|
scraperApi: {
|
||||||
|
enabled: !!SCRAPER_API_KEY,
|
||||||
|
configured: !!SCRAPER_API_KEY,
|
||||||
|
},
|
||||||
|
flaresolverr: {
|
||||||
|
enabled: !!FLARESOLVERR_URL,
|
||||||
|
configured: !!FLARESOLVERR_URL,
|
||||||
|
url: FLARESOLVERR_URL || null,
|
||||||
|
},
|
||||||
|
anyEnabled: !!SCRAPER_API_KEY || !!FLARESOLVERR_URL,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// SCRAPERAPI
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: ScraperAPI proxy fetch ----
|
||||||
|
/**
|
||||||
|
* Fetch a URL through ScraperAPI (premium residential IPs)
|
||||||
|
* @param {string} url - Target URL
|
||||||
|
* @param {Object} options - Request options
|
||||||
|
* @returns {Promise<{data: string, proxy: string}>}
|
||||||
|
*/
|
||||||
|
async function fetchViaScraperApi(url, options = {}) {
|
||||||
|
const { timeout = 30000, returnType = "text" } = options;
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
api_key: SCRAPER_API_KEY,
|
||||||
|
url: url,
|
||||||
|
render: "false",
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await axios.get(SCRAPER_API_URL, {
|
||||||
|
params,
|
||||||
|
timeout,
|
||||||
|
responseType: returnType === "text" ? "text" : "json",
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: response.data,
|
||||||
|
proxy: "scraperapi",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// FLARESOLVERR
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: FlareSolverr proxy fetch ----
|
||||||
|
/**
|
||||||
|
* Fetch a URL through FlareSolverr (self-hosted browser)
|
||||||
|
* @param {string} url - Target URL
|
||||||
|
* @param {Object} options - Request options
|
||||||
|
* @returns {Promise<{data: string, proxy: string}>}
|
||||||
|
*/
|
||||||
|
async function fetchViaFlareSolverr(url, options = {}) {
|
||||||
|
const { timeout = 60000, returnType = "text" } = options;
|
||||||
|
|
||||||
|
const response = await axios.post(
|
||||||
|
`${FLARESOLVERR_URL}/v1`,
|
||||||
|
{
|
||||||
|
cmd: "request.get",
|
||||||
|
url: url,
|
||||||
|
maxTimeout: timeout,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
timeout: timeout + 10000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data.status === "ok") {
|
||||||
|
return {
|
||||||
|
data: response.data.solution.response,
|
||||||
|
proxy: "flaresolverr",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`FlareSolverr error: ${response.data.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// UNIFIED PROXY FETCH
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Fetch with automatic proxy fallback ----
|
||||||
|
/**
|
||||||
|
* Fetch a URL with automatic proxy fallback when direct requests fail.
|
||||||
|
* Tries direct first, then falls back to configured proxies.
|
||||||
|
*
|
||||||
|
* @param {string} url - Target URL
|
||||||
|
* @param {Object} options - Request options
|
||||||
|
* @param {Object} options.requestHeaders - Custom headers for direct request
|
||||||
|
* @param {number} options.timeout - Request timeout (default: 15000ms)
|
||||||
|
* @param {string} options.returnType - Response type: "text" or "json"
|
||||||
|
* @returns {Promise<{data: string, proxy: string}>}
|
||||||
|
*/
|
||||||
|
async function fetchWithProxy(url, options = {}) {
|
||||||
|
const {
|
||||||
|
requestHeaders = headers,
|
||||||
|
timeout = 15000,
|
||||||
|
returnType = "text",
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// 1. Try direct request first
|
||||||
|
try {
|
||||||
|
const response = await axios.get(url, {
|
||||||
|
headers: requestHeaders,
|
||||||
|
timeout,
|
||||||
|
responseType: returnType === "text" ? "text" : "json",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
// Check if response is a valid result (not a Cloudflare block)
|
||||||
|
const data = response.data;
|
||||||
|
if (typeof data === "string" && data.includes("Just a moment...")) {
|
||||||
|
throw new Error("Cloudflare challenge detected");
|
||||||
|
}
|
||||||
|
if (typeof data === "string" && data.includes("Checking your browser")) {
|
||||||
|
throw new Error("Cloudflare challenge detected");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data, proxy: "direct" };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// If ScraperAPI or FlareSolverr configured, try them
|
||||||
|
if (!SCRAPER_API_KEY && !FLARESOLVERR_URL) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try ScraperAPI if configured
|
||||||
|
if (SCRAPER_API_KEY) {
|
||||||
|
try {
|
||||||
|
console.log(`[PROXY] Trying ScraperAPI for: ${url}`);
|
||||||
|
return await fetchViaScraperApi(url, { timeout, returnType });
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`[PROXY] ScraperAPI failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Try FlareSolverr if configured
|
||||||
|
if (FLARESOLVERR_URL) {
|
||||||
|
try {
|
||||||
|
console.log(`[PROXY] Trying FlareSolverr for: ${url}`);
|
||||||
|
return await fetchViaFlareSolverr(url, { timeout, returnType });
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`[PROXY] FlareSolverr failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. All methods failed — try direct one more time with higher timeout
|
||||||
|
try {
|
||||||
|
const response = await axios.get(url, {
|
||||||
|
headers: requestHeaders,
|
||||||
|
timeout: timeout * 2,
|
||||||
|
responseType: returnType === "text" ? "text" : "json",
|
||||||
|
});
|
||||||
|
|
||||||
|
return { data: response.data, proxy: "direct-retry" };
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`All fetch methods failed for ${url}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { fetchWithProxy, getProxyStatus };
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: proxy.helper.js
|
||||||
@@ -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 };
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* AniKotoAPI — creatorInfo.js
|
||||||
|
* Repository: https://github.com/Shineii86/AniKotoAPI
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* Express middleware that injects creator attribution metadata
|
||||||
|
* into every JSON API response. Overrides res.json() to append
|
||||||
|
* author details and a server-side timestamp, unless the
|
||||||
|
* response already contains creator information.
|
||||||
|
*
|
||||||
|
* @exports
|
||||||
|
* addCreatorInfo — Express middleware function
|
||||||
|
* creatorInfo — Static creator metadata object
|
||||||
|
*
|
||||||
|
* @author Shinei Nouzen
|
||||||
|
* @license MIT
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// CREATOR METADATA
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Creator Attribution Object ----
|
||||||
|
// NOTE: Injected into every response unless `data.creator` already exists
|
||||||
|
const creatorInfo = {
|
||||||
|
creator: "Shinei Nouzen",
|
||||||
|
github: "https://github.com/Shineii86",
|
||||||
|
telegram: "https://telegram.me/Shineii86",
|
||||||
|
message: "Built with ❤️ by Shinei Nouzen",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// MIDDLEWARE
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Response Creator Injection Middleware ----
|
||||||
|
/**
|
||||||
|
* Express middleware that enriches every JSON response with
|
||||||
|
* creator attribution and an IN-formatted timestamp.
|
||||||
|
*
|
||||||
|
* @param {import('express').Request} req - Express request object
|
||||||
|
* @param {import('express').Response} res - Express response object
|
||||||
|
* @param {import('express').NextFunction} next - Passes control forward
|
||||||
|
* @returns {void}
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* app.use(addCreatorInfo);
|
||||||
|
* res.json({ anime: [...] });
|
||||||
|
* // Response includes creator, github, telegram, message, and timestamp
|
||||||
|
*/
|
||||||
|
const addCreatorInfo = (req, res, next) => {
|
||||||
|
// NOTE: Preserve the original json method so we can wrap it
|
||||||
|
const originalJson = res.json.bind(res);
|
||||||
|
|
||||||
|
// NOTE: Monkey-patch res.json to inject creator info
|
||||||
|
res.json = (data) => {
|
||||||
|
// NOTE: Only inject if data is a plain object and missing creator field
|
||||||
|
if (typeof data === "object" && data !== null && !data.creator) {
|
||||||
|
// NOTE: Preserve existing 'message' field (e.g., error messages) — only add creator metadata
|
||||||
|
const preservedMessage = data.message;
|
||||||
|
data = {
|
||||||
|
...data,
|
||||||
|
creator: creatorInfo.creator,
|
||||||
|
github: creatorInfo.github,
|
||||||
|
telegram: creatorInfo.telegram,
|
||||||
|
};
|
||||||
|
// NOTE: Restore original message if it existed (error handler sets this)
|
||||||
|
if (preservedMessage) {
|
||||||
|
data.message = preservedMessage;
|
||||||
|
} else {
|
||||||
|
data.message = creatorInfo.message;
|
||||||
|
}
|
||||||
|
data.timestamp = new Date().toLocaleString("en-IN", { timeZone: "Asia/Kolkata", hour12: true });
|
||||||
|
}
|
||||||
|
return originalJson(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
export { addCreatorInfo, creatorInfo };
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: creatorInfo.js
|
||||||
+244
-4
@@ -15,6 +15,10 @@
|
|||||||
* @license MIT
|
* @license MIT
|
||||||
* ======= • ======= • ======= • ======= • =======• =======
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
*/
|
*/
|
||||||
|
import { createRequire } from "module";
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { version } = require("../../package.json");
|
||||||
|
|
||||||
|
|
||||||
import { getHomeInfo } from "../controllers/homeInfo.controller.js";
|
import { getHomeInfo } from "../controllers/homeInfo.controller.js";
|
||||||
import { getAnimeInfo } from "../controllers/animeInfo.controller.js";
|
import { getAnimeInfo } from "../controllers/animeInfo.controller.js";
|
||||||
@@ -22,6 +26,7 @@ import { getSearchResults, getSearchSuggestions } from "../controllers/search.co
|
|||||||
import { getEpisodeList } from "../controllers/episodeList.controller.js";
|
import { getEpisodeList } from "../controllers/episodeList.controller.js";
|
||||||
import { getEpisodeListAjax } from "../controllers/episodeListAjax.controller.js";
|
import { getEpisodeListAjax } from "../controllers/episodeListAjax.controller.js";
|
||||||
import { getStreamInfo, getServerList, getMapperServers } from "../controllers/streamInfo.controller.js";
|
import { getStreamInfo, getServerList, getMapperServers } from "../controllers/streamInfo.controller.js";
|
||||||
|
import { getResolvedStream, getStreamQualities } from "../controllers/streamResolver.controller.js";
|
||||||
import { getSchedule } from "../controllers/schedule.controller.js";
|
import { getSchedule } from "../controllers/schedule.controller.js";
|
||||||
import { getSpotlight } from "../controllers/spotlight.controller.js";
|
import { getSpotlight } from "../controllers/spotlight.controller.js";
|
||||||
import { getTrending } from "../controllers/trending.controller.js";
|
import { getTrending } from "../controllers/trending.controller.js";
|
||||||
@@ -32,12 +37,20 @@ import { getPopular } from "../controllers/popular.controller.js";
|
|||||||
import { getFilter } from "../controllers/filter.controller.js";
|
import { getFilter } from "../controllers/filter.controller.js";
|
||||||
import { getWatchPage } from "../controllers/watchPage.controller.js";
|
import { getWatchPage } from "../controllers/watchPage.controller.js";
|
||||||
import { getAzList } from "../controllers/azList.controller.js";
|
import { getAzList } from "../controllers/azList.controller.js";
|
||||||
import { getNewRelease, getNewlyAdded } from "../controllers/newRelease.controller.js";
|
import { getNewRelease, getNewlyAdded, getLatestUpdated } from "../controllers/newRelease.controller.js";
|
||||||
import { getStatus } from "../controllers/status.controller.js";
|
import { getStatus } from "../controllers/status.controller.js";
|
||||||
import { getTrendingSidebar } from "../controllers/trendingSidebar.controller.js";
|
import { getTrendingSidebar } from "../controllers/trendingSidebar.controller.js";
|
||||||
import { getSeasons } from "../controllers/seasons.controller.js";
|
import { getSeasons } from "../controllers/seasons.controller.js";
|
||||||
import { getWatchOrder } from "../controllers/watchOrder.controller.js";
|
import { getWatchOrder } from "../controllers/watchOrder.controller.js";
|
||||||
|
import { getDownloadLinks } from "../controllers/download.controller.js";
|
||||||
|
import { getUpcomingAnime } from "../controllers/upcomingAnime.controller.js";
|
||||||
|
import { getTopAnimeRankings } from "../controllers/topAnimeRankings.controller.js";
|
||||||
|
import { getRecentlyUpdatedTabs } from "../controllers/recentlyUpdatedTabs.controller.js";
|
||||||
|
import { getCompletedAnime } from "../controllers/completedAnime.controller.js";
|
||||||
import { categoryRoutes } from "./category.route.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
|
// ROUTE REGISTRATION
|
||||||
@@ -189,6 +202,120 @@ app.use((req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Download links for episode ----
|
||||||
|
app.get("/api/download", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await getDownloadLinks(req, res, next);
|
||||||
|
} catch (error) {
|
||||||
|
jsonError(res, error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Resolve actual stream URL (m3u8/mp4) from link ID ----
|
||||||
|
app.get("/api/stream/resolve", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await getResolvedStream(req, res, next);
|
||||||
|
} catch (error) {
|
||||||
|
jsonError(res, error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Available quality options for a stream URL ----
|
||||||
|
app.get("/api/stream/qualities", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await getStreamQualities(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 {
|
||||||
|
const { url } = req.query;
|
||||||
|
if (!url) {
|
||||||
|
return res.status(400).json({ success: false, message: "M3U8 URL is required" });
|
||||||
|
}
|
||||||
|
// 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;
|
||||||
|
const { headers: defaultHeaders } = await import("../configs/header.config.js");
|
||||||
|
|
||||||
|
const response = await axios.get(url, {
|
||||||
|
headers: {
|
||||||
|
...defaultHeaders,
|
||||||
|
...streamRefererHeaders(),
|
||||||
|
},
|
||||||
|
timeout: 15000,
|
||||||
|
responseType: "text",
|
||||||
|
});
|
||||||
|
|
||||||
|
// NOTE: Rewrite relative URLs in M3U8 to proxy URLs
|
||||||
|
let content = typeof response.data === "string" ? response.data : String(response.data);
|
||||||
|
const baseUrl = url.substring(0, url.lastIndexOf("/") + 1);
|
||||||
|
|
||||||
|
content = content.replace(/(^(?!#).+\.m3u8.*$)/gm, (match) => {
|
||||||
|
const absoluteUrl = match.startsWith("http") ? match : new URL(match, baseUrl).href;
|
||||||
|
return `/api/stream/proxy?url=${encodeURIComponent(absoluteUrl)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
content = content.replace(/(^(?!#).+\.ts.*$)/gm, (match) => {
|
||||||
|
const absoluteUrl = match.startsWith("http") ? match : new URL(match, baseUrl).href;
|
||||||
|
return `/api/stream/ts-proxy?url=${encodeURIComponent(absoluteUrl)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", "application/vnd.apple.mpegurl");
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
res.setHeader("Cache-Control", "public, max-age=30");
|
||||||
|
res.send(content);
|
||||||
|
} catch (error) {
|
||||||
|
jsonError(res, error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: TS segment proxy for video segments ----
|
||||||
|
app.get("/api/stream/ts-proxy", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { url } = req.query;
|
||||||
|
if (!url) {
|
||||||
|
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,
|
||||||
|
...streamRefererHeaders(),
|
||||||
|
},
|
||||||
|
timeout: 30000,
|
||||||
|
responseType: "arraybuffer",
|
||||||
|
});
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", "video/mp2t");
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
res.setHeader("Cache-Control", "public, max-age=3600");
|
||||||
|
res.send(Buffer.from(response.data));
|
||||||
|
} catch (error) {
|
||||||
|
jsonError(res, error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// SCHEDULE
|
// SCHEDULE
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
@@ -291,6 +418,15 @@ app.use((req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Recently updated anime (sorted by update time) ----
|
||||||
|
app.get("/api/latest-updated", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await getLatestUpdated(req, res, next);
|
||||||
|
} catch (error) {
|
||||||
|
jsonError(res, error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// SIDEBAR
|
// SIDEBAR
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
@@ -304,6 +440,58 @@ app.use((req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// UPCOMING ANIME
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Upcoming anime releases ----
|
||||||
|
app.get("/api/upcoming", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await getUpcomingAnime(req, res, next);
|
||||||
|
} catch (error) {
|
||||||
|
jsonError(res, error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// TOP ANIME RANKINGS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Top anime rankings with sort (top/newest) ----
|
||||||
|
app.get("/api/top-rankings", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await getTopAnimeRankings(req, res, next);
|
||||||
|
} catch (error) {
|
||||||
|
jsonError(res, error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// RECENTLY UPDATED (TAB FILTERED)
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Recently updated anime with tab filter (all/dub/sub) ----
|
||||||
|
app.get("/api/recently-updated", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await getRecentlyUpdatedTabs(req, res, next);
|
||||||
|
} catch (error) {
|
||||||
|
jsonError(res, error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// COMPLETED ANIME
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Completed anime series ----
|
||||||
|
app.get("/api/completed", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await getCompletedAnime(req, res, next);
|
||||||
|
} catch (error) {
|
||||||
|
jsonError(res, error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// SEASONS & WATCH ORDER
|
// SEASONS & WATCH ORDER
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
@@ -361,7 +549,7 @@ app.get("/api/health", (req, res) => {
|
|||||||
success: true,
|
success: true,
|
||||||
results: {
|
results: {
|
||||||
status: "healthy",
|
status: "healthy",
|
||||||
version: "1.8.0",
|
version: version,
|
||||||
uptime: `${hours}h ${minutes}m ${seconds}s`,
|
uptime: `${hours}h ${minutes}m ${seconds}s`,
|
||||||
uptimeSeconds: uptime,
|
uptimeSeconds: uptime,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@@ -394,20 +582,49 @@ app.get("/api/stats", (req, res) => {
|
|||||||
ttl: "5 minutes",
|
ttl: "5 minutes",
|
||||||
description: "Map-based cache with TTL expiration",
|
description: "Map-based cache with TTL expiration",
|
||||||
},
|
},
|
||||||
endpoints: 30,
|
endpoints: 38,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Mirror status and health check ----
|
||||||
|
app.get("/api/mirrors", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const status = await getMirrorStatus();
|
||||||
|
res.json({ success: true, results: status });
|
||||||
|
} catch (error) {
|
||||||
|
jsonError(res, error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Cache statistics ----
|
||||||
|
app.get("/api/cache/stats", (req, res) => {
|
||||||
|
const stats = getCacheStats();
|
||||||
|
res.json({ success: true, results: stats });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Reset mirror cache ----
|
||||||
|
app.post("/api/mirrors/reset", (req, res) => {
|
||||||
|
resetMirrorCache();
|
||||||
|
res.json({ success: true, message: "Mirror cache reset" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Proxy status (ScraperAPI / FlareSolverr) ----
|
||||||
|
app.get("/api/proxy/status", (req, res) => {
|
||||||
|
const status = getProxyStatus();
|
||||||
|
res.json({ success: true, results: status });
|
||||||
|
});
|
||||||
|
|
||||||
// ---- FEATURE: OpenAPI specification ----
|
// ---- FEATURE: OpenAPI specification ----
|
||||||
app.get("/api/openapi", (req, res) => {
|
app.get("/api/openapi", (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
|
success: true,
|
||||||
openapi: "3.0.3",
|
openapi: "3.0.3",
|
||||||
info: {
|
info: {
|
||||||
title: "AniKotoAPI",
|
title: "AniKotoAPI",
|
||||||
description: "Free REST API for anime data from anikototv.to",
|
description: "Free REST API for anime data from anikototv.to",
|
||||||
version: "1.8.0",
|
version: version,
|
||||||
contact: {
|
contact: {
|
||||||
name: "Shinei Nouzen",
|
name: "Shinei Nouzen",
|
||||||
url: "https://github.com/Shineii86/AniKotoAPI",
|
url: "https://github.com/Shineii86/AniKotoAPI",
|
||||||
@@ -423,6 +640,7 @@ app.get("/api/openapi", (req, res) => {
|
|||||||
"/episodes/{id}": { get: { summary: "Episode list", tags: ["Episodes"] } },
|
"/episodes/{id}": { get: { summary: "Episode list", tags: ["Episodes"] } },
|
||||||
"/servers": { get: { summary: "Server list", tags: ["Streaming"] } },
|
"/servers": { get: { summary: "Server list", tags: ["Streaming"] } },
|
||||||
"/stream": { get: { summary: "Stream URL", tags: ["Streaming"] } },
|
"/stream": { get: { summary: "Stream URL", tags: ["Streaming"] } },
|
||||||
|
"/download": { get: { summary: "Download links", tags: ["Streaming"] } },
|
||||||
"/schedule": { get: { summary: "Anime schedule", tags: ["Schedule"] } },
|
"/schedule": { get: { summary: "Anime schedule", tags: ["Schedule"] } },
|
||||||
"/random": { get: { summary: "Random anime", tags: ["Discovery"] } },
|
"/random": { get: { summary: "Random anime", tags: ["Discovery"] } },
|
||||||
"/top-ten": { get: { summary: "Top 10 rankings", tags: ["Rankings"] } },
|
"/top-ten": { get: { summary: "Top 10 rankings", tags: ["Rankings"] } },
|
||||||
@@ -436,7 +654,29 @@ app.get("/api/openapi", (req, res) => {
|
|||||||
"/status/{name}": { get: { summary: "By status", tags: ["Categories"] } },
|
"/status/{name}": { get: { summary: "By status", tags: ["Categories"] } },
|
||||||
"/health": { get: { summary: "Health check", tags: ["System"] } },
|
"/health": { get: { summary: "Health check", tags: ["System"] } },
|
||||||
"/stats": { get: { summary: "API statistics", tags: ["System"] } },
|
"/stats": { get: { summary: "API statistics", tags: ["System"] } },
|
||||||
|
"/mirrors": { get: { summary: "Mirror status", tags: ["System"] } },
|
||||||
|
"/mirrors/reset": { post: { summary: "Reset mirror cache", tags: ["System"] } },
|
||||||
"/openapi": { get: { summary: "OpenAPI spec", tags: ["System"] } },
|
"/openapi": { get: { summary: "OpenAPI spec", tags: ["System"] } },
|
||||||
|
"/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/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"] } },
|
||||||
|
"/episodes-ajax/{id}": { get: { summary: "AJAX episode list", tags: ["Episodes"] } },
|
||||||
|
"/mapper-servers": { get: { summary: "Mapper servers", tags: ["Streaming"] } },
|
||||||
|
"/newly-added": { get: { summary: "Newly added", tags: ["Releases"] } },
|
||||||
|
"/latest-updated": { get: { summary: "Recently updated", tags: ["Releases"] } },
|
||||||
|
"/trending-sidebar": { get: { summary: "Trending sidebar", tags: ["Discovery"] } },
|
||||||
|
"/seasons/{id}": { get: { summary: "Season data", tags: ["Anime"] } },
|
||||||
|
"/watch-order/{id}": { get: { summary: "Watch order", tags: ["Anime"] } },
|
||||||
|
"/az-list/{letter}": { get: { summary: "A-Z listing", tags: ["Discovery"] } },
|
||||||
|
"/suggestions": { get: { summary: "Anime suggestions", tags: ["Search"] } },
|
||||||
|
"/upcoming": { get: { summary: "Upcoming anime", tags: ["Discovery"] } },
|
||||||
|
"/top-rankings": { get: { summary: "Top anime rankings (day/week/month)", tags: ["Rankings"] } },
|
||||||
|
"/recently-updated": { get: { summary: "Recently updated (all/dub/sub tabs)", tags: ["Releases"] } },
|
||||||
|
"/completed": { get: { summary: "Completed anime series", tags: ["Rankings"] } },
|
||||||
|
"/proxy/status": { get: { summary: "Proxy status (ScraperAPI / FlareSolverr)", tags: ["System"] } },
|
||||||
},
|
},
|
||||||
tags: [
|
tags: [
|
||||||
{ name: "Home", description: "Homepage data" },
|
{ name: "Home", description: "Homepage data" },
|
||||||
|
|||||||
@@ -4,66 +4,227 @@
|
|||||||
* Repository: https://github.com/Shineii86/AniKotoAPI
|
* Repository: https://github.com/Shineii86/AniKotoAPI
|
||||||
*
|
*
|
||||||
* @description
|
* @description
|
||||||
* Integration test suite for AniKotoAPI endpoints.
|
* Comprehensive integration test suite for AniKotoAPI endpoints.
|
||||||
* Tests all major endpoints for correct response format.
|
* Tests all major endpoints for correct response format, status codes,
|
||||||
|
* error handling, and performance benchmarks.
|
||||||
*
|
*
|
||||||
* @author Shinei Nouzen
|
* @author Shinei Nouzen
|
||||||
* @license MIT
|
* @license MIT
|
||||||
* ======= • ======= • ======= • ======= • =======• =======
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const BASE = process.env.API_URL || "https://anikototvapi.vercel.app/api";
|
const LIVE_API = "https://anikototvapi.vercel.app/api";
|
||||||
|
const LOCAL_API = "http://localhost:4444/api";
|
||||||
|
|
||||||
|
let BASE = process.env.API_URL || LIVE_API;
|
||||||
|
|
||||||
|
async function detectBase() {
|
||||||
|
if (process.env.API_URL) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${LOCAL_API}/health`, { signal: AbortSignal.timeout(2000) });
|
||||||
|
if (res.ok) {
|
||||||
|
BASE = LOCAL_API;
|
||||||
|
console.log("📡 Detected local server — using localhost:4444\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
BASE = LIVE_API;
|
||||||
|
console.log("📡 No local server found — using live API\n");
|
||||||
|
}
|
||||||
|
|
||||||
const tests = [
|
const tests = [
|
||||||
{ name: "Home", url: "/" },
|
// Core endpoints
|
||||||
{ name: "Search", url: "/search?keyword=naruto" },
|
{ name: "Home", url: "/", check: (d) => d.results?.spotlights || d.results?.trending },
|
||||||
{ name: "Info", url: "/info?id=road-of-naruto-ggjw8" },
|
{ name: "Search", url: "/search?keyword=naruto", check: (d) => d.results?.data },
|
||||||
{ name: "Episodes", url: "/episodes/road-of-naruto-ggjw8" },
|
{ name: "Info", url: "/info?id=one-piece-odmau", check: (d) => d.results?.title },
|
||||||
{ name: "Suggestions", url: "/suggestions?keyword=naruto" },
|
{ name: "Episodes", url: "/episodes/one-piece-odmau", check: (d) => d.results?.episodes },
|
||||||
{ name: "Spotlight", url: "/spotlight" },
|
{ name: "Servers", url: "/servers?ids=1", check: (d) => d.results, optional: true },
|
||||||
{ name: "Trending", url: "/trending" },
|
// Streaming
|
||||||
{ name: "Top 10", url: "/top-ten" },
|
{ name: "Stream", url: "/watch?slug=one-piece-odmau&ep=1", check: (d) => d.results?.servers || d.results?.episodeNumber },
|
||||||
{ name: "Random", url: "/random" },
|
|
||||||
{ name: "Most Popular", url: "/most-popular" },
|
|
||||||
{ name: "New Release", url: "/new-release" },
|
// Discovery endpoints
|
||||||
{ name: "Schedule", url: "/schedule" },
|
{ name: "Suggestions", url: "/suggestions?keyword=naruto", check: (d) => d.results },
|
||||||
{ name: "Genre", url: "/genre/action" },
|
{ name: "Spotlight", url: "/spotlight", check: (d) => d.results },
|
||||||
{ name: "Type", url: "/type/tv" },
|
{ name: "Trending", url: "/trending", check: (d) => d.results },
|
||||||
{ name: "Status", url: "/status/airing" },
|
{ name: "Top 10", url: "/top-ten", check: (d) => d.results },
|
||||||
{ name: "Health", url: "/health" },
|
{ name: "Random", url: "/random", check: (d) => d.results },
|
||||||
{ name: "Stats", url: "/stats" },
|
{ name: "Most Popular", url: "/most-popular", check: (d) => d.results },
|
||||||
{ name: "OpenAPI", url: "/openapi" },
|
{ 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 },
|
||||||
|
{ name: "Newly Added", url: "/newly-added", check: (d) => d.results },
|
||||||
|
{ name: "Latest Updated", url: "/latest-updated", check: (d) => d.results },
|
||||||
|
|
||||||
|
// 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, optional: true },
|
||||||
|
{ name: "AZ List", url: "/az-list/a", check: (d) => d.results },
|
||||||
|
{ name: "Filter", url: "/filter?keyword=naruto", check: (d) => d.results },
|
||||||
|
|
||||||
|
// Search variants
|
||||||
|
{ name: "Search Suggest", url: "/search/suggest?keyword=naruto", check: (d) => d.results },
|
||||||
|
{ name: "Trending Sidebar", url: "/trending-sidebar", check: (d) => d.results },
|
||||||
|
|
||||||
|
// Anime detail
|
||||||
|
{ name: "Seasons", url: "/seasons/one-piece-odmau", check: (d) => d.results?.seasons || d.results?.totalSeasons, optional: true },
|
||||||
|
{ name: "Watch Order", url: "/watch-order/one-piece-odmau", check: (d) => d.results?.related || d.results?.totalRelated, optional: true },
|
||||||
|
{ name: "Download", url: "/download?slug=one-piece-odmau&ep=1", check: (d) => d.results, optional: true },
|
||||||
|
|
||||||
|
// Utility endpoints
|
||||||
|
{ name: "Health", url: "/health", check: (d) => d.results?.status === "healthy" },
|
||||||
|
{ 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", optional: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
let passed = 0;
|
let passed = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let total = 0;
|
||||||
|
const results = [];
|
||||||
|
|
||||||
async function runTest(test) {
|
async function runTest(test) {
|
||||||
|
total++;
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${BASE}${test.url}`);
|
const res = await fetch(`${BASE}${test.url}`);
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
if (test.optional && (res.status === 404 || res.status === 500)) {
|
||||||
|
console.log(`⏭️ ${test.name} - Skipped (HTTP ${res.status}) (${duration}ms)`);
|
||||||
|
skipped++;
|
||||||
|
results.push({ name: test.name, status: "SKIP", httpStatus: res.status, duration });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`❌ ${test.name} - HTTP ${res.status} (${duration}ms)`);
|
||||||
|
failed++;
|
||||||
|
results.push({ name: test.name, status: "FAIL", httpStatus: res.status, duration });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (data.success === true && data.results) {
|
if (!data.success) {
|
||||||
console.log(`✅ ${test.name}`);
|
if (test.optional) {
|
||||||
passed++;
|
console.log(`⏭️ ${test.name} - Skipped (success=false) (${duration}ms)`);
|
||||||
} else {
|
skipped++;
|
||||||
console.log(`❌ ${test.name} - Invalid response format`);
|
results.push({ name: test.name, status: "SKIP", reason: "success=false", duration });
|
||||||
failed++;
|
return;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
console.log(`❌ ${test.name} - success=false (${duration}ms)`);
|
||||||
console.log(`❌ ${test.name} - ${error.message}`);
|
|
||||||
failed++;
|
failed++;
|
||||||
|
results.push({ name: test.name, status: "FAIL", reason: "success=false", duration });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check custom validation
|
||||||
|
if (test.check && !test.check(data)) {
|
||||||
|
if (test.optional) {
|
||||||
|
console.log(`⏭️ ${test.name} - Skipped (validation failed) (${duration}ms)`);
|
||||||
|
skipped++;
|
||||||
|
results.push({ name: test.name, status: "SKIP", reason: "Validation failed", duration });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`❌ ${test.name} - Validation failed (${duration}ms)`);
|
||||||
|
failed++;
|
||||||
|
results.push({ name: test.name, status: "FAIL", reason: "Validation failed", duration });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ ${test.name} (${duration}ms)`);
|
||||||
|
passed++;
|
||||||
|
results.push({ name: test.name, status: "PASS", duration });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
if (test.optional) {
|
||||||
|
console.log(`⏭️ ${test.name} - Skipped (${error.message}) (${duration}ms)`);
|
||||||
|
skipped++;
|
||||||
|
results.push({ name: test.name, status: "SKIP", reason: error.message, duration });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`❌ ${test.name} - ${error.message} (${duration}ms)`);
|
||||||
|
failed++;
|
||||||
|
results.push({ name: test.name, status: "FAIL", reason: error.message, duration });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runAll() {
|
async function fetchJson(url) {
|
||||||
console.log(`\n🧪 Running ${tests.length} tests...\n`);
|
const res = await fetch(`${BASE}${url}`);
|
||||||
|
return res.ok ? res.json() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAll() {
|
||||||
|
await detectBase();
|
||||||
|
console.log(`🧪 Running ${tests.length} tests...\n`);
|
||||||
|
console.log(`📡 API: ${BASE}\n`);
|
||||||
|
|
||||||
|
// ---- FEATURE: Resolve dynamic test dependencies ----
|
||||||
|
// NOTE: Fetch info to get numeric animeId for episodes-ajax
|
||||||
|
let animeId = "1642";
|
||||||
|
let linkId = "";
|
||||||
|
try {
|
||||||
|
const info = await fetchJson("/info?id=one-piece-odmau");
|
||||||
|
if (info?.success && info.results?.animeId) {
|
||||||
|
animeId = info.results.animeId;
|
||||||
|
}
|
||||||
|
// NOTE: Get a valid link_id via episodes → servers flow
|
||||||
|
const epData = await fetchJson("/episodes/one-piece-odmau");
|
||||||
|
if (epData?.success && epData.results?.episodes?.length > 0) {
|
||||||
|
const sid = epData.results.episodes[0].server_ids;
|
||||||
|
if (sid) {
|
||||||
|
const srvData = await fetchJson(`/servers?ids=${encodeURIComponent(sid)}`);
|
||||||
|
if (srvData?.success && srvData.results?.length > 0) {
|
||||||
|
linkId = srvData.results[0].link_id || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) { /* use defaults */ }
|
||||||
|
|
||||||
|
// ---- 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, optional: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- FEATURE: Run all tests sequentially ----
|
||||||
for (const test of tests) {
|
for (const test of tests) {
|
||||||
await runTest(test);
|
await runTest(test);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`\n📊 Results: ${passed} passed, ${failed} failed, ${tests.length} total\n`);
|
console.log(`\n${"=".repeat(50)}`);
|
||||||
|
console.log(`📊 Results: ${passed} passed, ${failed} failed, ${skipped} skipped, ${total} total`);
|
||||||
|
console.log(`${"=".repeat(50)}`);
|
||||||
|
|
||||||
|
// Performance summary
|
||||||
|
const durations = results.filter(r => r.status === "PASS").map(r => r.duration);
|
||||||
|
if (durations.length > 0) {
|
||||||
|
const avgDuration = durations.reduce((a, b) => a + b, 0) / durations.length;
|
||||||
|
const maxDuration = Math.max(...durations);
|
||||||
|
const minDuration = Math.min(...durations);
|
||||||
|
|
||||||
|
console.log(`\n⚡ Performance:`);
|
||||||
|
console.log(` Average: ${avgDuration.toFixed(0)}ms`);
|
||||||
|
console.log(` Min: ${minDuration}ms`);
|
||||||
|
console.log(` Max: ${maxDuration}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detailed results
|
||||||
|
console.log(`\n📋 Detailed Results:`);
|
||||||
|
results.forEach(r => {
|
||||||
|
const icon = r.status === "PASS" ? "✅" : r.status === "SKIP" ? "⏭️" : "❌";
|
||||||
|
console.log(` ${icon} ${r.name} - ${r.status} (${r.duration}ms)${r.reason ? ` - ${r.reason}` : ""}`);
|
||||||
|
});
|
||||||
|
|
||||||
process.exit(failed > 0 ? 1 : 0);
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user