Aniwatch API Version 2 (#66)

BREAKING CHANGE:

* chore: remove files that are not necessary for api v2

* test: update existing tests to use  pkg

* feat: organized aniwatch api envs and add more info about them

* feat: update tsconfig to include strict noUnsed params

* feat(api homepage): revamp api home page

* feat: update wani kuni image

* feat: add dot img

* feat: use hono cors

* feat: use hono rate limiter

* build: remove unnecessary deps, add ones needed and update description

* feat: add hianime routes and their handlers

* feat: update vercel deployment file

* docs: update logo and scraper docs, add envs section

* feat: update main server file

* feat: update peronal deployments caution section
This commit is contained in:
Ritesh Ghosh
2024-10-06 01:13:23 +05:30
committed by GitHub
parent 55810ccf23
commit 46f688ac12
84 changed files with 1028 additions and 3893 deletions
+21 -5
View File
@@ -1,7 +1,23 @@
DOMAIN="aniwatchtv.to" # port number of the aniwatch api
PORT=4000 ANIWATCH_API_PORT=4000
CORS_ALLOWED_ORIGINS=https://your-production-domain.com,https://another-trusted-domain.com
# env to control allowed origins
ANIWATCH_API_CORS_ALLOWED_ORIGINS=https://your-production-domain.com,https://another-trusted-domain.com
# RATE LIMIT # RATE LIMIT
WINDOW_MS=1800000 # duration to track requests (in milliseconds) for rate limiting. here, 30*60*1000 = 1800000 = 30 minutes # duration to track requests (in milliseconds) for rate limiting. here, 30*60*1000 = 1800000 = 30 minutes
MAX=70 # maximum number of requests in this timeperiod ANIWATCH_API_WINDOW_MS=1800000
# maximum number of requests in this timeperiod
ANIWATCH_API_MAX_REQS=70
# CAUTION:
# For personal deployments, if you wanna have rate limitting
# in your application, then set this env to your deployed
# instance's hostname, otherwise don't set or have this env at all.
# If you set this env to an incorrect value, you may face other issues.
# ANIWATCH_API_HOSTNAME="api-aniwatch.onrender.com"
# NOTE: this env is "required" for vercel deployments
# ANIWATCH_VERCEL_DEPLOYMENT=<true or any non zero value>
+92 -55
View File
@@ -1,10 +1,10 @@
<p align="center"> <p align="center">
<a href="https://github.com/ghoshRitesh12/aniwatch-api"> <a href="https://github.com/ghoshRitesh12/aniwatch-api">
<img <img
src="https://github.com/ghoshRitesh12/aniwatch-api/blob/main/public/img/img1.gif?raw=true" src="https://raw.githubusercontent.com/ghoshRitesh12/aniwatch-api/refs/heads/main/public/img/hianime_v2.png"
alt="Logo" alt="aniwatch_logo"
width="120" width="175"
height="120" height="175"
decoding="async" decoding="async"
fetchpriority="high" fetchpriority="high"
/> />
@@ -51,6 +51,9 @@
</div> </div>
> [!NOTE]
> The API has shifted to version 2, thereby naturally introducing breaking changes. If you'd want to use the previous version of this API, then refer to [the last stable version](https://github.com/ghoshRitesh12/aniwatch-api/tree/v1.40.0).
> [!IMPORTANT] > [!IMPORTANT]
> >
> 1. [https://api-aniwatch.onrender.com](https://api-aniwatch.onrender.com/) is only meant to demo the API and has rate-limiting enabled to minimise bandwidth consumption. It is recommended to deploy your own instance for personal use by customizing the api as you need it to be. > 1. [https://api-aniwatch.onrender.com](https://api-aniwatch.onrender.com/) is only meant to demo the API and has rate-limiting enabled to minimise bandwidth consumption. It is recommended to deploy your own instance for personal use by customizing the api as you need it to be.
@@ -62,6 +65,7 @@
- [Installation](#installation) - [Installation](#installation)
- [Local](#local) - [Local](#local)
- [Docker](#docker) - [Docker](#docker)
- [Envs](#envs)
- [Host your instance](#-host-your-instance) - [Host your instance](#-host-your-instance)
- [Vercel](#vercel) - [Vercel](#vercel)
- [Render](#render) - [Render](#render)
@@ -72,7 +76,7 @@
- [GET Search Suggestions](#get-search-suggestions) - [GET Search Suggestions](#get-search-suggestions)
- [GET Producer Animes](#get-producer-animes) - [GET Producer Animes](#get-producer-animes)
- [GET Genre Animes](#get-genre-animes) - [GET Genre Animes](#get-genre-animes)
- [GET Category Anime](#get-category-anime) - [GET Category Animes](#get-category-animes)
- [GET Estimated Schedules](#get-estimated-schedules) - [GET Estimated Schedules](#get-estimated-schedules)
- [GET Anime Episodes](#get-anime-episodes) - [GET Anime Episodes](#get-anime-episodes)
- [GET Anime Episode Servers](#get-anime-episode-servers) - [GET Anime Episode Servers](#get-anime-episode-servers)
@@ -124,11 +128,25 @@ The above command will start the server on port 4000. You can access the server
You can also add the `-d` flag to run the container in detached mode. You can also add the `-d` flag to run the container in detached mode.
## <span id="envs">⚙️ Envs</span>
More info can be found in [`.env.example`](https://github.com/ghoshRitesh12/aniwatch-api/blob/main/.env.example) file
- `ANIWATCH_API_PORT`: port number of the aniwatch api
- `ANIWATCH_API_WINDOW_MS`: duration to track requests for rate limitting (in milliseconds)
- `ANIWATCH_API_MAX_REQS`: maximum number of requests in the `ANIWATCH_API_WINDOW_MS` timeperiod
- `ANIWATCH_API_CORS_ALLOWED_ORIGINS`: allowed origins, separated by commas and no spaces in between
- `ANIWATCH_VERCEL_DEPLOYMENT`: required for distinguishing vercel deployment from other ones, set it to true of any other non-zero value
- `ANIWATCH_API_HOSTNAME`: set this to your api instance's hostname to enable rate limitting, don't have this value if you don't wish to rate limit
## <span id="host-your-instance">⛅ Host your instance</span> ## <span id="host-your-instance">⛅ Host your instance</span>
> [!CAUTION] > [!CAUTION]
> >
> For personal deployments, **refrain from having an env** named `ANIWATCH_API_HOSTNAME`. You may face rate limitting and other issues if you do. > For personal deployments:
>
> - If you wanna have rate limitting in your application, then set the `ANIWATCH_API_HOSTNAME` env to your deployed instance's hostname, otherwise don't set or have this env at all. If you set this env to an incorrect value, you may face other issues.
> - Remove the if block from the [`server.ts`](https://github.com/ghoshRitesh12/aniwatch-api/blob/main/src/server.ts) file, spanning from lines [60](https://github.com/ghoshRitesh12/aniwatch-api/blob/main/src/server.ts#L60) to [84](https://github.com/ghoshRitesh12/aniwatch-api/blob/main/src/server.ts#L84).
### Vercel ### Vercel
@@ -138,7 +156,7 @@ Deploy your own instance of Aniwatch API on Vercel.
> [!NOTE] > [!NOTE]
> >
> When deploying to vercel, set an env named `IS_VERCEL_DEPLOYMENT` to `true` or anything, but this env should be present. > When deploying to vercel, set an env named `ANIWATCH_VERCEL_DEPLOYMENT` to `true` or any non-zero value, but this env must be present.
### Render ### Render
@@ -161,13 +179,13 @@ The endpoints exposed by the api are listed below with examples that uses the [F
#### Endpoint #### Endpoint
```bash ```bash
https://api-aniwatch.onrender.com/anime/home /api/v2/hianime/home
``` ```
#### Request sample #### Request sample
```javascript ```javascript
const resp = await fetch("https://api-aniwatch.onrender.com/anime/home"); const resp = await fetch("/api/v2/hianime/home");
const data = await resp.json(); const data = await resp.json();
console.log(data); console.log(data);
``` ```
@@ -176,6 +194,8 @@ console.log(data);
```javascript ```javascript
{ {
success: true,
data: {
genres: ["Action", "Cars", "Adventure", ...], genres: ["Action", "Cars", "Adventure", ...],
latestEpisodeAnimes: [ latestEpisodeAnimes: [
{ {
@@ -295,6 +315,7 @@ console.log(data);
}, },
{...}, {...},
], ],
}
} }
``` ```
@@ -314,21 +335,19 @@ console.log(data);
#### Endpoint #### Endpoint
```sh ```sh
https://api-aniwatch.onrender.com/anime/info?id={anime-id} /api/v2/hianime/anime/{animeId}
``` ```
#### Query Parameters #### Query Parameters
| Parameter | Type | Description | Required? | Default | | Parameter | Type | Description | Required? | Default |
| :-------: | :----: | :----------------------------------: | :-------: | :-----: | | :-------: | :----: | :----------------------------------: | :-------: | :-----: |
| `id` | string | The unique anime id (in kebab case). | Yes | -- | | `animeId` | string | The unique anime id (in kebab case). | Yes | -- |
#### Request sample #### Request sample
```javascript ```javascript
const resp = await fetch( const resp = await fetch("/api/v2/hianime/anime/attack-on-titan-112");
"https://api-aniwatch.onrender.com/anime/info?id=attack-on-titan-112"
);
const data = await resp.json(); const data = await resp.json();
console.log(data); console.log(data);
``` ```
@@ -337,6 +356,8 @@ console.log(data);
```javascript ```javascript
{ {
success: true,
data: {
anime: [ anime: [
info: { info: {
id: string, id: string,
@@ -442,6 +463,7 @@ console.log(data);
}, },
{...} {...}
] ]
}
} }
``` ```
@@ -461,10 +483,10 @@ console.log(data);
```sh ```sh
# basic example # basic example
https://api-aniwatch.onrender.com/anime/search?q={query}&page={page} /api/v2/hianime/search?q={query}&page={page}
# advanced example # advanced example
https://api-aniwatch.onrender.com/anime/search?q={query}&genres={genres}&type={type}&sort={sort}&season={season}&language={sub_or_dub}&status={status}&rated={rating}&start_date={yyyy-mm-dd}&end_date={yyyy-mm-dd}&score={score} /api/v2/hianime/search?q={query}&page={page}&genres={genres}&type={type}&sort={sort}&season={season}&language={sub_or_dub}&status={status}&rated={rating}&start_date={yyyy-mm-dd}&end_date={yyyy-mm-dd}&score={score}
``` ```
#### Query Parameters #### Query Parameters
@@ -492,15 +514,13 @@ https://api-aniwatch.onrender.com/anime/search?q={query}&genres={genres}&type={t
```javascript ```javascript
// basic example // basic example
const resp = await fetch( const resp = await fetch("/api/v2/hianime/search?q=titan&page=1");
"https://api-aniwatch.onrender.com/anime/search?q=titan&page=1"
);
const data = await resp.json(); const data = await resp.json();
console.log(data); console.log(data);
// advanced example // advanced example
const resp = await fetch( const resp = await fetch(
"https://api-aniwatch.onrender.com/anime/search?q=girls&genres=action,adventure&type=movie&sort=score&season=spring&language=dub&status=finished-airing&rated=pg-13&start_date=2014-0-0&score=good" "/api/v2/hianime/search?q=girls&genres=action,adventure&type=movie&sort=score&season=spring&language=dub&status=finished-airing&rated=pg-13&start_date=2014-0-0&score=good"
); );
const data = await resp.json(); const data = await resp.json();
console.log(data); console.log(data);
@@ -510,6 +530,8 @@ console.log(data);
```javascript ```javascript
{ {
success: true,
data: {
animes: [ animes: [
{ {
id: string, id: string,
@@ -547,6 +569,7 @@ console.log(data);
[filter_name]: [filter_value] [filter_name]: [filter_value]
... ...
} }
}
} }
``` ```
@@ -565,7 +588,7 @@ console.log(data);
#### Endpoint #### Endpoint
```sh ```sh
https://api-aniwatch.onrender.com/anime/search/suggest?q={query} /api/v2/hianime/search/suggestion?q={query}
``` ```
#### Query Parameters #### Query Parameters
@@ -577,9 +600,7 @@ https://api-aniwatch.onrender.com/anime/search/suggest?q={query}
#### Request sample #### Request sample
```javascript ```javascript
const resp = await fetch( const resp = await fetch("/api/v2/hianime/search/suggestion?q=monster");
"https://api-aniwatch.onrender.com/anime/search/suggest?q=monster"
);
const data = await resp.json(); const data = await resp.json();
console.log(data); console.log(data);
``` ```
@@ -588,6 +609,8 @@ console.log(data);
```javascript ```javascript
{ {
success: true,
data: {
suggestions: [ suggestions: [
{ {
id: string, id: string,
@@ -597,7 +620,8 @@ console.log(data);
moreInfo: ["Jan 21, 2022", "Movie", "17m"] moreInfo: ["Jan 21, 2022", "Movie", "17m"]
}, },
{...}, {...},
], ]
}
} }
``` ```
@@ -616,7 +640,7 @@ console.log(data);
#### Endpoint #### Endpoint
```sh ```sh
https://api-aniwatch.onrender.com/anime/producer/{name}?page={page} /api/v2/hianime/producer/{name}?page={page}
``` ```
#### Path Parameters #### Path Parameters
@@ -634,9 +658,7 @@ https://api-aniwatch.onrender.com/anime/producer/{name}?page={page}
#### Request sample #### Request sample
```javascript ```javascript
const resp = await fetch( const resp = await fetch("/api/v2/hianime/producer/toei-animation?page=2");
"https://api-aniwatch.onrender.com/anime/producer/toei-animation?page=2"
);
const data = await resp.json(); const data = await resp.json();
console.log(data); console.log(data);
``` ```
@@ -645,6 +667,8 @@ console.log(data);
```javascript ```javascript
{ {
success: true,
data: {
producerName: "Toei Animation Anime", producerName: "Toei Animation Anime",
animes: [ animes: [
{ {
@@ -694,7 +718,8 @@ console.log(data);
], ],
currentPage: 2, currentPage: 2,
totalPages: 11, totalPages: 11,
hasNextPage: true, hasNextPage: true
}
} }
``` ```
@@ -713,7 +738,7 @@ console.log(data);
#### Endpoint #### Endpoint
```sh ```sh
https://api-aniwatch.onrender.com/anime/genre/{name}?page={page} /api/v2/hianime/genre/{name}?page={page}
``` ```
#### Path Parameters #### Path Parameters
@@ -731,9 +756,7 @@ https://api-aniwatch.onrender.com/anime/genre/{name}?page={page}
#### Request sample #### Request sample
```javascript ```javascript
const resp = await fetch( const resp = await fetch("/api/v2/hianime/genre/shounen?page=2");
"https://api-aniwatch.onrender.com/anime/genre/shounen?page=2"
);
const data = await resp.json(); const data = await resp.json();
console.log(data); console.log(data);
``` ```
@@ -742,6 +765,8 @@ console.log(data);
```javascript ```javascript
{ {
success: true,
data: {
genreName: "Shounen Anime", genreName: "Shounen Anime",
animes: [ animes: [
{ {
@@ -776,6 +801,7 @@ console.log(data);
currentPage: 2, currentPage: 2,
totalPages: 38, totalPages: 38,
hasNextPage: true hasNextPage: true
}
} }
``` ```
@@ -787,14 +813,14 @@ console.log(data);
<summary> <summary>
### `GET` Category Anime ### `GET` Category Animes
</summary> </summary>
#### Endpoint #### Endpoint
```sh ```sh
https://api-aniwatch.onrender.com/anime/{category}?page={page} /api/v2/hianime/category/{name}?page={page}
``` ```
#### Path Parameters #### Path Parameters
@@ -814,7 +840,7 @@ https://api-aniwatch.onrender.com/anime/{category}?page={page}
```javascript ```javascript
// categories -> "most-favorite", "most-popular", "subbed-anime", "dubbed-anime", "recently-updated", "recently-added", "top-upcoming", "top-airing", "movie", "special", "ova", "ona", "tv", "completed" // categories -> "most-favorite", "most-popular", "subbed-anime", "dubbed-anime", "recently-updated", "recently-added", "top-upcoming", "top-airing", "movie", "special", "ova", "ona", "tv", "completed"
const resp = await fetch("https://api-aniwatch.onrender.com/anime/tv?page=2"); const resp = await fetch("/api/v2/hianime/category/tv?page=2");
const data = await resp.json(); const data = await resp.json();
console.log(data); console.log(data);
``` ```
@@ -823,6 +849,8 @@ console.log(data);
```javascript ```javascript
{ {
success: true,
data: {
category: "TV Series Anime", category: "TV Series Anime",
animes: [ animes: [
{ {
@@ -860,6 +888,7 @@ console.log(data);
currentPage: 2, currentPage: 2,
totalPages: 100, totalPages: 100,
hasNextPage: true hasNextPage: true
}
} }
``` ```
@@ -878,7 +907,7 @@ console.log(data);
#### Endpoint #### Endpoint
```sh ```sh
https://api-aniwatch.onrender.com/anime/schedule?date={date} /api/v2/hianime/schedule?date={date}
``` ```
#### Query Parameters #### Query Parameters
@@ -890,9 +919,7 @@ https://api-aniwatch.onrender.com/anime/schedule?date={date}
#### Request sample #### Request sample
```javascript ```javascript
const resp = await fetch( const resp = await fetch("/api/v2/hianime/schedule?date=2024-06-09");
"https://api-aniwatch.onrender.com/anime/schedule?date=2023-01-14"
);
const data = await resp.json(); const data = await resp.json();
console.log(data); console.log(data);
``` ```
@@ -901,6 +928,8 @@ console.log(data);
```javascript ```javascript
{ {
success: true,
data: {
scheduledAnimes: [ scheduledAnimes: [
{ {
id: string, id: string,
@@ -912,6 +941,7 @@ console.log(data);
}, },
{...} {...}
] ]
}
} }
``` ```
@@ -930,7 +960,7 @@ console.log(data);
#### Endpoint #### Endpoint
```sh ```sh
https://api-aniwatch.onrender.com/anime/episodes/{animeId} /api/v2/hianime/anime/{animeId}/episodes
``` ```
#### Path Parameters #### Path Parameters
@@ -942,9 +972,7 @@ https://api-aniwatch.onrender.com/anime/episodes/{animeId}
#### Request sample #### Request sample
```javascript ```javascript
const resp = await fetch( const resp = await fetch("/api/v2/hianime/anime/steinsgate-3/episodes");
"https://api-aniwatch.onrender.com/anime/episodes/steinsgate-3"
);
const data = await resp.json(); const data = await resp.json();
console.log(data); console.log(data);
``` ```
@@ -953,6 +981,8 @@ console.log(data);
```javascript ```javascript
{ {
success: true,
data: {
totalEpisodes: 24, totalEpisodes: 24,
episodes: [ episodes: [
{ {
@@ -963,6 +993,7 @@ console.log(data);
}, },
{...} {...}
] ]
}
} }
``` ```
@@ -981,20 +1012,20 @@ console.log(data);
#### Endpoint #### Endpoint
```sh ```sh
https://api-aniwatch.onrender.com/anime/servers?episodeId={id} /api/v2/hianime/episode/servers?animeEpisodeId={id}
``` ```
#### Query Parameters #### Query Parameters
| Parameter | Type | Description | Required? | Default | | Parameter | Type | Description | Required? | Default |
| :---------: | :----: | :--------------------: | :-------: | :-----: | | :--------------: | :----: | :--------------------------: | :-------: | :-----: |
| `episodeId` | string | The unique episode id. | Yes | -- | | `animeEpisodeId` | string | The unique anime episode id. | Yes | -- |
#### Request sample #### Request sample
```javascript ```javascript
const resp = await fetch( const resp = await fetch(
"https://api-aniwatch.onrender.com/anime/servers?episodeId=steinsgate-0-92?ep=2055" "/api/v2/hianime/episode/servers?animeEpisodeId=steinsgate-0-92?ep=2055"
); );
const data = await resp.json(); const data = await resp.json();
console.log(data); console.log(data);
@@ -1004,6 +1035,8 @@ console.log(data);
```javascript ```javascript
{ {
success: true,
data: {
episodeId: "steinsgate-0-92?ep=2055", episodeId: "steinsgate-0-92?ep=2055",
episodeNo: 5, episodeNo: 5,
sub: [ sub: [
@@ -1026,7 +1059,8 @@ console.log(data);
serverName: "megacloud", serverName: "megacloud",
}, },
{...} {...}
], ]
}
} }
``` ```
@@ -1045,14 +1079,14 @@ console.log(data);
#### Endpoint #### Endpoint
```sh ```sh
https://api-aniwatch.onrender.com/anime/episode-srcs?id={episodeId}&server={server}&category={category} /api/v2/hianime/episode/sources?animeEpisodeId={id}?server={server}&category={dub || sub || raw}
``` ```
#### Query Parameters #### Query Parameters
| Parameter | Type | Description | Required? | Default | | Parameter | Type | Description | Required? | Default |
| :--------: | :----: | :--------------------------------------------------: | :-------: | :--------------: | | :--------------: | :----: | :--------------------------------------------------: | :-------: | :------: |
| `id` | string | The id of the episode. | Yes | -- | | `animeEpisodeId` | string | The unique anime episode id. | Yes | -- |
| `server` | string | The name of the server. | No | `"hd-1"` | | `server` | string | The name of the server. | No | `"hd-1"` |
| `category` | string | The category of the episode ('sub', 'dub' or 'raw'). | No | `"sub"` | | `category` | string | The category of the episode ('sub', 'dub' or 'raw'). | No | `"sub"` |
@@ -1060,7 +1094,7 @@ https://api-aniwatch.onrender.com/anime/episode-srcs?id={episodeId}&server={serv
```javascript ```javascript
const resp = await fetch( const resp = await fetch(
"https://api-aniwatch.onrender.com/anime/episode-srcs?id=steinsgate-3?ep=230&server=hd-1&category=dub" "/api/v2/hianime/episode/sources?animeEpisodeId=steinsgate-3?ep=230&server=hd-1&category=dub"
); );
const data = await resp.json(); const data = await resp.json();
console.log(data); console.log(data);
@@ -1070,6 +1104,8 @@ console.log(data);
```javascript ```javascript
{ {
success: true,
data: {
headers: { headers: {
Referer: string, Referer: string,
"User-Agent": string, "User-Agent": string,
@@ -1091,7 +1127,8 @@ console.log(data);
{...} {...}
], ],
anilistID: number | null, anilistID: number | null,
malID: number | null, malID: number | null
}
} }
``` ```
+8 -1
View File
@@ -1,3 +1,10 @@
import app from "../src/server.js"; import app from "../src/server.js";
import { handle } from "hono/vercel";
export default app; const handler = handle(app);
export const GET = handler;
export const POST = handler;
export const PATCH = handler;
export const PUT = handler;
export const OPTIONS = handler;
+9 -18
View File
@@ -1,7 +1,7 @@
{ {
"name": "aniwatch-api", "name": "aniwatch-api",
"version": "1.40.0", "version": "1.40.0",
"description": "Node.js API for obtaining anime information from hianime.to (formerly aniwatch.to)", "description": "Node.js API for obtaining anime information from hianime.to",
"main": "src/server.ts", "main": "src/server.ts",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -31,26 +31,17 @@
"author": "https://github.com/ghoshRitesh12", "author": "https://github.com/ghoshRitesh12",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.7.7", "@hono/node-server": "^1.13.1",
"cheerio": "1.0.0", "aniwatch": "^2.4.1",
"cors": "^2.8.5",
"crypto-js": "^4.2.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.19.2", "hono": "^4.6.3",
"express-rate-limit": "^7.4.0", "hono-rate-limiter": "^0.4.0"
"http-errors": "^2.0.0",
"morgan": "^1.10.0"
}, },
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.17", "@types/node": "^22.7.4",
"@types/crypto-js": "^4.2.2",
"@types/express": "^4.17.21",
"@types/http-errors": "^2.0.4",
"@types/morgan": "^1.9.9",
"@types/node": "^22.5.4",
"husky": "^8.0.3", "husky": "^8.0.3",
"tsx": "^4.19.0", "tsx": "^4.19.1",
"typescript": "^5.5.4", "typescript": "^5.6.2",
"vitest": "^2.0.5" "vitest": "^2.1.2"
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 939 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 356 KiB

+150 -38
View File
@@ -5,67 +5,151 @@
<meta charset="utf-8"> <meta charset="utf-8">
<title>Aniwatch API</title> <title>Aniwatch API</title>
<meta name="content-language" content="en"> <meta name="content-language" content="en">
<meta http-equiv="Content-Security-Policy" <!-- <meta http-equiv="Content-Security-Policy"
content="default-src 'self' https://api-aniwatch.onrender.com; style-src 'unsafe-inline'; img-src 'self' https://raw.githubusercontent.com https://img.shields.io https://contrib.rocks"> content="default-src 'self' https://api-aniwatch.onrender.com; style-src 'unsafe-inline'; img-src 'self' https://raw.githubusercontent.com https://img.shields.io https://contrib.rocks"> -->
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="title" content="Aniwatch API"> <meta name="title" content="Aniwatch API">
<meta name="theme-color" content="#d5b3ff">
<meta name="color-scheme" content="dark">
<link rel="canonical" href="https://api-aniwatch.onrender.com"> <link rel="canonical" href="https://api-aniwatch.onrender.com">
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1"> <meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1">
<meta property="og:image" <meta property="og:image"
content="https://raw.githubusercontent.com/ghoshRitesh12/aniwatch-api/main/public/img/img1.gif"> content="https://raw.githubusercontent.com/ghoshRitesh12/aniwatch-api/refs/heads/main/public/img/hianime_v2.png">
<meta property="og:url" content="https://api-aniwatch.onrender.com"> <meta property="og:url" content="https://api-aniwatch.onrender.com">
<meta property="og:image:width" content="457"> <meta property="og:image:width" content="500">
<meta property="og:image:height" content="480"> <meta property="og:image:height" content="500">
<meta property="og:site_name" content="aniwatch"> <meta property="og:site_name" content="aniwatch">
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_image">
<meta name="twitter:site" content="@aniwatch-api"> <meta name="twitter:site" content="@aniwatch-api">
<meta name="twitter:title" content="Aniwatch API"> <meta name="twitter:title" content="Aniwatch API">
<meta name="twitter:description" <meta name="twitter:description" content="Node.js API for obtaining anime information from hianime.to">
content="Node Express API for obtaining anime information from hianime.to (formerly aniwatch.to)">
<meta name="twitter:image:src" <meta name="twitter:image:src"
content="https://raw.githubusercontent.com/ghoshRitesh12/aniwatch-api/main/public/img/img1.gif"> content="https://raw.githubusercontent.com/ghoshRitesh12/aniwatch-api/refs/heads/main/public/img/hianime_v2.png">
<meta name="keywords" content="hianime api scraper anime aniwatch node express typescript"> <meta name="keywords" content="hianime api scraper anime aniwatch node express typescript">
<meta property="og:title" content="Aniwatch API"> <meta property="og:title" content="Aniwatch API">
<meta name="description" <meta name="description" content="Node.js API for obtaining anime information from hianime.to">
content="Node Express API for obtaining anime information from hianime.to (formerly aniwatch.to)"> <meta property="og:description" content="Node.js API for obtaining anime information from hianime.to">
<meta property="og:description"
content="Node Express API for obtaining anime information from hianime.to (formerly aniwatch.to)">
<link rel="shortcut icon" <link rel="shortcut icon"
href="https://raw.githubusercontent.com/ghoshRitesh12/aniwatch-api/main/public/img/img1.gif"> href="https://raw.githubusercontent.com/ghoshRitesh12/aniwatch-api/refs/heads/main/public/img/hianime_v2.png">
<style> <style>
* { * {
--accent: #d5b3ff;
box-sizing: border-box; box-sizing: border-box;
font-family: sans-serif; font-family: sans-serif;
transition:
0.15s ease scale,
0.15s ease width,
0.15s ease height,
0.15s ease gap;
} }
html { html {
color-scheme: dark; color-scheme: dark;
height: 100%;
} }
body { body {
display: flex; display: flex;
align-items: center;
justify-content: start;
flex-direction: column; flex-direction: column;
font-family: sans-serif; font-family: sans-serif;
background: #18161A; background-color: #151518;
padding: 10rem 1rem 0 1rem; padding: 0rem 1rem 0 1rem;
min-height: 98vh;
}
section {
margin-block: auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: start;
position: relative;
isolation: isolate;
}
section::before {
content: "";
width: 100%;
height: 100%;
position: absolute;
inset: -2rem 0 0 0;
isolation: isolate;
z-index: -1;
background-color: #7300ff;
opacity: .35;
border-radius: 50%;
filter: blur(12rem);
}
@media (min-width: 640px) {
section::before {
background-color: #7300ffbb;
opacity: .3;
}
}
.overlay {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
overflow: hidden;
width: 100%;
min-height: 100%;
isolation: isolate;
z-index: -1;
}
.overlay .child {
background-position: center center;
background-size: cover;
position: absolute;
inset: 0;
opacity: .4;
filter: blur(5rem);
background-image: url(https://raw.githubusercontent.com/ghoshRitesh12/aniwatch-api/refs/heads/main/public/img/wano_kuni.png);
}
@media (min-width: 640px) {
.overlay .child {
opacity: .35;
filter: blur(8rem);
}
}
.overlay::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
z-index: 2;
background: url("https://hianime.to/images/live-thumb.png") repeat;
} }
h2 { h2 {
background-clip: text; color: var(--accent);
background: linear-gradient(to left, #ff5a7e 40%, #00aeff); margin-top: 0;
text-align: center; font-size: 1.65rem;
font-weight: 600; font-weight: 600;
color: transparent; text-align: center;
-webkit-background-clip: text; text-wrap: balance;
-webkit-text-fill-color: transparent;
} }
a { a {
color: #00AEDD; line-height: 1.3;
text-wrap: balance;
color: var(--accent);
} }
img { img {
@@ -74,13 +158,24 @@
font-size: .9rem; font-size: .9rem;
} }
#anime_girl {
width: 100%;
max-width: 10rem;
}
@media (min-width: 640px) {
#anime_girl {
max-width: 12rem;
}
}
.img-wrapper { .img-wrapper {
aspect-ratio: 1/1; aspect-ratio: 1/1;
max-width: 12rem; max-width: 12rem;
border-radius: 1rem; border-radius: 1rem;
overflow: hidden; overflow: hidden;
width: fit-content; width: fit-content;
margin: 1rem auto 1rem auto; margin: 1rem auto 1.5rem auto;
} }
.api-stats-container { .api-stats-container {
@@ -106,19 +201,24 @@
<!-- <!--
IMPORTANT: IMPORTANT:
1. https://api-aniwatch.onrender.com is only meant to demo the API and has rate-limiting enabled to minimise bandwidth 1. The hosted version of this API is only meant to demo the API and would have rate-limiting enabled to minimise bandwidth
consumption. It is recommended to deploy your own instance for personal use by customizing the api as you need it to be. consumption. It is recommended to deploy your own instance for personal use by customizing the API as you need it to be.
2. This API is just an unofficial api for hianime.to and is in no other way officially related to the same. 2. This API is just an unofficial API for hianime.to and is in no other way officially related to the same.
3. The content that this api provides is not mine, nor is it hosted by me. These belong to their respective owners. This 3. The content that this API provides is not mine, nor is it hosted by me. These belong to their respective owners. This
api just demonstrates how to build an api that scrapes websites and uses their content. API just demonstrates how to build an API that scrapes websites and uses their content.
--> -->
<body> <body>
<span class="overlay">
<span class="child"></span>
</span>
<section>
<h2> <h2>
Welcome to the unofficial Welcome to the unofficial
<a href="https://hianime.to" style="text-decoration: underline;">hianime.to (formerly aniwatch.to)</a> <a href="https://hianime.to/home" style="text-underline-offset: 3px;">hianime.to</a>
api API
<span style="-webkit-text-fill-color: white">⚔️</span> <span style="-webkit-text-fill-color: var(--accent)">⚔️</span>
</h2> </h2>
<div class="api-stats-container"> <div class="api-stats-container">
@@ -141,25 +241,37 @@ api just demonstrates how to build an api that scrapes websites and uses their c
</a> </a>
</div> </div>
<div style="font-size: 1.2rem; text-align: center;"> <div style="text-align: center;">
<div class="img-wrapper"> <div class="img-wrapper">
<img draggable="false" src="https://raw.githubusercontent.com/ghoshRitesh12/aniwatch-api/main/public/img/img1.gif" <img draggable="false" id="anime_girl"
alt="kawai anime girl" decoding="async" fetchpriority="high" style="user-select: none;" /> src="https://raw.githubusercontent.com/ghoshRitesh12/aniwatch-api/refs/heads/main/public/img/hianime_v2.png"
alt="kawai_anime_girl" decoding="async" fetchpriority="high" style="user-select: none;" />
</div> </div>
<div style="display: flex; align-items: center; gap: .5rem; text-underline-offset: 3px;">
<a href="https://github.com/ghoshRitesh12/aniwatch-api/releases/latest" rel="noopener noreferer">
Checkout latest changelogs
</a>
<span style="font-size: .9rem;"></span>
<a href="https://github.com/ghoshRitesh12/aniwatch-api#documentation" rel="noopener noreferer"> <a href="https://github.com/ghoshRitesh12/aniwatch-api#documentation" rel="noopener noreferer">
Visit docs for more information Visit docs for more information
</a> </a>
</div> </div>
</div>
<div class="contributors"> <div class="contributors">
<p>Thanks to the following people for keeping this project alive and relevant.</p> <p style="text-wrap: balance; line-height: 1.2;">Thanks to the following people for keeping this
project alive and
relevant.</p>
<a href="https://github.com/ghoshRitesh12/aniwatch-api/graphs/contributors" <a href="https://github.com/ghoshRitesh12/aniwatch-api/graphs/contributors"
style="display: block; margin-bottom: 1rem; user-select: none;"> style="display: block; margin-bottom: 1rem; user-select: none;">
<img src="https://contrib.rocks/image?repo=ghoshRitesh12/aniwatch-api" alt="Contributors" decoding="async"> <img src="https://contrib.rocks/image?repo=ghoshRitesh12/aniwatch-api" alt="Contributors" decoding="async">
</a> </a>
</div> </div>
</section>
</body> </body>
</html> </html>
-21
View File
@@ -1,21 +0,0 @@
import axios, { AxiosError, type AxiosRequestConfig } from "axios";
import {
SRC_BASE_URL,
ACCEPT_HEADER,
USER_AGENT_HEADER,
ACCEPT_ENCODING_HEADER,
} from "../utils/constants.js";
const clientConfig: AxiosRequestConfig = {
timeout: 10000,
baseURL: SRC_BASE_URL,
headers: {
Accept: ACCEPT_HEADER,
"User-Agent": USER_AGENT_HEADER,
"Accept-Encoding": ACCEPT_ENCODING_HEADER,
},
};
const client = axios.create(clientConfig);
export { client, AxiosError };
+5 -12
View File
@@ -1,24 +1,17 @@
import cors from "cors";
import { config } from "dotenv"; import { config } from "dotenv";
import { cors } from "hono/cors";
config(); config();
const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS const allowedOrigins = process.env.ANIWATCH_API_CORS_ALLOWED_ORIGINS
? process.env.CORS_ALLOWED_ORIGINS.split(",") ? process.env.ANIWATCH_API_CORS_ALLOWED_ORIGINS.split(",")
: ["http://localhost:4000", "*"]; : ["http://localhost:4000", "*"];
const corsConfig = cors({ const corsConfig = cors({
origin: function (origin, callback) { allowMethods: ["GET"],
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error("Not allowed by CORS"));
}
},
methods: ["GET"],
maxAge: 600, maxAge: 600,
credentials: true, credentials: true,
optionsSuccessStatus: 200, origin: allowedOrigins,
}); });
export default corsConfig; export default corsConfig;
-11
View File
@@ -1,11 +0,0 @@
import type { ErrorRequestHandler } from "express";
const errorHandler: ErrorRequestHandler = (error, req, res, next) => {
const status = error?.status || 500;
res.status(status).json({
status,
message: error?.message || "Something Went Wrong",
});
};
export default errorHandler;
-8
View File
@@ -1,8 +0,0 @@
import type { RequestHandler } from "express";
import createHttpError from "http-errors";
const notFoundHandler: RequestHandler = (req, res, next) => {
return next(createHttpError.NotFound());
};
export default notFoundHandler;
+14 -10
View File
@@ -1,17 +1,21 @@
import { config } from "dotenv"; import { config } from "dotenv";
import createHttpError from "http-errors"; import { rateLimiter } from "hono-rate-limiter";
import { rateLimit } from "express-rate-limit"; import { getConnInfo } from "@hono/node-server/conninfo";
config(); config();
export const ratelimit = rateLimit({ export const ratelimit = rateLimiter({
windowMs: Number(process.env.WINDOWMS) || 30 * 60 * 1000, windowMs: Number(process.env.ANIWATCH_API_WINDOW_MS) || 30 * 60 * 1000,
limit: Number(process.env.MAX) || 6, limit: Number(process.env.ANIWATCH_API_MAX_REQS) || 6,
legacyHeaders: true,
standardHeaders: "draft-7", standardHeaders: "draft-7",
handler: function (_, __, next) { keyGenerator(c) {
next( const { remote } = getConnInfo(c);
createHttpError.TooManyRequests("Too many API requests, try again later") const key =
); `${String(remote.addressType)}_` +
`${String(remote.address)}:${String(remote.port)}`;
return key;
}, },
handler: (c) =>
c.json({ status: 429, message: "Too Many Requests 😵" }, { status: 429 }),
}); });
@@ -1,31 +0,0 @@
import createHttpError from "http-errors";
import { type RequestHandler } from "express";
import { scrapeAnimeAboutInfo } from "../parsers/index.js";
import { type AnimeAboutInfoQueryParams } from "../types/controllers/index.js";
// /anime/info?id=${anime-id}
const getAnimeAboutInfo: RequestHandler<
unknown,
Awaited<ReturnType<typeof scrapeAnimeAboutInfo>>,
unknown,
AnimeAboutInfoQueryParams
> = async (req, res, next) => {
try {
const animeId = req.query.id
? decodeURIComponent(req.query.id as string)
: null;
if (animeId === null) {
throw createHttpError.BadRequest("Anime unique id required");
}
const data = await scrapeAnimeAboutInfo(animeId);
res.status(200).json(data);
} catch (err: any) {
console.error(err);
next(err);
}
};
export default getAnimeAboutInfo;
@@ -1,39 +0,0 @@
import createHttpError from "http-errors";
import type { RequestHandler } from "express";
import type { AnimeCategories } from "../types/anime.js";
import { scrapeAnimeCategory } from "../parsers/index.js";
import type {
CategoryAnimePathParams,
CategoryAnimeQueryParams,
} from "../types/controllers/index.js";
// /anime/:category?page=${page}
const getAnimeCategory: RequestHandler<
CategoryAnimePathParams,
Awaited<ReturnType<typeof scrapeAnimeCategory>>,
unknown,
CategoryAnimeQueryParams
> = async (req, res, next) => {
try {
const category = req.params.category
? decodeURIComponent(req.params.category)
: null;
const page: number = req.query.page
? Number(decodeURIComponent(req.query?.page as string))
: 1;
if (category === null) {
throw createHttpError.BadRequest("category required");
}
const data = await scrapeAnimeCategory(category as AnimeCategories, page);
res.status(200).json(data);
} catch (err: any) {
console.error(err);
next(err);
}
};
export default getAnimeCategory;
@@ -1,75 +0,0 @@
import axios from "axios";
import createHttpError from "http-errors";
import { type RequestHandler } from "express";
import { type CheerioAPI, load } from "cheerio";
import { scrapeAnimeEpisodeSources } from "../parsers/index.js";
import { USER_AGENT_HEADER, SRC_BASE_URL } from "../utils/constants.js";
import { type AnimeServers, Servers } from "../types/anime.js";
import { type AnimeEpisodeSrcsQueryParams } from "../types/controllers/index.js";
type AnilistID = number | null;
type MalID = number | null;
// /anime/episode-srcs?id=${episodeId}?server=${server}&category=${category (dub or sub)}
const getAnimeEpisodeSources: RequestHandler<
unknown,
Awaited<ReturnType<typeof scrapeAnimeEpisodeSources & AnilistID>>,
unknown,
AnimeEpisodeSrcsQueryParams
> = async (req, res, next) => {
try {
const episodeId = req.query.id ? decodeURIComponent(req.query.id) : null;
const server = (
req.query.server
? decodeURIComponent(req.query.server)
: Servers.VidStreaming
) as AnimeServers;
const category = (
req.query.category ? decodeURIComponent(req.query.category) : "sub"
) as "sub" | "dub";
if (episodeId === null) {
throw createHttpError.BadRequest("Anime episode id required");
}
let malID: MalID;
let anilistID: AnilistID;
const animeURL = new URL(episodeId?.split("?ep=")[0], SRC_BASE_URL)?.href;
const [episodeSrcData, animeSrc] = await Promise.all([
scrapeAnimeEpisodeSources(episodeId, server, category),
axios.get(animeURL, {
headers: {
Referer: SRC_BASE_URL,
"User-Agent": USER_AGENT_HEADER,
"X-Requested-With": "XMLHttpRequest",
},
}),
]);
const $: CheerioAPI = load(animeSrc?.data);
try {
anilistID = Number(
JSON.parse($("body")?.find("#syncData")?.text())?.anilist_id
);
malID = Number(JSON.parse($("body")?.find("#syncData")?.text())?.mal_id);
} catch (err) {
anilistID = null;
malID = null;
}
res.status(200).json({
...episodeSrcData,
anilistID,
malID,
});
} catch (err: any) {
console.error(err);
next(err);
}
};
export default getAnimeEpisodeSources;
@@ -1,31 +0,0 @@
import createHttpError from "http-errors";
import { type RequestHandler } from "express";
import { scrapeAnimeEpisodes } from "../parsers/index.js";
import { type AnimeEpisodePathParams } from "../types/controllers/index.js";
// /anime/episodes/${anime-id}
const getAnimeEpisodes: RequestHandler<
AnimeEpisodePathParams,
Awaited<ReturnType<typeof scrapeAnimeEpisodes>>,
unknown,
unknown
> = async (req, res, next) => {
try {
const animeId = req.params.animeId
? decodeURIComponent(req.params.animeId)
: null;
if (animeId === null) {
throw createHttpError.BadRequest("Anime Id required");
}
const data = await scrapeAnimeEpisodes(animeId);
res.status(200).json(data);
} catch (err: any) {
console.error(err);
next(err);
}
};
export default getAnimeEpisodes;
-37
View File
@@ -1,37 +0,0 @@
import createHttpError from "http-errors";
import { type RequestHandler } from "express";
import { scrapeGenreAnime } from "../parsers/index.js";
import type {
GenreAnimePathParams,
GenreAnimeQueryParams,
} from "../types/controllers/index.js";
// /anime/genre/${name}?page=${page}
const getGenreAnime: RequestHandler<
GenreAnimePathParams,
Awaited<ReturnType<typeof scrapeGenreAnime>>,
unknown,
GenreAnimeQueryParams
> = async (req, res, next) => {
try {
const name: string | null = req.params.name
? decodeURIComponent(req.params.name as string)
: null;
const page: number = req.query.page
? Number(decodeURIComponent(req.query?.page as string))
: 1;
if (name === null) {
throw createHttpError.BadRequest("Anime genre required");
}
const data = await scrapeGenreAnime(name, page);
res.status(200).json(data);
} catch (err: any) {
console.error(err);
next(err);
}
};
export default getGenreAnime;
@@ -1,37 +0,0 @@
import createHttpError from "http-errors";
import { type RequestHandler } from "express";
import { scrapeProducerAnimes } from "../parsers/index.js";
import type {
AnimeProducerPathParams,
AnimeProducerQueryParams,
} from "../types/controllers/index.js";
// /anime/producer/${name}?page=${page}
const getProducerAnimes: RequestHandler<
AnimeProducerPathParams,
Awaited<ReturnType<typeof scrapeProducerAnimes>>,
unknown,
AnimeProducerQueryParams
> = async (req, res, next) => {
try {
const name: string | null = req.params.name
? decodeURIComponent(req.params.name as string)
: null;
const page: number = req.query.page
? Number(decodeURIComponent(req.query?.page as string))
: 1;
if (name === null) {
throw createHttpError.BadRequest("Anime producer name required");
}
const data = await scrapeProducerAnimes(name, page);
res.status(200).json(data);
} catch (err: any) {
console.error(err);
next(err);
}
};
export default getProducerAnimes;
-57
View File
@@ -1,57 +0,0 @@
import createHttpError from "http-errors";
import { type RequestHandler } from "express";
import { scrapeAnimeSearch } from "../parsers/index.js";
import type {
SearchFilters,
AnimeSearchQueryParams,
} from "../types/controllers/index.js";
const searchFilters: Record<string, boolean> = {
filter: true,
type: true,
status: true,
rated: true,
score: true,
season: true,
language: true,
start_date: true,
end_date: true,
sort: true,
genres: true,
} as const;
// /anime/search?q=${query}&page=${page}
const getAnimeSearch: RequestHandler<
unknown,
Awaited<ReturnType<typeof scrapeAnimeSearch>>,
unknown,
AnimeSearchQueryParams
> = async (req, res, next) => {
try {
let { q: query, page, ...filters } = req.query;
query = query ? decodeURIComponent(query) : undefined;
const pageNo = page ? Number(decodeURIComponent(page as string)) : 1;
if (query === undefined) {
throw createHttpError.BadRequest("Search keyword required");
}
const parsedFilters: SearchFilters = {};
for (const key in filters) {
if (searchFilters[key]) {
parsedFilters[key as keyof SearchFilters] =
filters[key as keyof SearchFilters];
}
}
const data = await scrapeAnimeSearch(query, pageNo, parsedFilters);
res.status(200).json(data);
} catch (err: any) {
console.error(err);
next(err);
}
};
export default getAnimeSearch;
@@ -1,31 +0,0 @@
import createHttpError from "http-errors";
import { type RequestHandler } from "express";
import { scrapeAnimeSearchSuggestion } from "../parsers/index.js";
import { type AnimeSearchSuggestQueryParams } from "../types/controllers/index.js";
// /anime/search/suggest?q=${query}
const getAnimeSearchSuggestion: RequestHandler<
unknown,
Awaited<ReturnType<typeof scrapeAnimeSearchSuggestion>>,
unknown,
AnimeSearchSuggestQueryParams
> = async (req, res, next) => {
try {
const query: string | null = req.query.q
? decodeURIComponent(req.query.q as string)
: null;
if (query === null) {
throw createHttpError.BadRequest("Search keyword required");
}
const data = await scrapeAnimeSearchSuggestion(query);
res.status(200).json(data);
} catch (err: any) {
console.error(err);
next(err);
}
};
export default getAnimeSearchSuggestion;
@@ -1,30 +0,0 @@
import createHttpError from "http-errors";
import { type RequestHandler } from "express";
import { scrapeEpisodeServers } from "../parsers/index.js";
import { type EpisodeServersQueryParams } from "../types/controllers/index.js";
// /anime/servers?episodeId=${id}
const getEpisodeServers: RequestHandler<
unknown,
Awaited<ReturnType<typeof scrapeEpisodeServers>>,
unknown,
EpisodeServersQueryParams
> = async (req, res, next) => {
try {
const episodeId = req.query.episodeId
? decodeURIComponent(req.query?.episodeId as string)
: null;
if (episodeId === null) {
throw createHttpError.BadRequest("Episode id required");
}
const data = await scrapeEpisodeServers(episodeId);
res.status(200).json(data);
} catch (err: any) {
console.error(err);
next(err);
}
};
export default getEpisodeServers;
@@ -1,36 +0,0 @@
import createHttpError from "http-errors";
import { type RequestHandler } from "express";
import { scrapeEstimatedSchedule } from "../parsers/index.js";
import { type EstimatedScheduleQueryParams } from "../types/controllers/index.js";
// /anime/schedule?date=${date}
const getEstimatedSchedule: RequestHandler<
unknown,
Awaited<ReturnType<typeof scrapeEstimatedSchedule>>,
unknown,
EstimatedScheduleQueryParams
> = async (req, res, next) => {
try {
const dateQuery = req.query.date
? decodeURIComponent(req.query.date as string)
: null;
if (dateQuery === null) {
throw createHttpError.BadRequest("Date payload required");
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateQuery)) {
throw createHttpError.BadRequest(
"Invalid date payload format. Months and days must have 2 digits"
);
}
const data = await scrapeEstimatedSchedule(dateQuery);
res.status(200).json(data);
} catch (err: any) {
console.error(err);
next(err);
}
};
export default getEstimatedSchedule;
-18
View File
@@ -1,18 +0,0 @@
import { type RequestHandler } from "express";
import { scrapeHomePage } from "../parsers/index.js";
// /anime/home
const getHomePageInfo: RequestHandler<
unknown,
Awaited<ReturnType<typeof scrapeHomePage>>
> = async (req, res, next) => {
try {
const data = await scrapeHomePage();
res.status(200).json(data);
} catch (err: any) {
console.error(err);
next(err);
}
};
export default getHomePageInfo;
-25
View File
@@ -1,25 +0,0 @@
import getGenreAnime from "./animeGenre.controller.js";
import getHomePageInfo from "./homePage.controller.js";
import getAnimeSearch from "./animeSearch.controller.js";
import getAnimeEpisodes from "./animeEpisodes.controller.js";
import getAnimeCategory from "./animeCategory.controller.js";
import getProducerAnimes from "./animeProducer.controller.js";
import getEpisodeServers from "./episodeServers.controller.js";
import getAnimeAboutInfo from "./animeAboutInfo.controller.js";
import getEstimatedSchedule from "./estimatedSchedule.controller.js";
import getAnimeEpisodeSources from "./animeEpisodeSrcs.controller.js";
import getAnimeSearchSuggestion from "./animeSearchSuggestion.controller.js";
export {
getGenreAnime,
getAnimeSearch,
getHomePageInfo,
getAnimeEpisodes,
getAnimeCategory,
getEpisodeServers,
getProducerAnimes,
getAnimeAboutInfo,
getEstimatedSchedule,
getAnimeEpisodeSources,
getAnimeSearchSuggestion,
};
-6
View File
@@ -1,6 +0,0 @@
import StreamSB from "./streamsb.js";
import StreamTape from "./streamtape.js";
import RapidCloud from "./rapidcloud.js";
import MegaCloud from "./megacloud.js";
export { StreamSB, StreamTape, RapidCloud, MegaCloud };
-226
View File
@@ -1,226 +0,0 @@
import axios from "axios";
import crypto from "crypto";
import createHttpError from "http-errors";
// https://megacloud.tv/embed-2/e-1/dBqCr5BcOhnD?k=1
const megacloud = {
script: "https://megacloud.tv/js/player/a/prod/e1-player.min.js?v=",
sources: "https://megacloud.tv/embed-2/ajax/e-1/getSources?id=",
} as const;
type track = {
file: string;
kind: string;
label?: string;
default?: boolean;
};
type intro_outro = {
start: number;
end: number;
};
type unencryptedSrc = {
file: string;
type: string;
};
type extractedSrc = {
sources: string | unencryptedSrc[];
tracks: track[];
encrypted: boolean;
intro: intro_outro;
outro: intro_outro;
server: number;
};
interface ExtractedData
extends Pick<extractedSrc, "intro" | "outro" | "tracks"> {
sources: { url: string; type: string }[];
}
class MegaCloud {
private serverName = "megacloud";
async extract(videoUrl: URL) {
try {
const extractedData: ExtractedData = {
tracks: [],
intro: {
start: 0,
end: 0,
},
outro: {
start: 0,
end: 0,
},
sources: [],
};
const videoId = videoUrl?.href?.split("/")?.pop()?.split("?")[0];
const { data: srcsData } = await axios.get<extractedSrc>(
megacloud.sources.concat(videoId || ""),
{
headers: {
Accept: "*/*",
"X-Requested-With": "XMLHttpRequest",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
Referer: videoUrl.href,
},
}
);
if (!srcsData) {
throw createHttpError.NotFound("Url may have an invalid video id");
}
// console.log(JSON.stringify(srcsData, null, 2));
const encryptedString = srcsData.sources;
if (!srcsData.encrypted && Array.isArray(encryptedString)) {
extractedData.intro = srcsData.intro;
extractedData.outro = srcsData.outro;
extractedData.tracks = srcsData.tracks;
extractedData.sources = encryptedString.map((s) => ({
url: s.file,
type: s.type,
}));
return extractedData;
}
let text: string;
const { data } = await axios.get(
megacloud.script.concat(Date.now().toString())
);
text = data;
if (!text) {
throw createHttpError.InternalServerError(
"Couldn't fetch script to decrypt resource"
);
}
const vars = this.extractVariables(text);
if (!vars.length) {
throw new Error("Can't find variables. Perhaps the extractor is outdated.");
}
const { secret, encryptedSource } = this.getSecret(
encryptedString as string,
vars
);
const decrypted = this.decrypt(encryptedSource, secret);
try {
const sources = JSON.parse(decrypted);
extractedData.intro = srcsData.intro;
extractedData.outro = srcsData.outro;
extractedData.tracks = srcsData.tracks;
extractedData.sources = sources.map((s: any) => ({
url: s.file,
type: s.type,
}));
return extractedData;
} catch (error) {
throw createHttpError.InternalServerError("Failed to decrypt resource");
}
} catch (err) {
// console.log(err);
throw err;
}
}
extractVariables(text: string) {
// copied from github issue #30 'https://github.com/ghoshRitesh12/aniwatch-api/issues/30'
const regex =
/case\s*0x[0-9a-f]+:(?![^;]*=partKey)\s*\w+\s*=\s*(\w+)\s*,\s*\w+\s*=\s*(\w+);/g;
const matches = text.matchAll(regex);
const vars = Array.from(matches, (match) => {
const matchKey1 = this.matchingKey(match[1], text);
const matchKey2 = this.matchingKey(match[2], text);
try {
return [parseInt(matchKey1, 16), parseInt(matchKey2, 16)];
} catch (e) {
return [];
}
}).filter((pair) => pair.length > 0);
return vars;
}
getSecret(encryptedString: string, values: number[][]) {
let secret = "",
encryptedSource = "",
encryptedSourceArray = encryptedString.split(""),
currentIndex = 0;
for (const index of values) {
const start = index[0] + currentIndex;
const end = start + index[1];
for (let i = start; i < end; i++) {
secret += encryptedString[i];
encryptedSourceArray[i] = "";
}
currentIndex += index[1];
}
encryptedSource = encryptedSourceArray.join("");
return { secret, encryptedSource };
}
decrypt(encrypted: string, keyOrSecret: string, maybe_iv?: string) {
let key;
let iv;
let contents;
if (maybe_iv) {
key = keyOrSecret;
iv = maybe_iv;
contents = encrypted;
} else {
// copied from 'https://github.com/brix/crypto-js/issues/468'
const cypher = Buffer.from(encrypted, "base64");
const salt = cypher.subarray(8, 16);
const password = Buffer.concat([
Buffer.from(keyOrSecret, "binary"),
salt,
]);
const md5Hashes = [];
let digest = password;
for (let i = 0; i < 3; i++) {
md5Hashes[i] = crypto.createHash("md5").update(digest).digest();
digest = Buffer.concat([md5Hashes[i], password]);
}
key = Buffer.concat([md5Hashes[0], md5Hashes[1]]);
iv = md5Hashes[2];
contents = cypher.subarray(16);
}
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
const decrypted =
decipher.update(
contents as any,
typeof contents === "string" ? "base64" : undefined,
"utf8"
) + decipher.final();
return decrypted;
}
// function copied from github issue #30 'https://github.com/ghoshRitesh12/aniwatch-api/issues/30'
matchingKey(value: string, script: string) {
const regex = new RegExp(`,${value}=((?:0x)?([0-9a-fA-F]+))`);
const match = script.match(regex);
if (match) {
return match[1].replace(/^0x/, "");
} else {
throw new Error("Failed to match the key");
}
}
}
export default MegaCloud;
-166
View File
@@ -1,166 +0,0 @@
import axios from "axios";
import CryptoJS from "crypto-js";
import { substringAfter, substringBefore } from "../utils/index.js";
import type { Video, Subtitle, Intro } from "../types/extractor.js";
type extractReturn = {
sources: Video[];
subtitles: Subtitle[];
};
// https://megacloud.tv/embed-2/e-1/IxJ7GjGVCyml?k=1
class RapidCloud {
private serverName = "RapidCloud";
private sources: Video[] = [];
// https://rapid-cloud.co/embed-6/eVZPDXwVfrY3?vast=1
private readonly fallbackKey = "c1d17096f2ca11b7";
private readonly host = "https://rapid-cloud.co";
async extract(videoUrl: URL): Promise<extractReturn> {
const result: extractReturn & { intro?: Intro; outro?: Intro } = {
sources: [],
subtitles: [],
};
try {
const id = videoUrl.href.split("/").pop()?.split("?")[0];
const options = {
headers: {
"X-Requested-With": "XMLHttpRequest",
},
};
let res = null;
res = await axios.get(
`https://${videoUrl.hostname}/embed-2/ajax/e-1/getSources?id=${id}`,
options
);
let {
data: { sources, tracks, intro, outro, encrypted },
} = res;
let decryptKey = await (
await axios.get(
"https://raw.githubusercontent.com/cinemaxhq/keys/e1/key"
)
).data;
decryptKey = substringBefore(
substringAfter(decryptKey, '"blob-code blob-code-inner js-file-line">'),
"</td>"
);
if (!decryptKey) {
decryptKey = await (
await axios.get(
"https://raw.githubusercontent.com/cinemaxhq/keys/e1/key"
)
).data;
}
if (!decryptKey) decryptKey = this.fallbackKey;
try {
if (encrypted) {
const sourcesArray = sources.split("");
let extractedKey = "";
let currentIndex = 0;
for (const index of decryptKey) {
const start = index[0] + currentIndex;
const end = start + index[1];
for (let i = start; i < end; i++) {
extractedKey += res.data.sources[i];
sourcesArray[i] = "";
}
currentIndex += index[1];
}
decryptKey = extractedKey;
sources = sourcesArray.join("");
const decrypt = CryptoJS.AES.decrypt(sources, decryptKey);
sources = JSON.parse(decrypt.toString(CryptoJS.enc.Utf8));
}
} catch (err: any) {
console.log(err.message);
throw new Error("Cannot decrypt sources. Perhaps the key is invalid.");
}
this.sources = sources?.map((s: any) => ({
url: s.file,
isM3U8: s.file.includes(".m3u8"),
}));
result.sources.push(...this.sources);
if (videoUrl.href.includes(new URL(this.host).host)) {
result.sources = [];
this.sources = [];
for (const source of sources) {
const { data } = await axios.get(source.file, options);
const m3u8data = data
.split("\n")
.filter(
(line: string) =>
line.includes(".m3u8") && line.includes("RESOLUTION=")
);
const secondHalf = m3u8data.map((line: string) =>
line.match(/RESOLUTION=.*,(C)|URI=.*/g)?.map((s) => s.split("=")[1])
);
const TdArray = secondHalf.map((s: string[]) => {
const f1 = s[0].split(",C")[0];
const f2 = s[1].replace(/"/g, "");
return [f1, f2];
});
for (const [f1, f2] of TdArray) {
this.sources.push({
url: `${source.file?.split("master.m3u8")[0]}${f2.replace(
"iframes",
"index"
)}`,
quality: f1.split("x")[1] + "p",
isM3U8: f2.includes(".m3u8"),
});
}
result.sources.push(...this.sources);
}
}
result.intro =
intro?.end > 1 ? { start: intro.start, end: intro.end } : undefined;
result.outro =
outro?.end > 1 ? { start: outro.start, end: outro.end } : undefined;
result.sources.push({
url: sources[0].file,
isM3U8: sources[0].file.includes(".m3u8"),
quality: "auto",
});
result.subtitles = tracks
.map((s: any) =>
s.file
? { url: s.file, lang: s.label ? s.label : "Thumbnails" }
: null
)
.filter((s: any) => s);
return result;
} catch (err: any) {
console.log(err.message);
throw err;
}
}
}
export default RapidCloud;
-83
View File
@@ -1,83 +0,0 @@
import axios from "axios";
import type { Video } from "../types/extractor.js";
import { USER_AGENT_HEADER } from "../utils/index.js";
class StreamSB {
private serverName = "streamSB";
private sources: Video[] = [];
private readonly host = "https://watchsb.com/sources50";
private readonly host2 = "https://streamsss.net/sources16";
private PAYLOAD(hex: string): string {
// `5363587530696d33443675687c7c${hex}7c7c433569475830474c497a65767c7c73747265616d7362`;
return `566d337678566f743674494a7c7c${hex}7c7c346b6767586d6934774855537c7c73747265616d7362/6565417268755339773461447c7c346133383438333436313335376136323337373433383634376337633465366534393338373136643732373736343735373237613763376334363733353737303533366236333463353333363534366137633763373337343732363536313664373336327c7c6b586c3163614468645a47617c7c73747265616d7362`;
}
async extract(videoUrl: URL, isAlt: boolean = false): Promise<Video[]> {
let headers: Record<string, string> = {
watchsb: "sbstream",
Referer: videoUrl.href,
"User-Agent": USER_AGENT_HEADER,
};
let id = videoUrl.href.split("/e/").pop();
if (id?.includes("html")) {
id = id.split(".html")[0];
}
const bytes = new TextEncoder().encode(id);
const res = await axios
.get(
`${isAlt ? this.host2 : this.host}/${this.PAYLOAD(
Buffer.from(bytes).toString("hex")
)}`,
{ headers }
)
.catch(() => null);
if (!res?.data.stream_data) {
throw new Error("No source found. Try a different server");
}
headers = {
"User-Agent": USER_AGENT_HEADER,
Referer: videoUrl.href.split("e/")[0],
};
const m3u8_urls = await axios.get(res.data.stream_data.file, {
headers,
});
const videoList = m3u8_urls?.data?.split("#EXT-X-STREAM-INF:") ?? [];
for (const video of videoList) {
if (!video.includes("m3u8")) continue;
const url = video.split("\n")[1];
const quality = video.split("RESOLUTION=")[1].split(",")[0].split("x")[1];
this.sources.push({
url: url,
quality: `${quality}p`,
isM3U8: true,
});
}
this.sources.push({
url: res.data.stream_data.file,
quality: "auto",
isM3U8: res.data.stream_data.file.includes(".m3u8"),
});
return this.sources;
}
private addSources(source: any): void {
this.sources.push({
url: source.file,
isM3U8: source.file.includes(".m3u8"),
});
}
}
export default StreamSB;
-37
View File
@@ -1,37 +0,0 @@
import axios from "axios";
import { load, type CheerioAPI } from "cheerio";
import type { Video } from "../types/extractor.js";
class StreamTape {
private serverName = "StreamTape";
private sources: Video[] = [];
async extract(videoUrl: URL): Promise<Video[]> {
try {
const { data } = await axios.get(videoUrl.href).catch(() => {
throw new Error("Video not found");
});
const $: CheerioAPI = load(data);
let [fh, sh] = $.html()
?.match(/robotlink'\).innerHTML = (.*)'/)![1]
.split("+ ('");
sh = sh.substring(3);
fh = fh.replace(/\'/g, "");
const url = `https:${fh}${sh}`;
this.sources.push({
url: url,
isM3U8: url.includes(".m3u8"),
});
return this.sources;
} catch (err) {
throw new Error((err as Error).message);
}
}
}
export default StreamTape;
-229
View File
@@ -1,229 +0,0 @@
import {
SRC_BASE_URL,
extractAnimes,
ACCEPT_HEADER,
USER_AGENT_HEADER,
ACCEPT_ENCODING_HEADER,
extractMostPopularAnimes,
} from "../utils/index.js";
import axios, { AxiosError } from "axios";
import createHttpError, { type HttpError } from "http-errors";
import { load, type CheerioAPI, type SelectorType } from "cheerio";
import { type ScrapedAnimeAboutInfo } from "../types/parsers/index.js";
// /anime/info?id=${anime-id}
async function scrapeAnimeAboutInfo(
id: string
): Promise<ScrapedAnimeAboutInfo | HttpError> {
const res: ScrapedAnimeAboutInfo = {
anime: {
info: {
id: null,
anilistId: null,
malId: null,
name: null,
poster: null,
description: null,
stats: {
rating: null,
quality: null,
episodes: {
sub: null,
dub: null,
},
type: null,
duration: null,
},
promotionalVideos: [],
charactersVoiceActors: [],
},
moreInfo: {},
},
seasons: [],
mostPopularAnimes: [],
relatedAnimes: [],
recommendedAnimes: [],
};
try {
const animeUrl: URL = new URL(id, SRC_BASE_URL);
const mainPage = await axios.get(animeUrl.href, {
headers: {
"User-Agent": USER_AGENT_HEADER,
"Accept-Encoding": ACCEPT_ENCODING_HEADER,
Accept: ACCEPT_HEADER,
},
});
const $: CheerioAPI = load(mainPage.data);
try {
res.anime.info.anilistId = Number(
JSON.parse($("body")?.find("#syncData")?.text())?.anilist_id
);
res.anime.info.malId = Number(JSON.parse($("body")?.find("#syncData")?.text())?.mal_id);
} catch (err) {
res.anime.info.anilistId = null;
res.anime.info.malId = null;
}
const selector: SelectorType = "#ani_detail .container .anis-content";
res.anime.info.id =
$(selector)
?.find(".anisc-detail .film-buttons a.btn-play")
?.attr("href")
?.split("/")
?.pop() || null;
res.anime.info.name =
$(selector)
?.find(".anisc-detail .film-name.dynamic-name")
?.text()
?.trim() || null;
res.anime.info.description =
$(selector)
?.find(".anisc-detail .film-description .text")
.text()
?.split("[")
?.shift()
?.trim() || null;
res.anime.info.poster =
$(selector)?.find(".film-poster .film-poster-img")?.attr("src")?.trim() ||
null;
// stats
res.anime.info.stats.rating =
$(`${selector} .film-stats .tick .tick-pg`)?.text()?.trim() || null;
res.anime.info.stats.quality =
$(`${selector} .film-stats .tick .tick-quality`)?.text()?.trim() || null;
res.anime.info.stats.episodes = {
sub:
Number($(`${selector} .film-stats .tick .tick-sub`)?.text()?.trim()) ||
null,
dub:
Number($(`${selector} .film-stats .tick .tick-dub`)?.text()?.trim()) ||
null,
};
res.anime.info.stats.type =
$(`${selector} .film-stats .tick`)
?.text()
?.trim()
?.replace(/[\s\n]+/g, " ")
?.split(" ")
?.at(-2) || null;
res.anime.info.stats.duration =
$(`${selector} .film-stats .tick`)
?.text()
?.trim()
?.replace(/[\s\n]+/g, " ")
?.split(" ")
?.pop() || null;
// get promotional videos
$(".block_area.block_area-promotions .block_area-promotions-list .screen-items .item").each(
(_, el) => {
res.anime.info.promotionalVideos.push({
title: $(el).attr("data-title"),
source: $(el).attr("data-src"),
thumbnail: $(el).find("img").attr("src"),
});
}
);
// get characters and voice actors
$(".block_area.block_area-actors .block-actors-content .bac-list-wrap .bac-item").each(
(_, el) => {
res.anime.info.charactersVoiceActors.push({
character: {
id: $(el).find($(".per-info.ltr .pi-avatar")).attr("href")?.split("/")[2] || "",
poster: $(el).find($(".per-info.ltr .pi-avatar img")).attr("data-src") || "",
name: $(el).find($(".per-info.ltr .pi-detail a")).text(),
cast: $(el).find($(".per-info.ltr .pi-detail .pi-cast")).text(),
},
voiceActor: {
id: $(el).find($(".per-info.rtl .pi-avatar")).attr("href")?.split("/")[2] || "",
poster: $(el).find($(".per-info.rtl .pi-avatar img")).attr("data-src") || "",
name: $(el).find($(".per-info.rtl .pi-detail a")).text(),
cast: $(el).find($(".per-info.rtl .pi-detail .pi-cast")).text(),
},
});
}
);
// more information
$(`${selector} .anisc-info-wrap .anisc-info .item:not(.w-hide)`).each(
(i, el) => {
let key = $(el)
.find(".item-head")
.text()
.toLowerCase()
.replace(":", "")
.trim();
key = key.includes(" ") ? key.replace(" ", "") : key;
const value = [
...$(el)
.find("*:not(.item-head)")
.map((i, el) => $(el).text().trim()),
]
.map((i) => `${i}`)
.toString()
.trim();
if (key === "genres") {
res.anime.moreInfo[key] = value.split(",").map((i) => i.trim());
return;
}
if (key === "producers") {
res.anime.moreInfo[key] = value.split(",").map((i) => i.trim());
return;
}
res.anime.moreInfo[key] = value;
}
);
// more seasons
const seasonsSelector: SelectorType = "#main-content .os-list a.os-item";
$(seasonsSelector).each((i, el) => {
res.seasons.push({
id: $(el)?.attr("href")?.slice(1)?.trim() || null,
name: $(el)?.attr("title")?.trim() || null,
title: $(el)?.find(".title")?.text()?.trim(),
poster:
$(el)
?.find(".season-poster")
?.attr("style")
?.split(" ")
?.pop()
?.split("(")
?.pop()
?.split(")")[0] || null,
isCurrent: $(el).hasClass("active"),
});
});
const relatedAnimeSelector: SelectorType =
"#main-sidebar .block_area.block_area_sidebar.block_area-realtime:nth-of-type(1) .anif-block-ul ul li";
res.relatedAnimes = extractMostPopularAnimes($, relatedAnimeSelector);
const mostPopularSelector: SelectorType =
"#main-sidebar .block_area.block_area_sidebar.block_area-realtime:nth-of-type(2) .anif-block-ul ul li";
res.mostPopularAnimes = extractMostPopularAnimes($, mostPopularSelector);
const recommendedAnimeSelector: SelectorType =
"#main-content .block_area.block_area_category .tab-content .flw-item";
res.recommendedAnimes = extractAnimes($, recommendedAnimeSelector);
return res;
} catch (err: any) {
if (err instanceof AxiosError) {
throw createHttpError(
err?.response?.status || 500,
err?.response?.statusText || "Something went wrong"
);
}
throw createHttpError.InternalServerError(err?.message);
}
}
export default scrapeAnimeAboutInfo;
-118
View File
@@ -1,118 +0,0 @@
import {
SRC_BASE_URL,
extractAnimes,
ACCEPT_HEADER,
USER_AGENT_HEADER,
extractTop10Animes,
ACCEPT_ENCODING_HEADER,
} from "../utils/index.js";
import axios, { AxiosError } from "axios";
import { type AnimeCategories } from "../types/anime.js";
import createHttpError, { type HttpError } from "http-errors";
import { load, type CheerioAPI, type SelectorType } from "cheerio";
import { type ScrapedAnimeCategory } from "../types/parsers/index.js";
// /anime/:category?page=${page}
async function scrapeAnimeCategory(
category: AnimeCategories,
page: number = 1
): Promise<ScrapedAnimeCategory | HttpError> {
const res: ScrapedAnimeCategory = {
animes: [],
genres: [],
top10Animes: {
today: [],
week: [],
month: [],
},
category,
currentPage: Number(page),
hasNextPage: false,
totalPages: 1,
};
try {
const scrapeUrl: URL = new URL(category, SRC_BASE_URL);
const mainPage = await axios.get(`${scrapeUrl}?page=${page}`, {
headers: {
"User-Agent": USER_AGENT_HEADER,
"Accept-Encoding": ACCEPT_ENCODING_HEADER,
Accept: ACCEPT_HEADER,
},
});
const $: CheerioAPI = load(mainPage.data);
const selector: SelectorType =
"#main-content .tab-content .film_list-wrap .flw-item";
const categoryNameSelector: SelectorType =
"#main-content .block_area .block_area-header .cat-heading";
res.category = $(categoryNameSelector)?.text()?.trim() ?? category;
res.hasNextPage =
$(".pagination > li").length > 0
? $(".pagination li.active").length > 0
? $(".pagination > li").last().hasClass("active")
? false
: true
: false
: false;
res.totalPages =
Number(
$('.pagination > .page-item a[title="Last"]')
?.attr("href")
?.split("=")
.pop() ??
$('.pagination > .page-item a[title="Next"]')
?.attr("href")
?.split("=")
.pop() ??
$(".pagination > .page-item.active a")?.text()?.trim()
) || 1;
res.animes = extractAnimes($, selector);
if (res.animes.length === 0 && !res.hasNextPage) {
res.totalPages = 0;
}
const genreSelector: SelectorType =
"#main-sidebar .block_area.block_area_sidebar.block_area-genres .sb-genre-list li";
$(genreSelector).each((i, el) => {
res.genres.push(`${$(el).text().trim()}`);
});
const top10AnimeSelector: SelectorType =
'#main-sidebar .block_area-realtime [id^="top-viewed-"]';
$(top10AnimeSelector).each((i, el) => {
const period = $(el).attr("id")?.split("-")?.pop()?.trim();
if (period === "day") {
res.top10Animes.today = extractTop10Animes($, period);
return;
}
if (period === "week") {
res.top10Animes.week = extractTop10Animes($, period);
return;
}
if (period === "month") {
res.top10Animes.month = extractTop10Animes($, period);
}
});
return res;
} catch (err: any) {
if (err instanceof AxiosError) {
throw createHttpError(
err?.response?.status || 500,
err?.response?.statusText || "Something went wrong"
);
}
throw createHttpError.InternalServerError(err?.message);
}
}
export default scrapeAnimeCategory;
-129
View File
@@ -1,129 +0,0 @@
import {
SRC_AJAX_URL,
SRC_BASE_URL,
retrieveServerId,
USER_AGENT_HEADER,
} from "../utils/index.js";
import axios, { AxiosError } from "axios";
import { load, type CheerioAPI } from "cheerio";
import createHttpError, { type HttpError } from "http-errors";
import { type AnimeServers, Servers } from "../types/anime.js";
import {
RapidCloud,
StreamSB,
StreamTape,
MegaCloud,
} from "../extractors/index.js";
import { type ScrapedAnimeEpisodesSources } from "../types/parsers/index.js";
// vidtreaming -> 4
// rapidcloud -> 1
// streamsb -> 5
// streamtape -> 3
// /anime/episode-srcs?id=${episodeId}?server=${server}&category=${category (dub or sub)}
async function scrapeAnimeEpisodeSources(
episodeId: string,
server: AnimeServers = Servers.VidStreaming,
category: "sub" | "dub" | "raw" = "sub"
): Promise<ScrapedAnimeEpisodesSources | HttpError> {
if (episodeId.startsWith("http")) {
const serverUrl = new URL(episodeId);
switch (server) {
case Servers.VidStreaming:
case Servers.VidCloud:
return {
...(await new MegaCloud().extract(serverUrl)),
};
case Servers.StreamSB:
return {
headers: {
Referer: serverUrl.href,
watchsb: "streamsb",
"User-Agent": USER_AGENT_HEADER,
},
sources: await new StreamSB().extract(serverUrl, true),
};
case Servers.StreamTape:
return {
headers: { Referer: serverUrl.href, "User-Agent": USER_AGENT_HEADER },
sources: await new StreamTape().extract(serverUrl),
};
default: // vidcloud
return {
headers: { Referer: serverUrl.href },
...(await new RapidCloud().extract(serverUrl)),
};
}
}
const epId = new URL(`/watch/${episodeId}`, SRC_BASE_URL).href;
console.log(epId);
try {
const resp = await axios.get(
`${SRC_AJAX_URL}/v2/episode/servers?episodeId=${epId.split("?ep=")[1]}`,
{
headers: {
Referer: epId,
"User-Agent": USER_AGENT_HEADER,
"X-Requested-With": "XMLHttpRequest",
},
}
);
const $: CheerioAPI = load(resp.data.html);
let serverId: string | null = null;
try {
console.log("THE SERVER: ", server);
switch (server) {
case Servers.VidCloud: {
serverId = retrieveServerId($, 1, category);
if (!serverId) throw new Error("RapidCloud not found");
break;
}
case Servers.VidStreaming: {
serverId = retrieveServerId($, 4, category);
console.log("SERVER_ID: ", serverId);
if (!serverId) throw new Error("VidStreaming not found");
break;
}
case Servers.StreamSB: {
serverId = retrieveServerId($, 5, category);
if (!serverId) throw new Error("StreamSB not found");
break;
}
case Servers.StreamTape: {
serverId = retrieveServerId($, 3, category);
if (!serverId) throw new Error("StreamTape not found");
break;
}
}
} catch (err) {
throw createHttpError.NotFound(
"Couldn't find server. Try another server"
);
}
const {
data: { link },
} = await axios.get(`${SRC_AJAX_URL}/v2/episode/sources?id=${serverId}`);
console.log("THE LINK: ", link);
return await scrapeAnimeEpisodeSources(link, server);
} catch (err: any) {
console.log(err);
if (err instanceof AxiosError) {
throw createHttpError(
err?.response?.status || 500,
err?.response?.statusText || "Something went wrong"
);
}
throw createHttpError.InternalServerError(err?.message);
}
}
export default scrapeAnimeEpisodeSources;
-61
View File
@@ -1,61 +0,0 @@
import {
SRC_BASE_URL,
SRC_AJAX_URL,
ACCEPT_HEADER,
USER_AGENT_HEADER,
ACCEPT_ENCODING_HEADER,
} from "../utils/index.js";
import axios, { AxiosError } from "axios";
import { load, type CheerioAPI } from "cheerio";
import createHttpError, { type HttpError } from "http-errors";
import { type ScrapedAnimeEpisodes } from "../types/parsers/index.js";
// /anime/episodes/${anime-id}
async function scrapeAnimeEpisodes(
animeId: string
): Promise<ScrapedAnimeEpisodes | HttpError> {
const res: ScrapedAnimeEpisodes = {
totalEpisodes: 0,
episodes: [],
};
try {
const episodesAjax = await axios.get(
`${SRC_AJAX_URL}/v2/episode/list/${animeId.split("-").pop()}`,
{
headers: {
Accept: ACCEPT_HEADER,
"User-Agent": USER_AGENT_HEADER,
"X-Requested-With": "XMLHttpRequest",
"Accept-Encoding": ACCEPT_ENCODING_HEADER,
Referer: `${SRC_BASE_URL}/watch/${animeId}`,
},
}
);
const $: CheerioAPI = load(episodesAjax.data.html);
res.totalEpisodes = Number($(".detail-infor-content .ss-list a").length);
$(".detail-infor-content .ss-list a").each((i, el) => {
res.episodes.push({
title: $(el)?.attr("title")?.trim() || null,
episodeId: $(el)?.attr("href")?.split("/")?.pop() || null,
number: Number($(el).attr("data-number")),
isFiller: $(el).hasClass("ssl-item-filler"),
});
});
return res;
} catch (err: any) {
if (err instanceof AxiosError) {
throw createHttpError(
err?.response?.status || 500,
err?.response?.statusText || "Something went wrong"
);
}
throw createHttpError.InternalServerError(err?.message);
}
}
export default scrapeAnimeEpisodes;
-105
View File
@@ -1,105 +0,0 @@
import {
SRC_BASE_URL,
ACCEPT_HEADER,
extractAnimes,
USER_AGENT_HEADER,
ACCEPT_ENCODING_HEADER,
extractMostPopularAnimes,
} from "../utils/index.js";
import axios, { AxiosError } from "axios";
import createHttpError, { type HttpError } from "http-errors";
import { load, type CheerioAPI, type SelectorType } from "cheerio";
import type { ScrapedGenreAnime } from "../types/parsers/index.js";
// /anime/genre/${name}?page=${page}
async function scrapeGenreAnime(
genreName: string,
page: number = 1
): Promise<ScrapedGenreAnime | HttpError> {
const res: ScrapedGenreAnime = {
genreName,
animes: [],
genres: [],
topAiringAnimes: [],
totalPages: 1,
hasNextPage: false,
currentPage: Number(page),
};
// there's a typo with zoro where martial arts is marial arts
genreName = genreName === "martial-arts" ? "marial-arts" : genreName;
try {
const genreUrl: URL = new URL(
`/genre/${genreName}?page=${page}`,
SRC_BASE_URL
);
const mainPage = await axios.get(genreUrl.href, {
headers: {
"User-Agent": USER_AGENT_HEADER,
"Accept-Encoding": ACCEPT_ENCODING_HEADER,
Accept: ACCEPT_HEADER,
},
});
const $: CheerioAPI = load(mainPage.data);
const selector: SelectorType =
"#main-content .tab-content .film_list-wrap .flw-item";
const genreNameSelector: SelectorType =
"#main-content .block_area .block_area-header .cat-heading";
res.genreName = $(genreNameSelector)?.text()?.trim() ?? genreName;
res.hasNextPage =
$(".pagination > li").length > 0
? $(".pagination li.active").length > 0
? $(".pagination > li").last().hasClass("active")
? false
: true
: false
: false;
res.totalPages =
Number(
$('.pagination > .page-item a[title="Last"]')
?.attr("href")
?.split("=")
.pop() ??
$('.pagination > .page-item a[title="Next"]')
?.attr("href")
?.split("=")
.pop() ??
$(".pagination > .page-item.active a")?.text()?.trim()
) || 1;
res.animes = extractAnimes($, selector);
if (res.animes.length === 0 && !res.hasNextPage) {
res.totalPages = 0;
}
const genreSelector: SelectorType =
"#main-sidebar .block_area.block_area_sidebar.block_area-genres .sb-genre-list li";
$(genreSelector).each((i, el) => {
res.genres.push(`${$(el).text().trim()}`);
});
const topAiringSelector: SelectorType =
"#main-sidebar .block_area.block_area_sidebar.block_area-realtime .anif-block-ul ul li";
res.topAiringAnimes = extractMostPopularAnimes($, topAiringSelector);
return res;
} catch (err: any) {
if (err instanceof AxiosError) {
throw createHttpError(
err?.response?.status || 500,
err?.response?.statusText || "Something went wrong"
);
}
throw createHttpError.InternalServerError(err?.message);
}
}
export default scrapeGenreAnime;
-120
View File
@@ -1,120 +0,0 @@
import {
SRC_BASE_URL,
ACCEPT_HEADER,
USER_AGENT_HEADER,
ACCEPT_ENCODING_HEADER,
extractMostPopularAnimes,
extractAnimes,
extractTop10Animes,
} from "../utils/index.js";
import axios, { AxiosError } from "axios";
import createHttpError, { type HttpError } from "http-errors";
import { load, type CheerioAPI, type SelectorType } from "cheerio";
import type { ScrapedProducerAnime } from "../types/parsers/index.js";
// /anime/producer/${name}?page=${page}
async function scrapeProducerAnimes(
producerName: string,
page: number = 1
): Promise<ScrapedProducerAnime | HttpError> {
const res: ScrapedProducerAnime = {
producerName,
animes: [],
top10Animes: {
today: [],
week: [],
month: [],
},
topAiringAnimes: [],
totalPages: 1,
hasNextPage: false,
currentPage: Number(page),
};
try {
const producerUrl: URL = new URL(
`/producer/${producerName}?page=${page}`,
SRC_BASE_URL
);
const mainPage = await axios.get(producerUrl.href, {
headers: {
Accept: ACCEPT_HEADER,
"User-Agent": USER_AGENT_HEADER,
"Accept-Encoding": ACCEPT_ENCODING_HEADER,
},
});
const $: CheerioAPI = load(mainPage.data);
const animeSelector: SelectorType =
"#main-content .tab-content .film_list-wrap .flw-item";
res.hasNextPage =
$(".pagination > li").length > 0
? $(".pagination li.active").length > 0
? $(".pagination > li").last().hasClass("active")
? false
: true
: false
: false;
res.totalPages =
Number(
$('.pagination > .page-item a[title="Last"]')
?.attr("href")
?.split("=")
.pop() ??
$('.pagination > .page-item a[title="Next"]')
?.attr("href")
?.split("=")
.pop() ??
$(".pagination > .page-item.active a")?.text()?.trim()
) || 1;
res.animes = extractAnimes($, animeSelector);
if (res.animes.length === 0 && !res.hasNextPage) {
res.totalPages = 0;
}
const producerNameSelector: SelectorType =
"#main-content .block_area .block_area-header .cat-heading";
res.producerName = $(producerNameSelector)?.text()?.trim() ?? producerName;
const top10AnimeSelector: SelectorType =
'#main-sidebar .block_area-realtime [id^="top-viewed-"]';
$(top10AnimeSelector).each((_, el) => {
const period = $(el).attr("id")?.split("-")?.pop()?.trim();
if (period === "day") {
res.top10Animes.today = extractTop10Animes($, period);
return;
}
if (period === "week") {
res.top10Animes.week = extractTop10Animes($, period);
return;
}
if (period === "month") {
res.top10Animes.month = extractTop10Animes($, period);
}
});
const topAiringSelector: SelectorType =
"#main-sidebar .block_area_sidebar:nth-child(2) .block_area-content .anif-block-ul ul li";
res.topAiringAnimes = extractMostPopularAnimes($, topAiringSelector);
return res;
} catch (err: any) {
if (err instanceof AxiosError) {
throw createHttpError(
err?.response?.status || 500,
err?.response?.statusText || "Something went wrong"
);
}
throw createHttpError.InternalServerError(err?.message);
}
}
export default scrapeProducerAnimes;
-118
View File
@@ -1,118 +0,0 @@
import {
SRC_SEARCH_URL,
ACCEPT_HEADER,
USER_AGENT_HEADER,
ACCEPT_ENCODING_HEADER,
extractAnimes,
getSearchFilterValue,
extractMostPopularAnimes,
getSearchDateFilterValue,
} from "../utils/index.js";
import axios, { AxiosError } from "axios";
import createHttpError, { type HttpError } from "http-errors";
import { load, type CheerioAPI, type SelectorType } from "cheerio";
import type { ScrapedAnimeSearchResult } from "../types/parsers/index.js";
import type { SearchFilters, FilterKeys } from "../types/controllers/index.js";
// /anime/search?q=${query}&page=${page}
async function scrapeAnimeSearch(
q: string,
page: number = 1,
filters: SearchFilters
): Promise<ScrapedAnimeSearchResult | HttpError> {
const res: ScrapedAnimeSearchResult = {
animes: [],
mostPopularAnimes: [],
currentPage: Number(page),
hasNextPage: false,
totalPages: 1,
searchQuery: q,
searchFilters: filters,
};
try {
const url = new URL(SRC_SEARCH_URL);
url.searchParams.set("keyword", q);
url.searchParams.set("page", `${page}`);
url.searchParams.set("sort", "default");
for (const key in filters) {
if (key.includes("_date")) {
const dates = getSearchDateFilterValue(
key === "start_date",
filters[key as keyof SearchFilters] || ""
);
if (!dates) continue;
dates.map((dateParam) => {
const [key, val] = dateParam.split("=");
url.searchParams.set(key, val);
});
continue;
}
const filterVal = getSearchFilterValue(
key as FilterKeys,
filters[key as keyof SearchFilters] || ""
);
filterVal && url.searchParams.set(key, filterVal);
}
const mainPage = await axios.get(url.href, {
headers: {
"User-Agent": USER_AGENT_HEADER,
"Accept-Encoding": ACCEPT_ENCODING_HEADER,
Accept: ACCEPT_HEADER,
},
});
const $: CheerioAPI = load(mainPage.data);
const selector: SelectorType =
"#main-content .tab-content .film_list-wrap .flw-item";
res.hasNextPage =
$(".pagination > li").length > 0
? $(".pagination li.active").length > 0
? $(".pagination > li").last().hasClass("active")
? false
: true
: false
: false;
res.totalPages =
Number(
$('.pagination > .page-item a[title="Last"]')
?.attr("href")
?.split("=")
.pop() ??
$('.pagination > .page-item a[title="Next"]')
?.attr("href")
?.split("=")
.pop() ??
$(".pagination > .page-item.active a")?.text()?.trim()
) || 1;
res.animes = extractAnimes($, selector);
if (res.animes.length === 0 && !res.hasNextPage) {
res.totalPages = 0;
}
const mostPopularSelector: SelectorType =
"#main-sidebar .block_area.block_area_sidebar.block_area-realtime .anif-block-ul ul li";
res.mostPopularAnimes = extractMostPopularAnimes($, mostPopularSelector);
return res;
} catch (err: any) {
if (err instanceof AxiosError) {
throw createHttpError(
err?.response?.status || 500,
err?.response?.statusText || "Something went wrong"
);
}
throw createHttpError.InternalServerError(err?.message);
}
}
export default scrapeAnimeSearch;
-77
View File
@@ -1,77 +0,0 @@
import {
SRC_HOME_URL,
SRC_AJAX_URL,
USER_AGENT_HEADER,
ACCEPT_ENCODING_HEADER,
} from "../utils/index.js";
import axios, { AxiosError } from "axios";
import createHttpError, { type HttpError } from "http-errors";
import { load, type CheerioAPI, type SelectorType } from "cheerio";
import type { ScrapedAnimeSearchSuggestion } from "../types/parsers/index.js";
// /anime/search/suggest?q=${query}
async function scrapeAnimeSearchSuggestion(
q: string
): Promise<ScrapedAnimeSearchSuggestion | HttpError> {
const res: ScrapedAnimeSearchSuggestion = {
suggestions: [],
};
try {
const { data } = await axios.get(
`${SRC_AJAX_URL}/search/suggest?keyword=${encodeURIComponent(q)}`,
{
headers: {
Accept: "*/*",
Pragma: "no-cache",
Referer: SRC_HOME_URL,
"User-Agent": USER_AGENT_HEADER,
"X-Requested-With": "XMLHttpRequest",
"Accept-Encoding": ACCEPT_ENCODING_HEADER,
},
}
);
const $: CheerioAPI = load(data.html);
const selector: SelectorType = ".nav-item:has(.film-poster)";
if ($(selector).length < 1) return res;
$(selector).each((_, el) => {
const id = $(el).attr("href")?.split("?")[0].includes("javascript")
? null
: $(el).attr("href")?.split("?")[0]?.slice(1);
res.suggestions.push({
id,
name: $(el).find(".srp-detail .film-name")?.text()?.trim() || null,
jname:
$(el).find(".srp-detail .film-name")?.attr("data-jname")?.trim() ||
$(el).find(".srp-detail .alias-name")?.text()?.trim() ||
null,
poster: $(el)
.find(".film-poster .film-poster-img")
?.attr("data-src")
?.trim(),
moreInfo: [
...$(el)
.find(".film-infor")
.contents()
.map((_, el) => $(el).text().trim()),
].filter((i) => i),
});
});
return res;
} catch (err: any) {
if (err instanceof AxiosError) {
throw createHttpError(
err?.response?.status || 500,
err?.response?.statusText || "Something went wrong"
);
}
throw createHttpError.InternalServerError(err?.message);
}
}
export default scrapeAnimeSearchSuggestion;
-85
View File
@@ -1,85 +0,0 @@
import {
SRC_BASE_URL,
SRC_AJAX_URL,
ACCEPT_HEADER,
USER_AGENT_HEADER,
ACCEPT_ENCODING_HEADER,
} from "../utils/index.js";
import axios, { AxiosError } from "axios";
import createHttpError, { type HttpError } from "http-errors";
import { load, type CheerioAPI, type SelectorType } from "cheerio";
import type { ScrapedEpisodeServers } from "../types/parsers/index.js";
// /anime/servers?episodeId=${id}
async function scrapeEpisodeServers(
episodeId: string
): Promise<ScrapedEpisodeServers | HttpError> {
const res: ScrapedEpisodeServers = {
sub: [],
dub: [],
raw: [],
episodeId,
episodeNo: 0,
};
try {
const epId = episodeId.split("?ep=")[1];
const { data } = await axios.get(
`${SRC_AJAX_URL}/v2/episode/servers?episodeId=${epId}`,
{
headers: {
Accept: ACCEPT_HEADER,
"User-Agent": USER_AGENT_HEADER,
"X-Requested-With": "XMLHttpRequest",
"Accept-Encoding": ACCEPT_ENCODING_HEADER,
Referer: new URL(`/watch/${episodeId}`, SRC_BASE_URL).href,
},
}
);
const $: CheerioAPI = load(data.html);
const epNoSelector: SelectorType = ".server-notice strong";
res.episodeNo = Number($(epNoSelector).text().split(" ").pop()) || 0;
$(`.ps_-block.ps_-block-sub.servers-sub .ps__-list .server-item`).each(
(_, el) => {
res.sub.push({
serverName: $(el).find("a").text().toLowerCase().trim(),
serverId: Number($(el)?.attr("data-server-id")?.trim()) || null,
});
}
);
$(`.ps_-block.ps_-block-sub.servers-dub .ps__-list .server-item`).each(
(_, el) => {
res.dub.push({
serverName: $(el).find("a").text().toLowerCase().trim(),
serverId: Number($(el)?.attr("data-server-id")?.trim()) || null,
});
}
);
$(`.ps_-block.ps_-block-sub.servers-raw .ps__-list .server-item`).each(
(_, el) => {
res.raw.push({
serverName: $(el).find("a").text().toLowerCase().trim(),
serverId: Number($(el)?.attr("data-server-id")?.trim()) || null,
});
}
);
return res;
} catch (err: any) {
if (err instanceof AxiosError) {
throw createHttpError(
err?.response?.status || 500,
err?.response?.statusText || "Something went wrong"
);
}
throw createHttpError.InternalServerError(err?.message);
}
}
export default scrapeEpisodeServers;
-74
View File
@@ -1,74 +0,0 @@
import {
SRC_HOME_URL,
SRC_AJAX_URL,
USER_AGENT_HEADER,
ACCEPT_ENCODING_HEADER,
} from "../utils/index.js";
import axios, { AxiosError } from "axios";
import createHttpError, { type HttpError } from "http-errors";
import { load, type CheerioAPI, type SelectorType } from "cheerio";
import { type ScrapedEstimatedSchedule } from "../types/parsers/index.js";
// /anime/schedule?date=${date}
async function scrapeEstimatedSchedule(
date: string
): Promise<ScrapedEstimatedSchedule | HttpError> {
const res: ScrapedEstimatedSchedule = {
scheduledAnimes: [],
};
try {
const estScheduleURL =
`${SRC_AJAX_URL}/schedule/list?tzOffset=-330&date=${date}` as const;
const mainPage = await axios.get(estScheduleURL, {
headers: {
Accept: "*/*",
Referer: SRC_HOME_URL,
"User-Agent": USER_AGENT_HEADER,
"X-Requested-With": "XMLHttpRequest",
"Accept-Encoding": ACCEPT_ENCODING_HEADER,
},
});
const $: CheerioAPI = load(mainPage?.data?.html);
const selector: SelectorType = "li";
if ($(selector)?.text()?.trim()?.includes("No data to display")) {
return res;
}
$(selector).each((_, el) => {
const airingTimestamp = new Date(
`${date}T${$(el)?.find("a .time")?.text()?.trim()}:00`
).getTime();
res.scheduledAnimes.push({
id: $(el)?.find("a")?.attr("href")?.slice(1)?.trim() || null,
time: $(el)?.find("a .time")?.text()?.trim() || null,
name: $(el)?.find("a .film-name.dynamic-name")?.text()?.trim() || null,
jname:
$(el)
?.find("a .film-name.dynamic-name")
?.attr("data-jname")
?.trim() || null,
airingTimestamp,
secondsUntilAiring: Math.floor((airingTimestamp - Date.now()) / 1000),
episode: Number($(el).find("a .fd-play button").text().trim().split(" ")[1])
});
});
return res;
} catch (err: any) {
if (err instanceof AxiosError) {
throw createHttpError(
err?.response?.status || 500,
err?.response?.statusText || "Something went wrong"
);
}
throw createHttpError.InternalServerError(err?.message);
}
}
export default scrapeEstimatedSchedule;
-186
View File
@@ -1,186 +0,0 @@
import {
SRC_HOME_URL,
ACCEPT_HEADER,
USER_AGENT_HEADER,
ACCEPT_ENCODING_HEADER,
extractTop10Animes,
extractAnimes,
extractMostPopularAnimes,
} from "../utils/index.js";
import axios, { AxiosError } from "axios";
import createHttpError, { type HttpError } from "http-errors";
import type { ScrapedHomePage } from "../types/parsers/index.js";
import { load, type CheerioAPI, type SelectorType } from "cheerio";
// /anime/home
async function scrapeHomePage(): Promise<ScrapedHomePage | HttpError> {
const res: ScrapedHomePage = {
spotlightAnimes: [],
trendingAnimes: [],
latestEpisodeAnimes: [],
topUpcomingAnimes: [],
top10Animes: {
today: [],
week: [],
month: [],
},
topAiringAnimes: [],
mostPopularAnimes: [],
mostFavoriteAnimes: [],
latestCompletedAnimes: [],
genres: [],
};
try {
const mainPage = await axios.get(SRC_HOME_URL as string, {
headers: {
"User-Agent": USER_AGENT_HEADER,
"Accept-Encoding": ACCEPT_ENCODING_HEADER,
Accept: ACCEPT_HEADER,
},
});
const $: CheerioAPI = load(mainPage.data);
const spotlightSelector: SelectorType =
"#slider .swiper-wrapper .swiper-slide";
$(spotlightSelector).each((i, el) => {
const otherInfo = $(el)
.find(".deslide-item-content .sc-detail .scd-item")
.map((i, el) => $(el).text().trim())
.get()
.slice(0, -1);
res.spotlightAnimes.push({
rank:
Number(
$(el)
.find(".deslide-item-content .desi-sub-text")
?.text()
.trim()
.split(" ")[0]
.slice(1)
) || null,
id: $(el)
.find(".deslide-item-content .desi-buttons a")
?.last()
?.attr("href")
?.slice(1)
?.trim(),
name: $(el)
.find(".deslide-item-content .desi-head-title.dynamic-name")
?.text()
.trim(),
description: $(el)
.find(".deslide-item-content .desi-description")
?.text()
?.split("[")
?.shift()
?.trim(),
poster: $(el)
.find(".deslide-cover .deslide-cover-img .film-poster-img")
?.attr("data-src")
?.trim(),
jname: $(el)
.find(".deslide-item-content .desi-head-title.dynamic-name")
?.attr("data-jname")
?.trim(),
episodes: {
sub:
Number(
$(el)
.find(
".deslide-item-content .sc-detail .scd-item .tick-item.tick-sub"
)
?.text()
?.trim()
) || null,
dub:
Number(
$(el)
.find(
".deslide-item-content .sc-detail .scd-item .tick-item.tick-dub"
)
?.text()
?.trim()
) || null,
},
otherInfo,
});
});
const trendingSelector: SelectorType =
"#trending-home .swiper-wrapper .swiper-slide";
$(trendingSelector).each((i, el) => {
res.trendingAnimes.push({
rank: parseInt(
$(el).find(".item .number")?.children()?.first()?.text()?.trim()
),
id: $(el).find(".item .film-poster")?.attr("href")?.slice(1)?.trim(),
name: $(el)
.find(".item .number .film-title.dynamic-name")
?.text()
?.trim(),
jname: $(el)
.find(".item .number .film-title.dynamic-name")
?.attr("data-jname")
?.trim(),
poster: $(el)
.find(".item .film-poster .film-poster-img")
?.attr("data-src")
?.trim(),
});
});
const latestEpisodeSelector: SelectorType =
"#main-content .block_area_home:nth-of-type(1) .tab-content .film_list-wrap .flw-item";
res.latestEpisodeAnimes = extractAnimes($, latestEpisodeSelector);
const topUpcomingSelector: SelectorType =
"#main-content .block_area_home:nth-of-type(3) .tab-content .film_list-wrap .flw-item";
res.topUpcomingAnimes = extractAnimes($, topUpcomingSelector);
const genreSelector: SelectorType =
"#main-sidebar .block_area.block_area_sidebar.block_area-genres .sb-genre-list li";
$(genreSelector).each((i, el) => {
res.genres.push(`${$(el).text().trim()}`);
});
const mostViewedSelector: SelectorType =
'#main-sidebar .block_area-realtime [id^="top-viewed-"]';
$(mostViewedSelector).each((i, el) => {
const period = $(el).attr("id")?.split("-")?.pop()?.trim();
if (period === "day") {
res.top10Animes.today = extractTop10Animes($, period);
return;
}
if (period === "week") {
res.top10Animes.week = extractTop10Animes($, period);
return;
}
if (period === "month") {
res.top10Animes.month = extractTop10Animes($, period);
}
});
res.topAiringAnimes = extractMostPopularAnimes($, "#anime-featured .row div:nth-of-type(1) .anif-block-ul ul li");
res.mostPopularAnimes = extractMostPopularAnimes($, "#anime-featured .row div:nth-of-type(2) .anif-block-ul ul li");
res.mostFavoriteAnimes = extractMostPopularAnimes($, "#anime-featured .row div:nth-of-type(3) .anif-block-ul ul li");
res.latestCompletedAnimes = extractMostPopularAnimes($, "#anime-featured .row div:nth-of-type(4) .anif-block-ul ul li");
return res;
} catch (err: any) {
if (err instanceof AxiosError) {
throw createHttpError(
err?.response?.status || 500,
err?.response?.statusText || "Something went wrong"
);
}
throw createHttpError.InternalServerError(err?.message);
}
}
export default scrapeHomePage;
-25
View File
@@ -1,25 +0,0 @@
import scrapeHomePage from "./homePage.js";
import scrapeGenreAnime from "./animeGenre.js";
import scrapeAnimeSearch from "./animeSearch.js";
import scrapeAnimeEpisodes from "./animeEpisodes.js";
import scrapeAnimeCategory from "./animeCategory.js";
import scrapeProducerAnimes from "./animeProducer.js";
import scrapeEpisodeServers from "./episodeServers.js";
import scrapeAnimeAboutInfo from "./animeAboutInfo.js";
import scrapeEstimatedSchedule from "./estimatedSchedule.js";
import scrapeAnimeEpisodeSources from "./animeEpisodeSrcs.js";
import scrapeAnimeSearchSuggestion from "./animeSearchSuggestion.js";
export {
scrapeHomePage,
scrapeGenreAnime,
scrapeAnimeSearch,
scrapeAnimeEpisodes,
scrapeAnimeCategory,
scrapeEpisodeServers,
scrapeProducerAnimes,
scrapeAnimeAboutInfo,
scrapeEstimatedSchedule,
scrapeAnimeEpisodeSources,
scrapeAnimeSearchSuggestion,
};
+125
View File
@@ -0,0 +1,125 @@
import { Hono } from "hono";
import { HiAnime } from "aniwatch";
const hianime = new HiAnime.Scraper();
const hianimeRouter = new Hono();
// /api/v2/hianime
hianimeRouter.get("/", (c) => c.redirect("/", 301));
// /api/v2/hianime/home
hianimeRouter.get("/home", async (c) => {
const data = await hianime.getHomePage();
return c.json({ success: true, data }, { status: 200 });
});
// /api/v2/hianime/category/{name}?page={page}
hianimeRouter.get("/category/:name", async (c) => {
const categoryName = decodeURIComponent(
c.req.param("name").trim()
) as HiAnime.AnimeCategories;
const page: number =
Number(decodeURIComponent(c.req.query("page") || "")) || 1;
const data = await hianime.getCategoryAnime(categoryName, page);
return c.json({ success: true, data }, { status: 200 });
});
// /api/v2/hianime/genre/{name}?page={page}
hianimeRouter.get("/genre/:name", async (c) => {
const genreName = decodeURIComponent(c.req.param("name").trim());
const page: number =
Number(decodeURIComponent(c.req.query("page") || "")) || 1;
const data = await hianime.getGenreAnime(genreName, page);
return c.json({ success: true, data }, { status: 200 });
});
// /api/v2/hianime/producer/{name}?page={page}
hianimeRouter.get("/producer/:name", async (c) => {
const producerName = decodeURIComponent(c.req.param("name").trim());
const page: number =
Number(decodeURIComponent(c.req.query("page") || "")) || 1;
const data = await hianime.getProducerAnimes(producerName, page);
return c.json({ success: true, data }, { status: 200 });
});
// /api/v2/hianime/schedule?date={date}
hianimeRouter.get("/schedule", async (c) => {
const date = decodeURIComponent(c.req.query("date") || "");
const data = await hianime.getEstimatedSchedule(date);
return c.json({ success: true, data }, { status: 200 });
});
// /api/v2/hianime/search?q={query}&page={page}&filters={...filters}
hianimeRouter.get("/search", async (c) => {
let { q: query, page, ...filters } = c.req.query();
query = decodeURIComponent(query || "");
const pageNo = Number(decodeURIComponent(page || "")) || 1;
const data = await hianime.search(query, pageNo, filters);
return c.json({ success: true, data }, { status: 200 });
});
// /api/v2/hianime/search/suggestion?q={query}
hianimeRouter.get("/search/suggestion", async (c) => {
const query = decodeURIComponent(c.req.query("q") || "");
const data = await hianime.searchSuggestions(query);
return c.json({ success: true, data }, { status: 200 });
});
// /api/v2/hianime/anime/{animeId}
hianimeRouter.get("/anime/:animeId", async (c) => {
const animeId = decodeURIComponent(c.req.param("animeId").trim());
const data = await hianime.getInfo(animeId);
return c.json({ success: true, data }, { status: 200 });
});
// /api/v2/hianime/episode/servers?animeEpisodeId={id}
hianimeRouter.get("/episode/servers", async (c) => {
const animeEpisodeId = decodeURIComponent(
c.req.query("animeEpisodeId") || ""
);
const data = await hianime.getEpisodeServers(animeEpisodeId);
return c.json({ success: true, data }, { status: 200 });
});
// episodeId=steinsgate-3?ep=230
// /api/v2/hianime/episode/sources?animeEpisodeId={episodeId}?server={server}&category={category (dub or sub)}
hianimeRouter.get("/episode/sources", async (c) => {
const animeEpisodeId = decodeURIComponent(
c.req.query("animeEpisodeId") || ""
);
const server = decodeURIComponent(
c.req.query("server") || HiAnime.Servers.VidStreaming
) as HiAnime.AnimeServers;
const category = decodeURIComponent(c.req.query("category") || "sub") as
| "sub"
| "dub"
| "raw";
const data = await hianime.getEpisodeSources(
animeEpisodeId,
server,
category
);
return c.json({ success: true, data }, { status: 200 });
});
// /api/v2/hianime/anime/{anime-id}/episodes
hianimeRouter.get("/anime/:animeId/episodes", async (c) => {
const animeId = decodeURIComponent(c.req.param("animeId").trim());
const data = await hianime.getEpisodes(animeId);
return c.json({ success: true, data }, { status: 200 });
});
export { hianimeRouter };
-55
View File
@@ -1,55 +0,0 @@
import { Router, type IRouter } from "express";
import {
getGenreAnime,
getAnimeSearch,
getHomePageInfo,
getAnimeCategory,
getAnimeEpisodes,
getEpisodeServers,
getProducerAnimes,
getAnimeAboutInfo,
getEstimatedSchedule,
getAnimeEpisodeSources,
getAnimeSearchSuggestion,
} from "../controllers/index.js";
const router: IRouter = Router();
// /anime
router.get("/", (_, res) => res.redirect("/"));
// /anime/home
router.get("/home", getHomePageInfo);
// /anime/info?id=${anime-id}
router.get("/info", getAnimeAboutInfo);
// /anime/genre/${name}?page=${page}
router.get("/genre/:name", getGenreAnime);
// /anime/search?q=${query}&page=${page}
router.get("/search", getAnimeSearch);
// /anime/search/suggest?q=${query}
router.get("/search/suggest", getAnimeSearchSuggestion);
// /anime/episodes/${anime-id}
router.get("/episodes/:animeId", getAnimeEpisodes);
// /anime/servers?episodeId=${id}
router.get("/servers", getEpisodeServers);
// episodeId=steinsgate-3?ep=230
// /anime/episode-srcs?id=${episodeId}?server=${server}&category=${category (dub or sub)}
router.get("/episode-srcs", getAnimeEpisodeSources);
// /anime/schedule?date=${date}
router.get("/schedule", getEstimatedSchedule);
// /anime/producer/${name}?page=${page}
router.get("/producer/:name", getProducerAnimes);
// /anime/:category?page=${page}
router.get("/:category", getAnimeCategory);
export default router;
+53 -27
View File
@@ -1,59 +1,85 @@
import https from "https"; import https from "https";
import morgan from "morgan";
import express from "express";
import { resolve } from "path";
import { config } from "dotenv"; import { config } from "dotenv";
import corsConfig from "./config/cors.js"; import corsConfig from "./config/cors.js";
import { ratelimit } from "./config/ratelimit.js"; import { ratelimit } from "./config/ratelimit.js";
import errorHandler from "./config/errorHandler.js";
import notFoundHandler from "./config/notFoundHandler.js";
import animeRouter from "./routes/index.js"; import { hianimeRouter } from "./routes/hianime.js";
import { serveStatic } from "@hono/node-server/serve-static";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { logger } from "hono/logger";
import { HiAnimeError } from "aniwatch";
config(); config();
const app: express.Application = express();
const PORT: number = Number(process.env.PORT) || 4000;
app.use(morgan("dev")); const BASE_PATH = "/api/v2" as const;
const PORT: number = Number(process.env.ANIWATCH_API_PORT) || 4000;
const ANIWATCH_API_HOSTNAME = process.env?.ANIWATCH_API_HOSTNAME;
const app = new Hono();
app.use(logger());
app.use(corsConfig); app.use(corsConfig);
// CAUTION: For personal deployments, "refrain" from having an env // CAUTION: For personal deployments, "refrain" from having an env
// named "ANIWATCH_API_HOSTNAME". You may face rate limitting // named "ANIWATCH_API_HOSTNAME". You may face rate limitting
// and other issues if you do. // or other issues if you do.
const ISNT_PERSONAL_DEPLOYMENT = Boolean(process?.env?.ANIWATCH_API_HOSTNAME); const ISNT_PERSONAL_DEPLOYMENT = Boolean(ANIWATCH_API_HOSTNAME);
if (ISNT_PERSONAL_DEPLOYMENT) { if (ISNT_PERSONAL_DEPLOYMENT) {
app.use(ratelimit); app.use(ratelimit);
} }
app.use(express.static(resolve("public"))); app.use("/", serveStatic({ root: "public" }));
app.get("/health", (_, res) => res.sendStatus(200)); app.get("/health", (c) => c.text("OK", { status: 200 }));
app.use("/anime", animeRouter);
app.use(notFoundHandler); app.basePath(BASE_PATH).route("/hianime", hianimeRouter);
app.use(errorHandler); app
.basePath(BASE_PATH)
.get("/anicrush", (c) => c.text("Anicrush could be implemented in future."));
app.notFound((c) =>
c.json({ status: 404, message: "Resource Not Found" }, 404)
);
app.onError((err, c) => {
console.error(err);
const res = { status: 500, message: "Internal Server Error" };
if (err instanceof HiAnimeError) {
res.status = err.status;
res.message = err.message;
}
return c.json(res, { status: res.status });
});
// NOTE: this env is "required" for vercel deployments // NOTE: this env is "required" for vercel deployments
if (!Boolean(process?.env?.IS_VERCEL_DEPLOYMENT)) { if (!Boolean(process?.env?.ANIWATCH_API_VERCEL_DEPLOYMENT)) {
app.listen(PORT, () => { serve({
console.log(`⚔️ api @ http://localhost:${PORT}`); port: PORT,
}); fetch: app.fetch,
}).addListener("listening", () =>
console.info(
"\x1b[1;36m" + `aniwatch-api at http://localhost:${PORT}` + "\x1b[0m"
)
);
// NOTE: remove the `if` block below for personal deployments // NOTE: remove the `if` block below for personal deployments
if (ISNT_PERSONAL_DEPLOYMENT) { if (ISNT_PERSONAL_DEPLOYMENT) {
const interval = 9 * 60 * 1000; // 9mins
// don't sleep // don't sleep
const intervalTime = 9 * 60 * 1000; // 9mins
setInterval(() => { setInterval(() => {
console.log("HEALTHCHECK ;)", new Date().toLocaleString()); console.log("aniwatch-api HEALTH_CHECK at", new Date().toISOString());
https https
.get( .get(`https://${ANIWATCH_API_HOSTNAME}/health`)
new URL("/health", `https://${process.env.ANIWATCH_API_HOSTNAME}`)
.href
)
.on("error", (err) => { .on("error", (err) => {
console.error(err.message); console.error(err.message);
}); });
}, intervalTime); }, interval);
} }
} }
-140
View File
@@ -1,140 +0,0 @@
export interface Anime {
id: string | null;
name: string | null;
jname: string | null;
poster: string | null;
duration: string | null;
type: string | null;
rating: string | null;
episodes: {
sub: number | null;
dub: number | null;
};
}
type CommonAnimeProps = "id" | "name" | "poster";
export interface Top10Anime extends Pick<Anime, CommonAnimeProps | "episodes"> {
rank: number | null;
jname: string | null;
}
export type Top10AnimeTimePeriod = "day" | "week" | "month";
export interface MostPopularAnime
extends Pick<Anime, CommonAnimeProps | "episodes" | "type"> {
jname: string | null;
}
export interface SpotlightAnime
extends MostPopularAnime,
Pick<Top10Anime, "rank"> {
description: string | null;
}
export interface TrendingAnime
extends Pick<Anime, CommonAnimeProps>,
Pick<Top10Anime, "rank"> {}
export interface LatestEpisodeAnime extends Anime {}
export interface TopUpcomingAnime extends Anime {}
export interface TopAiringAnime extends MostPopularAnime {}
export interface MostFavoriteAnime extends MostPopularAnime {}
export interface LatestCompletedAnime extends MostPopularAnime {}
export interface AnimeGeneralAboutInfo
extends Pick<Anime, CommonAnimeProps>,
Pick<SpotlightAnime, "description"> {
anilistId: number | null;
malId: number | null;
stats: {
quality: string | null;
} & Pick<Anime, "duration" | "episodes" | "rating" | "type">;
promotionalVideos: AnimePromotionalVideo[];
charactersVoiceActors: AnimeCharactersAndVoiceActors[];
}
export interface RecommendedAnime extends Anime {}
export interface RelatedAnime extends MostPopularAnime {}
export interface Season extends Pick<Anime, CommonAnimeProps> {
isCurrent: boolean;
title: string | null;
}
export interface AnimePromotionalVideo {
title: string | undefined;
source: string | undefined;
thumbnail: string | undefined;
}
export interface AnimeCharactersAndVoiceActors {
character: AnimeCharacter;
voiceActor: AnimeCharacter;
}
export interface AnimeCharacter {
id: string;
poster: string;
name: string;
cast: string;
}
export interface AnimeSearchSuggestion
extends Omit<MostPopularAnime, "episodes" | "type"> {
moreInfo: Array<string>;
}
export interface AnimeEpisode extends Pick<Season, "title"> {
episodeId: string | null;
number: number;
isFiller: boolean;
}
export interface SubEpisode {
serverName: string;
serverId: number | null;
}
export interface DubEpisode extends SubEpisode {}
export interface RawEpisode extends SubEpisode {}
export type AnimeCategories =
| "most-favorite"
| "most-popular"
| "subbed-anime"
| "dubbed-anime"
| "recently-updated"
| "recently-added"
| "top-upcoming"
| "top-airing"
| "movie"
| "special"
| "ova"
| "ona"
| "tv"
| "completed";
export type AnimeServers =
| "hd-1"
| "hd-2"
| "megacloud"
| "streamsb"
| "streamtape";
export enum Servers {
VidStreaming = "hd-1",
MegaCloud = "megacloud",
StreamSB = "streamsb",
StreamTape = "streamtape",
VidCloud = "hd-2",
AsianLoad = "asianload",
GogoCDN = "gogocdn",
MixDrop = "mixdrop",
UpCloud = "upcloud",
VizCloud = "vizcloud",
MyCloud = "mycloud",
Filemoon = "filemoon",
}
-3
View File
@@ -1,3 +0,0 @@
export type AnimeAboutInfoQueryParams = {
id?: string;
};
-7
View File
@@ -1,7 +0,0 @@
export type CategoryAnimePathParams = {
category?: string;
};
export type CategoryAnimeQueryParams = {
page?: string;
};
@@ -1,7 +0,0 @@
import { type AnimeServers } from "../anime.js";
export type AnimeEpisodeSrcsQueryParams = {
id?: string;
server?: AnimeServers;
category?: "sub" | "dub";
};
-3
View File
@@ -1,3 +0,0 @@
export type AnimeEpisodePathParams = {
animeId?: string;
};
-7
View File
@@ -1,7 +0,0 @@
export type GenreAnimePathParams = {
name?: string;
};
export type GenreAnimeQueryParams = {
page?: string;
};
-7
View File
@@ -1,7 +0,0 @@
export type AnimeProducerPathParams = {
name?: string;
};
export type AnimeProducerQueryParams = {
page?: string;
};
-20
View File
@@ -1,20 +0,0 @@
export type AnimeSearchQueryParams = {
q?: string;
page?: string;
type?: string;
status?: string;
rated?: string;
score?: string;
season?: string;
language?: string;
start_date?: string;
end_date?: string;
sort?: string;
genres?: string;
};
export type SearchFilters = Omit<AnimeSearchQueryParams, "q" | "page">;
export type FilterKeys = Partial<
keyof Omit<SearchFilters, "start_date" | "end_date">
>;
@@ -1,3 +0,0 @@
export type AnimeSearchSuggestQueryParams = {
q?: string;
};
-3
View File
@@ -1,3 +0,0 @@
export type EpisodeServersQueryParams = {
episodeId?: string;
};
@@ -1,3 +0,0 @@
export type EstimatedScheduleQueryParams = {
date?: string;
};
-10
View File
@@ -1,10 +0,0 @@
export type * from "./animeGenre.js";
export type * from "./animeCategory.js";
export type * from "./animeProducer.js";
export type * from "./animeSearch.js";
export type * from "./animeEpisodes.js";
export type * from "./episodeServers.js";
export type * from "./animeAboutInfo.js";
export type * from "./animeEpisodeSrcs.js";
export type * from "./estimatedSchedule.js";
export type * from "./animeSearchSuggestion.js";
-18
View File
@@ -1,18 +0,0 @@
export interface Video {
url: string;
quality?: string;
isM3U8?: boolean;
size?: number;
[x: string]: unknown;
}
export interface Subtitle {
id?: string;
url: string;
lang: string;
}
export interface Intro {
start: number;
end: number;
}
-19
View File
@@ -1,19 +0,0 @@
import type {
Season,
RelatedAnime,
RecommendedAnime,
AnimeGeneralAboutInfo,
} from "../anime.js";
import { type HttpError } from "http-errors";
import { type ScrapedAnimeSearchResult } from "./animeSearch.js";
export interface ScrapedAnimeAboutInfo
extends Pick<ScrapedAnimeSearchResult, "mostPopularAnimes"> {
anime: {
info: AnimeGeneralAboutInfo;
moreInfo: Record<string, string | string[]>;
};
seasons: Array<Season>;
relatedAnimes: Array<RelatedAnime> | HttpError;
recommendedAnimes: Array<RecommendedAnime> | HttpError;
}
-22
View File
@@ -1,22 +0,0 @@
import type { HttpError } from "http-errors";
import type { Anime, Top10Anime } from "../anime.js";
export interface ScrapedAnimeCategory {
animes: Array<Anime> | HttpError;
genres: Array<string>;
top10Animes: {
today: Array<Top10Anime> | HttpError;
week: Array<Top10Anime> | HttpError;
month: Array<Top10Anime> | HttpError;
};
category: string;
totalPages: number;
currentPage: number;
hasNextPage: boolean;
}
export type CommonAnimeScrapeTypes =
| "animes"
| "totalPages"
| "hasNextPage"
| "currentPage";
-12
View File
@@ -1,12 +0,0 @@
import type { Intro, Subtitle, Video } from "../extractor.js";
export interface ScrapedAnimeEpisodesSources {
headers?: {
[k: string]: string;
};
intro?: Intro;
subtitles?: Subtitle[];
sources: Video[];
download?: string;
embedURL?: string;
}
-6
View File
@@ -1,6 +0,0 @@
import { type AnimeEpisode } from "../anime.js";
export interface ScrapedAnimeEpisodes {
totalEpisodes: number;
episodes: Array<AnimeEpisode>;
}
-11
View File
@@ -1,11 +0,0 @@
import type {
ScrapedAnimeCategory,
CommonAnimeScrapeTypes,
} from "./animeCategory.js";
import { type ScrapedHomePage } from "./homePage.js";
export interface ScrapedGenreAnime
extends Pick<ScrapedAnimeCategory, CommonAnimeScrapeTypes | "genres">,
Pick<ScrapedHomePage, "topAiringAnimes"> {
genreName: string;
}
-8
View File
@@ -1,8 +0,0 @@
import type { ScrapedHomePage } from "./homePage.js";
import type { ScrapedAnimeCategory } from "./animeCategory.js";
export interface ScrapedProducerAnime
extends Omit<ScrapedAnimeCategory, "genres" | "category">,
Pick<ScrapedHomePage, "topAiringAnimes"> {
producerName: string;
}
-14
View File
@@ -1,14 +0,0 @@
import type {
ScrapedAnimeCategory,
CommonAnimeScrapeTypes,
} from "./animeCategory.js";
import type { HttpError } from "http-errors";
import type { MostPopularAnime } from "../anime.js";
import type { SearchFilters } from "../controllers/animeSearch.js";
export interface ScrapedAnimeSearchResult
extends Pick<ScrapedAnimeCategory, CommonAnimeScrapeTypes> {
mostPopularAnimes: Array<MostPopularAnime> | HttpError;
searchQuery: string;
searchFilters: SearchFilters;
}
@@ -1,6 +0,0 @@
import type { HttpError } from "http-errors";
import type { AnimeSearchSuggestion } from "../anime.js";
export interface ScrapedAnimeSearchSuggestion {
suggestions: Array<AnimeSearchSuggestion> | HttpError;
}
-9
View File
@@ -1,9 +0,0 @@
import type { SubEpisode, DubEpisode, RawEpisode } from "../anime.js";
export interface ScrapedEpisodeServers {
sub: SubEpisode[];
dub: DubEpisode[];
raw: RawEpisode[];
episodeNo: number;
episodeId: string;
}
-13
View File
@@ -1,13 +0,0 @@
type EstimatedSchedule = {
id: string | null;
time: string | null;
name: string | null;
jname: string | null;
airingTimestamp: number;
secondsUntilAiring: number;
episode: number;
};
export type ScrapedEstimatedSchedule = {
scheduledAnimes: Array<EstimatedSchedule>;
};
-24
View File
@@ -1,24 +0,0 @@
import type {
TrendingAnime,
SpotlightAnime,
TopAiringAnime,
TopUpcomingAnime,
LatestEpisodeAnime,
MostFavoriteAnime,
MostPopularAnime,
LatestCompletedAnime,
} from "../anime.js";
import type { HttpError } from "http-errors";
import type { ScrapedAnimeCategory } from "./animeCategory.js";
export interface ScrapedHomePage
extends Pick<ScrapedAnimeCategory, "genres" | "top10Animes"> {
spotlightAnimes: Array<SpotlightAnime> | HttpError;
trendingAnimes: Array<TrendingAnime> | HttpError;
latestEpisodeAnimes: Array<LatestEpisodeAnime> | HttpError;
topUpcomingAnimes: Array<TopUpcomingAnime> | HttpError;
topAiringAnimes: Array<TopAiringAnime> | HttpError;
mostPopularAnimes: Array<MostPopularAnime> | HttpError;
mostFavoriteAnimes: Array<MostFavoriteAnime> | HttpError;
latestCompletedAnimes: Array<LatestCompletedAnime> | HttpError;
}
-25
View File
@@ -1,25 +0,0 @@
import type { ScrapedHomePage } from "./homePage.js";
import type { ScrapedGenreAnime } from "./animeGenre.js";
import type { ScrapedAnimeEpisodes } from "./animeEpisodes.js";
import type { ScrapedAnimeCategory } from "./animeCategory.js";
import type { ScrapedProducerAnime } from "./animeProducer.js";
import type { ScrapedEpisodeServers } from "./episodeServers.js";
import type { ScrapedAnimeAboutInfo } from "./animeAboutInfo.js";
import type { ScrapedAnimeSearchResult } from "./animeSearch.js";
import type { ScrapedEstimatedSchedule } from "./estimatedSchedule.js";
import type { ScrapedAnimeEpisodesSources } from "./animeEpisodeSrcs.js";
import type { ScrapedAnimeSearchSuggestion } from "./animeSearchSuggestion.js";
export type {
ScrapedHomePage,
ScrapedGenreAnime,
ScrapedAnimeEpisodes,
ScrapedProducerAnime,
ScrapedAnimeCategory,
ScrapedEpisodeServers,
ScrapedAnimeAboutInfo,
ScrapedAnimeSearchResult,
ScrapedEstimatedSchedule,
ScrapedAnimeEpisodesSources,
ScrapedAnimeSearchSuggestion,
};
-129
View File
@@ -1,129 +0,0 @@
import { config } from "dotenv";
config();
export const ACCEPT_ENCODING_HEADER = "gzip, deflate, br";
export const USER_AGENT_HEADER =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4692.71 Safari/537.36";
export const ACCEPT_HEADER =
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9";
// previously aniwatch.to || aniwatchtv.to
const DOMAIN = process.env.DOMAIN || "hianime.to";
export const SRC_BASE_URL = `https://${DOMAIN}` as const;
export const SRC_AJAX_URL = `${SRC_BASE_URL}/ajax` as const;
export const SRC_HOME_URL = `${SRC_BASE_URL}/home` as const;
export const SRC_SEARCH_URL = `${SRC_BASE_URL}/search` as const;
// <SearchPageFilters>
export const genresIdMap: Record<string, number> = {
action: 1,
adventure: 2,
cars: 3,
comedy: 4,
dementia: 5,
demons: 6,
drama: 8,
ecchi: 9,
fantasy: 10,
game: 11,
harem: 35,
historical: 13,
horror: 14,
isekai: 44,
josei: 43,
kids: 15,
magic: 16,
"martial-arts": 17,
mecha: 18,
military: 38,
music: 19,
mystery: 7,
parody: 20,
police: 39,
psychological: 40,
romance: 22,
samurai: 21,
school: 23,
"sci-fi": 24,
seinen: 42,
shoujo: 25,
"shoujo-ai": 26,
shounen: 27,
"shounen-ai": 28,
"slice-of-life": 36,
space: 29,
sports: 30,
"super-power": 31,
supernatural: 37,
thriller: 41,
vampire: 32,
} as const;
export const typeIdMap: Record<string, number> = {
all: 0,
movie: 1,
tv: 2,
ova: 3,
ona: 4,
special: 5,
music: 6,
} as const;
export const statusIdMap: Record<string, number> = {
all: 0,
"finished-airing": 1,
"currently-airing": 2,
"not-yet-aired": 3,
} as const;
export const ratedIdMap: Record<string, number> = {
all: 0,
g: 1,
pg: 2,
"pg-13": 3,
r: 4,
"r+": 5,
rx: 6,
} as const;
export const scoreIdMap: Record<string, number> = {
all: 0,
appalling: 1,
horrible: 2,
"very-bad": 3,
bad: 4,
average: 5,
fine: 6,
good: 7,
"very-good": 8,
great: 9,
masterpiece: 10,
} as const;
export const seasonIdMap: Record<string, number> = {
all: 0,
spring: 1,
summer: 2,
fall: 3,
winter: 4,
} as const;
export const languageIdMap: Record<string, number> = {
all: 0,
sub: 1,
dub: 2,
"sub-&-dub": 3,
} as const;
export const sortIdMap: Record<string, string> = {
default: "default",
"recently-added": "recently_added",
"recently-updated": "recently_updated",
score: "score",
"name-a-z": "name_az",
"released-date": "released_date",
"most-watched": "most_watched",
} as const;
// </SearchPageFilters>
-2
View File
@@ -1,2 +0,0 @@
export * from "./methods.js";
export * from "./constants.js";
-297
View File
@@ -1,297 +0,0 @@
import type {
Anime,
Top10Anime,
MostPopularAnime,
Top10AnimeTimePeriod,
} from "../types/anime.js";
import type { CheerioAPI, SelectorType } from "cheerio";
import {
genresIdMap,
languageIdMap,
ratedIdMap,
scoreIdMap,
seasonIdMap,
sortIdMap,
statusIdMap,
typeIdMap,
} from "./constants.js";
import { type FilterKeys } from "../types/controllers/animeSearch.js";
import createHttpError, { HttpError } from "http-errors";
export const extractAnimes = (
$: CheerioAPI,
selector: SelectorType
): Array<Anime> | HttpError => {
try {
const animes: Array<Anime> = [];
$(selector).each((i, el) => {
const animeId =
$(el)
.find(".film-detail .film-name .dynamic-name")
?.attr("href")
?.slice(1)
.split("?ref=search")[0] || null;
animes.push({
id: animeId,
name: $(el)
.find(".film-detail .film-name .dynamic-name")
?.text()
?.trim(),
jname:
$(el)
.find(".film-detail .film-name .dynamic-name")
?.attr("data-jname")
?.trim() || null,
poster:
$(el)
.find(".film-poster .film-poster-img")
?.attr("data-src")
?.trim() || null,
duration: $(el)
.find(".film-detail .fd-infor .fdi-item.fdi-duration")
?.text()
?.trim(),
type: $(el)
.find(".film-detail .fd-infor .fdi-item:nth-of-type(1)")
?.text()
?.trim(),
rating: $(el).find(".film-poster .tick-rate")?.text()?.trim() || null,
episodes: {
sub:
Number(
$(el)
.find(".film-poster .tick-sub")
?.text()
?.trim()
.split(" ")
.pop()
) || null,
dub:
Number(
$(el)
.find(".film-poster .tick-dub")
?.text()
?.trim()
.split(" ")
.pop()
) || null,
},
});
});
return animes;
} catch (err: any) {
throw createHttpError.InternalServerError(
err?.message || "Something went wrong"
);
}
};
export const extractTop10Animes = (
$: CheerioAPI,
period: Top10AnimeTimePeriod
): Array<Top10Anime> | HttpError => {
try {
const animes: Array<Top10Anime> = [];
const selector = `#top-viewed-${period} ul li`;
$(selector).each((i, el) => {
animes.push({
id:
$(el)
.find(".film-detail .dynamic-name")
?.attr("href")
?.slice(1)
.trim() || null,
rank: Number($(el).find(".film-number span")?.text()?.trim()) || null,
name: $(el).find(".film-detail .dynamic-name")?.text()?.trim() || null,
jname:
$(el)
.find(".film-detail .dynamic-name")
?.attr("data-jname")
?.trim() || null,
poster:
$(el)
.find(".film-poster .film-poster-img")
?.attr("data-src")
?.trim() || null,
episodes: {
sub:
Number(
$(el)
.find(".film-detail .fd-infor .tick-item.tick-sub")
?.text()
?.trim()
) || null,
dub:
Number(
$(el)
.find(".film-detail .fd-infor .tick-item.tick-dub")
?.text()
?.trim()
) || null,
},
});
});
return animes;
} catch (err: any) {
throw createHttpError.InternalServerError(
err?.message || "Something went wrong"
);
}
};
export const extractMostPopularAnimes = (
$: CheerioAPI,
selector: SelectorType
): Array<MostPopularAnime> | HttpError => {
try {
const animes: Array<MostPopularAnime> = [];
$(selector).each((i, el) => {
animes.push({
id:
$(el)
.find(".film-detail .dynamic-name")
?.attr("href")
?.slice(1)
.trim() || null,
name: $(el).find(".film-detail .dynamic-name")?.text()?.trim() || null,
jname:
$(el)
.find(".film-detail .film-name .dynamic-name")
.attr("data-jname")
?.trim() || null,
poster:
$(el)
.find(".film-poster .film-poster-img")
?.attr("data-src")
?.trim() || null,
episodes: {
sub:
Number($(el)?.find(".fd-infor .tick .tick-sub")?.text()?.trim()) ||
null,
dub:
Number($(el)?.find(".fd-infor .tick .tick-dub")?.text()?.trim()) ||
null,
},
type:
$(el)
?.find(".fd-infor .tick")
?.text()
?.trim()
?.replace(/[\s\n]+/g, " ")
?.split(" ")
?.pop() || null,
});
});
return animes;
} catch (err: any) {
throw createHttpError.InternalServerError(
err?.message || "Something went wrong"
);
}
};
export function retrieveServerId(
$: CheerioAPI,
index: number,
category: "sub" | "dub" | "raw"
) {
return (
$(`.ps_-block.ps_-block-sub.servers-${category} > .ps__-list .server-item`)
?.map((_, el) =>
$(el).attr("data-server-id") == `${index}` ? $(el) : null
)
?.get()[0]
?.attr("data-id") || null
);
}
function getGenresFilterVal(genreNames: string[]): string | undefined {
if (genreNames.length < 1) {
return undefined;
}
return genreNames.map((name) => genresIdMap[name]).join(",");
}
export function getSearchFilterValue(
key: FilterKeys,
rawValue: string
): string | undefined {
rawValue = rawValue.trim();
if (!rawValue) return undefined;
switch (key) {
case "genres": {
return getGenresFilterVal(rawValue.split(","));
}
case "type": {
const val = typeIdMap[rawValue] ?? 0;
return val === 0 ? undefined : `${val}`;
}
case "status": {
const val = statusIdMap[rawValue] ?? 0;
return val === 0 ? undefined : `${val}`;
}
case "rated": {
const val = ratedIdMap[rawValue] ?? 0;
return val === 0 ? undefined : `${val}`;
}
case "score": {
const val = scoreIdMap[rawValue] ?? 0;
return val === 0 ? undefined : `${val}`;
}
case "season": {
const val = seasonIdMap[rawValue] ?? 0;
return val === 0 ? undefined : `${val}`;
}
case "language": {
const val = languageIdMap[rawValue] ?? 0;
return val === 0 ? undefined : `${val}`;
}
case "sort": {
return sortIdMap[rawValue] ?? undefined;
}
default:
return undefined;
}
}
// this fn tackles both start_date and end_date
export function getSearchDateFilterValue(
isStartDate: boolean,
rawValue: string
): string[] | undefined {
rawValue = rawValue.trim();
if (!rawValue) return undefined;
const dateRegex = /^\d{4}-([0-9]|1[0-2])-([0-9]|[12][0-9]|3[01])$/;
const dateCategory = isStartDate ? "s" : "e";
const [year, month, date] = rawValue.split("-");
if (!dateRegex.test(rawValue)) {
return undefined;
}
// sample return -> [sy=2023, sm=10, sd=11]
return [
Number(year) > 0 ? `${dateCategory}y=${year}` : "",
Number(month) > 0 ? `${dateCategory}m=${month}` : "",
Number(date) > 0 ? `${dateCategory}d=${date}` : "",
].filter((d) => Boolean(d));
}
export function substringAfter(str: string, toFind: string) {
const index = str.indexOf(toFind);
return index == -1 ? "" : str.substring(index + toFind.length);
}
export function substringBefore(str: string, toFind: string) {
const index = str.indexOf(toFind);
return index == -1 ? "" : str.substring(0, index);
}
+7 -3
View File
@@ -1,8 +1,12 @@
import { HiAnime } from "aniwatch";
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import { scrapeAnimeAboutInfo } from "../src/parsers/index.js";
test("returns information about an anime", async () => { const animeId = "steinsgate-3";
const data = await scrapeAnimeAboutInfo("steinsgate-3");
// npx vitest run animeAboutInfo.test.ts
test(`GET /api/v2/hianime/anime/${animeId}`, async () => {
const hianime = new HiAnime.Scraper();
const data = await hianime.getInfo(animeId);
expect(data.anime.info.name).not.toEqual(null); expect(data.anime.info.name).not.toEqual(null);
expect(data.recommendedAnimes).not.toEqual([]); expect(data.recommendedAnimes).not.toEqual([]);
+7 -3
View File
@@ -1,8 +1,12 @@
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import { scrapeAnimeCategory } from "../src/parsers/index.js"; import { HiAnime } from "aniwatch";
test("returns animes belonging to a category", async () => { const category = "subbed-anime";
const data = await scrapeAnimeCategory("subbed-anime");
// npx vitest run animeCategory.test.ts
test(`GET /api/v2/hianime/category/${category}`, async () => {
const hianime = new HiAnime.Scraper();
const data = await hianime.getCategoryAnime(category);
expect(data.animes).not.toEqual([]); expect(data.animes).not.toEqual([]);
expect(data.genres).not.toEqual([]); expect(data.genres).not.toEqual([]);
+12 -7
View File
@@ -1,13 +1,18 @@
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import { scrapeAnimeEpisodeSources } from "../src/parsers/index.js"; import { HiAnime } from "aniwatch";
test("returns anime episode streaming link(s)", async () => { const animeEpisodeId = "steinsgate-3?ep=230";
const data = await scrapeAnimeEpisodeSources( const server = "hd-1";
"steinsgate-3?ep=230", const category = "sub";
"hd-1",
"sub" // npx vitest run animeEpisodeSrcs.test.ts
test(`GET /api/v2/hianime/episode/sources?animeEpisodeId=${animeEpisodeId}&server=${server}&category=${category}`, async () => {
const hianime = new HiAnime.Scraper();
const data = await hianime.getEpisodeSources(
animeEpisodeId,
server,
category
); );
expect(data.sources).not.toEqual([]); expect(data.sources).not.toEqual([]);
// expect(data)
}); });
+7 -3
View File
@@ -1,8 +1,12 @@
import { HiAnime } from "aniwatch";
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import { scrapeAnimeEpisodes } from "../src/parsers/index.js";
test("returns episodes info of an anime", async () => { const animeId = "steinsgate-3";
const data = await scrapeAnimeEpisodes("steinsgate-3");
// npx vitest run animeEpisodes.test.ts
test(`GET /api/v2/hianime/anime/${animeId}/episodes`, async () => {
const hianime = new HiAnime.Scraper();
const data = await hianime.getEpisodes(animeId);
expect(data.totalEpisodes).not.toEqual(0); expect(data.totalEpisodes).not.toEqual(0);
expect(data.episodes).not.toEqual([]); expect(data.episodes).not.toEqual([]);
+9 -3
View File
@@ -1,8 +1,14 @@
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import { scrapeGenreAnime } from "../src/parsers/index.js"; import { HiAnime } from "aniwatch";
test("returns animes belonging to a genre", async () => { const genreName = "shounen";
const data = await scrapeGenreAnime("shounen", 2); const page = 2;
// npx vitest run animeGenre.test.ts
test(`GET /api/v2/hianime/genre/${genreName}?page=${page}`, async () => {
const hianime = new HiAnime.Scraper();
const data = await hianime.getGenreAnime(genreName, page);
expect(data.animes).not.toEqual([]); expect(data.animes).not.toEqual([]);
expect(data.genres).not.toEqual([]); expect(data.genres).not.toEqual([]);
+8 -3
View File
@@ -1,8 +1,13 @@
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import { scrapeProducerAnimes } from "../src/parsers/index.js"; import { HiAnime } from "aniwatch";
test("returns animes produced by a producer", async () => { const producerName = "toei-animation";
const data = await scrapeProducerAnimes("toei-animation", 2); const page = 2;
// npx vitest run animeProducer.test.ts
test(`GET /api/v2/hianime/producer/${producerName}?page=${page}`, async () => {
const hianime = new HiAnime.Scraper();
const data = await hianime.getProducerAnimes(producerName, page);
expect(data.animes).not.toEqual([]); expect(data.animes).not.toEqual([]);
expect(data.topAiringAnimes).not.toEqual([]); expect(data.topAiringAnimes).not.toEqual([]);
+11 -3
View File
@@ -1,8 +1,16 @@
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import { scrapeAnimeSearch } from "../src/parsers/index.js"; import { HiAnime } from "aniwatch";
test("returns animes related to search query", async () => { const query = "monster";
const data = await scrapeAnimeSearch("monster", 2); const page = 1;
const filter: HiAnime.SearchFilters = {
genres: "seinen,psychological",
};
// npx vitest run animeSearch.test.ts
test(`GET /api/v2/hianime/search?q=${query}&page=${page}&genres=${filter.genres}`, async () => {
const hianime = new HiAnime.Scraper();
const data = await hianime.search(query, page, filter);
expect(data.animes).not.toEqual([]); expect(data.animes).not.toEqual([]);
expect(data.mostPopularAnimes).not.toEqual([]); expect(data.mostPopularAnimes).not.toEqual([]);
+7 -3
View File
@@ -1,8 +1,12 @@
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import { scrapeAnimeSearchSuggestion } from "../src/parsers/index.js"; import { HiAnime } from "aniwatch";
test("returns animes search suggestions related to search query", async () => { const query = "one piece";
const data = await scrapeAnimeSearchSuggestion("one piece");
// npx vitest run animeSearchSuggestion.test.ts
test(`GET /api/v2/hianime/search/suggestion?q=${query}`, async () => {
const hianime = new HiAnime.Scraper();
const data = await hianime.searchSuggestions(query);
expect(data.suggestions).not.toEqual([]); expect(data.suggestions).not.toEqual([]);
}); });
+7 -3
View File
@@ -1,8 +1,12 @@
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import { scrapeEpisodeServers } from "../src/parsers/index.js"; import { HiAnime } from "aniwatch";
test("returns episode source servers", async () => { const animeEpisodeId = "steinsgate-0-92?ep=2055";
const data = await scrapeEpisodeServers("steinsgate-0-92?ep=2055");
// npx vitest run episodeServers.test.ts
test(`GET /api/v2/hianime/episode/servers?animeEpisodeId=${animeEpisodeId}`, async () => {
const hianime = new HiAnime.Scraper();
const data = await hianime.getEpisodeServers(animeEpisodeId);
expect(data.episodeId).not.toEqual(null); expect(data.episodeId).not.toEqual(null);
expect(data.episodeNo).not.toEqual(0); expect(data.episodeNo).not.toEqual(0);
+11 -9
View File
@@ -1,15 +1,17 @@
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import { scrapeEstimatedSchedule } from "../src/parsers/index.js"; import { HiAnime } from "aniwatch";
function padZero(num: number) { const padZero = (num: number) => (num < 10 ? `0${num}` : num.toString());
return num < 10 ? `0${num}` : num.toString();
}
test("returns estimated schedule anime release", async () => { const d = new Date();
const d = new Date(); const date = `${d.getFullYear()}-${padZero(d.getMonth() + 1)}-${padZero(
const data = await scrapeEstimatedSchedule( d.getDate()
`${d.getFullYear()}-${padZero(d.getMonth() + 1)}-${padZero(d.getDate())}` )}`;
);
// npx vitest run estimatedSchedule.test.ts
test(`GET /api/v2/hianime/schedule?date=${date}`, async () => {
const hianime = new HiAnime.Scraper();
const data = await hianime.getEstimatedSchedule(date);
expect(data.scheduledAnimes).not.toEqual([]); expect(data.scheduledAnimes).not.toEqual([]);
}); });
+4 -3
View File
@@ -1,8 +1,9 @@
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import { scrapeHomePage } from "../src/parsers/index.js"; import { HiAnime } from "aniwatch";
test("returns anime information present in homepage", async () => { test("GET /api/v2/hianime/home", async () => {
const data = await scrapeHomePage(); const hianime = new HiAnime.Scraper();
const data = await hianime.getHomePage();
expect(data.spotlightAnimes).not.toEqual([]); expect(data.spotlightAnimes).not.toEqual([]);
expect(data.trendingAnimes).not.toEqual([]); expect(data.trendingAnimes).not.toEqual([]);
+6 -14
View File
@@ -2,7 +2,7 @@
"compilerOptions": { "compilerOptions": {
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,
"target": "ES2022", "target": "ES2023",
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"allowJs": true, "allowJs": true,
"resolveJsonModule": true, "resolveJsonModule": true,
@@ -13,6 +13,8 @@
// //
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",
"module": "NodeNext", "module": "NodeNext",
"noUnusedLocals": true,
"noUnusedParameters": true,
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./", "rootDir": "./",
"sourceMap": true, "sourceMap": true,
@@ -21,18 +23,8 @@
"removeComments": true, "removeComments": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"strictFunctionTypes": true, "strictFunctionTypes": true,
"lib": [ "lib": ["ES2023"]
"ES2022"
]
}, },
"include": [ "include": ["./src", "./api"],
"./src", "exclude": ["node_modules"]
"./api"
],
"ts-node": {
"esm": true
},
"exclude": [
"node_modules",
],
} }