mirror of
https://github.com/Shineii86/MiruroAPI.git
synced 2026-10-22 10:15:10 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d0e0497f7 | ||
|
|
2c97ca2dd8 | ||
|
|
3f667fe87f | ||
|
|
015aa67ec2 | ||
|
|
a918ed9d1a | ||
|
|
125f088570 |
+2
-1
@@ -16,9 +16,10 @@ PRU_PROXY_KEY=a54d389c18527d9fd3e7f0643e27edbe
|
|||||||
# SELF-HEALING FALLBACK METHODS (optional, enables auto-recovery)
|
# SELF-HEALING FALLBACK METHODS (optional, enables auto-recovery)
|
||||||
# ══════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
# Method 2: ScraperAPI (free 1,000 req/month, no CC needed)
|
# Method 2: ScraperAPI (requires premium plan — $49/month)
|
||||||
# Sign up at https://www.scraperapi.com/
|
# Sign up at https://www.scraperapi.com/
|
||||||
# Enables automatic fallback when direct requests are blocked by Cloudflare
|
# Enables automatic fallback when direct requests are blocked by Cloudflare
|
||||||
|
# NOTE: Miruro is a protected domain — free plan will NOT work
|
||||||
SCRAPER_API_KEY=
|
SCRAPER_API_KEY=
|
||||||
|
|
||||||
# Method 3: FlareSolverr (self-hosted browser proxy)
|
# Method 3: FlareSolverr (self-hosted browser proxy)
|
||||||
|
|||||||
@@ -2,3 +2,9 @@ node_modules/
|
|||||||
.env
|
.env
|
||||||
.vercel/
|
.vercel/
|
||||||
deploy.cjs
|
deploy.cjs
|
||||||
|
|
||||||
|
# wrangler files
|
||||||
|
.wrangler
|
||||||
|
.dev.vars*
|
||||||
|
!.dev.vars.example
|
||||||
|
!.env.example
|
||||||
|
|||||||
+31
-1
@@ -1,5 +1,35 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v2.3.1
|
||||||
|
### Cloudflare Worker Edition — Streaming Bypasses Cloudflare
|
||||||
|
|
||||||
|
#### New: `worker.js` (Cloudflare Worker)
|
||||||
|
- Full API running on Cloudflare's edge network
|
||||||
|
- **Streaming endpoints work** — edge-to-edge requests bypass bot detection
|
||||||
|
- Zero cold starts, 100K free requests/day
|
||||||
|
- No npm dependencies (native `fetch` + `DecompressionStream`)
|
||||||
|
- 15 endpoints: search, trending, info, episodes, watch, genres, random
|
||||||
|
|
||||||
|
#### Why Workers Work
|
||||||
|
```
|
||||||
|
Vercel (datacenter) → miruro.to → Cloudflare blocks ❌
|
||||||
|
Worker (edge) → miruro.to → Cloudflare trusts itself ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Deploy
|
||||||
|
```bash
|
||||||
|
npm install -g wrangler
|
||||||
|
wrangler login
|
||||||
|
wrangler deploy worker.js --name miruroapi
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Files Changed
|
||||||
|
- `worker.js` — Cloudflare Worker implementation (885 lines)
|
||||||
|
- `README.md` — Added Worker deployment section, updated troubleshooting
|
||||||
|
- `docs/architecture.md` — Added Worker architecture section
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v2.3.0
|
## v2.3.0
|
||||||
### Self-Healing Fallback System — Auto-Recovery from Cloudflare Blocks
|
### Self-Healing Fallback System — Auto-Recovery from Cloudflare Blocks
|
||||||
|
|
||||||
@@ -25,7 +55,7 @@ GET /api/pipe-health
|
|||||||
#### Configuration (all optional, zero-config to start)
|
#### Configuration (all optional, zero-config to start)
|
||||||
| Env Var | Purpose | Free? |
|
| Env Var | Purpose | Free? |
|
||||||
|---------|---------|-------|
|
|---------|---------|-------|
|
||||||
| `SCRAPER_API_KEY` | ScraperAPI proxy | Yes (1K req/mo) |
|
| `SCRAPER_API_KEY` | ScraperAPI proxy | No ($49/mo premium required for protected domains) |
|
||||||
| `FLARESOLVERR_URL` | FlareSolverr browser | Yes (self-hosted) |
|
| `FLARESOLVERR_URL` | FlareSolverr browser | Yes (self-hosted) |
|
||||||
| `PIPE_OBF_KEY` | XOR decode key | N/A |
|
| `PIPE_OBF_KEY` | XOR decode key | N/A |
|
||||||
|
|
||||||
|
|||||||
@@ -102,10 +102,10 @@
|
|||||||
|
|
||||||
When Cloudflare blocks streaming requests, the API automatically tries fallback methods:
|
When Cloudflare blocks streaming requests, the API automatically tries fallback methods:
|
||||||
- **Direct** → Mirror rotation across 4 domains (miruro.to, miruro.ru, miruro.bz, miruro.tv)
|
- **Direct** → Mirror rotation across 4 domains (miruro.to, miruro.ru, miruro.bz, miruro.tv)
|
||||||
- **ScraperAPI** → Free proxy bypass (1K req/month)
|
- **ScraperAPI** → Proxy bypass via premium residential IPs ($49/mo)
|
||||||
- **FlareSolverr** → Self-hosted browser proxy
|
- **FlareSolverr** → Self-hosted browser proxy
|
||||||
|
|
||||||
Zero-config: works out of the box. Add `SCRAPER_API_KEY` env var for automatic Cloudflare bypass.
|
Zero-config: works out of the box. Add `SCRAPER_API_KEY` env var for automatic Cloudflare bypass (requires ScraperAPI premium plan for protected domains).
|
||||||
|
|
||||||
### ⚠️ Streaming Status
|
### ⚠️ Streaming Status
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ Zero-config: works out of the box. Add `SCRAPER_API_KEY` env var for automatic C
|
|||||||
**To fix streaming**, set one of these environment variables in Vercel:
|
**To fix streaming**, set one of these environment variables in Vercel:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Option 1: ScraperAPI (free 1K requests/month)
|
# Option 1: ScraperAPI (requires premium plan — $49/month)
|
||||||
SCRAPER_API_KEY=your_scraperapi_key
|
SCRAPER_API_KEY=your_scraperapi_key
|
||||||
|
|
||||||
# Option 2: FlareSolverr (self-hosted, unlimited)
|
# Option 2: FlareSolverr (self-hosted, unlimited)
|
||||||
@@ -436,12 +436,12 @@ bun dev
|
|||||||
| `ALLOWED_ORIGINS` | `*` | Comma-separated allowed origins (restricts CORS in production) |
|
| `ALLOWED_ORIGINS` | `*` | Comma-separated allowed origins (restricts CORS in production) |
|
||||||
| `PIPE_OBF_KEY` | `71951034...` | Pipe response XOR obfuscation key (hex, 32 chars) |
|
| `PIPE_OBF_KEY` | `71951034...` | Pipe response XOR obfuscation key (hex, 32 chars) |
|
||||||
| `PRU_PROXY_KEY` | `a54d389c...` | PRU proxy URL XOR key (hex, 32 chars) |
|
| `PRU_PROXY_KEY` | `a54d389c...` | PRU proxy URL XOR key (hex, 32 chars) |
|
||||||
| `SCRAPER_API_KEY` | — | ScraperAPI key for Cloudflare bypass (free 1K req/month) |
|
| `SCRAPER_API_KEY` | — | ScraperAPI key for Cloudflare bypass (requires premium plan, $49/mo) |
|
||||||
| `FLARESOLVERR_URL` | — | FlareSolverr URL for browser-based bypass (e.g., `http://127.0.0.1:8191`) |
|
| `FLARESOLVERR_URL` | — | FlareSolverr URL for browser-based bypass (e.g., `http://127.0.0.1:8191`) |
|
||||||
|
|
||||||
> ⚠️ **Required for production:** Set `ALLOWED_ORIGINS` to restrict CORS. Set `PIPE_OBF_KEY` and `PRU_PROXY_KEY` for pipe decoding.
|
> ⚠️ **Required for production:** Set `ALLOWED_ORIGINS` to restrict CORS. Set `PIPE_OBF_KEY` and `PRU_PROXY_KEY` for pipe decoding.
|
||||||
>
|
>
|
||||||
> 📺 **For streaming:** Set `SCRAPER_API_KEY` or `FLARESOLVERR_URL` to bypass Cloudflare bot protection. See [How to Fix Streaming Issues](#-how-to-fix-streaming-issues).
|
> 📺 **For streaming:** Set `SCRAPER_API_KEY` (requires ScraperAPI premium plan) or `FLARESOLVERR_URL` (free, self-hosted) to bypass Cloudflare bot protection. See [How to Fix Streaming Issues](#-how-to-fix-streaming-issues).
|
||||||
|
|
||||||
### Vercel Configuration
|
### Vercel Configuration
|
||||||
|
|
||||||
@@ -1414,6 +1414,32 @@ docker build -t miruroapi .
|
|||||||
docker run -p 3000:3000 miruroapi
|
docker run -p 3000:3000 miruroapi
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### ⚡ Cloudflare Worker (Streaming Works!)
|
||||||
|
|
||||||
|
> **Streaming endpoints work on Workers** — requests to Miruro bypass Cloudflare bot detection because Workers run on Cloudflare's edge network (edge-to-edge traffic is trusted).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install wrangler CLI
|
||||||
|
npm install -g wrangler
|
||||||
|
|
||||||
|
# Login to Cloudflare
|
||||||
|
wrangler login
|
||||||
|
|
||||||
|
# Deploy
|
||||||
|
wrangler deploy worker.js --name miruroapi
|
||||||
|
```
|
||||||
|
|
||||||
|
Your API is live at: `https://miruroapi.YOUR_SUBDOMAIN.workers.dev`
|
||||||
|
|
||||||
|
| Feature | Vercel | Cloudflare Worker |
|
||||||
|
|---------|--------|-------------------|
|
||||||
|
| Streaming (pipe) | Blocked (403) | Works |
|
||||||
|
| Cold starts | ~500ms | Zero |
|
||||||
|
| Free tier | Unlimited | 100K req/day |
|
||||||
|
| npm deps | Yes | No (native fetch) |
|
||||||
|
|
||||||
|
> See `worker.js` for the full Worker implementation with 15 endpoints.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📜 Available Scripts
|
## 📜 Available Scripts
|
||||||
@@ -1492,7 +1518,27 @@ Cloudflare detects:
|
|||||||
- Missing browser fingerprint headers
|
- Missing browser fingerprint headers
|
||||||
- Non-residential traffic patterns
|
- Non-residential traffic patterns
|
||||||
|
|
||||||
#### Fix 1: ScraperAPI (Easiest — Free Tier)
|
#### Fix 1: Cloudflare Worker (Best — Free, Works!)
|
||||||
|
|
||||||
|
Workers run on Cloudflare's edge network. Edge-to-edge requests are trusted — no bot detection.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Install wrangler
|
||||||
|
npm install -g wrangler
|
||||||
|
|
||||||
|
# 2. Login
|
||||||
|
wrangler login
|
||||||
|
|
||||||
|
# 3. Deploy
|
||||||
|
wrangler deploy worker.js --name miruroapi
|
||||||
|
|
||||||
|
# 4. Your streaming now works at:
|
||||||
|
# https://miruroapi.YOUR_SUBDOMAIN.workers.dev/api/episodes/20
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cost:** Free (100K requests/day). No API key needed.
|
||||||
|
|
||||||
|
#### Fix 2: ScraperAPI (Paid — $49/month)
|
||||||
|
|
||||||
ScraperAPI proxies your requests through residential IPs, bypassing Cloudflare.
|
ScraperAPI proxies your requests through residential IPs, bypassing Cloudflare.
|
||||||
|
|
||||||
@@ -1503,9 +1549,9 @@ ScraperAPI proxies your requests through residential IPs, bypassing Cloudflare.
|
|||||||
SCRAPER_API_KEY=your_api_key_here
|
SCRAPER_API_KEY=your_api_key_here
|
||||||
```
|
```
|
||||||
|
|
||||||
**Cost:** Free for 1K requests/month. Paid plans from $49/month for 250K requests.
|
**Cost:** $49/month (premium plan required for protected domains like Miruro).
|
||||||
|
|
||||||
#### Fix 2: FlareSolverr (Self-Hosted — Unlimited)
|
#### Fix 3: FlareSolverr (Self-Hosted — Unlimited)
|
||||||
|
|
||||||
FlareSolverr runs a headless browser that bypasses Cloudflare automatically.
|
FlareSolverr runs a headless browser that bypasses Cloudflare automatically.
|
||||||
|
|
||||||
@@ -1524,7 +1570,7 @@ FLARESOLVERR_URL=http://your-server-ip:8191
|
|||||||
- Public IP address
|
- Public IP address
|
||||||
- Docker installed
|
- Docker installed
|
||||||
|
|
||||||
#### Fix 3: Self-Host the Entire API
|
#### Fix 4: Self-Host the Entire API
|
||||||
|
|
||||||
If you run the API on your own VPS (not Vercel), requests come from your residential/datacenter IP which may not be blocked.
|
If you run the API on your own VPS (not Vercel), requests come from your residential/datacenter IP which may not be blocked.
|
||||||
|
|
||||||
@@ -1626,7 +1672,7 @@ Yes! Use <code>npm start</code> to run the Express server on any VPS, Docker con
|
|||||||
<details>
|
<details>
|
||||||
<summary><b>❌ Why are streaming endpoints returning 500 errors?</b></summary>
|
<summary><b>❌ Why are streaming endpoints returning 500 errors?</b></summary>
|
||||||
<br/>
|
<br/>
|
||||||
Cloudflare's bot protection blocks requests from Vercel's datacenter IPs. The pipe endpoint (<code>miruro.to/api/secure/pipe</code>) returns HTTP 403. To fix this, set <code>SCRAPER_API_KEY</code> (free 1K req/month from ScraperAPI) or <code>FLARESOLVERR_URL</code> (self-hosted browser proxy) in your Vercel environment variables. See <a href="#-how-to-fix-streaming-issues">How to Fix Streaming Issues</a> above.
|
Cloudflare's bot protection blocks requests from Vercel's datacenter IPs. The pipe endpoint (<code>miruro.to/api/secure/pipe</code>) returns HTTP 403. To fix this, set <code>SCRAPER_API_KEY</code> (requires ScraperAPI premium plan, $49/mo) or <code>FLARESOLVERR_URL</code> (self-hosted, free) in your Vercel environment variables. See <a href="#-how-to-fix-streaming-issues">How to Fix Streaming Issues</a> above.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
```
|
```
|
||||||
MiruroAPI/
|
MiruroAPI/
|
||||||
├── server.js # Express entry point, port 3000
|
├── server.js # Express entry point, port 3000
|
||||||
|
├── worker.js # Cloudflare Worker edition (edge runtime)
|
||||||
├── package.json # name: "miruro-api"
|
├── package.json # name: "miruro-api"
|
||||||
├── vercel.json # Routes /api/* and /* to server.js
|
├── vercel.json # Routes /api/* and /* to server.js
|
||||||
├── Dockerfile # Docker support (node:20-alpine)
|
├── Dockerfile # Docker support (node:20-alpine)
|
||||||
@@ -198,3 +199,52 @@ Error responses:
|
|||||||
| axios | ^1.8.0 | HTTP requests |
|
| axios | ^1.8.0 | HTTP requests |
|
||||||
| cors | ^2.8.5 | CORS headers |
|
| cors | ^2.8.5 | CORS headers |
|
||||||
| dotenv | ^16.4.0 | Environment variables |
|
| dotenv | ^16.4.0 | Environment variables |
|
||||||
|
|
||||||
|
## Cloudflare Worker Architecture (v2.3.1)
|
||||||
|
|
||||||
|
The Worker edition (`worker.js`) runs the same API on Cloudflare's edge network. Key difference: **streaming endpoints work** because edge-to-edge requests bypass Cloudflare bot detection.
|
||||||
|
|
||||||
|
### Why Workers Work
|
||||||
|
|
||||||
|
```
|
||||||
|
Vercel (datacenter IP) → miruro.to → Cloudflare WAF → 403 Blocked
|
||||||
|
Cloudflare Worker (edge) → miruro.to → Cloudflare internal network → 200 OK
|
||||||
|
```
|
||||||
|
|
||||||
|
### Worker Runtime Differences
|
||||||
|
|
||||||
|
| Feature | Express (Node.js) | Cloudflare Worker |
|
||||||
|
|---------|-------------------|-------------------|
|
||||||
|
| HTTP client | axios | native fetch |
|
||||||
|
| Decompression | zlib (Node.js) | DecompressionStream (Web API) |
|
||||||
|
| Caching | in-memory Map | Workers KV (optional) |
|
||||||
|
| npm packages | Yes | No (bundle only) |
|
||||||
|
| Cold starts | ~500ms | Zero |
|
||||||
|
| CPU limit | None | 10ms (free) / 30s (paid) |
|
||||||
|
|
||||||
|
### Worker Endpoints (15)
|
||||||
|
|
||||||
|
| Endpoint | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `GET /api/health` | System health check |
|
||||||
|
| `GET /api/search?query=` | Full-text anime search |
|
||||||
|
| `GET /api/suggestions?query=` | Autocomplete suggestions |
|
||||||
|
| `GET /api/filter?genre=` | Advanced filter |
|
||||||
|
| `GET /api/trending` | Trending anime |
|
||||||
|
| `GET /api/popular` | Most popular |
|
||||||
|
| `GET /api/top` | Top scored |
|
||||||
|
| `GET /api/random` | Random anime |
|
||||||
|
| `GET /api/info/:id` | Full anime info |
|
||||||
|
| `GET /api/anime/:id/characters` | Characters + voice actors |
|
||||||
|
| `GET /api/anime/:id/relations` | Related anime |
|
||||||
|
| `GET /api/genres` | All genres |
|
||||||
|
| `GET /api/episodes/:id` | Episode list (pipe) |
|
||||||
|
| `GET /api/watch/:provider/:anilistId/:category/:slug` | Streaming sources (pipe) |
|
||||||
|
|
||||||
|
### Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g wrangler
|
||||||
|
wrangler login
|
||||||
|
wrangler deploy worker.js --name miruroapi
|
||||||
|
```
|
||||||
|
|||||||
Generated
+1466
-3
File diff suppressed because it is too large
Load Diff
+5
-2
@@ -6,7 +6,9 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"dev": "nodemon server.js",
|
"dev": "nodemon server.js",
|
||||||
"test": "node test.js"
|
"test": "node test.js",
|
||||||
|
"deploy": "wrangler deploy",
|
||||||
|
"preview": "wrangler dev"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.8.0",
|
"axios": "^1.8.0",
|
||||||
@@ -16,7 +18,8 @@
|
|||||||
"express": "^4.21.0"
|
"express": "^4.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.0"
|
"nodemon": "^3.1.0",
|
||||||
|
"wrangler": "^4.106.0"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"anime",
|
"anime",
|
||||||
|
|||||||
+1
-1
@@ -153,7 +153,7 @@ const methodScraperAPI = async (encodedReq) => {
|
|||||||
if (!SCRAPER_API_KEY) throw new Error("SCRAPER_API_KEY not configured");
|
if (!SCRAPER_API_KEY) throw new Error("SCRAPER_API_KEY not configured");
|
||||||
|
|
||||||
const targetUrl = `${MIRURO_ORIGINS[0]}${PIPE_PATH}?e=${encodedReq}`;
|
const targetUrl = `${MIRURO_ORIGINS[0]}${PIPE_PATH}?e=${encodedReq}`;
|
||||||
const scraperUrl = `https://api.scraperapi.com/?api_key=${SCRAPER_API_KEY}&url=${encodeURIComponent(targetUrl)}`;
|
const scraperUrl = `https://api.scraperapi.com/?api_key=${SCRAPER_API_KEY}&premium=true&url=${encodeURIComponent(targetUrl)}`;
|
||||||
|
|
||||||
const res = await axios.get(scraperUrl, {
|
const res = await axios.get(scraperUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -0,0 +1,885 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* MiruroAPI — worker.js (Cloudflare Worker Edition)
|
||||||
|
* Repository: https://github.com/Shineii86/MiruroAPI
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* Cloudflare Worker version of MiruroAPI. Runs entirely on
|
||||||
|
* Cloudflare's edge network — pipe requests bypass bot detection
|
||||||
|
* automatically since they route through Cloudflare's internal network.
|
||||||
|
*
|
||||||
|
* Key advantages over Vercel deployment:
|
||||||
|
* - Pipe requests NOT blocked by Cloudflare (edge → edge)
|
||||||
|
* - Zero cold starts (V8 isolates)
|
||||||
|
* - Free tier: 100K requests/day
|
||||||
|
* - No npm dependencies required (native fetch + DecompressionStream)
|
||||||
|
*
|
||||||
|
* @endpoints
|
||||||
|
* System: /api/health
|
||||||
|
* Search: /api/search, /api/suggestions, /api/filter
|
||||||
|
* Collections: /api/trending, /api/popular, /api/top, /api/random
|
||||||
|
* Details: /api/info/:id, /api/anime/:id/characters, /api/anime/:id/relations
|
||||||
|
* Streaming: /api/episodes/:id, /api/watch/:provider/:anilistId/:category/:slug
|
||||||
|
* Metadata: /api/genres
|
||||||
|
*
|
||||||
|
* @author Shinei Nouzen
|
||||||
|
* @license MIT
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// CONFIGURATION
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AniList GraphQL endpoint.
|
||||||
|
* All metadata (search, trending, info, characters) comes from here.
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
const ANILIST_URL = "https://graphql.anilist.co";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Miruro pipe origins, ordered by reliability.
|
||||||
|
* The worker tries each mirror in sequence until one succeeds.
|
||||||
|
* Running on Cloudflare edge means these requests go through
|
||||||
|
* Cloudflare's internal network — no bot detection triggered.
|
||||||
|
*
|
||||||
|
* @type {string[]}
|
||||||
|
*/
|
||||||
|
const MIRURO_ORIGINS = [
|
||||||
|
"https://www.miruro.to",
|
||||||
|
"https://www.miruro.ru",
|
||||||
|
"https://www.miruro.bz",
|
||||||
|
"https://www.miruro.tv",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical Miruro origin for referer/origin headers.
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
const CANONICAL_ORIGIN = "https://www.miruro.to";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pipe API path — appended to each origin.
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
const PIPE_PATH = "/api/secure/pipe";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* XOR obfuscation key for pipe response decoding (scheme 2).
|
||||||
|
* 16-byte hex key used when x-obfuscated header = "2".
|
||||||
|
*
|
||||||
|
* @type {Uint8Array}
|
||||||
|
*/
|
||||||
|
const PIPE_OBF_KEY = new Uint8Array([
|
||||||
|
0x71, 0x95, 0x10, 0x34, 0xf8, 0xfb, 0xcf, 0x53,
|
||||||
|
0xd8, 0x9d, 0xb5, 0x2c, 0xeb, 0x3d, 0xc2, 0x2c,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browser-accurate request headers for Cloudflare fingerprinting.
|
||||||
|
* These headers must match what a real Chrome browser sends —
|
||||||
|
* missing or incorrect headers trigger Cloudflare's bot detection.
|
||||||
|
*
|
||||||
|
* @type {Object}
|
||||||
|
*/
|
||||||
|
const HEADERS = {
|
||||||
|
"User-Agent":
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||||
|
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||||
|
"Accept-Language": "en-US,en;q=0.5",
|
||||||
|
"sec-ch-ua": '"Chromium";v="131", "Not_A Brand";v="24"',
|
||||||
|
"sec-ch-ua-mobile": "?0",
|
||||||
|
"sec-ch-ua-platform": '"Windows"',
|
||||||
|
"sec-fetch-dest": "document",
|
||||||
|
"sec-fetch-mode": "navigate",
|
||||||
|
"sec-fetch-site": "none",
|
||||||
|
"sec-fetch-user": "?1",
|
||||||
|
Referer: `${CANONICAL_ORIGIN}/`,
|
||||||
|
Origin: CANONICAL_ORIGIN,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// CORS & RESPONSE UTILITIES
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard CORS headers applied to every response.
|
||||||
|
* Allows any origin to call the API (open CORS policy).
|
||||||
|
*
|
||||||
|
* @type {Object}
|
||||||
|
*/
|
||||||
|
const CORS = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a JSON response with CORS headers.
|
||||||
|
*
|
||||||
|
* @param {*} data - Data to serialize as JSON
|
||||||
|
* @param {number} [status=200] - HTTP status code
|
||||||
|
* @returns {Response} Cloudflare Worker Response object
|
||||||
|
*/
|
||||||
|
function json(data, status = 200) {
|
||||||
|
return new Response(JSON.stringify(data), {
|
||||||
|
status,
|
||||||
|
headers: { "Content-Type": "application/json", ...CORS },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a standardized error response.
|
||||||
|
* All errors follow the same shape: { success, message, timestamp }.
|
||||||
|
*
|
||||||
|
* @param {string} msg - Error description
|
||||||
|
* @param {number} [status=500] - HTTP status code
|
||||||
|
* @returns {Response} Error response with CORS headers
|
||||||
|
*/
|
||||||
|
function error(msg, status = 500) {
|
||||||
|
return json(
|
||||||
|
{ success: false, message: msg, timestamp: new Date().toISOString() },
|
||||||
|
status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// ANILIST GRAPHQL CLIENT
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a GraphQL query against the AniList API.
|
||||||
|
*
|
||||||
|
* @param {string} query - GraphQL query string
|
||||||
|
* @ @param {Object} [variables={}] - Query variables
|
||||||
|
* @returns {Promise<Object>} Parsed data from the GraphQL response
|
||||||
|
* @throws {Error} If the GraphQL response contains errors
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const data = await anilistQuery(`{ GenreCollection }`);
|
||||||
|
* console.log(data.GenreCollection); // ["Action", "Comedy", ...]
|
||||||
|
*/
|
||||||
|
async function anilistQuery(query, variables = {}) {
|
||||||
|
const res = await fetch(ANILIST_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ query, variables }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.errors) {
|
||||||
|
throw new Error(json.errors[0]?.message || "AniList query failed");
|
||||||
|
}
|
||||||
|
return json.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// PIPE ENCODE / DECODE
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encodes a pipe request payload to base64url.
|
||||||
|
* The pipe endpoint expects the request as base64url-encoded JSON.
|
||||||
|
*
|
||||||
|
* @param {Object} payload - Request payload { path, method, query, body }
|
||||||
|
* @returns {string} Base64url-encoded string (no padding)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* encodePipeRequest({ path: "/api/episodes", method: "GET", query: { id: 20 } })
|
||||||
|
* // "eyJwYXRoIjoiL2FwaS9lcGlzb2RlcyIs..."
|
||||||
|
*/
|
||||||
|
function encodePipeRequest(payload) {
|
||||||
|
return btoa(JSON.stringify(payload))
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes a pipe response based on the x-obfuscated header.
|
||||||
|
*
|
||||||
|
* Miruro's pipe now signals the decode scheme via a response header:
|
||||||
|
* - Header absent → plain JSON (no encoding)
|
||||||
|
* - Header = "1" → base64url + gzip (no XOR)
|
||||||
|
* - Header = "2" → base64url + XOR(PIPE_OBF_KEY) + gzip
|
||||||
|
*
|
||||||
|
* This function handles all three schemes automatically.
|
||||||
|
*
|
||||||
|
* @param {string} encodedStr - Raw response body from pipe endpoint
|
||||||
|
* @param {string|null} obfHeader - Value of x-obfuscated response header
|
||||||
|
* @returns {Promise<Object>} Decoded JSON object
|
||||||
|
* @throws {Error} If decoding fails
|
||||||
|
*
|
||||||
|
* @trick
|
||||||
|
* The DecompressionStream API is available in Workers but not in Node.js.
|
||||||
|
* This is why we can't share this exact code with the Express version.
|
||||||
|
*/
|
||||||
|
async function decodePipeResponse(encodedStr, obfHeader) {
|
||||||
|
// Scheme 0: plain JSON (no header)
|
||||||
|
if (!obfHeader) {
|
||||||
|
return JSON.parse(encodedStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: base64url → raw bytes
|
||||||
|
let bytes = Uint8Array.from(
|
||||||
|
atob(encodedStr.replace(/-/g, "+").replace(/_/g, "/")),
|
||||||
|
(c) => c.charCodeAt(0)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Scheme 2: XOR decrypt before gzip
|
||||||
|
// The XOR key is applied cyclically to each byte
|
||||||
|
if (obfHeader === "2") {
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
bytes[i] ^= PIPE_OBF_KEY[i % PIPE_OBF_KEY.length];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: gzip → decompressed bytes
|
||||||
|
const decompressed = new DecompressionStream("gzip");
|
||||||
|
const writer = decompressed.writable.getWriter();
|
||||||
|
writer.write(bytes);
|
||||||
|
writer.close();
|
||||||
|
|
||||||
|
// Step 3: bytes → JSON
|
||||||
|
return new Response(decompressed.readable).json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// PIPE REQUEST (MIRROR ROTATION)
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a request to the Miruro pipe endpoint with automatic mirror rotation.
|
||||||
|
*
|
||||||
|
* Tries each origin in MIRURO_ORIGINS order. If one returns a non-200
|
||||||
|
* status or throws, the next mirror is tried. This provides resilience
|
||||||
|
* against individual mirror outages or rate limits.
|
||||||
|
*
|
||||||
|
* @param {string} path - Pipe API path (e.g. "/api/episodes")
|
||||||
|
* @param {Object} query - Query parameters to encode
|
||||||
|
* @returns {Promise<Object>} Decoded pipe response
|
||||||
|
* @throws {Error} If all mirrors fail
|
||||||
|
*
|
||||||
|
* @trick
|
||||||
|
* On Cloudflare Workers, fetch() to *.miruro.* routes through
|
||||||
|
* Cloudflare's internal network — no bot detection triggered.
|
||||||
|
* This is why Workers succeed where Vercel/serverless fails.
|
||||||
|
*/
|
||||||
|
async function pipeRequest(path, query) {
|
||||||
|
const payload = { path, method: "GET", query, body: null };
|
||||||
|
const encodedReq = encodePipeRequest(payload);
|
||||||
|
|
||||||
|
// Try each mirror in order
|
||||||
|
for (const origin of MIRURO_ORIGINS) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${origin}${PIPE_PATH}?e=${encodedReq}`, {
|
||||||
|
headers: HEADERS,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cloudflare returns 403 for blocked requests
|
||||||
|
if (!res.ok) continue;
|
||||||
|
|
||||||
|
const obf = res.headers.get("x-obfuscated");
|
||||||
|
const text = await res.text();
|
||||||
|
return await decodePipeResponse(text, obf);
|
||||||
|
} catch (e) {
|
||||||
|
// Mirror failed — try next one
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("All pipe mirrors failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// ROUTE HANDLERS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Health check endpoint ----
|
||||||
|
/**
|
||||||
|
* Returns API health status, version, and runtime info.
|
||||||
|
* Used by monitoring tools and uptime checkers.
|
||||||
|
*
|
||||||
|
* @param {URLSearchParams} params - Unused
|
||||||
|
* @returns {Promise<Response>} Health status JSON
|
||||||
|
*/
|
||||||
|
async function healthHandler(params) {
|
||||||
|
return json({
|
||||||
|
success: true,
|
||||||
|
results: {
|
||||||
|
status: "healthy",
|
||||||
|
version: "2.3.0-worker",
|
||||||
|
runtime: "cloudflare-workers",
|
||||||
|
endpoints: 15,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- FEATURE: Full-text anime search ----
|
||||||
|
/**
|
||||||
|
* Searches AniList for anime matching the given query.
|
||||||
|
* Returns paginated results with full metadata per result.
|
||||||
|
*
|
||||||
|
* @param {URLSearchParams} params - query (required), page, per_page
|
||||||
|
* @returns {Promise<Response>} Paginated search results
|
||||||
|
* @throws {400} If query parameter is missing
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* GET /api/search?query=naruto&page=1&per_page=10
|
||||||
|
*/
|
||||||
|
async function searchHandler(params) {
|
||||||
|
const query = params.get("query");
|
||||||
|
if (!query) return error("query parameter is required", 400);
|
||||||
|
|
||||||
|
const page = parseInt(params.get("page") || "1");
|
||||||
|
const perPage = parseInt(params.get("per_page") || "20");
|
||||||
|
|
||||||
|
const gql = `query ($search: String, $page: Int, $perPage: Int) {
|
||||||
|
Page(page: $page, perPage: $perPage) {
|
||||||
|
pageInfo { total currentPage hasNextPage perPage }
|
||||||
|
media(search: $search, type: ANIME, sort: SEARCH_MATCH) {
|
||||||
|
id title { romaji english native }
|
||||||
|
coverImage { large }
|
||||||
|
format season seasonYear episodes
|
||||||
|
status averageScore genres popularity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const data = await anilistQuery(gql, { search: query, page, perPage });
|
||||||
|
return json({ success: true, results: data.Page });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- FEATURE: Autocomplete suggestions ----
|
||||||
|
/**
|
||||||
|
* Returns fast autocomplete suggestions for search input.
|
||||||
|
* Limited to 8 results for low-latency response.
|
||||||
|
*
|
||||||
|
* @param {URLSearchParams} params - query (required)
|
||||||
|
* @returns {Promise<Response>} Array of suggestion objects
|
||||||
|
*/
|
||||||
|
async function suggestionsHandler(params) {
|
||||||
|
const query = params.get("query");
|
||||||
|
if (!query) return error("query parameter is required", 400);
|
||||||
|
|
||||||
|
const gql = `query ($search: String) {
|
||||||
|
Page(page: 1, perPage: 8) {
|
||||||
|
media(search: $search, type: ANIME, sort: SEARCH_MATCH) {
|
||||||
|
id title { romaji english }
|
||||||
|
coverImage { large }
|
||||||
|
format status seasonYear episodes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const data = await anilistQuery(gql, { search: query });
|
||||||
|
return json({ success: true, results: data.Page.media });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- FEATURE: Advanced filter with multiple params ----
|
||||||
|
/**
|
||||||
|
* Filters anime by genre, tag, year, season, format, and status.
|
||||||
|
* All parameters are optional — combine them for narrow results.
|
||||||
|
*
|
||||||
|
* @param {URLSearchParams} params - genre, tag, year, season, format, status, sort, page, per_page
|
||||||
|
* @returns {Promise<Response>} Filtered and paginated results
|
||||||
|
*
|
||||||
|
* @trick
|
||||||
|
* GraphQL variables are built dynamically based on which params
|
||||||
|
* are present. This avoids sending null variables to AniList.
|
||||||
|
*/
|
||||||
|
async function filterHandler(params) {
|
||||||
|
const genre = params.get("genre");
|
||||||
|
const tag = params.get("tag");
|
||||||
|
const year = params.get("year") ? parseInt(params.get("year")) : undefined;
|
||||||
|
const season = params.get("season");
|
||||||
|
const format = params.get("format");
|
||||||
|
const status = params.get("status");
|
||||||
|
const sort = params.get("sort") || "POPULARITY_DESC";
|
||||||
|
const page = parseInt(params.get("page") || "1");
|
||||||
|
const perPage = parseInt(params.get("per_page") || "20");
|
||||||
|
|
||||||
|
// Build dynamic variables and filter strings
|
||||||
|
const variables = { page, perPage, sort };
|
||||||
|
const filters = [];
|
||||||
|
|
||||||
|
if (genre) { filters.push("genre: $genre"); variables.genre = genre; }
|
||||||
|
if (tag) { filters.push("tag: $tag"); variables.tag = tag; }
|
||||||
|
if (year) { filters.push("seasonYear: $seasonYear"); variables.seasonYear = year; }
|
||||||
|
if (season) { filters.push("season: $season"); variables.season = season; }
|
||||||
|
if (format) { filters.push("format: $format"); variables.format = format; }
|
||||||
|
if (status) { filters.push("status: $status"); variables.status = status; }
|
||||||
|
|
||||||
|
// Build type declarations for present variables
|
||||||
|
const typeDecls = [
|
||||||
|
"$page: Int", "$perPage: Int", "$sort: [MediaSort]",
|
||||||
|
genre && "$genre: Genre",
|
||||||
|
tag && "$tag: String",
|
||||||
|
year && "$seasonYear: Int",
|
||||||
|
season && "$season: Season",
|
||||||
|
format && "$format: MediaFormat",
|
||||||
|
status && "$status: MediaStatus",
|
||||||
|
].filter(Boolean).join(", ");
|
||||||
|
|
||||||
|
const gql = `query (${typeDecls}) {
|
||||||
|
Page(page: $page, perPage: $perPage) {
|
||||||
|
pageInfo { total currentPage hasNextPage perPage }
|
||||||
|
media(${filters.join(", ")}, type: ANIME, sort: $sort) {
|
||||||
|
id title { romaji english }
|
||||||
|
coverImage { large }
|
||||||
|
format status averageScore episodes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const data = await anilistQuery(gql, variables);
|
||||||
|
return json({ success: true, results: data.Page });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- FEATURE: Trending anime ----
|
||||||
|
/**
|
||||||
|
* Returns currently trending anime (releasing only).
|
||||||
|
* Sorted by trending score on AniList.
|
||||||
|
*
|
||||||
|
* @param {URLSearchParams} params - per_page (optional, default 20)
|
||||||
|
* @returns {Promise<Response>} Trending anime list
|
||||||
|
*/
|
||||||
|
async function trendingHandler(params) {
|
||||||
|
const perPage = parseInt(params.get("per_page") || "20");
|
||||||
|
|
||||||
|
const gql = `query ($perPage: Int) {
|
||||||
|
Page(page: 1, perPage: $perPage) {
|
||||||
|
media(type: ANIME, sort: TRENDING, status: RELEASING) {
|
||||||
|
id title { romaji english }
|
||||||
|
coverImage { large }
|
||||||
|
format status averageScore episodes genres
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const data = await anilistQuery(gql, { perPage });
|
||||||
|
return json({ success: true, results: { results: data.Page.media } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- FEATURE: Popular anime ----
|
||||||
|
/**
|
||||||
|
* Returns most popular anime of all time.
|
||||||
|
* Sorted by AniList popularity metric.
|
||||||
|
*
|
||||||
|
* @param {URLSearchParams} params - per_page (optional, default 20)
|
||||||
|
* @returns {Promise<Response>} Popular anime list
|
||||||
|
*/
|
||||||
|
async function popularHandler(params) {
|
||||||
|
const perPage = parseInt(params.get("per_page") || "20");
|
||||||
|
|
||||||
|
const gql = `query ($perPage: Int) {
|
||||||
|
Page(page: 1, perPage: $perPage) {
|
||||||
|
media(type: ANIME, sort: POPULARITY_DESC) {
|
||||||
|
id title { romaji english }
|
||||||
|
coverImage { large }
|
||||||
|
format status averageScore episodes genres
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const data = await anilistQuery(gql, { perPage });
|
||||||
|
return json({ success: true, results: { results: data.Page.media } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- FEATURE: Top scored anime ----
|
||||||
|
/**
|
||||||
|
* Returns highest scored anime on AniList.
|
||||||
|
* Sorted by average score descending.
|
||||||
|
*
|
||||||
|
* @param {URLSearchParams} params - per_page (optional, default 20)
|
||||||
|
* @returns {Promise<Response>} Top scored anime list
|
||||||
|
*/
|
||||||
|
async function topHandler(params) {
|
||||||
|
const perPage = parseInt(params.get("per_page") || "20");
|
||||||
|
|
||||||
|
const gql = `query ($perPage: Int) {
|
||||||
|
Page(page: 1, perPage: $perPage) {
|
||||||
|
media(type: ANIME, sort: SCORE_DESC) {
|
||||||
|
id title { romaji english }
|
||||||
|
coverImage { large }
|
||||||
|
format status averageScore episodes genres
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const data = await anilistQuery(gql, { perPage });
|
||||||
|
return json({ success: true, results: { results: data.Page.media } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- FEATURE: Complete anime info by AniList ID ----
|
||||||
|
/**
|
||||||
|
* Returns full anime details including description, studios,
|
||||||
|
* tags, next airing episode, and more.
|
||||||
|
*
|
||||||
|
* @param {string} id - AniList anime ID (path param)
|
||||||
|
* @returns {Promise<Response>} Complete anime info
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* GET /api/info/20 → Naruto
|
||||||
|
* GET /api/info/1535 → Death Note
|
||||||
|
*/
|
||||||
|
async function infoHandler(params, id) {
|
||||||
|
const gql = `query ($id: Int) {
|
||||||
|
Media(id: $id, type: ANIME) {
|
||||||
|
id idMal
|
||||||
|
title { romaji english native }
|
||||||
|
description
|
||||||
|
coverImage { large extraLarge }
|
||||||
|
bannerImage
|
||||||
|
format season seasonYear
|
||||||
|
episodes duration
|
||||||
|
status averageScore meanScore
|
||||||
|
popularity favourites
|
||||||
|
genres
|
||||||
|
tags { name }
|
||||||
|
studios { nodes { name isAnimationStudio } }
|
||||||
|
startDate { year month day }
|
||||||
|
endDate { year month day }
|
||||||
|
nextAiringEpisode { episode airingAt timeUntilAiring }
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const data = await anilistQuery(gql, { id: parseInt(id) });
|
||||||
|
return json({ success: true, results: data.Media });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- FEATURE: Anime characters with voice actors ----
|
||||||
|
/**
|
||||||
|
* Returns characters for an anime, including Japanese voice actors.
|
||||||
|
* Characters are sorted by role importance (MAIN first).
|
||||||
|
*
|
||||||
|
* @param {string} id - AniList anime ID (path param)
|
||||||
|
* @returns {Promise<Response>} Character list with voice actors
|
||||||
|
*/
|
||||||
|
async function charactersHandler(params, id) {
|
||||||
|
const gql = `query ($id: Int) {
|
||||||
|
Media(id: $id, type: ANIME) {
|
||||||
|
characters(sort: ROLE, perPage: 25) {
|
||||||
|
edges {
|
||||||
|
role
|
||||||
|
node { id name { full native } image { large } }
|
||||||
|
voiceActors(language: JAPANESE) {
|
||||||
|
id name { full native } languageV2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const data = await anilistQuery(gql, { id: parseInt(id) });
|
||||||
|
return json({ success: true, results: data.Media.characters });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- FEATURE: Anime relations (sequels, prequels, etc.) ----
|
||||||
|
/**
|
||||||
|
* Returns related anime (sequels, prequels, side stories, etc.).
|
||||||
|
* Useful for building "watch order" guides.
|
||||||
|
*
|
||||||
|
* @param {string} id - AniList anime ID (path param)
|
||||||
|
* @returns {Promise<Response>} Relations with connected anime
|
||||||
|
*/
|
||||||
|
async function relationsHandler(params, id) {
|
||||||
|
const gql = `query ($id: Int) {
|
||||||
|
Media(id: $id, type: ANIME) {
|
||||||
|
relations {
|
||||||
|
edges {
|
||||||
|
relationType
|
||||||
|
node {
|
||||||
|
id title { romaji english }
|
||||||
|
coverImage { large }
|
||||||
|
format status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const data = await anilistQuery(gql, { id: parseInt(id) });
|
||||||
|
return json({ success: true, results: data.Media.relations });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- FEATURE: All genres list ----
|
||||||
|
/**
|
||||||
|
* Returns the complete list of AniList genres.
|
||||||
|
* Static data — rarely changes.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Response>} Array of genre strings
|
||||||
|
*/
|
||||||
|
async function genresHandler() {
|
||||||
|
const gql = `{ GenreCollection }`;
|
||||||
|
const data = await anilistQuery(gql);
|
||||||
|
return json({ success: true, results: data.GenreCollection });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- FEATURE: Episode list from Miruro pipe ----
|
||||||
|
/**
|
||||||
|
* Fetches episode list for an anime from the Miruro pipe.
|
||||||
|
* Uses mirror rotation to maximize success rate.
|
||||||
|
*
|
||||||
|
* @param {string} id - AniList anime ID (path param)
|
||||||
|
* @returns {Promise<Response>} Episode data from all providers
|
||||||
|
*
|
||||||
|
* @trick
|
||||||
|
* This endpoint calls the pipe, which is where Cloudflare Workers
|
||||||
|
* shine — edge-to-edge requests bypass bot detection entirely.
|
||||||
|
*/
|
||||||
|
async function episodesHandler(params, id) {
|
||||||
|
try {
|
||||||
|
const data = await pipeRequest("/api/episodes", { id });
|
||||||
|
return json({ success: true, results: data });
|
||||||
|
} catch (e) {
|
||||||
|
return error(`All pipe methods failed: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- FEATURE: Streaming sources for a specific episode ----
|
||||||
|
/**
|
||||||
|
* Fetches streaming sources for a specific episode.
|
||||||
|
* Returns M3U8 URLs, subtitles, and quality info.
|
||||||
|
*
|
||||||
|
* @param {string} provider - Provider name (kiwi, pewe, bonk, etc.)
|
||||||
|
* @param {string} anilistId - AniList anime ID
|
||||||
|
* @param {string} category - sub or dub
|
||||||
|
* @param {string} slug - Episode slug from episodes response
|
||||||
|
* @returns {Promise<Response>} Streaming sources and subtitles
|
||||||
|
*/
|
||||||
|
async function watchHandler(params, provider, anilistId, category, slug) {
|
||||||
|
try {
|
||||||
|
const data = await pipeRequest("/api/watch", { provider, anilistId, category, slug });
|
||||||
|
return json({ success: true, results: data });
|
||||||
|
} catch (e) {
|
||||||
|
return error(`All pipe methods failed: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- FEATURE: Random anime ----
|
||||||
|
/**
|
||||||
|
* Returns a random anime from AniList.
|
||||||
|
* Uses random pagination to pick from the top 1000 most popular.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Response>} Random anime details
|
||||||
|
*
|
||||||
|
* @trick
|
||||||
|
* Two levels of randomness: random page (1-50) + random index
|
||||||
|
* within that page. This gives a uniform distribution across
|
||||||
|
* the top ~1000 most popular anime.
|
||||||
|
*/
|
||||||
|
async function randomHandler() {
|
||||||
|
const page = Math.floor(Math.random() * 50) + 1;
|
||||||
|
const perPage = Math.floor(Math.random() * 20) + 1;
|
||||||
|
|
||||||
|
const gql = `query ($page: Int, $perPage: Int) {
|
||||||
|
Page(page: $page, perPage: $perPage) {
|
||||||
|
media(type: ANIME, sort: POPULARITY_DESC) {
|
||||||
|
id title { romaji english native }
|
||||||
|
coverImage { large }
|
||||||
|
format season seasonYear
|
||||||
|
episodes status averageScore genres popularity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const data = await anilistQuery(gql, { page, perPage });
|
||||||
|
const media = data.Page.media;
|
||||||
|
return json({
|
||||||
|
success: true,
|
||||||
|
results: media[Math.floor(Math.random() * media.length)] || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// ROUTER
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route table mapping "METHOD /pattern" to handler functions.
|
||||||
|
* Patterns support :param placeholders for dynamic path segments.
|
||||||
|
*
|
||||||
|
* @type {Object<string, Function>}
|
||||||
|
*
|
||||||
|
* @trick
|
||||||
|
* Routes are matched longest-first, so /api/anime/:id/characters
|
||||||
|
* matches before /api/anime/:id. This prevents ambiguous matches.
|
||||||
|
*/
|
||||||
|
const ROUTES = {
|
||||||
|
// ---- System ----
|
||||||
|
"GET /api/health": healthHandler,
|
||||||
|
|
||||||
|
// ---- Search ----
|
||||||
|
"GET /api/search": searchHandler,
|
||||||
|
"GET /api/suggestions": suggestionsHandler,
|
||||||
|
"GET /api/filter": filterHandler,
|
||||||
|
|
||||||
|
// ---- Collections ----
|
||||||
|
"GET /api/trending": trendingHandler,
|
||||||
|
"GET /api/popular": popularHandler,
|
||||||
|
"GET /api/top": topHandler,
|
||||||
|
"GET /api/random": randomHandler,
|
||||||
|
|
||||||
|
// ---- Anime Details ----
|
||||||
|
"GET /api/info/:id": infoHandler,
|
||||||
|
"GET /api/anime/:id/characters": charactersHandler,
|
||||||
|
"GET /api/anime/:id/relations": relationsHandler,
|
||||||
|
|
||||||
|
// ---- Metadata ----
|
||||||
|
"GET /api/genres": genresHandler,
|
||||||
|
|
||||||
|
// ---- Streaming (pipe) ----
|
||||||
|
"GET /api/episodes/:id": episodesHandler,
|
||||||
|
"GET /api/watch/:provider/:anilistId/:category/:slug": watchHandler,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches a request method + path against the route table.
|
||||||
|
* Supports :param placeholders for dynamic segments.
|
||||||
|
*
|
||||||
|
* @param {string} method - HTTP method (GET, POST, etc.)
|
||||||
|
* @param {string} path - URL path (e.g. /api/info/20)
|
||||||
|
* @returns {{ handler: Function, params: string[] } | null} Matched route info, or null
|
||||||
|
*
|
||||||
|
* @trick
|
||||||
|
* The router tries exact matches first, then pattern matches.
|
||||||
|
* Pattern matches extract :param values into the params array
|
||||||
|
* which are passed as positional arguments to the handler.
|
||||||
|
*/
|
||||||
|
function matchRoute(method, path) {
|
||||||
|
// Try exact match first (fast path)
|
||||||
|
const exact = `${method} ${path}`;
|
||||||
|
if (ROUTES[exact]) return { handler: ROUTES[exact], params: [] };
|
||||||
|
|
||||||
|
// Try pattern matches (slower, handles :params)
|
||||||
|
for (const key of Object.keys(ROUTES)) {
|
||||||
|
const [m, pattern] = key.split(" ");
|
||||||
|
if (m !== method) continue;
|
||||||
|
|
||||||
|
const patternParts = pattern.split("/");
|
||||||
|
const pathParts = path.split("/");
|
||||||
|
if (patternParts.length !== pathParts.length) continue;
|
||||||
|
|
||||||
|
const params = [];
|
||||||
|
let match = true;
|
||||||
|
|
||||||
|
for (let i = 0; i < patternParts.length; i++) {
|
||||||
|
if (patternParts[i].startsWith(":")) {
|
||||||
|
// Dynamic segment — extract value
|
||||||
|
params.push(pathParts[i]);
|
||||||
|
} else if (patternParts[i] !== pathParts[i]) {
|
||||||
|
// Static segment — must match exactly
|
||||||
|
match = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match) return { handler: ROUTES[key], params };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// WORKER ENTRY POINT
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main fetch event handler. Cloudflare Workers use this instead
|
||||||
|
* of Express app.listen(). Every incoming request goes through here.
|
||||||
|
*
|
||||||
|
* @param {FetchEvent} event - Cloudflare fetch event
|
||||||
|
* @returns {Response} HTTP response
|
||||||
|
*
|
||||||
|
* @trick
|
||||||
|
* Workers don't have a server process — they're stateless functions
|
||||||
|
* that spin up on demand and die after responding. No persistent
|
||||||
|
* memory between requests (use KV or Durable Objects for state).
|
||||||
|
*/
|
||||||
|
addEventListener("fetch", (event) => {
|
||||||
|
event.respondWith(handleRequest(event));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core request handler. Routes requests, handles CORS preflight,
|
||||||
|
* and serves the landing page.
|
||||||
|
*
|
||||||
|
* @param {FetchEvent} event - Cloudflare fetch event
|
||||||
|
* @returns {Promise<Response>} HTTP response
|
||||||
|
*/
|
||||||
|
async function handleRequest(event) {
|
||||||
|
const req = event.request;
|
||||||
|
const url = new URL(req.url);
|
||||||
|
|
||||||
|
// CORS preflight
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return new Response(null, { status: 204, headers: CORS });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Landing page
|
||||||
|
if (url.pathname === "/") {
|
||||||
|
return new Response(
|
||||||
|
`<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="UTF-8"><title>MiruroAPI — Cloudflare Worker</title></head>
|
||||||
|
<body style="font-family:system-ui;background:#0A0A12;color:#E2E8F0;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0">
|
||||||
|
<div style="text-align:center;max-width:600px;padding:40px">
|
||||||
|
<h1 style="font-size:2.5rem;margin-bottom:8px">📺 <span style="background:linear-gradient(135deg,#A855F7,#EC4899);-webkit-background-clip:text;-webkit-text-fill-color:transparent">MiruroAPI</span></h1>
|
||||||
|
<p style="color:#94A3B8;margin-bottom:24px">Cloudflare Worker Edition — 15 endpoints, zero cold starts</p>
|
||||||
|
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap">
|
||||||
|
<a href="/api/health" style="background:#A855F7;color:white;padding:10px 20px;border-radius:8px;text-decoration:none;font-weight:600">Health Check</a>
|
||||||
|
<a href="/api/search?query=naruto" style="background:#6366F1;color:white;padding:10px 20px;border-radius:8px;text-decoration:none;font-weight:600">Search Anime</a>
|
||||||
|
<a href="/api/trending" style="background:#EC4899;color:white;padding:10px 20px;border-radius:8px;text-decoration:none;font-weight:600">Trending</a>
|
||||||
|
</div>
|
||||||
|
<p style="color:#64748B;font-size:0.8rem;margin-top:40px">Shinei Nouzen • MIT License</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
{ headers: { "Content-Type": "text/html", ...CORS } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route matching
|
||||||
|
const route = matchRoute(req.method, url.pathname);
|
||||||
|
if (!route) return error("Not found", 404);
|
||||||
|
|
||||||
|
// Execute handler
|
||||||
|
try {
|
||||||
|
return await route.handler(url.searchParams, ...route.params);
|
||||||
|
} catch (e) {
|
||||||
|
return error(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// MODULE FOOTER
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
//
|
||||||
|
// End of MiruroAPI Worker Edition
|
||||||
|
//
|
||||||
|
// Deploy with: wrangler deploy
|
||||||
|
// Test locally: wrangler dev
|
||||||
|
//
|
||||||
|
// Routes:
|
||||||
|
// /api/health → System health check
|
||||||
|
// /api/search?query=naruto → Full-text search
|
||||||
|
// /api/suggestions?query=naruto → Autocomplete
|
||||||
|
// /api/filter?genre=Action → Advanced filter
|
||||||
|
// /api/trending → Trending anime
|
||||||
|
// /api/popular → Most popular
|
||||||
|
// /api/top → Top scored
|
||||||
|
// /api/random → Random anime
|
||||||
|
// /api/info/20 → Full anime info
|
||||||
|
// /api/anime/20/characters → Characters + VAs
|
||||||
|
// /api/anime/20/relations → Related anime
|
||||||
|
// /api/genres → All genres
|
||||||
|
// /api/episodes/20 → Episode list (pipe)
|
||||||
|
// /api/watch/kiwi/20/sub/episode-1 → Streaming sources (pipe)
|
||||||
|
//
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"$schema": "node_modules/wrangler/config-schema.json",
|
||||||
|
"name": "miruroapi",
|
||||||
|
"compatibility_date": "2026-07-02",
|
||||||
|
"observability": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"assets": {
|
||||||
|
"directory": "public"
|
||||||
|
},
|
||||||
|
"compatibility_flags": [
|
||||||
|
"nodejs_compat"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user