mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-07-26 20:42:08 +00:00
1089 lines
No EOL
35 KiB
JavaScript
1089 lines
No EOL
35 KiB
JavaScript
/**
|
|
* AnimeSama route module.
|
|
* Extracted from server.js -- handles anime search, season/episode scraping, and caching.
|
|
*
|
|
* Mounted as: app.use('/anime', require('./routes/animeSama'));
|
|
* Route paths are relative to the mount point.
|
|
*/
|
|
|
|
const express = require('express');
|
|
const router = express.Router();
|
|
const fsp = require('fs').promises;
|
|
const path = require('path');
|
|
const axios = require('axios');
|
|
const writeFileAtomic = require('write-file-atomic');
|
|
|
|
const { ANIME_SAMA_CACHE_DIR, generateCacheKey } = require('../utils/cacheManager');
|
|
const { memoryCache } = require('../config/redis');
|
|
|
|
// ---- Lazy-bound dependencies injected via configure() ----
|
|
let deps = {
|
|
ANIME_SAMA_URL: '',
|
|
axiosAnimeSama: null,
|
|
axiosAnimeSamaRequest: async () => { throw new Error('animeSama not configured'); },
|
|
getFromCacheNoExpiration: async () => null,
|
|
saveToCache: async () => false,
|
|
normalizeAnimeSamaUrls: (data) => data,
|
|
mergeStreamingLinks: () => [],
|
|
cleanupOldCacheFiles: async () => {},
|
|
migrateOldCacheFiles: async () => {},
|
|
limitConcurrency10: async (fn) => fn()
|
|
};
|
|
|
|
function configure(injected) {
|
|
Object.assign(deps, injected);
|
|
// Re-bind normalizeAnimeSamaUrls with the current ANIME_SAMA_URL
|
|
deps.normalizeAnimeSamaUrls = normalizeAnimeSamaUrls;
|
|
}
|
|
|
|
// ---- normalizeAnimeSamaUrls (was inline in server.js) ----
|
|
const normalizeAnimeSamaUrls = (data) => {
|
|
if (!data) return data;
|
|
const currentDomain = (deps.ANIME_SAMA_URL || '').replace(/\/$/, '');
|
|
|
|
const isValidPlayerUrl = (url) => {
|
|
if (typeof url !== 'string' || url.length === 0) return false;
|
|
if (!url.startsWith('http://') && !url.startsWith('https://')) return false;
|
|
if (!url.includes('.')) return false;
|
|
const invalidPatterns = ['_self', 'containerSamedi', '\u00e9lite', 'Sectes', 'prouesses', 'discord.gg'];
|
|
if (invalidPatterns.some(pattern => url.includes(pattern))) return false;
|
|
return true;
|
|
};
|
|
|
|
const replaceUrls = (obj, key = null) => {
|
|
if (typeof obj === 'string') {
|
|
let cleanedUrl = obj.replace(/https:\/\/proxy\.movix\.(blog|club|site)\/proxy\//gi, '');
|
|
return cleanedUrl.replace(/https?:\/\/anime-sama\.[a-z]+/gi, currentDomain);
|
|
}
|
|
if (Array.isArray(obj)) {
|
|
if (key === 'players') {
|
|
return obj.map(item => replaceUrls(item)).filter(item => {
|
|
if (item && typeof item === 'object' && item.link) return true;
|
|
return isValidPlayerUrl(item);
|
|
});
|
|
}
|
|
if (key === 'streaming_links') {
|
|
return obj.map(item => replaceUrls(item)).filter(item => item && item.players && item.players.length > 0);
|
|
}
|
|
return obj.map(item => replaceUrls(item));
|
|
}
|
|
if (obj && typeof obj === 'object') {
|
|
const newObj = {};
|
|
for (const [k, value] of Object.entries(obj)) {
|
|
newObj[k] = replaceUrls(value, k);
|
|
}
|
|
return newObj;
|
|
}
|
|
return obj;
|
|
};
|
|
|
|
return replaceUrls(data);
|
|
};
|
|
|
|
// ---- Utility functions ----
|
|
|
|
const zipVarlen = (...arrays) => {
|
|
const maxLength = Math.max(...arrays.map(arr => arr.length));
|
|
const result = [];
|
|
|
|
for (let i = 0; i < maxLength; i++) {
|
|
result.push(arrays.map(arr => i < arr.length ? arr[i] : []));
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
const splitAndStrip = (str, delimiter) => {
|
|
return str.split(delimiter).map(item => item.trim()).filter(item => item);
|
|
};
|
|
|
|
const removeQuotes = (str) => {
|
|
if (typeof str !== 'string') return str;
|
|
return str.replace(/^["'](.*)["']$/, '$1');
|
|
};
|
|
|
|
const safeFilename = (str) => {
|
|
return str.replace(/[^a-zA-Z0-9\s\-_]/g, '').trim();
|
|
};
|
|
|
|
// Language constants
|
|
const LANG = {
|
|
VOSTFR: 'VOSTFR',
|
|
VF: 'VF',
|
|
VOST_ENG: 'VOSTEng',
|
|
VOST_SPA: 'VOSTSpa',
|
|
VJ: 'VJ'
|
|
};
|
|
|
|
const LANG_ID = {
|
|
VOSTFR: 'vostfr',
|
|
VF: 'vf',
|
|
VOST_ENG: 'vosteng',
|
|
VOST_SPA: 'vostspa',
|
|
VJ: 'vj'
|
|
};
|
|
|
|
const flags = {
|
|
'VOSTFR': '\ud83c\uddef\ud83c\uddf5',
|
|
'VF': '\ud83c\uddeb\ud83c\uddf7',
|
|
'VOSTEng': '\ud83c\uddec\ud83c\udde7',
|
|
'VOSTSpa': '\ud83c\uddea\ud83c\uddf8',
|
|
'VJ': '\ud83c\uddef\ud83c\uddf5'
|
|
};
|
|
|
|
const id2lang = {
|
|
'vostfr': LANG.VOSTFR,
|
|
'vf': LANG.VF,
|
|
'vosteng': LANG.VOST_ENG,
|
|
'vostspa': LANG.VOST_SPA,
|
|
'vj': LANG.VJ
|
|
};
|
|
|
|
const lang2ids = {
|
|
[LANG.VOSTFR]: [LANG_ID.VOSTFR],
|
|
[LANG.VF]: [LANG_ID.VF],
|
|
[LANG.VOST_ENG]: [LANG_ID.VOST_ENG],
|
|
[LANG.VOST_SPA]: [LANG_ID.VOST_SPA],
|
|
[LANG.VJ]: [LANG_ID.VJ]
|
|
};
|
|
|
|
const langIds = ['vostfr', 'vf', 'vosteng', 'vostspa', 'vj', 'va', 'vf1', 'vf2', 'vkr'];
|
|
|
|
// Helper function to validate player URLs
|
|
const isValidPlayerUrl = (url) => {
|
|
if (typeof url !== 'string' || url.length === 0) return false;
|
|
if (!url.startsWith('http://') && !url.startsWith('https://')) return false;
|
|
if (!url.includes('.')) return false;
|
|
const invalidPatterns = ['_self', 'containerSamedi', '\u00e9lite', 'Sectes', 'prouesses', 'discord.gg'];
|
|
if (invalidPatterns.some(pattern => url.includes(pattern))) return false;
|
|
return true;
|
|
};
|
|
|
|
// ---- Classes ----
|
|
|
|
class Players {
|
|
constructor(availables = []) {
|
|
this.availables = availables;
|
|
this._best = null;
|
|
this.index = 1;
|
|
}
|
|
|
|
get best() {
|
|
if (!this._best) {
|
|
this.setBest();
|
|
}
|
|
return this._best;
|
|
}
|
|
|
|
setBest(prefers = [], bans = []) {
|
|
if (!this.availables.length) {
|
|
return;
|
|
}
|
|
|
|
for (const prefer of prefers) {
|
|
for (const player of this.availables) {
|
|
if (player.includes(prefer)) {
|
|
this._best = player;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (let i = this.index; i < this.availables.length + this.index; i++) {
|
|
const candidate = this.availables[i % this.availables.length];
|
|
if (bans.every(ban => !candidate.includes(ban))) {
|
|
this._best = candidate;
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!this._best) {
|
|
console.warn(`WARNING: No suitable player found. Defaulting to ${this.availables[0]}`);
|
|
this._best = this.availables[0];
|
|
}
|
|
}
|
|
}
|
|
|
|
class Languages {
|
|
constructor(players, preferLanguages = []) {
|
|
this.players = players;
|
|
this.preferLanguages = preferLanguages;
|
|
|
|
Object.keys(this.players).forEach(langId => {
|
|
if (!this.players[langId].availables.length) {
|
|
delete this.players[langId];
|
|
}
|
|
});
|
|
|
|
if (Object.keys(this.players).length === 0) {
|
|
console.warn('WARNING: No player available');
|
|
}
|
|
|
|
this.availables = {};
|
|
for (const langId in this.players) {
|
|
const lang = id2lang[langId];
|
|
if (!this.availables[lang]) {
|
|
this.availables[lang] = [];
|
|
}
|
|
this.availables[lang].push(this.players[langId]);
|
|
}
|
|
}
|
|
|
|
get best() {
|
|
for (const preferLanguage of this.preferLanguages) {
|
|
if (this.availables[preferLanguage]) {
|
|
for (const player of this.availables[preferLanguage]) {
|
|
if (player.availables.length) {
|
|
return player.best;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const language in this.availables) {
|
|
for (const player of this.availables[language]) {
|
|
if (player.availables.length) {
|
|
console.warn(`WARNING: Language preference not respected. Defaulting to ${language}`);
|
|
return player.best;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
setBest(...args) {
|
|
for (const langId in this.players) {
|
|
this.players[langId].setBest(...args);
|
|
}
|
|
}
|
|
}
|
|
|
|
class Episode {
|
|
constructor(languages, serieName = "", seasonName = "", episodeName = "", index = 1) {
|
|
this.languages = languages;
|
|
this.serieName = serieName;
|
|
this.seasonName = seasonName;
|
|
this.episodeName = episodeName;
|
|
this._index = index;
|
|
|
|
this.name = this.episodeName;
|
|
this.fancyName = this.name;
|
|
|
|
for (const lang in this.languages.availables) {
|
|
this.fancyName += ` ${flags[lang]}`;
|
|
}
|
|
|
|
this.index = this._index;
|
|
|
|
const seasonNumberMatch = seasonName.match(/\d+/);
|
|
this.seasonNumber = seasonNumberMatch ? parseInt(seasonNumberMatch[0]) : 0;
|
|
|
|
this.longName = `${this.seasonName} - ${this.episodeName}`;
|
|
this.shortName = `${this.serieName} S${this.seasonNumber.toString().padStart(2, '0')}E${this.index.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
get index() {
|
|
return this._index;
|
|
}
|
|
|
|
set index(value) {
|
|
this._index = value;
|
|
for (const langId in this.languages.players) {
|
|
this.languages.players[langId].index = this._index;
|
|
}
|
|
}
|
|
|
|
toString() {
|
|
return this.fancyName;
|
|
}
|
|
}
|
|
|
|
class Season {
|
|
constructor(url, name = "", serieName = "", client = null) {
|
|
const normalizedUrl = url.endsWith('/') ? url : url + '/';
|
|
this.pages = langIds.map(lang => normalizedUrl + lang + "/");
|
|
this.siteUrl = url.split('/').slice(0, 3).join('/') + '/';
|
|
|
|
this.name = name || url.split('/').slice(-2)[0];
|
|
this.serieName = serieName || url.split('/').slice(-3)[0];
|
|
|
|
this.client = client || deps.axiosAnimeSama;
|
|
}
|
|
|
|
async _getPlayersLinksFrom(page) {
|
|
try {
|
|
const episodesUrl = page + 'episodes.js';
|
|
const episodesJsResponse = await deps.axiosAnimeSamaRequest({
|
|
method: 'get',
|
|
url: episodesUrl,
|
|
timeout: 10000
|
|
});
|
|
|
|
if (episodesJsResponse.status !== 200) {
|
|
return [];
|
|
}
|
|
|
|
const episodesJs = episodesJsResponse.data;
|
|
|
|
if (typeof episodesJs === 'string' && (
|
|
episodesJs.includes('<!doctype html>') ||
|
|
episodesJs.includes('<!DOCTYPE html>') ||
|
|
episodesJs.includes('<html') ||
|
|
episodesJs.includes('Page introuvable') ||
|
|
episodesJs.includes('Acces Introuvable')
|
|
)) {
|
|
return [];
|
|
}
|
|
|
|
if (typeof episodesJs !== 'string' || !episodesJs.includes('[')) {
|
|
return [];
|
|
}
|
|
|
|
const playersList = episodesJs.split('[').slice(1);
|
|
const playersListLinks = playersList.map(player => {
|
|
const matches = player.match(/'(.+?)'/g);
|
|
if (!matches) return [];
|
|
|
|
const allLinks = matches.map(link => {
|
|
let cleanLink = link.replace(/'/g, '');
|
|
const proxyPrefix = 'https://proxy.liyao.space/------';
|
|
if (cleanLink.startsWith(proxyPrefix)) {
|
|
cleanLink = cleanLink.substring(proxyPrefix.length);
|
|
}
|
|
return cleanLink;
|
|
});
|
|
|
|
const validLinks = allLinks.filter(isValidPlayerUrl);
|
|
return validLinks;
|
|
});
|
|
|
|
const result = zipVarlen(...playersListLinks);
|
|
return result;
|
|
} catch (error) {
|
|
if (!error.response || error.response.status !== 404) {
|
|
// Non-404 error
|
|
}
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async episodes(existingEpisodes = null) {
|
|
const episodesPagesPromises = this.pages.map(page => this._getPlayersLinksFrom(page));
|
|
const episodesPages = await Promise.all(episodesPagesPromises);
|
|
const episodesInSeason = Math.max(...episodesPages.map(ep => ep.length));
|
|
|
|
const padding = episodesInSeason.toString().length;
|
|
const episodeNames = Array.from({ length: episodesInSeason }, (_, i) =>
|
|
`Episode ${(i + 1).toString().padStart(padding, '0')}`
|
|
);
|
|
|
|
const episodeObjs = episodeNames.map((name, index) => {
|
|
const playersLinks = episodesPages.map(pages => pages[index] || []);
|
|
|
|
const languages = new Languages(
|
|
Object.fromEntries(
|
|
langIds.map((langId, i) => [langId, new Players(playersLinks[i])])
|
|
)
|
|
);
|
|
return new Episode(languages, this.serieName, this.name, name, index + 1);
|
|
});
|
|
|
|
if (!existingEpisodes) {
|
|
return episodeObjs.map(ep => ({
|
|
name: ep.name,
|
|
serie_name: ep.serieName,
|
|
season_name: ep.seasonName,
|
|
index: ep.index,
|
|
streaming_links: Object.entries(ep.languages.players).map(([langId, players]) => ({
|
|
language: langId,
|
|
players: (Array.isArray(players.availables) ? players.availables : []).filter(isValidPlayerUrl)
|
|
})).filter(link => link.players.length > 0)
|
|
}));
|
|
}
|
|
|
|
return episodeObjs.map((ep, idx) => {
|
|
const oldEp = existingEpisodes[idx];
|
|
if (!oldEp) {
|
|
return {
|
|
name: ep.name,
|
|
serie_name: ep.serieName,
|
|
season_name: ep.seasonName,
|
|
index: ep.index,
|
|
streaming_links: Object.entries(ep.languages.players).map(([langId, players]) => ({
|
|
language: langId,
|
|
players: (Array.isArray(players.availables) ? players.availables : []).filter(isValidPlayerUrl)
|
|
})).filter(link => link.players.length > 0)
|
|
};
|
|
}
|
|
const oldLinks = oldEp.streaming_links || [];
|
|
const newLinks = Object.entries(ep.languages.players).map(([langId, players]) => ({
|
|
language: langId,
|
|
players: (Array.isArray(players.availables) ? players.availables : []).filter(isValidPlayerUrl)
|
|
}));
|
|
const mergedLinks = deps.mergeStreamingLinks(oldLinks, newLinks);
|
|
return {
|
|
name: ep.name,
|
|
serie_name: ep.serieName,
|
|
season_name: ep.seasonName,
|
|
index: ep.index,
|
|
streaming_links: mergedLinks.filter(link => link.players && link.players.length > 0)
|
|
};
|
|
});
|
|
}
|
|
}
|
|
|
|
class Catalogue {
|
|
constructor(url, name = "", client = null, additionalData = null) {
|
|
if (url.startsWith('/')) {
|
|
this.url = deps.ANIME_SAMA_URL + url.substring(1);
|
|
} else if (url.startsWith('http')) {
|
|
try {
|
|
const urlObj = new URL(url);
|
|
let urlPath = urlObj.pathname;
|
|
if (urlPath.startsWith('/')) urlPath = urlPath.substring(1);
|
|
this.url = deps.ANIME_SAMA_URL + urlPath + urlObj.search;
|
|
} catch (e) {
|
|
console.error("Error parsing URL in Catalogue constructor:", url);
|
|
this.url = url;
|
|
}
|
|
} else {
|
|
this.url = url.endsWith('/') ? url : url + '/';
|
|
}
|
|
this.name = name || url.split('/').slice(-2)[0];
|
|
this.siteUrl = url.split('/').slice(0, 3).join('/') + '/';
|
|
this.client = client || deps.axiosAnimeSama;
|
|
|
|
if (additionalData) {
|
|
this.image = additionalData.image || '';
|
|
this.alternative_names = additionalData.alternative_names || [];
|
|
this.alternative_names_string = additionalData.alternative_names_string || '';
|
|
} else {
|
|
this.image = '';
|
|
this.alternative_names = [];
|
|
this.alternative_names_string = '';
|
|
}
|
|
}
|
|
|
|
async seasons() {
|
|
try {
|
|
const response = await deps.axiosAnimeSamaRequest({
|
|
method: 'get',
|
|
url: this.url
|
|
});
|
|
const responseData = response.data;
|
|
|
|
const seasonsMatches = responseData.match(/panneauAnime\("(.+?)", *"(.+?)(?:vostfr|vf)"\);/g) || [];
|
|
|
|
const seasons = [];
|
|
for (const match of seasonsMatches) {
|
|
const [_, name, link] = match.match(/panneauAnime\("(.+?)", *"(.+?)(?:vostfr|vf)"\);/) || [];
|
|
|
|
if (name && link) {
|
|
const urlParts = this.url.split('/');
|
|
const animeNameFromUrl = urlParts[urlParts.length - 2] || urlParts[urlParts.length - 1];
|
|
|
|
let normalizedLink = link;
|
|
if (animeNameFromUrl && normalizedLink.startsWith(animeNameFromUrl)) {
|
|
normalizedLink = normalizedLink.substring(animeNameFromUrl.length);
|
|
}
|
|
|
|
if (!normalizedLink.startsWith('/')) {
|
|
normalizedLink = '/' + normalizedLink;
|
|
}
|
|
|
|
const baseUrl = this.url.endsWith('/') ? this.url.slice(0, -1) : this.url;
|
|
const seasonUrl = baseUrl + normalizedLink;
|
|
|
|
seasons.push(
|
|
new Season(
|
|
seasonUrl,
|
|
name,
|
|
this.name,
|
|
this.client
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
return seasons;
|
|
} catch (error) {
|
|
console.error(`Error getting seasons for ${this.name}:`, error.message);
|
|
return [];
|
|
}
|
|
}
|
|
}
|
|
|
|
class AnimeSama {
|
|
constructor(siteUrl) {
|
|
this.siteUrl = siteUrl;
|
|
this.client = deps.axiosAnimeSama;
|
|
}
|
|
|
|
async search(query, forceNoCache = false) {
|
|
try {
|
|
const cacheKey = generateCacheKey(query);
|
|
if (!forceNoCache) {
|
|
const cachedResults = await deps.getFromCacheNoExpiration(ANIME_SAMA_CACHE_DIR, cacheKey);
|
|
if (cachedResults) {
|
|
return cachedResults.map(result =>
|
|
new Catalogue(result.url, result.name, this.client, result)
|
|
);
|
|
}
|
|
}
|
|
|
|
const requestUrl = `${this.siteUrl}template-php/defaut/fetch.php`;
|
|
const requestData = `query=${encodeURIComponent(query)}`;
|
|
console.log(`\n[AnimeSama Search] DEBUG INFO:`);
|
|
console.log(`[AnimeSama Search] Query: ${query}`);
|
|
console.log(`[AnimeSama Search] URL: ${requestUrl}`);
|
|
console.log(`[AnimeSama Search] Payload: ${requestData}`);
|
|
|
|
const response = await deps.axiosAnimeSamaRequest({
|
|
method: 'post',
|
|
url: requestUrl,
|
|
data: requestData,
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded'
|
|
}
|
|
});
|
|
|
|
console.log(`[AnimeSama Search] Response status: ${response.status}`);
|
|
console.log(`[AnimeSama Search] Response content-type: ${response.headers?.['content-type'] || 'n/a'}`);
|
|
|
|
if (response.status !== 200) {
|
|
console.warn(`[AnimeSama Search] Non-200 status, abort. Body snippet:`, typeof response.data === 'string' ? response.data.slice(0, 500) : response.data);
|
|
return [];
|
|
}
|
|
|
|
const responseData = response.data;
|
|
const bodyLen = typeof responseData === 'string' ? responseData.length : -1;
|
|
console.log(`[AnimeSama Search] Body length: ${bodyLen}`);
|
|
if (typeof responseData === 'string') {
|
|
console.log(`[AnimeSama Search] Body snippet (first 500):`, responseData.slice(0, 500));
|
|
console.log(`[AnimeSama Search] Body snippet (last 500):`, responseData.slice(-500));
|
|
}
|
|
|
|
const results = this.parseSearchResults(responseData);
|
|
console.log(`[AnimeSama Search] parseSearchResults -> ${results.length} results`);
|
|
|
|
await deps.saveToCache(ANIME_SAMA_CACHE_DIR, cacheKey, results);
|
|
|
|
return results.map(result =>
|
|
new Catalogue(result.url, result.name, this.client, result)
|
|
);
|
|
} catch (error) {
|
|
console.error(`\n[AnimeSama Search] ERROR FAIL:`);
|
|
console.error(`[AnimeSama Search] Query: ${query}`);
|
|
console.error(`[AnimeSama Search] Error Message: ${error.message}`);
|
|
if (error.response) {
|
|
console.error(`[AnimeSama Search] Status Code: ${error.response.status}`);
|
|
console.error(`[AnimeSama Search] Response Data:`, JSON.stringify(error.response.data, null, 2));
|
|
}
|
|
if (error.config) {
|
|
console.error(`[AnimeSama Search] Request Config URL:`, error.config.url);
|
|
}
|
|
return [];
|
|
}
|
|
}
|
|
|
|
parseSearchResults(htmlData) {
|
|
const results = [];
|
|
|
|
try {
|
|
if (typeof htmlData !== 'string') {
|
|
console.warn('[AnimeSama Parse] htmlData is not a string, type:', typeof htmlData);
|
|
return [];
|
|
}
|
|
|
|
// Diagnostic counters
|
|
const allAnchors = (htmlData.match(/<a\b/gi) || []).length;
|
|
const asnAnchors = (htmlData.match(/class="asn-search-result"/gi) || []).length;
|
|
const catalogueHrefs = (htmlData.match(/href="[^"]*\/catalogue\/[^"]*"/gi) || []).length;
|
|
console.log(`[AnimeSama Parse] anchors=${allAnchors} asn-class=${asnAnchors} catalogue-hrefs=${catalogueHrefs}`);
|
|
|
|
const anchorRegex = /<a\b([^>]*\bclass="asn-search-result"[^>]*)>([\s\S]*?)<\/a>/gi;
|
|
let match;
|
|
let iterations = 0;
|
|
|
|
while ((match = anchorRegex.exec(htmlData)) !== null) {
|
|
iterations++;
|
|
const attrs = match[1];
|
|
const inner = match[2];
|
|
|
|
const hrefMatch = attrs.match(/href="([^"]+)"/i);
|
|
if (!hrefMatch) {
|
|
console.log(`[AnimeSama Parse] block #${iterations}: no href in attrs:`, attrs.slice(0, 200));
|
|
continue;
|
|
}
|
|
const href = hrefMatch[1];
|
|
|
|
if (!href.includes('/catalogue/')) {
|
|
console.log(`[AnimeSama Parse] block #${iterations}: skipped (not catalogue): ${href}`);
|
|
continue;
|
|
}
|
|
if (!/\/catalogue\/[a-zA-Z0-9][a-zA-Z0-9\-_.]+/.test(href)) {
|
|
console.log(`[AnimeSama Parse] block #${iterations}: skipped (bad slug): ${href}`);
|
|
continue;
|
|
}
|
|
|
|
const imgMatch = inner.match(/<img\b[^>]*\bsrc="([^"]+)"/i);
|
|
const imageUrl = imgMatch ? imgMatch[1] : '';
|
|
|
|
const titleMatch = inner.match(/<h3\b[^>]*>([\s\S]*?)<\/h3>/i);
|
|
const mainTitle = titleMatch ? titleMatch[1].replace(/<[^>]+>/g, '').trim() : '';
|
|
|
|
const subtitleMatch = inner.match(/<p\b[^>]*class="asn-search-result-subtitle"[^>]*>([\s\S]*?)<\/p>/i);
|
|
const alternativeNames = subtitleMatch ? subtitleMatch[1].replace(/<[^>]+>/g, '').trim() : '';
|
|
|
|
const altNamesArray = alternativeNames
|
|
? alternativeNames.split(',').map(name => name.trim()).filter(name => name.length > 0)
|
|
: [];
|
|
|
|
if (mainTitle) {
|
|
results.push({
|
|
url: href,
|
|
name: mainTitle,
|
|
image: imageUrl,
|
|
alternative_names: altNamesArray,
|
|
alternative_names_string: alternativeNames
|
|
});
|
|
} else {
|
|
console.log(`[AnimeSama Parse] block #${iterations}: skipped (no title). inner snippet:`, inner.slice(0, 300));
|
|
}
|
|
}
|
|
|
|
console.log(`[AnimeSama Parse] iterations=${iterations} kept=${results.length}`);
|
|
return results;
|
|
} catch (error) {
|
|
console.error('[AnimeSama Parse] Error:', error.message);
|
|
return [];
|
|
}
|
|
}
|
|
}
|
|
|
|
// Episode cache for Anime Sama
|
|
class EpisodeCache {
|
|
constructor(cacheDir = ANIME_SAMA_CACHE_DIR, ttl = 3600) {
|
|
this.cacheDir = cacheDir;
|
|
this.ttl = ttl * 1000;
|
|
}
|
|
|
|
_getCachePath(serieName) {
|
|
const safeSerie = safeFilename(serieName);
|
|
return path.join(this.cacheDir, `${safeSerie}.json`);
|
|
}
|
|
|
|
async getAnimeData(serieName) {
|
|
const cachePath = this._getCachePath(serieName);
|
|
|
|
try {
|
|
const fileContent = await fsp.readFile(cachePath, 'utf-8');
|
|
const data = deps.normalizeAnimeSamaUrls(JSON.parse(fileContent));
|
|
|
|
if (Date.now() - data.timestamp > this.ttl) {
|
|
return null;
|
|
}
|
|
|
|
return data.seasons || {};
|
|
} catch (error) {
|
|
if (error.code !== 'ENOENT') {
|
|
console.error('Error reading cache:', error);
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async getEpisodes(serieName, seasonName) {
|
|
const animeData = await this.getAnimeData(serieName);
|
|
if (!animeData) return null;
|
|
|
|
const seasonData = animeData[seasonName];
|
|
return seasonData ? seasonData.episodes : null;
|
|
}
|
|
|
|
async saveAnimeData(serieName, seasonsData) {
|
|
const cachePath = this._getCachePath(serieName);
|
|
|
|
const data = {
|
|
timestamp: Date.now(),
|
|
seasons: seasonsData
|
|
};
|
|
|
|
try {
|
|
await writeFileAtomic(cachePath, JSON.stringify(data), 'utf-8');
|
|
await memoryCache.set(`anime:${serieName}`, data);
|
|
} catch (error) {
|
|
console.error('Error saving cache:', error);
|
|
}
|
|
}
|
|
|
|
async saveEpisodes(serieName, seasonName, episodesData) {
|
|
let animeData = await this.getAnimeData(serieName) || {};
|
|
|
|
animeData[seasonName] = {
|
|
timestamp: Date.now(),
|
|
episodes: episodesData
|
|
};
|
|
|
|
await this.saveAnimeData(serieName, animeData);
|
|
}
|
|
}
|
|
|
|
// ---- Routes ----
|
|
|
|
router.get('/search/:query', async (req, res) => {
|
|
try {
|
|
const { query } = req.params;
|
|
const cacheKey = generateCacheKey(query);
|
|
const animeCacheDir = ANIME_SAMA_CACHE_DIR;
|
|
let cachedResults = await deps.getFromCacheNoExpiration(animeCacheDir, cacheKey);
|
|
let dataReturned = false;
|
|
|
|
if (!cachedResults || !Array.isArray(cachedResults) || cachedResults.length === 0) {
|
|
try {
|
|
const client = new AnimeSama(deps.ANIME_SAMA_URL);
|
|
const searchResults = await client.search(query, false);
|
|
const serializedResults = searchResults.map(cat => ({
|
|
url: cat.url,
|
|
name: cat.name,
|
|
image: cat.image,
|
|
alternative_names: cat.alternative_names,
|
|
alternative_names_string: cat.alternative_names_string
|
|
}));
|
|
await deps.saveToCache(animeCacheDir, cacheKey, serializedResults);
|
|
cachedResults = serializedResults;
|
|
} catch (err) {
|
|
console.error('Erreur scraping Anime Sama:', err);
|
|
return res.status(500).json({ error: 'Erreur lors de la recherche Anime Sama' });
|
|
}
|
|
}
|
|
|
|
const allCacheFiles = await fsp.readdir(animeCacheDir).catch(() => []);
|
|
|
|
const animesWithSeasons = await Promise.all(cachedResults.map(async (anime) => {
|
|
const safeAnimeName = anime.name.replace(/[^a-zA-Z0-9\s\-_]/g, '').trim();
|
|
const animeFile = `${safeAnimeName}.json`;
|
|
|
|
let saisons = [];
|
|
|
|
if (allCacheFiles.includes(animeFile)) {
|
|
try {
|
|
const animeContent = await fsp.readFile(path.join(animeCacheDir, animeFile), 'utf-8');
|
|
const animeCache = deps.normalizeAnimeSamaUrls(JSON.parse(animeContent));
|
|
|
|
if (animeCache.seasons) {
|
|
saisons = Object.entries(animeCache.seasons).map(([seasonName, seasonData]) => ({
|
|
name: seasonName,
|
|
episodes: seasonData.episodes || [],
|
|
episodeCount: (seasonData.episodes || []).length,
|
|
cacheFile: animeFile,
|
|
timestamp: seasonData.timestamp || animeCache.timestamp || null
|
|
}));
|
|
}
|
|
} catch (e) {
|
|
console.error(`Error reading unified cache for ${anime.name}:`, e.message);
|
|
}
|
|
} else {
|
|
const seasonFiles = allCacheFiles.filter(f => f.startsWith(safeAnimeName + '_') && f !== cacheKey + '.json');
|
|
|
|
saisons = (await Promise.all(seasonFiles.map(async seasonFile => {
|
|
try {
|
|
const seasonContent = await fsp.readFile(path.join(animeCacheDir, seasonFile), 'utf-8');
|
|
const seasonCache = deps.normalizeAnimeSamaUrls(JSON.parse(seasonContent));
|
|
return {
|
|
name: seasonFile.replace(safeAnimeName + '_', '').replace('.json', ''),
|
|
episodes: seasonCache.episodes || [],
|
|
episodeCount: (seasonCache.episodes || []).length,
|
|
cacheFile: seasonFile,
|
|
timestamp: seasonCache.timestamp || null
|
|
};
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}))).filter(Boolean);
|
|
}
|
|
|
|
const sortSeasons = (seasons) => {
|
|
return seasons;
|
|
};
|
|
|
|
return {
|
|
...anime,
|
|
seasons: sortSeasons(saisons)
|
|
};
|
|
}));
|
|
|
|
// --- Filtering unwanted URLs before response ---
|
|
const unwantedUrls = [
|
|
'https://video.sibnet.ru/shell.php?videoid=',
|
|
'https://vidmoly.to/embed-.html',
|
|
'https://sendvid.com/embed/',
|
|
'https://vk.com/video_ext.php?oid=&hd=3'
|
|
];
|
|
animesWithSeasons.forEach(anime => {
|
|
if (anime.seasons && Array.isArray(anime.seasons)) {
|
|
anime.seasons.forEach(season => {
|
|
if (season.episodes && Array.isArray(season.episodes)) {
|
|
season.episodes.forEach(ep => {
|
|
if (ep.streaming_links && Array.isArray(ep.streaming_links)) {
|
|
ep.streaming_links.forEach(linkObj => {
|
|
if (linkObj.players && Array.isArray(linkObj.players)) {
|
|
linkObj.players = linkObj.players.filter(url => !unwantedUrls.includes(url));
|
|
}
|
|
});
|
|
}
|
|
});
|
|
season.episodes = season.episodes.filter(ep =>
|
|
Array.isArray(ep.streaming_links) &&
|
|
ep.streaming_links.some(linkObj => Array.isArray(linkObj.players) && linkObj.players.length > 0)
|
|
);
|
|
season.episodeCount = season.episodes.length;
|
|
}
|
|
});
|
|
|
|
anime.seasons = anime.seasons.filter(season =>
|
|
Array.isArray(season.episodes) && season.episodes.length > 0
|
|
);
|
|
}
|
|
});
|
|
|
|
res.json(animesWithSeasons);
|
|
dataReturned = true;
|
|
|
|
// --- Background update ---
|
|
(async () => {
|
|
const client = new AnimeSama(deps.ANIME_SAMA_URL);
|
|
|
|
for (const anime of animesWithSeasons) {
|
|
// Skip entries without valid catalogue URLs
|
|
if (!anime.url || !anime.url.includes('/catalogue/') || !anime.name) {
|
|
continue;
|
|
}
|
|
let catalogueObj = null;
|
|
try {
|
|
catalogueObj = new Catalogue(anime.url, anime.name, client.client, anime);
|
|
} catch (e) {
|
|
continue;
|
|
}
|
|
if (!catalogueObj) continue;
|
|
|
|
let seasonsList = [];
|
|
try {
|
|
seasonsList = await catalogueObj.seasons();
|
|
} catch (e) {
|
|
continue;
|
|
}
|
|
|
|
const safeAnimeName = anime.name.replace(/[^a-zA-Z0-9\s\-_]/g, '').trim();
|
|
const animeCacheFile = `${safeAnimeName}.json`;
|
|
const animeCachePath = path.join(animeCacheDir, animeCacheFile);
|
|
|
|
let existingAnimeCache = {};
|
|
try {
|
|
const animeContent = await fsp.readFile(animeCachePath, 'utf-8');
|
|
const animeData = JSON.parse(animeContent);
|
|
existingAnimeCache = animeData.seasons || {};
|
|
} catch (e) {
|
|
// No existing cache
|
|
}
|
|
|
|
const RECENT_UPDATE_THRESHOLD = 1 * 60 * 60 * 1000;
|
|
let shouldSkipAnime = false;
|
|
try {
|
|
const stats = await fsp.stat(animeCachePath);
|
|
const timeSinceLastUpdate = Date.now() - stats.mtime.getTime();
|
|
if (timeSinceLastUpdate < RECENT_UPDATE_THRESHOLD) {
|
|
shouldSkipAnime = true;
|
|
}
|
|
} catch (e) {
|
|
// File doesn't exist
|
|
}
|
|
|
|
if (shouldSkipAnime) continue;
|
|
|
|
let animeDataUpdated = false;
|
|
const updatedAnimeCache = { ...existingAnimeCache };
|
|
|
|
for (const seasonObj of seasonsList) {
|
|
const safeSeasonName = seasonObj.name.replace(/[^a-zA-Z0-9\s\-_]/g, '').trim();
|
|
let cachedEpisodes = null;
|
|
let shouldUpdate = false;
|
|
|
|
try {
|
|
const seasonCache = existingAnimeCache[seasonObj.name];
|
|
if (seasonCache && seasonCache.episodes) {
|
|
cachedEpisodes = seasonCache.episodes;
|
|
|
|
const scrapedEpisodes = await seasonObj.episodes(cachedEpisodes);
|
|
|
|
const hasNewEpisodes = scrapedEpisodes.length > cachedEpisodes.length;
|
|
const hasNewLang = scrapedEpisodes.some((ep, idx) => {
|
|
const oldEp = cachedEpisodes[idx];
|
|
if (!oldEp) return true;
|
|
const oldLangs = (oldEp.streaming_links || []).map(l => l.language);
|
|
const newLangs = (ep.streaming_links || []).map(l => l.language);
|
|
return newLangs.some(l => !oldLangs.includes(l));
|
|
});
|
|
|
|
const hasNewPlayers = scrapedEpisodes.some((ep, idx) => {
|
|
const oldEp = cachedEpisodes[idx];
|
|
if (!oldEp) return false;
|
|
|
|
return (ep.streaming_links || []).some(newLink => {
|
|
const oldLink = (oldEp.streaming_links || []).find(ol => ol.language === newLink.language);
|
|
if (!oldLink) return false;
|
|
|
|
const oldPlayers = Array.isArray(oldLink.players) ? oldLink.players : [];
|
|
const newPlayers = Array.isArray(newLink.players) ? newLink.players : [];
|
|
return newPlayers.length > oldPlayers.length ||
|
|
newPlayers.some(player => !oldPlayers.includes(player));
|
|
});
|
|
});
|
|
|
|
if (hasNewEpisodes || hasNewLang || hasNewPlayers) {
|
|
shouldUpdate = true;
|
|
cachedEpisodes = scrapedEpisodes;
|
|
}
|
|
} else {
|
|
shouldUpdate = true;
|
|
cachedEpisodes = await seasonObj.episodes();
|
|
}
|
|
} catch (e) {
|
|
shouldUpdate = true;
|
|
cachedEpisodes = await seasonObj.episodes();
|
|
}
|
|
|
|
if (shouldUpdate) {
|
|
try {
|
|
const unwantedUrlsForCache = [
|
|
'https://video.sibnet.ru/shell.php?videoid=',
|
|
'https://vidmoly.to/embed-.html',
|
|
'https://sendvid.com/embed/',
|
|
'https://vk.com/video_ext.php?oid=&hd=3'
|
|
];
|
|
const episodesData = cachedEpisodes.map(episode => ({
|
|
name: episode.name,
|
|
serie_name: episode.serie_name || episode.serieName,
|
|
season_name: episode.season_name || episode.seasonName,
|
|
index: episode.index,
|
|
streaming_links: (episode.streaming_links || []).map(linkObj => ({
|
|
language: linkObj.language,
|
|
players: Array.isArray(linkObj.players)
|
|
? linkObj.players.filter(url => !unwantedUrlsForCache.includes(url))
|
|
: linkObj.players
|
|
}))
|
|
}));
|
|
|
|
updatedAnimeCache[seasonObj.name] = {
|
|
timestamp: Date.now(),
|
|
episodes: episodesData
|
|
};
|
|
animeDataUpdated = true;
|
|
|
|
} catch (e) {
|
|
console.error(`Erreur lors du scraping de la saison ${seasonObj.name} (${anime.name}):`, e.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (animeDataUpdated) {
|
|
try {
|
|
const unifiedCacheData = {
|
|
timestamp: Date.now(),
|
|
seasons: updatedAnimeCache
|
|
};
|
|
await writeFileAtomic(animeCachePath, JSON.stringify(unifiedCacheData), 'utf-8');
|
|
|
|
await deps.cleanupOldCacheFiles(safeAnimeName, animeCacheDir);
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
} else if (Object.keys(existingAnimeCache).length === 0) {
|
|
await deps.migrateOldCacheFiles(safeAnimeName, animeCacheDir);
|
|
}
|
|
}
|
|
})();
|
|
|
|
} catch (error) {
|
|
console.error('Erreur /anime/search/:query:', error);
|
|
return res.status(500).json({ error: 'Erreur serveur' });
|
|
}
|
|
});
|
|
|
|
// Delete anime cache
|
|
router.delete('/search/:query/cache', async (req, res) => {
|
|
try {
|
|
const { query } = req.params;
|
|
const cacheKey = generateCacheKey(query);
|
|
const animeCacheDir = ANIME_SAMA_CACHE_DIR;
|
|
|
|
let deletedFiles = [];
|
|
let errors = [];
|
|
|
|
// 1. Delete search cache file
|
|
try {
|
|
const searchCacheFile = path.join(animeCacheDir, `${cacheKey}.json`);
|
|
await fsp.unlink(searchCacheFile);
|
|
deletedFiles.push(`search cache: ${cacheKey}.json`);
|
|
} catch (err) {
|
|
if (err.code !== 'ENOENT') {
|
|
errors.push(`Erreur suppression cache de recherche: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
// 2. Delete unified anime cache
|
|
try {
|
|
const decodedQuery = decodeURIComponent(query);
|
|
const safeAnimeName = decodedQuery.replace(/[^a-zA-Z0-9\s\-_]/g, '').trim();
|
|
const animeFile = path.join(animeCacheDir, `${safeAnimeName}.json`);
|
|
|
|
try {
|
|
await fsp.unlink(animeFile);
|
|
deletedFiles.push(`unified cache: ${safeAnimeName}.json`);
|
|
} catch (err) {
|
|
if (err.code !== 'ENOENT') {
|
|
errors.push(`Erreur suppression cache unifie: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
// 3. Delete old separate cache files
|
|
const allCacheFiles = await fsp.readdir(animeCacheDir).catch(() => []);
|
|
const oldSeasonFiles = allCacheFiles.filter(f =>
|
|
f.startsWith(safeAnimeName + '_') && f.endsWith('.json')
|
|
);
|
|
|
|
for (const oldFile of oldSeasonFiles) {
|
|
try {
|
|
await fsp.unlink(path.join(animeCacheDir, oldFile));
|
|
deletedFiles.push(`old season cache: ${oldFile}`);
|
|
} catch (err) {
|
|
errors.push(`Erreur suppression ancien cache ${oldFile}: ${err.message}`);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
errors.push(`Erreur lors de la recherche des fichiers: ${err.message}`);
|
|
}
|
|
|
|
if (deletedFiles.length > 0) {
|
|
return res.status(200).json({
|
|
success: true,
|
|
message: `Cache anime "${decodeURIComponent(query)}" supprime.`,
|
|
deletedFiles,
|
|
errors: errors.length > 0 ? errors : undefined
|
|
});
|
|
} else {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: 'Aucun cache trouve pour cet anime.',
|
|
errors: errors.length > 0 ? errors : undefined
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error('Erreur suppression cache anime:', err);
|
|
return res.status(500).json({ success: false, error: 'Erreur serveur.' });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|
|
module.exports.configure = configure; |