mirror of
https://github.com/shafat-96/anime-mapper
synced 2026-04-17 15:51:45 +00:00
Initial commit
This commit is contained in:
287
README.md
Normal file
287
README.md
Normal file
@@ -0,0 +1,287 @@
|
||||
# Anilist to AnimePahe Mapper API
|
||||
|
||||
A specialized Node.js API that maps anime data between Anilist and streaming platforms (AnimePahe, HiAnime, AnimeKai) using advanced string similarity algorithms.
|
||||
|
||||
## Features
|
||||
|
||||
- Map Anilist anime IDs to AnimePahe, HiAnime, and AnimeKai content
|
||||
- Advanced string similarity analysis with multiple algorithms
|
||||
- Season/year matching for multi-season anime series
|
||||
- Title variation detection across platforms
|
||||
- Get streaming links with proper headers
|
||||
- Support for both subbed and dubbed anime (AnimeKai)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/yourusername/anilist-animepahe-mapper.git
|
||||
cd anilist-animepahe-mapper
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start the server
|
||||
npm start
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### AnimePahe Endpoints
|
||||
|
||||
#### Map Anilist ID to AnimePahe
|
||||
|
||||
```
|
||||
GET /animepahe/map/:anilistId
|
||||
```
|
||||
|
||||
Maps an Anilist anime ID to its corresponding AnimePahe content.
|
||||
|
||||
#### Get AnimePahe Streaming Links
|
||||
|
||||
```
|
||||
GET /animepahe/sources/:session/:episodeId
|
||||
```
|
||||
|
||||
Returns streaming links for a specific AnimePahe episode.
|
||||
|
||||
Alternative format:
|
||||
```
|
||||
GET /animepahe/sources/:id
|
||||
```
|
||||
|
||||
### HiAnime Endpoints
|
||||
|
||||
#### Map Anilist ID to HiAnime
|
||||
|
||||
```
|
||||
GET /hianime/:anilistId
|
||||
```
|
||||
|
||||
Maps an Anilist anime ID to its corresponding HiAnime content.
|
||||
|
||||
#### Get HiAnime Servers
|
||||
|
||||
```
|
||||
GET /hianime/servers/:episodeId
|
||||
```
|
||||
|
||||
Get available servers for a HiAnime episode.
|
||||
|
||||
Parameters:
|
||||
- `ep` (optional): Episode number
|
||||
|
||||
Example:
|
||||
```
|
||||
GET /hianime/servers/one-piece-100?ep=2142
|
||||
```
|
||||
|
||||
#### Get HiAnime Streaming Sources
|
||||
|
||||
```
|
||||
GET /hianime/sources/:episodeId
|
||||
```
|
||||
|
||||
Returns streaming links for a specific HiAnime episode.
|
||||
|
||||
Parameters:
|
||||
- `ep` (required): Episode number
|
||||
- `server` (optional): Server name (default: vidstreaming)
|
||||
- `category` (optional): Content type (sub, dub, raw) (default: sub)
|
||||
|
||||
Example:
|
||||
```
|
||||
GET /hianime/sources/one-piece-100?ep=2142&server=vidstreaming&category=sub
|
||||
```
|
||||
|
||||
### AnimeKai Endpoints
|
||||
|
||||
#### Map Anilist ID to AnimeKai
|
||||
|
||||
```
|
||||
GET /animekai/map/:anilistId
|
||||
```
|
||||
|
||||
Maps an Anilist anime ID to its corresponding AnimeKai content.
|
||||
|
||||
#### Get AnimeKai Streaming Links
|
||||
|
||||
```
|
||||
GET /animekai/sources/:episodeId
|
||||
```
|
||||
|
||||
Returns streaming links for a specific AnimeKai episode.
|
||||
|
||||
Parameters:
|
||||
- `server` (optional): Specify a streaming server
|
||||
- `dub` (optional): Set to `true` or `1` to get dubbed sources instead of subbed
|
||||
|
||||
Example:
|
||||
```
|
||||
GET /animekai/sources/episode-id-here?dub=true
|
||||
```
|
||||
|
||||
## Handling 403 Errors
|
||||
|
||||
When accessing the streaming URLs (not the API endpoint), you will encounter 403 Forbidden errors unless you include the proper Referer header. This is a requirement from the underlying streaming provider.
|
||||
|
||||
### Required Headers for Streaming
|
||||
|
||||
```
|
||||
Referer: https://kwik.cx/
|
||||
```
|
||||
|
||||
### Example Implementation
|
||||
|
||||
```javascript
|
||||
// Javascript fetch example
|
||||
fetch('https://streaming-url-from-response.m3u8', {
|
||||
headers: {
|
||||
'Referer': 'https://kwik.cx/'
|
||||
}
|
||||
})
|
||||
|
||||
// Using axios
|
||||
axios.get('https://streaming-url-from-response.m3u8', {
|
||||
headers: {
|
||||
'Referer': 'https://kwik.cx/'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Video Player Examples
|
||||
|
||||
```javascript
|
||||
// Video.js player
|
||||
const player = videojs('my-player', {
|
||||
html5: {
|
||||
hls: {
|
||||
overrideNative: true,
|
||||
xhr: {
|
||||
beforeRequest: function(options) {
|
||||
options.headers = {
|
||||
...options.headers,
|
||||
'Referer': 'https://kwik.cx/'
|
||||
};
|
||||
return options;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// HLS.js player
|
||||
const hls = new Hls({
|
||||
xhrSetup: function(xhr, url) {
|
||||
xhr.setRequestHeader('Referer', 'https://kwik.cx/');
|
||||
}
|
||||
});
|
||||
hls.loadSource('https://streaming-url-from-response.m3u8');
|
||||
hls.attachMedia(document.getElementById('video'));
|
||||
```
|
||||
|
||||
## Mapping Approach
|
||||
|
||||
The API uses several techniques to find the best match between Anilist and streaming platforms:
|
||||
|
||||
1. Tries multiple possible titles (romaji, english, native, userPreferred, synonyms)
|
||||
2. Uses multiple string similarity algorithms to find the best match
|
||||
3. Ranks matches based on similarity score
|
||||
4. Uses year and season information to match the correct season of a series
|
||||
5. Extracts season numbers from titles for better matching
|
||||
|
||||
## Response Format Examples
|
||||
|
||||
### AnimePahe Mapping Response
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 131681,
|
||||
"animepahe": {
|
||||
"id": "5646-Re:ZERO -Starting Life in Another World- Season 3",
|
||||
"title": "Re:ZERO -Starting Life in Another World- Season 3",
|
||||
"type": "TV",
|
||||
"status": "Finished Airing",
|
||||
"season": "Fall",
|
||||
"year": 2024,
|
||||
"score": 8.5,
|
||||
"posterImage": "https://i.animepahe.ru/posters/016b3cda2c47fb5167e238a3f4e97f03e0d1bd3d5e8ffb079ad6d8665fb92455.jpg",
|
||||
"episodes": {
|
||||
"count": 16,
|
||||
"data": [...]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### AnimeKai Mapping Response
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 131681,
|
||||
"animekai": {
|
||||
"id": "re-zero-starting-life-in-another-world-season-3",
|
||||
"title": "Re:ZERO -Starting Life in Another World- Season 3",
|
||||
"malId": 51194,
|
||||
"posterImage": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx131681-OFQfZ5v67VYq.jpg",
|
||||
"episodes": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Episode Sources Response
|
||||
|
||||
```json
|
||||
{
|
||||
"headers": { "Referer": "https://kwik.cx/" },
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://na-191.files.nextcdn.org/hls/01/b49063a1225cf4350deb46d79b42a7572e323274d1c9d63f3b067cc4df09986a/uwu.m3u8",
|
||||
"isM3U8": true,
|
||||
"quality": "360",
|
||||
"size": 44617958
|
||||
},
|
||||
{
|
||||
"url": "https://na-191.files.nextcdn.org/hls/01/c32da1b1975a5106dcee7e7182219f9b4dbef836fb782d7939003a8cde8f057f/uwu.m3u8",
|
||||
"isM3U8": true,
|
||||
"quality": "720",
|
||||
"size": 78630133
|
||||
},
|
||||
{
|
||||
"url": "https://na-191.files.nextcdn.org/hls/01/b85d4450908232aa32b71bc67c80e8aedcc4f32a282e5df9ad82e4662786e9d8/uwu.m3u8",
|
||||
"isM3U8": true,
|
||||
"quality": "1080",
|
||||
"size": 118025148
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- This API is for educational purposes only
|
||||
- Respect the terms of service of all providers (Anilist, AnimePahe, HiAnime, AnimeKai)
|
||||
- Optimized for simplicity and focused exclusively on mapping functionality
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Express.js - Web framework
|
||||
- @consumet/extensions - For AnimePahe integration
|
||||
- AniWatch (HiAnime) - For HiAnime integration
|
||||
- Node-cache - For response caching
|
||||
|
||||
## Example Usage
|
||||
|
||||
Map by Anilist ID:
|
||||
```
|
||||
GET /animepahe/map/21
|
||||
GET /hianime/21
|
||||
GET /animekai/map/21
|
||||
```
|
||||
|
||||
Get streaming links for an episode:
|
||||
```
|
||||
GET /animepahe/sources/session-id/episode-id
|
||||
GET /animekai/sources/episode-id
|
||||
GET /animekai/sources/episode-id?dub=true
|
||||
```
|
||||
1784
package-lock.json
generated
Normal file
1784
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "anilist-animepahe-mapper",
|
||||
"version": "1.0.0",
|
||||
"description": "API to map Anilist to AnimePahe",
|
||||
"main": "src/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@consumet/extensions": "github:consumet/consumet.ts",
|
||||
"aniwatch": "^2.21.2",
|
||||
"axios": "^1.8.4",
|
||||
"cheerio": "^1.0.0",
|
||||
"express": "^4.21.2",
|
||||
"node-cache": "^5.1.2",
|
||||
"node-fetch": "^3.3.2",
|
||||
"string-similarity-js": "^2.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
}
|
||||
}
|
||||
25
src/constants/api-constants.js
Normal file
25
src/constants/api-constants.js
Normal file
@@ -0,0 +1,25 @@
|
||||
export const ANILIST_URL = 'https://graphql.anilist.co';
|
||||
export const ANILIST_QUERY = `
|
||||
query ($id: Int) {
|
||||
Media(id: $id, type: ANIME) {
|
||||
id
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
native
|
||||
userPreferred
|
||||
}
|
||||
episodes
|
||||
synonyms
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const HIANIME_URL = 'https://hianimez.to';
|
||||
export const ANIZIP_URL = 'https://api.ani.zip/mappings';
|
||||
|
||||
export default {
|
||||
ANILIST_URL,
|
||||
ANILIST_QUERY,
|
||||
HIANIME_URL,
|
||||
ANIZIP_URL
|
||||
};
|
||||
210
src/index.js
Normal file
210
src/index.js
Normal file
@@ -0,0 +1,210 @@
|
||||
import express from 'express';
|
||||
import { ANIME } from '@consumet/extensions';
|
||||
import { HiAnime } from 'aniwatch';
|
||||
import { mapAnilistToAnimePahe, mapAnilistToHiAnime, mapAnilistToAnimeKai } from './mappers/index.js';
|
||||
import { AniList } from './providers/anilist.js';
|
||||
import { AnimeKai } from './providers/animekai.js';
|
||||
import { getEpisodeServers } from './providers/hianime-servers.js';
|
||||
import { cache } from './utils/cache.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
// Return server information
|
||||
app.get('/', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
message: 'Anilist to AnimePahe Mapper API',
|
||||
routes: [
|
||||
'/animepahe/map/:anilistId',
|
||||
'/animepahe/sources/:session/:episodeId',
|
||||
'/animepahe/sources/:id',
|
||||
'/hianime/:anilistId',
|
||||
'/hianime/servers/:episodeId - For example: /hianime/servers/one-piece-100?ep=2142',
|
||||
'/hianime/sources/:episodeId - optional params: ?ep=episodeId&server=serverName&category=sub|dub|raw',
|
||||
'/animekai/map/:anilistId',
|
||||
'/animekai/sources/:episodeId - supports ?server and ?dub=true params'
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
// Map Anilist ID to AnimePahe
|
||||
app.get('/animepahe/map/:anilistId', cache('5 minutes'), async (req, res) => {
|
||||
try {
|
||||
const { anilistId } = req.params;
|
||||
|
||||
if (!anilistId) {
|
||||
return res.status(400).json({ error: 'AniList ID is required' });
|
||||
}
|
||||
|
||||
const mappingResult = await mapAnilistToAnimePahe(anilistId);
|
||||
return res.json(mappingResult);
|
||||
} catch (error) {
|
||||
console.error('Mapping error:', error.message);
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Map Anilist ID to HiAnime
|
||||
app.get('/hianime/:anilistId', cache('5 minutes'), async (req, res) => {
|
||||
try {
|
||||
const { anilistId } = req.params;
|
||||
|
||||
if (!anilistId) {
|
||||
return res.status(400).json({ error: 'AniList ID is required' });
|
||||
}
|
||||
|
||||
const episodes = await mapAnilistToHiAnime(anilistId);
|
||||
return res.json(episodes);
|
||||
} catch (error) {
|
||||
console.error('HiAnime mapping error:', error.message);
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get available servers for HiAnime episode
|
||||
app.get('/hianime/servers/:animeId', cache('15 minutes'), async (req, res) => {
|
||||
try {
|
||||
const { animeId } = req.params;
|
||||
const { ep } = req.query;
|
||||
|
||||
if (!animeId) {
|
||||
return res.status(400).json({ error: 'Anime ID is required' });
|
||||
}
|
||||
|
||||
// Combine animeId and ep to form the expected episodeId format
|
||||
const episodeId = ep ? `${animeId}?ep=${ep}` : animeId;
|
||||
|
||||
const servers = await getEpisodeServers(episodeId);
|
||||
return res.json(servers);
|
||||
} catch (error) {
|
||||
console.error('HiAnime servers error:', error.message);
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get streaming sources for HiAnime episode using AniWatch
|
||||
app.get('/hianime/sources/:animeId', cache('15 minutes'), async (req, res) => {
|
||||
try {
|
||||
const { animeId } = req.params;
|
||||
const { ep, server = 'vidstreaming', category = 'sub' } = req.query;
|
||||
|
||||
if (!animeId || !ep) {
|
||||
return res.status(400).json({ error: 'Both anime ID and episode number (ep) are required' });
|
||||
}
|
||||
|
||||
// Combine animeId and ep to form the expected episodeId format
|
||||
const episodeId = `${animeId}?ep=${ep}`;
|
||||
|
||||
// Use the AniWatch library directly
|
||||
const hianime = new HiAnime.Scraper();
|
||||
const sources = await hianime.getEpisodeSources(episodeId, server, category);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: sources
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('HiAnime sources error:', error.message);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Map Anilist ID to AnimeKai
|
||||
app.get('/animekai/map/:anilistId', cache('5 minutes'), async (req, res) => {
|
||||
try {
|
||||
const { anilistId } = req.params;
|
||||
|
||||
if (!anilistId) {
|
||||
return res.status(400).json({ error: 'AniList ID is required' });
|
||||
}
|
||||
|
||||
const mappingResult = await mapAnilistToAnimeKai(anilistId);
|
||||
return res.json(mappingResult);
|
||||
} catch (error) {
|
||||
console.error('AnimeKai mapping error:', error.message);
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get episode sources from AnimeKai
|
||||
app.get('/animekai/sources/:episodeId', cache('15 minutes'), async (req, res) => {
|
||||
try {
|
||||
const { episodeId } = req.params;
|
||||
const { server, dub } = req.query;
|
||||
|
||||
if (!episodeId) {
|
||||
return res.status(400).json({ error: 'Episode ID is required' });
|
||||
}
|
||||
|
||||
const animeKai = new AnimeKai();
|
||||
const isDub = dub === 'true' || dub === '1';
|
||||
const sources = await animeKai.fetchEpisodeSources(episodeId, server, isDub);
|
||||
return res.json(sources);
|
||||
} catch (error) {
|
||||
console.error('AnimeKai sources error:', error.message);
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get HLS streaming links for an episode using path parameter that may contain slashes
|
||||
app.get('/animepahe/sources/:session/:episodeId', cache('15 minutes'), async (req, res) => {
|
||||
try {
|
||||
const { session, episodeId } = req.params;
|
||||
const fullEpisodeId = `${session}/${episodeId}`;
|
||||
|
||||
// Initialize a new AnimePahe instance each time
|
||||
const consumetAnimePahe = new ANIME.AnimePahe();
|
||||
|
||||
// Directly fetch and return the sources without modification
|
||||
const sources = await consumetAnimePahe.fetchEpisodeSources(fullEpisodeId);
|
||||
|
||||
// Simply return the sources directly as provided by Consumet
|
||||
return res.status(200).json(sources);
|
||||
} catch (error) {
|
||||
console.error('Error fetching episode sources:', error.message);
|
||||
|
||||
// Keep error handling simple
|
||||
return res.status(500).json({
|
||||
error: error.message,
|
||||
message: 'Failed to fetch episode sources. If you receive a 403 error when accessing streaming URLs, add a Referer: "https://kwik.cx/" header to your requests.'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Backward compatibility for the single parameter version
|
||||
app.get('/animepahe/sources/:id', cache('15 minutes'), async (req, res) => {
|
||||
try {
|
||||
const episodeId = req.params.id;
|
||||
|
||||
if (!episodeId) {
|
||||
return res.status(400).json({ error: 'Episode ID is required' });
|
||||
}
|
||||
|
||||
// Initialize a new AnimePahe instance each time
|
||||
const consumetAnimePahe = new ANIME.AnimePahe();
|
||||
|
||||
// Directly fetch and return the sources without modification
|
||||
const sources = await consumetAnimePahe.fetchEpisodeSources(episodeId);
|
||||
|
||||
// Simply return the sources directly as provided by Consumet
|
||||
return res.status(200).json(sources);
|
||||
} catch (error) {
|
||||
console.error('Error fetching episode sources:', error.message);
|
||||
|
||||
// Keep error handling simple
|
||||
return res.status(500).json({
|
||||
error: error.message,
|
||||
message: 'Failed to fetch episode sources. If you receive a 403 error when accessing streaming URLs, add a Referer: "https://kwik.cx/" header to your requests.'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
});
|
||||
144
src/mappers/animekai-mapper.js
Normal file
144
src/mappers/animekai-mapper.js
Normal file
@@ -0,0 +1,144 @@
|
||||
import { AniList } from '../providers/anilist.js';
|
||||
import { AnimeKai } from '../providers/animekai.js';
|
||||
|
||||
/**
|
||||
* Maps an Anilist anime to AnimeKai
|
||||
* @param {string|number} anilistId - The AniList ID to map
|
||||
* @returns {Promise<Object>} The mapping result with episodes
|
||||
*/
|
||||
export async function mapAnilistToAnimeKai(anilistId) {
|
||||
const mapper = new AnimeKaiMapper();
|
||||
return await mapper.mapAnilistToAnimeKai(anilistId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapper class that provides mapping between Anilist and AnimeKai
|
||||
*/
|
||||
export class AnimeKaiMapper {
|
||||
constructor() {
|
||||
this.anilist = new AniList();
|
||||
this.animeKai = new AnimeKai();
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an Anilist anime to AnimeKai content
|
||||
* @param {string|number} anilistId - The AniList ID to map
|
||||
*/
|
||||
async mapAnilistToAnimeKai(anilistId) {
|
||||
try {
|
||||
// Get anime info from AniList
|
||||
const animeInfo = await this.anilist.getAnimeInfo(parseInt(anilistId));
|
||||
|
||||
if (!animeInfo) {
|
||||
throw new Error(`Anime with id ${anilistId} not found on AniList`);
|
||||
}
|
||||
|
||||
// Search for the anime on AnimeKai using the title
|
||||
const searchTitle = animeInfo.title.english || animeInfo.title.romaji || animeInfo.title.userPreferred;
|
||||
if (!searchTitle) {
|
||||
throw new Error('No title available for the anime');
|
||||
}
|
||||
|
||||
const searchResults = await this.animeKai.search(searchTitle);
|
||||
if (!searchResults || !searchResults.results || searchResults.results.length === 0) {
|
||||
return {
|
||||
id: animeInfo.id,
|
||||
title: searchTitle,
|
||||
animekai: null
|
||||
};
|
||||
}
|
||||
|
||||
// Find the best match from search results
|
||||
const bestMatch = this.findBestMatch(searchTitle, animeInfo, searchResults.results);
|
||||
if (!bestMatch) {
|
||||
return {
|
||||
id: animeInfo.id,
|
||||
title: searchTitle,
|
||||
animekai: null
|
||||
};
|
||||
}
|
||||
|
||||
// Get detailed info for the best match
|
||||
const animeDetails = await this.animeKai.fetchAnimeInfo(bestMatch.id);
|
||||
|
||||
return {
|
||||
id: animeInfo.id,
|
||||
title: searchTitle,
|
||||
animekai: {
|
||||
id: bestMatch.id,
|
||||
title: bestMatch.title,
|
||||
japaneseTitle: bestMatch.japaneseTitle,
|
||||
url: bestMatch.url,
|
||||
image: bestMatch.image,
|
||||
type: bestMatch.type,
|
||||
episodes: animeDetails.totalEpisodes,
|
||||
episodesList: animeDetails.episodes,
|
||||
hasSub: animeDetails.hasSub,
|
||||
hasDub: animeDetails.hasDub,
|
||||
subOrDub: animeDetails.subOrDub,
|
||||
status: animeDetails.status,
|
||||
season: animeDetails.season,
|
||||
genres: animeDetails.genres
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error mapping AniList to AnimeKai:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best match from search results
|
||||
* @param {string} searchTitle - The search title
|
||||
* @param {Object} animeInfo - The AniList anime info
|
||||
* @param {Array} results - The search results
|
||||
* @returns {Object|null} The best match or null if no good match found
|
||||
*/
|
||||
findBestMatch(searchTitle, animeInfo, results) {
|
||||
if (!results || results.length === 0) return null;
|
||||
|
||||
// Normalize titles for comparison
|
||||
const normalizeTitle = title => title.toLowerCase().replace(/[^\w\s]/g, '').replace(/\s+/g, ' ').trim();
|
||||
const normalizedSearch = normalizeTitle(searchTitle);
|
||||
|
||||
// Extract year from AniList title if present
|
||||
let year = null;
|
||||
if (animeInfo.startDate && animeInfo.startDate.year) {
|
||||
year = animeInfo.startDate.year;
|
||||
} else if (animeInfo.seasonYear) {
|
||||
year = animeInfo.seasonYear;
|
||||
}
|
||||
|
||||
// First try: find exact title match
|
||||
for (const result of results) {
|
||||
const resultTitle = normalizeTitle(result.title);
|
||||
const japaneseTitle = result.japaneseTitle ? normalizeTitle(result.japaneseTitle) : '';
|
||||
|
||||
if (resultTitle === normalizedSearch || japaneseTitle === normalizedSearch) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Second try: find partial match with proper episode count match
|
||||
const expectedEpisodes = animeInfo.episodes || 0;
|
||||
for (const result of results) {
|
||||
const resultTitle = normalizeTitle(result.title);
|
||||
const japaneseTitle = result.japaneseTitle ? normalizeTitle(result.japaneseTitle) : '';
|
||||
|
||||
// Check if this is likely the right anime by comparing episode count
|
||||
if (result.episodes === expectedEpisodes && expectedEpisodes > 0) {
|
||||
if (resultTitle.includes(normalizedSearch) ||
|
||||
normalizedSearch.includes(resultTitle) ||
|
||||
japaneseTitle.includes(normalizedSearch) ||
|
||||
normalizedSearch.includes(japaneseTitle)) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback: just return the first result
|
||||
return results[0];
|
||||
}
|
||||
}
|
||||
|
||||
export default mapAnilistToAnimeKai;
|
||||
321
src/mappers/animepahe-mapper.js
Normal file
321
src/mappers/animepahe-mapper.js
Normal file
@@ -0,0 +1,321 @@
|
||||
import { AniList } from '../providers/anilist.js';
|
||||
import { AnimePahe } from '../providers/animepahe.js';
|
||||
|
||||
/**
|
||||
* Maps an Anilist anime to AnimePahe content
|
||||
*/
|
||||
export async function mapAnilistToAnimePahe(anilistId) {
|
||||
const mapper = new AnimepaheMapper();
|
||||
return await mapper.mapAnilistToAnimePahe(anilistId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapper class that provides mapping between Anilist and AnimePahe
|
||||
*/
|
||||
export class AnimepaheMapper {
|
||||
constructor() {
|
||||
this.anilist = new AniList();
|
||||
this.animePahe = new AnimePahe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an Anilist anime to AnimePahe content
|
||||
*/
|
||||
async mapAnilistToAnimePahe(anilistId) {
|
||||
try {
|
||||
// Get anime info from AniList
|
||||
const animeInfo = await this.anilist.getAnimeInfo(parseInt(anilistId));
|
||||
|
||||
if (!animeInfo) {
|
||||
throw new Error(`Anime with id ${anilistId} not found on AniList`);
|
||||
}
|
||||
|
||||
// Try to find matching content on AnimePahe
|
||||
const bestMatch = await this.findAnimePaheMatch(animeInfo);
|
||||
|
||||
if (!bestMatch) {
|
||||
return {
|
||||
id: animeInfo.id,
|
||||
animepahe: null
|
||||
};
|
||||
}
|
||||
|
||||
// Get episode data for the matched anime
|
||||
const episodeData = await this.getAnimePaheEpisodes(bestMatch);
|
||||
|
||||
// Return the mapped result
|
||||
return {
|
||||
id: animeInfo.id,
|
||||
animepahe: {
|
||||
id: bestMatch.id,
|
||||
title: bestMatch.title || bestMatch.name,
|
||||
episodes: episodeData.episodes,
|
||||
type: bestMatch.type,
|
||||
status: bestMatch.status,
|
||||
season: bestMatch.season,
|
||||
year: bestMatch.year,
|
||||
score: bestMatch.score,
|
||||
posterImage: bestMatch.poster,
|
||||
session: bestMatch.session
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error mapping AniList to AnimePahe:', error.message);
|
||||
throw new Error('Failed to map AniList to AnimePahe: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the matching AnimePahe content for an AniList anime
|
||||
*/
|
||||
async findAnimePaheMatch(animeInfo) {
|
||||
// Only use one primary title to reduce API calls
|
||||
let bestTitle = animeInfo.title.romaji || animeInfo.title.english || animeInfo.title.userPreferred;
|
||||
const titleType = animeInfo.title.romaji ? 'romaji' : (animeInfo.title.english ? 'english' : 'userPreferred');
|
||||
|
||||
// First search attempt
|
||||
const searchResults = await this.animePahe.scrapeSearchResults(bestTitle);
|
||||
|
||||
// Process results if we found any
|
||||
if (searchResults && searchResults.length > 0) {
|
||||
// First try direct ID match (fastest path)
|
||||
const rawId = animeInfo.id.toString();
|
||||
for (const result of searchResults) {
|
||||
const resultId = (result.id || '').split('-')[0];
|
||||
if (resultId && resultId === rawId) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// If no direct ID match, find the best match with our algorithm
|
||||
return this.findBestMatchFromResults(animeInfo, searchResults);
|
||||
}
|
||||
|
||||
// If no results found, try a fallback search with a more generic title
|
||||
const genericTitle = this.getGenericTitle(animeInfo);
|
||||
|
||||
if (genericTitle && genericTitle !== bestTitle) {
|
||||
const fallbackResults = await this.animePahe.scrapeSearchResults(genericTitle);
|
||||
|
||||
if (fallbackResults && fallbackResults.length > 0) {
|
||||
return this.findBestMatchFromResults(animeInfo, fallbackResults);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best match from available search results
|
||||
*/
|
||||
findBestMatchFromResults(animeInfo, results) {
|
||||
if (!results || results.length === 0) return null;
|
||||
|
||||
// Normalize titles just once to avoid repeating work
|
||||
const normalizeTitle = t => t.toLowerCase().replace(/[^\w\s]/g, '').replace(/\s+/g, ' ').trim();
|
||||
const anilistTitles = [
|
||||
animeInfo.title.romaji,
|
||||
animeInfo.title.english,
|
||||
animeInfo.title.userPreferred
|
||||
].filter(Boolean).map(normalizeTitle);
|
||||
|
||||
// Prepare year information
|
||||
const anilistYear =
|
||||
(animeInfo.startDate && animeInfo.startDate.year) ?
|
||||
animeInfo.startDate.year : animeInfo.seasonYear;
|
||||
|
||||
const animeYear = anilistYear || this.extractYearFromTitle(animeInfo);
|
||||
|
||||
// Process matches sequentially with early returns
|
||||
let bestMatch = null;
|
||||
|
||||
// Try exact title match with year (highest priority)
|
||||
if (animeYear) {
|
||||
// Find matches with exact year
|
||||
const yearMatches = [];
|
||||
for (const result of results) {
|
||||
const resultYear = result.year ? parseInt(result.year) : this.extractYearFromTitle(result);
|
||||
if (resultYear === animeYear) {
|
||||
yearMatches.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
// If we have year matches, try to find the best title match among them
|
||||
if (yearMatches.length > 0) {
|
||||
for (const match of yearMatches) {
|
||||
const resultTitle = normalizeTitle(match.title || match.name);
|
||||
|
||||
// First try: exact title match with year
|
||||
for (const title of anilistTitles) {
|
||||
if (!title) continue;
|
||||
|
||||
if (resultTitle === title ||
|
||||
(resultTitle.includes(title) && title.length > 7) ||
|
||||
(title.includes(resultTitle) && resultTitle.length > 7)) {
|
||||
return match; // Early return for best match
|
||||
}
|
||||
}
|
||||
|
||||
// Second try: high similarity title match with year
|
||||
for (const title of anilistTitles) {
|
||||
if (!title) continue;
|
||||
|
||||
const similarity = this.calculateTitleSimilarity(title, resultTitle);
|
||||
if (similarity > 0.5) {
|
||||
bestMatch = match;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatch) break;
|
||||
}
|
||||
|
||||
// If we found a title similarity match with year, return it
|
||||
if (bestMatch) return bestMatch;
|
||||
|
||||
// Otherwise use the first year match as a fallback
|
||||
return yearMatches[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Try exact title match
|
||||
for (const result of results) {
|
||||
const resultTitle = normalizeTitle(result.title || result.name);
|
||||
|
||||
for (const title of anilistTitles) {
|
||||
if (!title) continue;
|
||||
|
||||
if (resultTitle === title) {
|
||||
return result; // Early return for exact title match
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try high similarity title match
|
||||
bestMatch = this.findBestSimilarityMatch(anilistTitles, results);
|
||||
if (bestMatch) return bestMatch;
|
||||
|
||||
// Just use the first result as a fallback
|
||||
return results[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best match based on title similarity
|
||||
*/
|
||||
findBestSimilarityMatch(titles, results) {
|
||||
const normalizeTitle = t => t.toLowerCase().replace(/[^\w\s]/g, '').replace(/\s+/g, ' ').trim();
|
||||
let bestMatch = null;
|
||||
let highestSimilarity = 0;
|
||||
|
||||
for (const result of results) {
|
||||
const resultTitle = normalizeTitle(result.title || result.name);
|
||||
|
||||
for (const title of titles) {
|
||||
if (!title) continue;
|
||||
|
||||
const similarity = this.calculateTitleSimilarity(title, resultTitle);
|
||||
if (similarity > highestSimilarity) {
|
||||
highestSimilarity = similarity;
|
||||
bestMatch = result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only return if we have a reasonably good match
|
||||
return highestSimilarity > 0.6 ? bestMatch : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the AnimePahe episodes for a match
|
||||
*/
|
||||
async getAnimePaheEpisodes(match) {
|
||||
try {
|
||||
const episodeData = await this.animePahe.scrapeEpisodes(match.id);
|
||||
return {
|
||||
totalEpisodes: episodeData.totalEpisodes || 0,
|
||||
episodes: episodeData.episodes || []
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting AnimePahe episodes:', error.message);
|
||||
return { totalEpisodes: 0, episodes: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate similarity between two titles
|
||||
*/
|
||||
calculateTitleSimilarity(title1, title2) {
|
||||
if (!title1 || !title2) return 0;
|
||||
|
||||
// Normalize both titles
|
||||
const norm1 = title1.toLowerCase().replace(/[^\w\s]/g, '').replace(/\s+/g, ' ').trim();
|
||||
const norm2 = title2.toLowerCase().replace(/[^\w\s]/g, '').replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Exact match is best
|
||||
if (norm1 === norm2) return 1;
|
||||
|
||||
// Split into words
|
||||
const words1 = norm1.split(' ').filter(Boolean);
|
||||
const words2 = norm2.split(' ').filter(Boolean);
|
||||
|
||||
// Count common words
|
||||
const commonCount = words1.filter(w => words2.includes(w)).length;
|
||||
|
||||
// Weight by percentage of common words
|
||||
return commonCount * 2 / (words1.length + words2.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract year from title (e.g., "JoJo's Bizarre Adventure (2012)" -> 2012)
|
||||
*/
|
||||
extractYearFromTitle(item) {
|
||||
if (!item) return null;
|
||||
|
||||
// Extract the title string based on the input type
|
||||
let titleStr = '';
|
||||
if (typeof item === 'string') {
|
||||
titleStr = item;
|
||||
} else if (typeof item === 'object') {
|
||||
// Handle both anime objects and result objects
|
||||
if (item.title) {
|
||||
if (typeof item.title === 'string') {
|
||||
titleStr = item.title;
|
||||
} else if (typeof item.title === 'object') {
|
||||
// AniList title object
|
||||
titleStr = item.title.userPreferred || item.title.english || item.title.romaji || '';
|
||||
}
|
||||
} else if (item.name) {
|
||||
titleStr = item.name;
|
||||
}
|
||||
}
|
||||
|
||||
if (!titleStr) return null;
|
||||
|
||||
// Look for year pattern in parentheses or brackets
|
||||
const yearMatches = titleStr.match(/[\(\[](\d{4})[\)\]]/);
|
||||
|
||||
if (yearMatches && yearMatches[1]) {
|
||||
const year = parseInt(yearMatches[1]);
|
||||
if (!isNaN(year) && year > 1950 && year <= new Date().getFullYear()) {
|
||||
return year;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a generic title by removing year information and other specific identifiers
|
||||
*/
|
||||
getGenericTitle(animeInfo) {
|
||||
if (!animeInfo || !animeInfo.title) return null;
|
||||
|
||||
const title = animeInfo.title.english || animeInfo.title.romaji || animeInfo.title.userPreferred;
|
||||
if (!title) return null;
|
||||
|
||||
// Remove year information and common specifiers
|
||||
return title.replace(/\([^)]*\d{4}[^)]*\)/g, '').replace(/\[[^\]]*\d{4}[^\]]*\]/g, '').trim();
|
||||
}
|
||||
}
|
||||
|
||||
export default mapAnilistToAnimePahe;
|
||||
18
src/mappers/hianime-mapper.js
Normal file
18
src/mappers/hianime-mapper.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { getEpisodesForAnime } from '../providers/hianime.js';
|
||||
|
||||
/**
|
||||
* Maps an Anilist anime to HiAnime episodes
|
||||
* @param {string|number} anilistId - The AniList ID to map
|
||||
* @returns {Promise<Object>} The mapping result with episodes
|
||||
*/
|
||||
export async function mapAnilistToHiAnime(anilistId) {
|
||||
try {
|
||||
const episodes = await getEpisodesForAnime(anilistId);
|
||||
return episodes;
|
||||
} catch (error) {
|
||||
console.error('Error mapping Anilist to HiAnime:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export default mapAnilistToHiAnime;
|
||||
9
src/mappers/index.js
Normal file
9
src/mappers/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import mapAnilistToAnimePahe from './animepahe-mapper.js';
|
||||
import mapAnilistToHiAnime from './hianime-mapper.js';
|
||||
import mapAnilistToAnimeKai from './animekai-mapper.js';
|
||||
|
||||
export {
|
||||
mapAnilistToAnimePahe,
|
||||
mapAnilistToHiAnime,
|
||||
mapAnilistToAnimeKai
|
||||
};
|
||||
103
src/providers/anilist.js
Normal file
103
src/providers/anilist.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import axios from 'axios';
|
||||
import { ANILIST_URL } from '../constants/api-constants.js';
|
||||
|
||||
export class AniList {
|
||||
constructor() {
|
||||
this.baseUrl = ANILIST_URL;
|
||||
}
|
||||
|
||||
async getAnimeInfo(id) {
|
||||
try {
|
||||
const query = `
|
||||
query ($id: Int) {
|
||||
Media(id: $id, type: ANIME) {
|
||||
id
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
native
|
||||
userPreferred
|
||||
}
|
||||
description
|
||||
coverImage {
|
||||
large
|
||||
medium
|
||||
}
|
||||
bannerImage
|
||||
episodes
|
||||
status
|
||||
season
|
||||
seasonYear
|
||||
startDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
endDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
genres
|
||||
source
|
||||
averageScore
|
||||
synonyms
|
||||
isAdult
|
||||
format
|
||||
type
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await axios.post(this.baseUrl, {
|
||||
query,
|
||||
variables: { id }
|
||||
});
|
||||
|
||||
return response.data.data.Media;
|
||||
} catch (error) {
|
||||
console.error('Error fetching anime info from AniList:', error.message);
|
||||
throw new Error('Failed to fetch anime info from AniList');
|
||||
}
|
||||
}
|
||||
|
||||
async searchAnime(query) {
|
||||
try {
|
||||
const gqlQuery = `
|
||||
query ($search: String) {
|
||||
Page(page: 1, perPage: 10) {
|
||||
media(search: $search, type: ANIME) {
|
||||
id
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
native
|
||||
}
|
||||
description
|
||||
coverImage {
|
||||
large
|
||||
medium
|
||||
}
|
||||
episodes
|
||||
status
|
||||
genres
|
||||
averageScore
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await axios.post(this.baseUrl, {
|
||||
query: gqlQuery,
|
||||
variables: { search: query }
|
||||
});
|
||||
|
||||
return response.data.data.Page.media;
|
||||
} catch (error) {
|
||||
console.error('Error searching anime on AniList:', error.message);
|
||||
throw new Error('Failed to search anime on AniList');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AniList;
|
||||
61
src/providers/animekai.js
Normal file
61
src/providers/animekai.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import { ANIME } from '@consumet/extensions';
|
||||
|
||||
/**
|
||||
* AnimeKai provider class that wraps the Consumet library
|
||||
*/
|
||||
export class AnimeKai {
|
||||
constructor() {
|
||||
this.client = new ANIME.AnimeKai();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for anime on AnimeKai
|
||||
* @param {string} query - The search query
|
||||
* @returns {Promise<Object>} Search results
|
||||
*/
|
||||
async search(query) {
|
||||
try {
|
||||
const results = await this.client.search(query);
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error('Error searching AnimeKai:', error);
|
||||
throw new Error('Failed to search AnimeKai');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch anime information including episodes
|
||||
* @param {string} id - The anime ID
|
||||
* @returns {Promise<Object>} Anime info with episodes
|
||||
*/
|
||||
async fetchAnimeInfo(id) {
|
||||
try {
|
||||
const info = await this.client.fetchAnimeInfo(id);
|
||||
return info;
|
||||
} catch (error) {
|
||||
console.error('Error fetching anime info from AnimeKai:', error);
|
||||
throw new Error('Failed to fetch anime info from AnimeKai');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch episode streaming sources
|
||||
* @param {string} episodeId - The episode ID
|
||||
* @param {string} server - Optional streaming server
|
||||
* @param {boolean} dub - Whether to fetch dubbed sources (true) or subbed (false)
|
||||
* @returns {Promise<Object>} Streaming sources
|
||||
*/
|
||||
async fetchEpisodeSources(episodeId, server = undefined, dub = false) {
|
||||
try {
|
||||
// Use the SubOrSub enum from Consumet if dub is true
|
||||
const subOrDub = dub ? 'dub' : 'sub';
|
||||
const sources = await this.client.fetchEpisodeSources(episodeId, server, subOrDub);
|
||||
return sources;
|
||||
} catch (error) {
|
||||
console.error('Error fetching episode sources from AnimeKai:', error);
|
||||
throw new Error('Failed to fetch episode sources from AnimeKai');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AnimeKai;
|
||||
281
src/providers/animepahe.js
Normal file
281
src/providers/animepahe.js
Normal file
@@ -0,0 +1,281 @@
|
||||
import axios from 'axios';
|
||||
import * as cheerio from 'cheerio';
|
||||
|
||||
export class AnimePahe {
|
||||
constructor() {
|
||||
this.baseUrl = "https://animepahe.ru";
|
||||
this.sourceName = 'AnimePahe';
|
||||
this.isMulti = false;
|
||||
}
|
||||
|
||||
async scrapeSearchResults(query) {
|
||||
try {
|
||||
const response = await axios.get(`${this.baseUrl}/api?m=search&l=8&q=${query}`, {
|
||||
headers: {
|
||||
'Cookie': "__ddg1_=;__ddg2_=;",
|
||||
}
|
||||
});
|
||||
|
||||
const jsonResult = response.data;
|
||||
const searchResults = [];
|
||||
|
||||
if (!jsonResult.data || !jsonResult.data.length) {
|
||||
return searchResults;
|
||||
}
|
||||
|
||||
for (const item of jsonResult.data) {
|
||||
searchResults.push({
|
||||
id: `${item.id}-${item.title}`,
|
||||
title: item.title,
|
||||
name: item.title,
|
||||
type: item.type || 'TV',
|
||||
episodes: item.episodes || 0,
|
||||
status: item.status || 'Unknown',
|
||||
season: item.season || 'Unknown',
|
||||
year: item.year || 0,
|
||||
score: item.score || 0,
|
||||
poster: item.poster,
|
||||
session: item.session,
|
||||
episodes: {
|
||||
sub: item.episodes || null,
|
||||
dub: '??'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return searchResults;
|
||||
} catch (error) {
|
||||
console.error('Error searching AnimePahe:', error.message);
|
||||
throw new Error('Failed to search AnimePahe');
|
||||
}
|
||||
}
|
||||
|
||||
async scrapeEpisodes(url) {
|
||||
try {
|
||||
const title = url.split('-')[1];
|
||||
const id = url.split('-')[0];
|
||||
|
||||
const session = await this._getSession(title, id);
|
||||
const epUrl = `${this.baseUrl}/api?m=release&id=${session}&sort=episode_desc&page=1`;
|
||||
|
||||
const response = await axios.get(epUrl, {
|
||||
headers: {
|
||||
'Cookie': "__ddg1_=;__ddg2_=;",
|
||||
}
|
||||
});
|
||||
|
||||
return await this._recursiveFetchEpisodes(epUrl, JSON.stringify(response.data), session);
|
||||
} catch (error) {
|
||||
console.error('Error fetching episodes:', error.message);
|
||||
throw new Error('Failed to fetch episodes');
|
||||
}
|
||||
}
|
||||
|
||||
async _recursiveFetchEpisodes(url, responseData, session) {
|
||||
try {
|
||||
const jsonResult = JSON.parse(responseData);
|
||||
const page = jsonResult.current_page;
|
||||
const hasNextPage = page < jsonResult.last_page;
|
||||
let animeTitle = 'Could not fetch title';
|
||||
let episodes = [];
|
||||
let animeDetails = {
|
||||
type: 'TV',
|
||||
status: 'Unknown',
|
||||
season: 'Unknown',
|
||||
year: 0,
|
||||
score: 0
|
||||
};
|
||||
|
||||
for (const item of jsonResult.data) {
|
||||
episodes.push({
|
||||
title: `Episode ${item.episode}`,
|
||||
episodeId: `${session}/${item.session}`,
|
||||
number: item.episode,
|
||||
image: item.snapshot,
|
||||
});
|
||||
}
|
||||
|
||||
if (hasNextPage) {
|
||||
const newUrl = `${url.split("&page=")[0]}&page=${page + 1}`;
|
||||
const newResponse = await axios.get(newUrl, {
|
||||
headers: {
|
||||
'Cookie': "__ddg1_=;__ddg2_=;",
|
||||
}
|
||||
});
|
||||
|
||||
const moreEpisodes = await this._recursiveFetchEpisodes(newUrl, JSON.stringify(newResponse.data), session);
|
||||
episodes = [...episodes, ...moreEpisodes.episodes];
|
||||
animeTitle = moreEpisodes.title;
|
||||
animeDetails = moreEpisodes.details || animeDetails;
|
||||
} else {
|
||||
const detailUrl = `https://animepahe.ru/a/${jsonResult.data[0].anime_id}`;
|
||||
const newResponse = await axios.get(detailUrl, {
|
||||
headers: {
|
||||
'Cookie': "__ddg1_=;__ddg2_=;",
|
||||
}
|
||||
});
|
||||
|
||||
if (newResponse.status === 200) {
|
||||
const $ = cheerio.load(newResponse.data);
|
||||
animeTitle = $('.title-wrapper span').text().trim() || 'Could not fetch title';
|
||||
|
||||
// Try to extract additional information
|
||||
try {
|
||||
// Parse type
|
||||
const typeText = $('.col-sm-4.anime-info p:contains("Type")').text();
|
||||
if (typeText) {
|
||||
animeDetails.type = typeText.replace('Type:', '').trim();
|
||||
}
|
||||
|
||||
// Parse status
|
||||
const statusText = $('.col-sm-4.anime-info p:contains("Status")').text();
|
||||
if (statusText) {
|
||||
animeDetails.status = statusText.replace('Status:', '').trim();
|
||||
}
|
||||
|
||||
// Parse season and year
|
||||
const seasonText = $('.col-sm-4.anime-info p:contains("Season")').text();
|
||||
if (seasonText) {
|
||||
const seasonMatch = seasonText.match(/Season:\s+(\w+)\s+(\d{4})/);
|
||||
if (seasonMatch) {
|
||||
animeDetails.season = seasonMatch[1];
|
||||
animeDetails.year = parseInt(seasonMatch[2]);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse score
|
||||
const scoreText = $('.col-sm-4.anime-info p:contains("Score")').text();
|
||||
if (scoreText) {
|
||||
const scoreMatch = scoreText.match(/Score:\s+([\d.]+)/);
|
||||
if (scoreMatch) {
|
||||
animeDetails.score = parseFloat(scoreMatch[1]);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error parsing anime details:', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: animeTitle,
|
||||
session: session,
|
||||
totalEpisodes: jsonResult.total,
|
||||
details: animeDetails,
|
||||
episodes: hasNextPage ? episodes : episodes.reverse(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error recursively fetching episodes:', error.message);
|
||||
throw new Error('Failed to fetch episodes recursively');
|
||||
}
|
||||
}
|
||||
|
||||
async scrapeEpisodesSrcs(episodeUrl, { category, lang } = {}) {
|
||||
try {
|
||||
const response = await axios.get(`${this.baseUrl}/play/${episodeUrl}`, {
|
||||
headers: {
|
||||
'Cookie': "__ddg1_=;__ddg2_=;",
|
||||
}
|
||||
});
|
||||
|
||||
const $ = cheerio.load(response.data);
|
||||
const buttons = $('#resolutionMenu > button');
|
||||
const videoLinks = [];
|
||||
|
||||
for (let i = 0; i < buttons.length; i++) {
|
||||
const btn = buttons[i];
|
||||
const kwikLink = $(btn).attr('data-src');
|
||||
const quality = $(btn).text();
|
||||
|
||||
// Instead of extracting, just return the link directly
|
||||
videoLinks.push({
|
||||
quality: quality,
|
||||
url: kwikLink,
|
||||
referer: "https://kwik.cx",
|
||||
});
|
||||
}
|
||||
|
||||
const result = {
|
||||
sources: videoLinks.length > 0 ? [{ url: videoLinks[0].url }] : [],
|
||||
multiSrc: videoLinks,
|
||||
};
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error fetching episode sources:', error.message);
|
||||
throw new Error('Failed to fetch episode sources');
|
||||
}
|
||||
}
|
||||
|
||||
async _getSession(title, animeId) {
|
||||
try {
|
||||
const response = await axios.get(`${this.baseUrl}/api?m=search&q=${title}`, {
|
||||
headers: {
|
||||
'Cookie': "__ddg1_=;__ddg2_=;",
|
||||
}
|
||||
});
|
||||
|
||||
const resBody = response.data;
|
||||
if (!resBody.data || resBody.data.length === 0) {
|
||||
throw new Error(`No results found for title: ${title}`);
|
||||
}
|
||||
|
||||
// First try: Direct ID match if provided and valid
|
||||
if (animeId) {
|
||||
const animeIdMatch = resBody.data.find(anime => String(anime.id) === String(animeId));
|
||||
if (animeIdMatch) {
|
||||
return animeIdMatch.session;
|
||||
}
|
||||
}
|
||||
|
||||
// Second try: Normalize titles and find best match
|
||||
const normalizeTitle = t => t.toLowerCase().replace(/[^\w\s]/g, '').replace(/\s+/g, ' ').trim();
|
||||
const normalizedSearchTitle = normalizeTitle(title);
|
||||
|
||||
let bestMatch = null;
|
||||
let highestSimilarity = 0;
|
||||
|
||||
for (const anime of resBody.data) {
|
||||
const normalizedAnimeTitle = normalizeTitle(anime.title);
|
||||
// Calculate simple similarity (more sophisticated than exact match)
|
||||
let similarity = 0;
|
||||
|
||||
// Exact match
|
||||
if (normalizedAnimeTitle === normalizedSearchTitle) {
|
||||
similarity = 1;
|
||||
}
|
||||
// Contains match
|
||||
else if (normalizedAnimeTitle.includes(normalizedSearchTitle) ||
|
||||
normalizedSearchTitle.includes(normalizedAnimeTitle)) {
|
||||
const lengthRatio = Math.min(normalizedAnimeTitle.length, normalizedSearchTitle.length) /
|
||||
Math.max(normalizedAnimeTitle.length, normalizedSearchTitle.length);
|
||||
similarity = 0.8 * lengthRatio;
|
||||
}
|
||||
// Word match
|
||||
else {
|
||||
const searchWords = normalizedSearchTitle.split(' ');
|
||||
const animeWords = normalizedAnimeTitle.split(' ');
|
||||
const commonWords = searchWords.filter(word => animeWords.includes(word));
|
||||
similarity = commonWords.length / Math.max(searchWords.length, animeWords.length);
|
||||
}
|
||||
|
||||
if (similarity > highestSimilarity) {
|
||||
highestSimilarity = similarity;
|
||||
bestMatch = anime;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatch && highestSimilarity > 0.5) {
|
||||
return bestMatch.session;
|
||||
}
|
||||
|
||||
// Default to first result if no good match found
|
||||
return resBody.data[0].session;
|
||||
} catch (error) {
|
||||
console.error('Error getting session:', error.message);
|
||||
throw new Error('Failed to get session');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AnimePahe;
|
||||
136
src/providers/hianime-servers.js
Normal file
136
src/providers/hianime-servers.js
Normal file
@@ -0,0 +1,136 @@
|
||||
import { load } from 'cheerio';
|
||||
import { client } from '../utils/client.js';
|
||||
import { HIANIME_URL } from '../constants/api-constants.js';
|
||||
|
||||
/**
|
||||
* Get all available servers for a HiAnime episode
|
||||
* @param {string} episodeId - Episode ID in format "anime-title-123?ep=456"
|
||||
* @returns {Promise<Object>} Object containing sub, dub, and raw server lists
|
||||
*/
|
||||
export async function getEpisodeServers(episodeId) {
|
||||
const result = {
|
||||
sub: [],
|
||||
dub: [],
|
||||
raw: [],
|
||||
episodeId,
|
||||
episodeNo: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
if (!episodeId || episodeId.trim() === "" || episodeId.indexOf("?ep=") === -1) {
|
||||
throw new Error("Invalid anime episode ID");
|
||||
}
|
||||
|
||||
const epId = episodeId.split("?ep=")[1];
|
||||
const ajaxUrl = `${HIANIME_URL}/ajax/v2/episode/servers?episodeId=${epId}`;
|
||||
|
||||
const { data } = await client.get(ajaxUrl, {
|
||||
headers: {
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"Referer": `${HIANIME_URL}/watch/${episodeId}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!data.html) {
|
||||
throw new Error("No server data found");
|
||||
}
|
||||
|
||||
const $ = load(data.html);
|
||||
|
||||
// Extract episode number
|
||||
const epNoSelector = ".server-notice strong";
|
||||
result.episodeNo = Number($(epNoSelector).text().split(" ").pop()) || 0;
|
||||
|
||||
// Extract SUB servers
|
||||
$(`.ps_-block.ps_-block-sub.servers-sub .ps__-list .server-item`).each((_, el) => {
|
||||
result.sub.push({
|
||||
serverName: $(el).find("a").text().toLowerCase().trim(),
|
||||
serverId: Number($(el)?.attr("data-server-id")?.trim()) || null,
|
||||
});
|
||||
});
|
||||
|
||||
// Extract DUB servers
|
||||
$(`.ps_-block.ps_-block-sub.servers-dub .ps__-list .server-item`).each((_, el) => {
|
||||
result.dub.push({
|
||||
serverName: $(el).find("a").text().toLowerCase().trim(),
|
||||
serverId: Number($(el)?.attr("data-server-id")?.trim()) || null,
|
||||
});
|
||||
});
|
||||
|
||||
// Extract RAW servers
|
||||
$(`.ps_-block.ps_-block-sub.servers-raw .ps__-list .server-item`).each((_, el) => {
|
||||
result.raw.push({
|
||||
serverName: $(el).find("a").text().toLowerCase().trim(),
|
||||
serverId: Number($(el)?.attr("data-server-id")?.trim()) || null,
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error fetching episode servers:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get streaming sources for a HiAnime episode
|
||||
* @param {string} episodeId - Episode ID in format "anime-title-123?ep=456"
|
||||
* @param {string} serverName - Name of the server to get sources from
|
||||
* @param {string} category - Type of episode: 'sub', 'dub', or 'raw'
|
||||
* @returns {Promise<Object>} Object containing sources and related metadata
|
||||
*/
|
||||
export async function getEpisodeSources(episodeId, serverName = 'vidstreaming', category = 'sub') {
|
||||
try {
|
||||
if (!episodeId || episodeId.trim() === "" || episodeId.indexOf("?ep=") === -1) {
|
||||
throw new Error("Invalid anime episode ID");
|
||||
}
|
||||
|
||||
// First get available servers
|
||||
const servers = await getEpisodeServers(episodeId);
|
||||
|
||||
// Find the requested server
|
||||
const serverList = servers[category] || [];
|
||||
const server = serverList.find(s => s.serverName.toLowerCase() === serverName.toLowerCase());
|
||||
|
||||
if (!server) {
|
||||
throw new Error(`Server '${serverName}' not found for category '${category}'`);
|
||||
}
|
||||
|
||||
const epId = episodeId.split("?ep=")[1];
|
||||
const serverId = server.serverId;
|
||||
|
||||
// Fetch the source URL
|
||||
const { data } = await client.get(
|
||||
`${HIANIME_URL}/ajax/v2/episode/sources?id=${epId}&server=${serverId}`,
|
||||
{
|
||||
headers: {
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"Referer": `${HIANIME_URL}/watch/${episodeId}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Return sources format similar to the AniWatch package
|
||||
return {
|
||||
headers: {
|
||||
Referer: data.link,
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36"
|
||||
},
|
||||
sources: [
|
||||
{
|
||||
url: data.link,
|
||||
isM3U8: data.link.includes('.m3u8'),
|
||||
}
|
||||
],
|
||||
subtitles: [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching episode sources:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
getEpisodeServers,
|
||||
getEpisodeSources
|
||||
};
|
||||
381
src/providers/hianime.js
Normal file
381
src/providers/hianime.js
Normal file
@@ -0,0 +1,381 @@
|
||||
import { load } from 'cheerio';
|
||||
import * as stringSimilarity from 'string-similarity-js';
|
||||
import { client } from '../utils/client.js';
|
||||
import { ANILIST_URL, ANILIST_QUERY, HIANIME_URL, ANIZIP_URL } from '../constants/api-constants.js';
|
||||
|
||||
// Common word replacements in anime titles - moved outside to avoid recreation
|
||||
const TITLE_REPLACEMENTS = {
|
||||
'season': ['s', 'sz'],
|
||||
's': ['season', 'sz'],
|
||||
'sz': ['season', 's'],
|
||||
'two': ['2', 'ii'],
|
||||
'three': ['3', 'iii'],
|
||||
'four': ['4', 'iv'],
|
||||
'part': ['pt', 'p'],
|
||||
'episode': ['ep'],
|
||||
'chapters': ['ch'],
|
||||
'chapter': ['ch'],
|
||||
'first': ['1', 'i'],
|
||||
'second': ['2', 'ii'],
|
||||
'third': ['3', 'iii'],
|
||||
'fourth': ['4', 'iv']
|
||||
};
|
||||
|
||||
// Cache for word variations to avoid recalculating
|
||||
const wordVariationsCache = new Map();
|
||||
|
||||
// Helper function to normalize text for comparison
|
||||
const normalizeText = (text) => {
|
||||
return text.toLowerCase()
|
||||
.replace(/[^\w\s]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
};
|
||||
|
||||
// Get word variations with caching
|
||||
const getWordVariations = (word) => {
|
||||
const cacheKey = word.toLowerCase();
|
||||
if (wordVariationsCache.has(cacheKey)) {
|
||||
return wordVariationsCache.get(cacheKey);
|
||||
}
|
||||
|
||||
const variations = new Set([word]);
|
||||
const normalized = normalizeText(word);
|
||||
variations.add(normalized);
|
||||
|
||||
const withoutNumbers = word.replace(/\d+/g, '').trim();
|
||||
if (withoutNumbers !== word) variations.add(withoutNumbers);
|
||||
|
||||
for (const [key, values] of Object.entries(TITLE_REPLACEMENTS)) {
|
||||
if (normalized === key) {
|
||||
values.forEach(v => variations.add(v));
|
||||
} else if (values.includes(normalized)) {
|
||||
variations.add(key);
|
||||
values.forEach(v => variations.add(v));
|
||||
}
|
||||
}
|
||||
|
||||
const result = [...variations];
|
||||
wordVariationsCache.set(cacheKey, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
// Fetch anime info from Anilist
|
||||
async function getAnimeInfo(anilistId) {
|
||||
try {
|
||||
const response = await client.post(ANILIST_URL, {
|
||||
query: ANILIST_QUERY,
|
||||
variables: { id: anilistId }
|
||||
});
|
||||
|
||||
const animeData = response.data.data.Media;
|
||||
if (!animeData) return null;
|
||||
|
||||
// Get all possible titles and synonyms at once
|
||||
const allTitles = new Set([
|
||||
...(animeData.synonyms || []),
|
||||
animeData.title.english,
|
||||
animeData.title.romaji
|
||||
].filter(Boolean) // Remove nulls/undefined
|
||||
.filter(t => !(/[\u4E00-\u9FFF]/.test(t)))); // Remove Chinese titles
|
||||
|
||||
return {
|
||||
id: animeData.id,
|
||||
title: animeData.title,
|
||||
episodes: animeData.episodes,
|
||||
synonyms: [...allTitles]
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching anime info:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate similarity score between two titles
|
||||
const calculateTitleScore = (searchTitle, hianimeTitle) => {
|
||||
const normalizedSearch = normalizeText(searchTitle);
|
||||
const normalizedTitle = normalizeText(hianimeTitle);
|
||||
|
||||
// Quick exact match check
|
||||
if (normalizedSearch === normalizedTitle) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const searchWords = normalizedSearch.split(' ');
|
||||
const titleWords = normalizedTitle.split(' ');
|
||||
|
||||
// Pre-calculate word variations for both titles
|
||||
const searchVariations = searchWords.map(w => getWordVariations(w));
|
||||
const titleVariations = titleWords.map(w => getWordVariations(w));
|
||||
|
||||
let matches = 0;
|
||||
let partialMatches = 0;
|
||||
|
||||
for (let i = 0; i < searchVariations.length; i++) {
|
||||
let bestWordMatch = 0;
|
||||
|
||||
for (let j = 0; j < titleVariations.length; j++) {
|
||||
for (const searchVar of searchVariations[i]) {
|
||||
for (const titleVar of titleVariations[j]) {
|
||||
if (searchVar === titleVar) {
|
||||
bestWordMatch = 1;
|
||||
break;
|
||||
}
|
||||
|
||||
if (searchVar.includes(titleVar) || titleVar.includes(searchVar)) {
|
||||
const matchLength = Math.min(searchVar.length, titleVar.length);
|
||||
const maxLength = Math.max(searchVar.length, titleVar.length);
|
||||
bestWordMatch = Math.max(bestWordMatch, matchLength / maxLength);
|
||||
}
|
||||
}
|
||||
if (bestWordMatch === 1) break;
|
||||
}
|
||||
if (bestWordMatch === 1) break;
|
||||
}
|
||||
|
||||
if (bestWordMatch === 1) {
|
||||
matches++;
|
||||
} else if (bestWordMatch > 0) {
|
||||
partialMatches += bestWordMatch;
|
||||
}
|
||||
}
|
||||
|
||||
const wordMatchScore = (matches + (partialMatches * 0.5)) / searchWords.length;
|
||||
const similarity = stringSimilarity.stringSimilarity(normalizedSearch, normalizedTitle);
|
||||
|
||||
return (wordMatchScore * 0.7) + (similarity * 0.3);
|
||||
};
|
||||
|
||||
// Search anime on Hianime and get the most similar match
|
||||
async function searchAnime(title, animeInfo) {
|
||||
try {
|
||||
let bestMatch = { score: 0, id: null };
|
||||
let seriesMatches = [];
|
||||
let year = null;
|
||||
|
||||
// Extract year from title if present (e.g., 2011 from "Hunter x Hunter (2011)")
|
||||
const yearMatch = title.match(/\((\d{4})\)/);
|
||||
if (yearMatch && yearMatch[1]) {
|
||||
year = yearMatch[1];
|
||||
}
|
||||
|
||||
// Try each title in order of priority
|
||||
const titlesToTry = [
|
||||
animeInfo.title.english,
|
||||
animeInfo.title.romaji,
|
||||
...animeInfo.synonyms
|
||||
].filter(Boolean)
|
||||
.filter((t, i, arr) => arr.indexOf(t) === i);
|
||||
|
||||
for (const searchTitle of titlesToTry) {
|
||||
const searchUrl = `${HIANIME_URL}/search?keyword=${encodeURIComponent(searchTitle)}`;
|
||||
|
||||
const response = await client.get(searchUrl);
|
||||
const $ = load(response.data);
|
||||
|
||||
$('.film_list-wrap > .flw-item').each((_, item) => {
|
||||
const el = $(item).find('.film-detail .film-name a');
|
||||
const hianimeTitle = el.text().trim();
|
||||
const hianimeId = el.attr('href')?.split('/').pop()?.split('?')[0];
|
||||
|
||||
// Check the data-jname attribute which may contain year information
|
||||
const jname = el.attr('data-jname')?.trim();
|
||||
|
||||
// Check if this is a TV series
|
||||
const isTV = $(item).find('.fd-infor .fdi-item').first().text().trim() === 'TV';
|
||||
|
||||
// Check episodes count to match with expected count from Anilist
|
||||
const episodesText = $(item).find('.tick-item.tick-eps').text().trim();
|
||||
const episodesCount = episodesText ? parseInt(episodesText, 10) : 0;
|
||||
|
||||
if (hianimeId) {
|
||||
// Calculate base similarity score
|
||||
let score = calculateTitleScore(searchTitle, hianimeTitle);
|
||||
|
||||
// Boost score for TV series when searching for TV series
|
||||
if (isTV && animeInfo.episodes > 12) {
|
||||
score += 0.1;
|
||||
}
|
||||
|
||||
// Boost score if episodes count matches Anilist data
|
||||
if (animeInfo.episodes && episodesCount === animeInfo.episodes) {
|
||||
score += 0.2;
|
||||
}
|
||||
|
||||
// Special handling for year matching
|
||||
if (year) {
|
||||
// Check if jname contains the year
|
||||
if (jname && jname.includes(year)) {
|
||||
score += 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
// Penalize movies when looking for series with episodes
|
||||
const isMovie = $(item).find('.fd-infor .fdi-item').first().text().trim() === 'Movie';
|
||||
if (isMovie && animeInfo.episodes > 1) {
|
||||
score -= 0.3;
|
||||
}
|
||||
|
||||
if (score > 0.5) {
|
||||
seriesMatches.push({
|
||||
title: hianimeTitle,
|
||||
id: hianimeId,
|
||||
score,
|
||||
isMovie,
|
||||
isTV,
|
||||
episodes: episodesCount,
|
||||
jname
|
||||
});
|
||||
}
|
||||
|
||||
if (score > bestMatch.score) {
|
||||
bestMatch = { score, id: hianimeId };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Stop if we found a very good match
|
||||
if (bestMatch.score > 0.85) {
|
||||
return bestMatch.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Special handling for Hunter x Hunter 2011 version
|
||||
const hxh2011Matches = seriesMatches.filter(match =>
|
||||
match.isTV &&
|
||||
match.episodes >= 100 &&
|
||||
(match.jname?.includes('2011') || match.title.toLowerCase().includes('hunter x hunter'))
|
||||
);
|
||||
|
||||
if (hxh2011Matches.length > 0 && year === '2011') {
|
||||
const bestHxH = hxh2011Matches.sort((a, b) => b.score - a.score)[0];
|
||||
return bestHxH.id;
|
||||
}
|
||||
|
||||
// If we have multiple matches, prioritize non-movies and TV series
|
||||
if (seriesMatches.length > 0) {
|
||||
// First prioritize by TV series with matching episode count
|
||||
const exactEpisodeMatches = seriesMatches.filter(m =>
|
||||
m.isTV && animeInfo.episodes && m.episodes === animeInfo.episodes
|
||||
);
|
||||
|
||||
if (exactEpisodeMatches.length > 0) {
|
||||
const bestExactMatch = exactEpisodeMatches.sort((a, b) => b.score - a.score)[0];
|
||||
return bestExactMatch.id;
|
||||
}
|
||||
|
||||
// Sort by score descending
|
||||
seriesMatches.sort((a, b) => b.score - a.score);
|
||||
|
||||
// Prioritize TV series over others if scores are close
|
||||
const bestTV = seriesMatches.filter(m => m.isTV)[0];
|
||||
const bestOverall = seriesMatches[0];
|
||||
|
||||
if (bestTV && bestOverall.score - bestTV.score < 0.2) {
|
||||
return bestTV.id;
|
||||
}
|
||||
|
||||
// Otherwise return best match
|
||||
return bestOverall.id;
|
||||
}
|
||||
|
||||
return bestMatch.score > 0.4 ? bestMatch.id : null;
|
||||
} catch (error) {
|
||||
console.error('Error searching Hianime:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Get episode IDs for an anime
|
||||
async function getEpisodeIds(animeId, anilistId) {
|
||||
try {
|
||||
const episodeUrl = `${HIANIME_URL}/ajax/v2/episode/list/${animeId.split('-').pop()}`;
|
||||
|
||||
// Fetch additional metadata from ani.zip
|
||||
const anizipUrl = `${ANIZIP_URL}?anilist_id=${anilistId}`;
|
||||
const [episodeResponse, anizipResponse] = await Promise.all([
|
||||
client.get(episodeUrl, {
|
||||
headers: {
|
||||
'Referer': `${HIANIME_URL}/watch/${animeId}`,
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
}),
|
||||
client.get(anizipUrl)
|
||||
]);
|
||||
|
||||
if (!episodeResponse.data.html) {
|
||||
return { totalEpisodes: 0, episodes: [] };
|
||||
}
|
||||
|
||||
const $ = load(episodeResponse.data.html);
|
||||
const episodes = [];
|
||||
const anizipData = anizipResponse.data;
|
||||
|
||||
$('#detail-ss-list div.ss-list a').each((i, el) => {
|
||||
const $el = $(el);
|
||||
const href = $el.attr('href');
|
||||
if (!href) return;
|
||||
|
||||
const fullPath = href.split('/').pop();
|
||||
const episodeNumber = i + 1;
|
||||
const anizipEpisode = anizipData?.episodes?.[episodeNumber];
|
||||
|
||||
if (fullPath) {
|
||||
episodes.push({
|
||||
episodeId: `${animeId}?ep=${fullPath.split('?ep=')[1]}`,
|
||||
title: anizipEpisode?.title?.en || $el.attr('title') || '',
|
||||
number: episodeNumber,
|
||||
image: anizipEpisode?.image || null,
|
||||
overview: anizipEpisode?.overview || null,
|
||||
airDate: anizipEpisode?.airDate || null,
|
||||
runtime: anizipEpisode?.runtime || null
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
totalEpisodes: episodes.length,
|
||||
episodes,
|
||||
titles: anizipData?.titles || null,
|
||||
images: anizipData?.images || null,
|
||||
mappings: anizipData?.mappings || null
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching episodes:', error);
|
||||
return { totalEpisodes: 0, episodes: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// Main function to get episodes for an Anilist ID
|
||||
export async function getEpisodesForAnime(anilistId) {
|
||||
try {
|
||||
const animeInfo = await getAnimeInfo(anilistId);
|
||||
if (!animeInfo) {
|
||||
throw new Error('Could not fetch anime info from Anilist');
|
||||
}
|
||||
|
||||
const title = animeInfo.title.english || animeInfo.title.romaji;
|
||||
if (!title) {
|
||||
throw new Error('No English or romaji title found');
|
||||
}
|
||||
|
||||
const hianimeId = await searchAnime(title, animeInfo);
|
||||
if (!hianimeId) {
|
||||
throw new Error('Could not find anime on Hianime');
|
||||
}
|
||||
|
||||
const episodes = await getEpisodeIds(hianimeId, anilistId);
|
||||
if (!episodes || episodes.totalEpisodes === 0) {
|
||||
throw new Error('Could not fetch episodes');
|
||||
}
|
||||
|
||||
return { anilistId, hianimeId, title, ...episodes };
|
||||
} catch (error) {
|
||||
console.error('Error in getEpisodesForAnime:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
getEpisodesForAnime
|
||||
};
|
||||
11
src/providers/index.js
Normal file
11
src/providers/index.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import AniList from './anilist.js';
|
||||
import AnimePahe from './animepahe.js';
|
||||
import HiAnime from './hianime.js';
|
||||
import AnimeKai from './animekai.js';
|
||||
|
||||
export {
|
||||
AniList,
|
||||
AnimePahe,
|
||||
HiAnime,
|
||||
AnimeKai
|
||||
};
|
||||
43
src/utils/cache.js
Normal file
43
src/utils/cache.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import NodeCache from 'node-cache';
|
||||
|
||||
// Create a new cache instance
|
||||
const apiCache = new NodeCache({ stdTTL: 300 }); // Default TTL 5 minutes
|
||||
|
||||
/**
|
||||
* Middleware for caching API responses
|
||||
* @param {string} duration Cache duration in format: '5 minutes', '1 hour', etc.
|
||||
*/
|
||||
export function cache(duration) {
|
||||
return (req, res, next) => {
|
||||
// Skip caching for non-GET requests
|
||||
if (req.method !== 'GET') {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Create a unique cache key from the request URL
|
||||
const key = req.originalUrl || req.url;
|
||||
|
||||
// Check if we have a cached response for this request
|
||||
const cachedResponse = apiCache.get(key);
|
||||
|
||||
if (cachedResponse) {
|
||||
console.log(`Cache hit for: ${key}`);
|
||||
return res.json(cachedResponse);
|
||||
}
|
||||
|
||||
// Store the original json method
|
||||
const originalJson = res.json;
|
||||
|
||||
// Override the json method to cache the response
|
||||
res.json = function(data) {
|
||||
// Cache the data
|
||||
console.log(`Caching response for: ${key}`);
|
||||
apiCache.set(key, data);
|
||||
|
||||
// Call the original json method
|
||||
return originalJson.call(this, data);
|
||||
};
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
12
src/utils/client.js
Normal file
12
src/utils/client.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export const client = axios.create({
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.71 Safari/537.36',
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'en-US,en;q=0.9'
|
||||
},
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
export default client;
|
||||
15
vercel.json
Normal file
15
vercel.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"version": 2,
|
||||
"builds": [
|
||||
{
|
||||
"src": "src/index.js",
|
||||
"use": "@vercel/node"
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"src": "/(.*)",
|
||||
"dest": "src/index.js"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user