mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-07-29 19:39:10 +00:00
991 lines
33 KiB
JavaScript
991 lines
33 KiB
JavaScript
/**
|
|
* High-level axios request helpers for each streaming site.
|
|
* Extracted from server.js — wraps proxy logic with site-specific headers and cookies.
|
|
*
|
|
* Some helpers depend on axios instances / session state that live in route modules
|
|
* (e.g. axiosFStream, axiosAnimeSama, axiosCoflix, cookieJar).
|
|
* Call `configure(deps)` once at startup to inject those dependencies.
|
|
*/
|
|
|
|
const axios = require('axios');
|
|
const fsp = require('fs').promises;
|
|
const path = require('path');
|
|
const writeFileAtomic = require('write-file-atomic');
|
|
|
|
const {
|
|
ENABLE_DARKINO_PROXY,
|
|
ENABLE_COFLIX_PROXY,
|
|
ENABLE_FRENCH_STREAM_PROXY,
|
|
ENABLE_LECTEURVIDEO_PROXY,
|
|
ENABLE_FSTREAM_PROXY,
|
|
ENABLE_ANIME_PROXY,
|
|
ENABLE_WIFLIX_PROXY,
|
|
DARKINO_403_COOLDOWN_MS,
|
|
DARKINO_PROXIES,
|
|
PROXIES,
|
|
HTTP_PROXIES,
|
|
CLOUDFLARE_WORKERS_PROXIES,
|
|
getProxyAgent,
|
|
getDarkinoHttpProxyAgent,
|
|
getAvailableProxies,
|
|
markProxyAsErrored,
|
|
markProxyAsHealthy,
|
|
buildProxiedUrl,
|
|
makeRequestWithCorsFallback,
|
|
makeCoflixRequest,
|
|
makeLecteurVideoRequest,
|
|
makeWiflixRequest,
|
|
makeAnimeSamaRequest,
|
|
} = require('./proxyManager');
|
|
|
|
// Lazy-bound dependencies injected via configure()
|
|
let deps = {
|
|
darkiHeaders: {},
|
|
coflixHeaders: {},
|
|
cookieJar: null,
|
|
axiosCoflix: null,
|
|
axiosAnimeSama: null,
|
|
axiosFStream: null,
|
|
COFLIX_BASE_URL: '',
|
|
ANIME_SAMA_URL: '',
|
|
FSTREAM_BASE_URL: '',
|
|
ensureFStreamSession: async () => {},
|
|
fstreamCookies: {},
|
|
fstreamRequestCounter: 0,
|
|
getFstreamRequestCounter: () => 0,
|
|
incrementFstreamRequestCounter: () => {}
|
|
};
|
|
|
|
const WIFLIX_BASE_URL = 'https://flemmix.farm';
|
|
|
|
const truncateForLog = (value, maxLength = 300) => {
|
|
if (typeof value !== 'string') return value;
|
|
return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value;
|
|
};
|
|
|
|
const summarizeRequestErrorForLog = (error) => {
|
|
const responseBody = error?.response?.data;
|
|
return {
|
|
message: error?.message,
|
|
code: error?.code,
|
|
status: error?.response?.status,
|
|
statusText: error?.response?.statusText,
|
|
responseSnippet: typeof responseBody === 'string'
|
|
? truncateForLog(responseBody, 500)
|
|
: responseBody && typeof responseBody === 'object'
|
|
? truncateForLog(JSON.stringify(responseBody), 500)
|
|
: undefined
|
|
};
|
|
};
|
|
|
|
const DARKIWORLD_BASE_URL = String(process.env.DARKIWORLD_BASE_URL || 'https://darkiworld2026.com')
|
|
.trim()
|
|
.replace(/\/+$/, '');
|
|
|
|
/**
|
|
* Inject site-specific dependencies that live outside this module.
|
|
* Call once at server startup after creating axios instances.
|
|
*
|
|
* @param {object} injected - Map of dependency names to values
|
|
*/
|
|
function configure(injected) {
|
|
Object.assign(deps, injected);
|
|
}
|
|
|
|
// === Proxy manager re-exports for convenience (used by axiosDarkinoRequest) ===
|
|
// We need mutable access to darkino403CooldownUntil via the proxyManager module
|
|
const proxyManager = require('./proxyManager');
|
|
|
|
// Fonction utilitaire pour g\u00e9n\u00e9rer le referer dynamiquement
|
|
function generateDarkiReferer(url) {
|
|
const baseUrl = DARKIWORLD_BASE_URL;
|
|
|
|
if (url.includes('/season/') && url.includes('/episode/') && url.includes('/download')) {
|
|
// Pour les \u00e9pisodes de s\u00e9ries: /titles/{titleId}/season/{seasonId}/episode/{episodeId}/download
|
|
const match = url.match(/\/titles\/(\d+)\/season\/(\d+)\/episode\/(\d+)\/download/);
|
|
if (match) {
|
|
const [, titleId, seasonId, episodeId] = match;
|
|
return `${baseUrl}/titles/${titleId}/season/${seasonId}/episode/${episodeId}/download`;
|
|
}
|
|
} else if (url.includes('/titles/') && url.includes('/content/liens')) {
|
|
// Nouvel endpoint download : /api/v1/titles/{titleId}/content/liens
|
|
// Le navigateur envoie referer = /titles/{titleId}/download, sinon
|
|
// l'API retourne 401 m\u00eame avec session valide.
|
|
const match = url.match(/\/titles\/(\d+)\/content\/liens/);
|
|
if (match) {
|
|
const [, titleId] = match;
|
|
return `${baseUrl}/titles/${titleId}/download`;
|
|
}
|
|
} else if (url.includes('/titles/') && url.includes('/download')) {
|
|
// Pour les films: /titles/{id}/download
|
|
const match = url.match(/\/titles\/(\d+)\/download/);
|
|
if (match) {
|
|
const [, titleId] = match;
|
|
return `${baseUrl}/titles/${titleId}/download`;
|
|
}
|
|
} else if (url.includes('/search/')) {
|
|
// Pour la recherche
|
|
return `${baseUrl}/search`;
|
|
}
|
|
|
|
// Par d\u00e9faut, utiliser l'URL de base
|
|
return baseUrl;
|
|
}
|
|
|
|
function splitCookieHeader(headerValue) {
|
|
if (typeof headerValue !== 'string' || headerValue.trim() === '') {
|
|
return [];
|
|
}
|
|
|
|
return headerValue
|
|
.split(';')
|
|
.map(part => part.trim())
|
|
.filter(Boolean)
|
|
.map(part => {
|
|
const separatorIndex = part.indexOf('=');
|
|
if (separatorIndex <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
part.slice(0, separatorIndex).trim(),
|
|
part.slice(separatorIndex + 1).trim()
|
|
];
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function mergeCookieHeaders(...headerValues) {
|
|
const cookieMap = new Map();
|
|
|
|
for (const headerValue of headerValues) {
|
|
for (const [name, value] of splitCookieHeader(headerValue)) {
|
|
cookieMap.set(name, value);
|
|
}
|
|
}
|
|
|
|
return [...cookieMap.entries()]
|
|
.map(([name, value]) => `${name}=${value}`)
|
|
.join('; ');
|
|
}
|
|
|
|
function decodeCookieValue(value) {
|
|
if (typeof value !== 'string' || value === '') {
|
|
return '';
|
|
}
|
|
|
|
try {
|
|
return decodeURIComponent(value);
|
|
} catch (_) {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
async function getDarkinoJarState() {
|
|
if (!deps.cookieJar) {
|
|
return {
|
|
cookieHeader: '',
|
|
xsrfToken: ''
|
|
};
|
|
}
|
|
|
|
try {
|
|
const cookies = await deps.cookieJar.getCookies(DARKIWORLD_BASE_URL);
|
|
const xsrfCookie = cookies.find(cookie => cookie.key.toUpperCase() === 'XSRF-TOKEN');
|
|
|
|
return {
|
|
cookieHeader: cookies.map(cookie => cookie.cookieString()).join('; '),
|
|
xsrfToken: xsrfCookie ? decodeCookieValue(xsrfCookie.value) : ''
|
|
};
|
|
} catch (error) {
|
|
console.warn(`[DARKINO REQUEST] Impossible de lire le cookieJar: ${error.message}`);
|
|
return {
|
|
cookieHeader: '',
|
|
xsrfToken: ''
|
|
};
|
|
}
|
|
}
|
|
|
|
async function buildDarkinoRequestHeaders(config) {
|
|
const dynamicReferer = generateDarkiReferer(config.url || '/');
|
|
const jarState = await getDarkinoJarState();
|
|
const requestHeaders = {
|
|
...deps.darkiHeaders,
|
|
referer: dynamicReferer,
|
|
...config.headers,
|
|
};
|
|
const configuredCookieHeader = requestHeaders.cookie || requestHeaders.Cookie || '';
|
|
// IMPORTANT: env cookies (DARKIWORLD_COOKIES) doivent gagner contre le
|
|
// cookieJar. Sans ça, la réponse du GET `/` (refresh session) émet un
|
|
// `set-cookie` avec une session anonyme qui écrase `darkiworld_session`
|
|
// dans le jar → toutes les requêtes suivantes vers les routes auth (ex.
|
|
// /api/v1/titles/{id}/content/liens) reçoivent 401 Unauthenticated.
|
|
// L'ordre des arguments compte : le DERNIER argument gagne dans le merge.
|
|
const mergedCookieHeader = mergeCookieHeaders(jarState.cookieHeader, configuredCookieHeader);
|
|
|
|
if (mergedCookieHeader) {
|
|
requestHeaders.cookie = mergedCookieHeader;
|
|
}
|
|
|
|
delete requestHeaders.Cookie;
|
|
|
|
// x-xsrf-token : env gagne aussi. On ne fallback sur le jar que si l'env
|
|
// ne fournit pas de token (sinon on enverrait un header qui ne matche
|
|
// pas le cookie XSRF-TOKEN env qu'on vient de privilégier).
|
|
if (jarState.xsrfToken && !requestHeaders['x-xsrf-token']) {
|
|
requestHeaders['x-xsrf-token'] = jarState.xsrfToken;
|
|
}
|
|
|
|
return requestHeaders;
|
|
}
|
|
|
|
// Fonction utilitaire pour requ\u00eates Darkino avec proxies (SOCKS5h)
|
|
async function axiosDarkinoRequest(config) {
|
|
// V\u00e9rifier si on est en cooldown apr\u00e8s une erreur 403
|
|
if (Date.now() < proxyManager.darkino403CooldownUntil) {
|
|
const remainingMs = proxyManager.darkino403CooldownUntil - Date.now();
|
|
const remainingMin = Math.ceil(remainingMs / 60000);
|
|
const error = new Error(`Darkino en cooldown (403 Cloudflare). R\u00e9essayez dans ${remainingMin} minute(s).`);
|
|
error.isDarkinoCooldown = true;
|
|
error.response = { status: 403 };
|
|
throw error;
|
|
}
|
|
|
|
const requestUrl = `${DARKIWORLD_BASE_URL}${config.url}`;
|
|
const darkinoRequestHeaders = await buildDarkinoRequestHeaders(config);
|
|
|
|
const debugDarkino = process.env.DEBUG_DARKINO !== 'false';
|
|
const darkinoAuthSnapshot = {
|
|
referer: darkinoRequestHeaders.referer,
|
|
xsrfToken: truncateForLog(darkinoRequestHeaders['x-xsrf-token'] || '', 200),
|
|
cookie: truncateForLog(darkinoRequestHeaders.cookie || '', 500),
|
|
cookieLength: (darkinoRequestHeaders.cookie || '').length,
|
|
};
|
|
if (debugDarkino) {
|
|
console.log(`[DARKINO REQUEST][DEBUG] ${String(config.method || 'get').toUpperCase()} ${requestUrl}`, darkinoAuthSnapshot);
|
|
}
|
|
|
|
if (!ENABLE_DARKINO_PROXY) {
|
|
// Si le proxy est d\u00e9sactiv\u00e9, faire la requ\u00eate directe
|
|
try {
|
|
const response = await axios({
|
|
...config,
|
|
url: requestUrl,
|
|
headers: darkinoRequestHeaders,
|
|
timeout: 5000,
|
|
withCredentials: false,
|
|
decompress: true
|
|
});
|
|
|
|
const setCookieHeader = response.headers['set-cookie'];
|
|
if (setCookieHeader && deps.cookieJar) {
|
|
await Promise.all(setCookieHeader.map(cookie => deps.cookieJar.setCookie(cookie, DARKIWORLD_BASE_URL)));
|
|
}
|
|
return response;
|
|
} catch (error) {
|
|
console.error(`[DARKINO REQUEST][DIRECT ERROR] ${String(config.method || 'get').toUpperCase()} ${config.url}`, {
|
|
...summarizeRequestErrorForLog(error),
|
|
auth: darkinoAuthSnapshot,
|
|
});
|
|
if (error.response && error.response.headers['set-cookie'] && deps.cookieJar) {
|
|
const setCookieHeader = error.response.headers['set-cookie'];
|
|
await Promise.all(setCookieHeader.map(cookie => deps.cookieJar.setCookie(cookie, DARKIWORLD_BASE_URL)));
|
|
}
|
|
// En cas d'erreur 403, activer le cooldown
|
|
if (error.response?.status === 403) {
|
|
proxyManager.darkino403CooldownUntil = Date.now() + DARKINO_403_COOLDOWN_MS;
|
|
console.log(`[DARKINO] Erreur 403 d\u00e9tect\u00e9e (direct) - Cooldown activ\u00e9 pour 5 minutes`);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Utiliser les proxies HTTP sp\u00e9cifiques de Darkino avec rotation al\u00e9atoire
|
|
const darkinoProxies = [...DARKINO_PROXIES]; // Copie pour pouvoir m\u00e9langer
|
|
|
|
// M\u00e9langer al\u00e9atoirement les proxies
|
|
for (let i = darkinoProxies.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[darkinoProxies[i], darkinoProxies[j]] = [darkinoProxies[j], darkinoProxies[i]];
|
|
}
|
|
|
|
if (darkinoProxies.length === 0) {
|
|
try {
|
|
const response = await axios({
|
|
...config,
|
|
url: requestUrl,
|
|
headers: darkinoRequestHeaders,
|
|
timeout: 5000,
|
|
withCredentials: false,
|
|
decompress: true
|
|
});
|
|
|
|
const setCookieHeader = response.headers['set-cookie'];
|
|
if (setCookieHeader && deps.cookieJar) {
|
|
await Promise.all(setCookieHeader.map(cookie => deps.cookieJar.setCookie(cookie, DARKIWORLD_BASE_URL)));
|
|
}
|
|
return response;
|
|
} catch (error) {
|
|
console.error(`[DARKINO REQUEST][DIRECT NO PROXY ERROR] ${String(config.method || 'get').toUpperCase()} ${config.url}`, {
|
|
...summarizeRequestErrorForLog(error),
|
|
auth: darkinoAuthSnapshot,
|
|
});
|
|
if (error.response && error.response.headers['set-cookie'] && deps.cookieJar) {
|
|
const setCookieHeader = error.response.headers['set-cookie'];
|
|
await Promise.all(setCookieHeader.map(cookie => deps.cookieJar.setCookie(cookie, DARKIWORLD_BASE_URL)));
|
|
}
|
|
if (error.response?.status === 403) {
|
|
proxyManager.darkino403CooldownUntil = Date.now() + DARKINO_403_COOLDOWN_MS;
|
|
console.log(`[DARKINO] Erreur 403 d\u00e9tect\u00e9e (direct sans proxy) - Cooldown activ\u00e9 pour 5 minutes`);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
let lastError = null;
|
|
const maxRetries = 1; // Une seule tentative
|
|
|
|
for (let i = 0; i < maxRetries; i++) {
|
|
const proxy = darkinoProxies[i];
|
|
const agents = getDarkinoHttpProxyAgent(proxy);
|
|
|
|
try {
|
|
const response = await axios({
|
|
...config,
|
|
method: config.method || 'get',
|
|
url: requestUrl,
|
|
headers: darkinoRequestHeaders,
|
|
timeout: 5000,
|
|
withCredentials: false,
|
|
decompress: true,
|
|
httpAgent: agents.httpAgent,
|
|
httpsAgent: agents.httpsAgent,
|
|
proxy: false
|
|
});
|
|
|
|
// Mettre \u00e0 jour le cookieJar avec la r\u00e9ponse
|
|
const setCookieHeader = response.headers['set-cookie'];
|
|
if (setCookieHeader && deps.cookieJar) {
|
|
await Promise.all(setCookieHeader.map(cookie => deps.cookieJar.setCookie(cookie, DARKIWORLD_BASE_URL)));
|
|
}
|
|
return response;
|
|
} catch (error) {
|
|
lastError = error;
|
|
const statusCode = error.response?.status;
|
|
console.warn(`[DARKINO REQUEST][PROXY ERROR] ${String(config.method || 'get').toUpperCase()} ${config.url}`, {
|
|
attempt: i + 1,
|
|
status: statusCode,
|
|
error: summarizeRequestErrorForLog(error),
|
|
auth: darkinoAuthSnapshot,
|
|
});
|
|
|
|
// Mettre \u00e0 jour les cookies m\u00eame en cas d'erreur si le header est pr\u00e9sent
|
|
if (error.response && error.response.headers['set-cookie'] && deps.cookieJar) {
|
|
const setCookieHeader = error.response.headers['set-cookie'];
|
|
await Promise.all(setCookieHeader.map(cookie => deps.cookieJar.setCookie(cookie, DARKIWORLD_BASE_URL)));
|
|
}
|
|
|
|
// En cas d'erreur 429 (Too Many Requests), essayer avec le prochain proxy si disponible
|
|
if (statusCode === 429) {
|
|
continue;
|
|
}
|
|
|
|
// En cas d'erreur 403 (Cloudflare challenge), activer le cooldown de 5 minutes
|
|
if (statusCode === 403) {
|
|
proxyManager.darkino403CooldownUntil = Date.now() + DARKINO_403_COOLDOWN_MS;
|
|
throw error;
|
|
}
|
|
|
|
// Pour les autres erreurs (400, 500, etc.), arr\u00eater imm\u00e9diatement
|
|
if (statusCode && statusCode !== 429) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Si on arrive ici, tous les proxies ont \u00e9chou\u00e9
|
|
console.error(`[DARKINO REQUEST][ALL FAILED] ${String(config.method || 'get').toUpperCase()} ${config.url}`, {
|
|
...summarizeRequestErrorForLog(lastError),
|
|
auth: darkinoAuthSnapshot,
|
|
});
|
|
throw lastError || new Error('Tous les proxies Darkino ont \u00e9chou\u00e9');
|
|
}
|
|
|
|
// Fonction utilitaire pour requ\u00eates Coflix avec rotation Cloudflare Workers
|
|
async function axiosCoflixRequest(config) {
|
|
// Si URL relative, on consid\u00e8re que c'est Coflix.
|
|
const targetUrl = config.url || '';
|
|
const isAbsolute = /^https?:\/\//i.test(targetUrl);
|
|
const isCoflixDomain = isAbsolute ? /^https?:\/\/(?:www\.)?coflix\.[^/]+/i.test(targetUrl) : true;
|
|
|
|
try {
|
|
if (!ENABLE_COFLIX_PROXY || !isCoflixDomain) {
|
|
return await deps.axiosCoflix({ ...config });
|
|
}
|
|
|
|
// Utiliser makeCoflixRequest avec rotation SOCKS5
|
|
const absoluteUrl = isAbsolute
|
|
? targetUrl
|
|
: `${deps.COFLIX_BASE_URL}${targetUrl.startsWith('/') ? '' : '/'}${targetUrl}`;
|
|
|
|
return await makeCoflixRequest(absoluteUrl, {
|
|
headers: { ...deps.coflixHeaders, ...(config.headers || {}) },
|
|
timeout: config.timeout || 7000,
|
|
decompress: true,
|
|
responseType: config.responseType
|
|
});
|
|
} catch (error) {
|
|
// Logger les erreurs 403
|
|
if (error.response?.status === 403) {
|
|
console.log(`[Coflix] Erreur 403 Forbidden dans axiosCoflixRequest`);
|
|
console.log(`[Coflix] URL: ${targetUrl}`);
|
|
if (error.coflixProxy) {
|
|
console.log(`[Coflix] Cloudflare Worker: ${error.coflixProxy}`);
|
|
}
|
|
if (error.coflixProxiedUrl) {
|
|
console.log(`[Coflix] Proxied URL: ${error.coflixProxiedUrl}`);
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Fonction utilitaire pour requ\u00eates French-Stream (SPA) via CORS fallback
|
|
async function axiosFrenchStreamRequest(config) {
|
|
const targetUrl = config.url || '';
|
|
const isAbsolute = /^https?:\/\//i.test(targetUrl);
|
|
const baseMatches = config.baseURL && /french-?stream/i.test(config.baseURL);
|
|
const isFrenchStream = /french-?stream/i.test(targetUrl) || baseMatches;
|
|
|
|
if (!ENABLE_FRENCH_STREAM_PROXY || !isFrenchStream) {
|
|
return axios({ timeout: 15000, ...config });
|
|
}
|
|
|
|
const absoluteUrl = isAbsolute
|
|
? targetUrl
|
|
: (config.baseURL ? `${config.baseURL.replace(/\/$/, '')}/${targetUrl.replace(/^\//, '')}` : targetUrl);
|
|
|
|
return makeRequestWithCorsFallback(absoluteUrl, {
|
|
headers: { ...(config.headers || {}) },
|
|
timeout: config.timeout || 5000,
|
|
decompress: true,
|
|
responseType: config.responseType
|
|
});
|
|
}
|
|
|
|
// Fonction utilitaire pour requ\u00eates LecteurVideo avec proxies Wiflix
|
|
async function axiosLecteurVideoRequest(config) {
|
|
const urlStr = config.url || '';
|
|
const isLecteur = /lecteurvideo|lecteur-video|lecteur/i.test(urlStr) || (config.baseURL && /lecteurvideo|lecteur-video|lecteur/i.test(config.baseURL));
|
|
|
|
if (!ENABLE_LECTEURVIDEO_PROXY || !isLecteur) {
|
|
return axios({ timeout: 15000, ...config });
|
|
}
|
|
|
|
try {
|
|
// Construire l'URL absolue si n\u00e9cessaire
|
|
let absoluteUrl = config.url;
|
|
if (config.baseURL && !config.url.startsWith('http')) {
|
|
// Nettoyer les URLs avant la concat\u00e9nation pour \u00e9viter les espaces
|
|
const cleanBaseURL = config.baseURL.trim();
|
|
const cleanUrl = config.url.trim();
|
|
absoluteUrl = cleanBaseURL + cleanUrl;
|
|
}
|
|
|
|
// Utiliser makeLecteurVideoRequest avec les proxies Wiflix
|
|
return await makeLecteurVideoRequest(absoluteUrl, {
|
|
timeout: config.timeout || 5000,
|
|
headers: config.headers,
|
|
decompress: config.decompress !== false,
|
|
responseType: config.responseType,
|
|
responseEncoding: config.responseEncoding
|
|
});
|
|
} catch (error) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Fonction utilitaire pour requ\u00eates FStream avec proxy (retry sur 429, pool SOCKS5 + Darkino)
|
|
function withOptionalFStreamCookies(headers = {}) {
|
|
if (headers['Cookie'] || headers['cookie']) {
|
|
return headers;
|
|
}
|
|
|
|
const cookieHeader = Object.entries(deps.fstreamCookies)
|
|
.filter(([, v]) => v !== '' && v != null)
|
|
.map(([k, v]) => `${k}=${v}`)
|
|
.join('; ');
|
|
|
|
if (!cookieHeader) {
|
|
return headers;
|
|
}
|
|
|
|
return {
|
|
...headers,
|
|
Cookie: cookieHeader
|
|
};
|
|
}
|
|
|
|
async function axiosFStreamRequest(config) {
|
|
const urlStr = config.url || '';
|
|
const fstreamBaseUrl = deps.FSTREAM_BASE_URL || '';
|
|
const isFStream = urlStr.includes(fstreamBaseUrl.replace('https://', '').replace('http://', '')) ||
|
|
(config.baseURL && config.baseURL.includes(fstreamBaseUrl.replace('https://', '').replace('http://', '')));
|
|
|
|
if (!ENABLE_FSTREAM_PROXY || !isFStream) {
|
|
await deps.ensureFStreamSession();
|
|
const existingHeaders = config.headers || {};
|
|
const response = await deps.axiosFStream({
|
|
...config,
|
|
timeout: 8000,
|
|
headers: withOptionalFStreamCookies(existingHeaders)
|
|
});
|
|
deps.incrementFstreamRequestCounter();
|
|
return response;
|
|
}
|
|
|
|
// Pool combine SOCKS5 + Darkino HTTP, 1 proxy aleatoire
|
|
const allProxies = [];
|
|
if (PROXIES && PROXIES.length > 0) {
|
|
PROXIES.forEach(p => allProxies.push({ proxy: p, type: 'socks5' }));
|
|
}
|
|
if (DARKINO_PROXIES && DARKINO_PROXIES.length > 0) {
|
|
DARKINO_PROXIES.forEach(p => allProxies.push({ proxy: p, type: 'darkino' }));
|
|
}
|
|
|
|
if (allProxies.length === 0) {
|
|
await deps.ensureFStreamSession();
|
|
const existingHeaders = config.headers || {};
|
|
const response = await deps.axiosFStream({
|
|
...config,
|
|
timeout: 8000,
|
|
headers: withOptionalFStreamCookies(existingHeaders)
|
|
});
|
|
deps.incrementFstreamRequestCounter();
|
|
return response;
|
|
}
|
|
|
|
const entry = allProxies[Math.floor(Math.random() * allProxies.length)];
|
|
const proxyLabel = `${entry.type}:${entry.proxy.host}:${entry.proxy.port}`;
|
|
|
|
try {
|
|
let agents;
|
|
if (entry.type === 'socks5') {
|
|
const agent = getProxyAgent(entry.proxy);
|
|
agents = { httpAgent: agent, httpsAgent: agent };
|
|
} else {
|
|
agents = getDarkinoHttpProxyAgent(entry.proxy);
|
|
}
|
|
|
|
await deps.ensureFStreamSession();
|
|
const existingHeaders = config.headers || {};
|
|
const response = await deps.axiosFStream({
|
|
...config,
|
|
timeout: 8000,
|
|
decompress: true,
|
|
httpAgent: agents.httpAgent,
|
|
httpsAgent: agents.httpsAgent,
|
|
proxy: false,
|
|
headers: withOptionalFStreamCookies(existingHeaders)
|
|
});
|
|
deps.incrementFstreamRequestCounter();
|
|
return response;
|
|
} catch (error) {
|
|
const status = error.response?.status;
|
|
console.error(`[FSTREAM REQUEST] Erreur ${status || error.code || 'unknown'} avec proxy ${proxyLabel}: ${error.message}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function isAnimeSamaRetriableError(error) {
|
|
const statusCode = error?.response?.status;
|
|
const errorCode = error?.code;
|
|
const message = String(error?.message || '').toLowerCase();
|
|
|
|
if (statusCode === 403 || statusCode === 407 || statusCode === 408 || statusCode === 425 || statusCode === 429) {
|
|
return true;
|
|
}
|
|
|
|
if (statusCode >= 500 && statusCode < 600) {
|
|
return true;
|
|
}
|
|
|
|
if (
|
|
errorCode === 'ECONNABORTED' ||
|
|
errorCode === 'ETIMEDOUT' ||
|
|
errorCode === 'ECONNRESET' ||
|
|
errorCode === 'ECONNREFUSED' ||
|
|
errorCode === 'EHOSTUNREACH' ||
|
|
errorCode === 'ENETUNREACH' ||
|
|
errorCode === 'EAI_AGAIN'
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
return (
|
|
message.includes('socks5 authentication failed') ||
|
|
message.includes('proxy authentication') ||
|
|
message.includes('tunneling socket could not be established')
|
|
);
|
|
}
|
|
|
|
// Fonction utilitaire pour requ\u00eates AnimeSama via CycleTLS (JA3 Chrome) + proxies
|
|
async function axiosAnimeSamaRequest(config) {
|
|
const debugAnimeSama = process.env.DEBUG_ANIMESAMA === 'true';
|
|
let urlStr = config.url || '';
|
|
|
|
const isAnimeSama = urlStr.includes('anime-sama.to') || urlStr.includes('anime-sama.si') || urlStr.includes('anime-sama.fr') ||
|
|
(config.baseURL && (config.baseURL.includes('anime-sama.to') || config.baseURL.includes('anime-sama.si') || config.baseURL.includes('anime-sama.fr')));
|
|
|
|
if (!ENABLE_ANIME_PROXY || !isAnimeSama) {
|
|
return deps.axiosAnimeSama({ ...config, timeout: 30000, proxy: false });
|
|
}
|
|
|
|
// Construire l'URL compl\u00e8te
|
|
let absoluteUrl = urlStr;
|
|
if (config.baseURL && !urlStr.startsWith('http')) {
|
|
absoluteUrl = config.baseURL + urlStr;
|
|
} else if (!urlStr.startsWith('http') && !absoluteUrl.startsWith('http')) {
|
|
// Si l'URL n'est pas compl\u00e8te et qu'on n'a pas de baseURL, utiliser ANIME_SAMA_URL
|
|
absoluteUrl = deps.ANIME_SAMA_URL + (urlStr.startsWith('/') ? urlStr.substring(1) : urlStr);
|
|
}
|
|
|
|
// Headers pour les requ\u00eates Anime-Sama via proxy
|
|
const animeSamaHeaders = {
|
|
'Accept-Language': 'fr-FR,fr;q=0.6',
|
|
'Cache-Control': 'no-cache',
|
|
'Pragma': 'no-cache',
|
|
'Priority': 'u=0, i',
|
|
'Sec-CH-UA': '"Chromium";v="142", "Brave";v="142", "Not_A Brand";v="99"',
|
|
'Sec-CH-UA-Mobile': '?0',
|
|
'Sec-CH-UA-Platform': '"Windows"',
|
|
'Sec-Fetch-Dest': 'document',
|
|
'Sec-Fetch-Mode': 'navigate',
|
|
'Sec-Fetch-Site': 'none',
|
|
'Sec-Fetch-User': '?1',
|
|
'Sec-GPC': '1',
|
|
'Upgrade-Insecure-Requests': '1',
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36'
|
|
};
|
|
|
|
// Supprimer les headers li\u00e9s \u00e0 l'IP d'origine pour \u00e9viter de les transmettre
|
|
const headersToRemove = ['X-Forwarded-For', 'X-Real-IP', 'X-Client-IP', 'CF-Connecting-IP', 'True-Client-IP', 'X-Original-Forwarded-For'];
|
|
const cleanHeaders = { ...animeSamaHeaders };
|
|
|
|
// Si config.headers existe, le nettoyer aussi
|
|
if (config.headers) {
|
|
const configHeaders = { ...config.headers };
|
|
headersToRemove.forEach(header => {
|
|
delete configHeaders[header];
|
|
delete configHeaders[header.toLowerCase()];
|
|
});
|
|
Object.assign(cleanHeaders, configHeaders);
|
|
}
|
|
|
|
// CycleTLS (JA3 Chrome) + rotation sur pool SOCKS5/HTTP
|
|
const method = String(config.method || 'get').toLowerCase();
|
|
let cycleBody = '';
|
|
if (config.data !== undefined && config.data !== null) {
|
|
cycleBody = typeof config.data === 'string' ? config.data : JSON.stringify(config.data);
|
|
}
|
|
const res = await makeAnimeSamaRequest(absoluteUrl, {
|
|
headers: cleanHeaders,
|
|
timeout: 30,
|
|
method,
|
|
body: cycleBody,
|
|
});
|
|
|
|
if (res.status >= 200 && res.status < 400) {
|
|
return {
|
|
data: res.data,
|
|
status: res.status,
|
|
headers: res.headers,
|
|
statusText: '',
|
|
config,
|
|
};
|
|
}
|
|
|
|
const cycleErr = new Error(`[ANIMESAMA CYCLETLS] status ${res.status}`);
|
|
cycleErr.response = { data: res.data, status: res.status, headers: res.headers };
|
|
if (debugAnimeSama) {
|
|
console.warn(`[ANIMESAMA REQUEST] cycletls returned ${res.status}`, summarizeRequestErrorForLog(cycleErr));
|
|
}
|
|
throw cycleErr;
|
|
}
|
|
|
|
// Fonction utilitaire pour requ\u00eates Wiflix avec proxy
|
|
// D\u00e9l\u00e8gue \u00e0 makeWiflixRequest (free proxies -> Cloudflare Workers -> direct)
|
|
async function axiosWiflixRequest(config) {
|
|
const urlStr = config.url || '';
|
|
|
|
// Construire l'URL compl\u00e8te
|
|
let absoluteUrl = urlStr;
|
|
if (config.baseURL && !urlStr.startsWith('http')) {
|
|
absoluteUrl = config.baseURL + urlStr;
|
|
} else if (!urlStr.startsWith('http')) {
|
|
absoluteUrl = WIFLIX_BASE_URL + (urlStr.startsWith('/') ? urlStr : '/' + urlStr);
|
|
}
|
|
|
|
const { url: _, baseURL: __, headers, ...restConfig } = config;
|
|
|
|
return makeWiflixRequest(absoluteUrl, {
|
|
method: restConfig.method || 'GET',
|
|
headers: headers || {},
|
|
timeout: restConfig.timeout || 15000,
|
|
...(restConfig.data !== undefined ? { data: restConfig.data } : {}),
|
|
...(restConfig.responseType ? { responseType: restConfig.responseType } : {}),
|
|
});
|
|
}
|
|
|
|
// === UTILITY: Fusion des streaming_links par langue ===
|
|
function mergeStreamingLinks(oldLinks, newLinks) {
|
|
// oldLinks et newLinks sont des tableaux d'objets { language, players }
|
|
const merged = {};
|
|
|
|
// D'abord, copier les anciens liens
|
|
(oldLinks || []).forEach(l => {
|
|
merged[l.language] = Array.isArray(l.players) ? [...l.players] : [];
|
|
});
|
|
|
|
// Ensuite, remplacer par les nouveaux liens si disponibles
|
|
(newLinks || []).forEach(l => {
|
|
if (l.players && l.players.length > 0) {
|
|
// Si on a de nouveaux lecteurs, on remplace compl\u00e8tement les anciens
|
|
merged[l.language] = Array.isArray(l.players) ? [...l.players] : [];
|
|
}
|
|
});
|
|
|
|
// Retourne sous forme d'array d'objets
|
|
return Object.entries(merged).map(([language, players]) => ({ language, players }));
|
|
}
|
|
|
|
// === UTILITY: Migration des anciens fichiers de cache s\u00e9par\u00e9s vers le cache unifi\u00e9 ===
|
|
async function migrateOldCacheFiles(safeAnimeName, animeCacheDir) {
|
|
try {
|
|
const allCacheFiles = await fsp.readdir(animeCacheDir).catch(() => []);
|
|
const oldSeasonFiles = allCacheFiles.filter(f => f.startsWith(safeAnimeName + '_') && f.endsWith('.json'));
|
|
|
|
if (oldSeasonFiles.length === 0) return;
|
|
|
|
const migratedSeasons = {};
|
|
|
|
for (const seasonFile of oldSeasonFiles) {
|
|
try {
|
|
const seasonContent = await fsp.readFile(path.join(animeCacheDir, seasonFile), 'utf-8');
|
|
const seasonCache = JSON.parse(seasonContent);
|
|
const seasonName = seasonFile.replace(safeAnimeName + '_', '').replace('.json', '');
|
|
|
|
migratedSeasons[seasonName] = {
|
|
timestamp: seasonCache.timestamp || Date.now(),
|
|
episodes: seasonCache.episodes || []
|
|
};
|
|
} catch (e) {
|
|
}
|
|
}
|
|
|
|
if (Object.keys(migratedSeasons).length > 0) {
|
|
const unifiedCacheData = {
|
|
timestamp: Date.now(),
|
|
seasons: migratedSeasons
|
|
};
|
|
|
|
const animeCachePath = path.join(animeCacheDir, `${safeAnimeName}.json`);
|
|
await writeFileAtomic(animeCachePath, JSON.stringify(unifiedCacheData), 'utf-8');
|
|
|
|
// Nettoyer les anciens fichiers apr\u00e8s migration r\u00e9ussie
|
|
await cleanupOldCacheFiles(safeAnimeName, animeCacheDir);
|
|
}
|
|
} catch (e) {
|
|
}
|
|
}
|
|
|
|
// === UTILITY: Nettoyage des anciens fichiers de cache s\u00e9par\u00e9s ===
|
|
async function cleanupOldCacheFiles(safeAnimeName, animeCacheDir) {
|
|
try {
|
|
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));
|
|
} catch (e) {
|
|
}
|
|
}
|
|
} catch (e) {
|
|
}
|
|
}
|
|
|
|
// Fonction pour supprimer les lecteurs fsvid des r\u00e9ponses FStream
|
|
function removeFsvidPlayers(data, isVip = false) {
|
|
// Si pas de donn\u00e9es, retourner tel quel
|
|
if (!data) {
|
|
return data;
|
|
}
|
|
|
|
// Si l'utilisateur est VIP, ne pas filtrer les lecteurs fsvid
|
|
if (isVip) {
|
|
return data;
|
|
}
|
|
|
|
// Par d\u00e9faut, toujours filtrer les lecteurs fsvid (m\u00eame si remove_fsvid est false)
|
|
|
|
// Pour les films : filtrer players.organized
|
|
if (data.players && typeof data.players === 'object') {
|
|
const filteredPlayers = {};
|
|
let totalPlayers = 0;
|
|
|
|
Object.keys(data.players).forEach(playerType => {
|
|
if (Array.isArray(data.players[playerType])) {
|
|
// Filtrer les lecteurs dont l'URL contient "fsvid"
|
|
const filteredPlayerList = data.players[playerType].filter(player => {
|
|
return !player.url || !player.url.includes('fsvid');
|
|
});
|
|
|
|
if (filteredPlayerList.length > 0) {
|
|
filteredPlayers[playerType] = filteredPlayerList;
|
|
totalPlayers += filteredPlayerList.length;
|
|
}
|
|
} else {
|
|
// Si ce n'est pas un array, garder tel quel
|
|
filteredPlayers[playerType] = data.players[playerType];
|
|
}
|
|
});
|
|
|
|
return {
|
|
...data,
|
|
players: filteredPlayers,
|
|
total: totalPlayers,
|
|
metadata: {
|
|
...data.metadata,
|
|
fsvidFiltered: true
|
|
}
|
|
};
|
|
}
|
|
|
|
// Pour les s\u00e9ries : filtrer episodes
|
|
if (data.episodes && typeof data.episodes === 'object') {
|
|
const filteredEpisodes = {};
|
|
let totalPlayers = 0;
|
|
|
|
Object.keys(data.episodes).forEach(episodeKey => {
|
|
const episode = data.episodes[episodeKey];
|
|
if (episode && typeof episode === 'object') {
|
|
const filteredEpisode = { ...episode };
|
|
|
|
// V\u00e9rifier si l'\u00e9pisode a une structure "languages"
|
|
if (episode.languages && typeof episode.languages === 'object') {
|
|
const filteredLanguages = {};
|
|
|
|
Object.keys(episode.languages).forEach(languageKey => {
|
|
const languagePlayers = episode.languages[languageKey];
|
|
|
|
if (Array.isArray(languagePlayers)) {
|
|
// Filtrer les lecteurs dont l'URL contient "fsvid"
|
|
const filteredPlayerList = languagePlayers.filter(player => {
|
|
return !player.url || !player.url.includes('fsvid');
|
|
});
|
|
|
|
if (filteredPlayerList.length > 0) {
|
|
filteredLanguages[languageKey] = filteredPlayerList;
|
|
totalPlayers += filteredPlayerList.length;
|
|
}
|
|
} else {
|
|
// Si ce n'est pas un array, garder tel quel
|
|
filteredLanguages[languageKey] = languagePlayers;
|
|
}
|
|
});
|
|
|
|
filteredEpisode.languages = filteredLanguages;
|
|
} else {
|
|
// Structure ancienne sans "languages" - filtrer directement
|
|
Object.keys(episode).forEach(playerType => {
|
|
if (Array.isArray(episode[playerType])) {
|
|
// Filtrer les lecteurs dont l'URL contient "fsvid"
|
|
const filteredPlayerList = episode[playerType].filter(player => {
|
|
return !player.url || !player.url.includes('fsvid');
|
|
});
|
|
|
|
if (filteredPlayerList.length > 0) {
|
|
filteredEpisode[playerType] = filteredPlayerList;
|
|
totalPlayers += filteredPlayerList.length;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Garder l'\u00e9pisode seulement s'il a encore des lecteurs
|
|
if (episode.languages ? Object.keys(filteredEpisode.languages).length > 0 : Object.keys(filteredEpisode).some(key => Array.isArray(filteredEpisode[key]) && filteredEpisode[key].length > 0)) {
|
|
filteredEpisodes[episodeKey] = filteredEpisode;
|
|
}
|
|
}
|
|
});
|
|
|
|
return {
|
|
...data,
|
|
episodes: filteredEpisodes,
|
|
total: totalPlayers,
|
|
metadata: {
|
|
...data.metadata,
|
|
fsvidFiltered: true
|
|
}
|
|
};
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
// Fonction pour formater les erreurs Coflix
|
|
function formatCoflixError(error, context = '') {
|
|
if (error && error.isAxiosError) {
|
|
const url = error.config && error.config.url ? error.config.url : '';
|
|
const statusCode = error.response ? error.response.status : '';
|
|
const statusText = error.response ? error.response.statusText : '';
|
|
|
|
// Ne pas logger les erreurs 400
|
|
if (statusCode === 400) {
|
|
return '';
|
|
}
|
|
|
|
return `[AxiosError] ${error.code || ''} ${error.message} ${statusCode ? `(${statusCode} ${statusText})` : ''} ${url}`;
|
|
} else {
|
|
const msg = error && error.message
|
|
? error.message
|
|
: (typeof error === 'string' ? error : JSON.stringify(error));
|
|
return msg;
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
configure,
|
|
|
|
// Darkino
|
|
generateDarkiReferer,
|
|
axiosDarkinoRequest,
|
|
|
|
// Coflix
|
|
axiosCoflixRequest,
|
|
formatCoflixError,
|
|
|
|
// French-Stream
|
|
axiosFrenchStreamRequest,
|
|
|
|
// LecteurVideo
|
|
axiosLecteurVideoRequest,
|
|
|
|
// FStream
|
|
axiosFStreamRequest,
|
|
|
|
// AnimeSama
|
|
axiosAnimeSamaRequest,
|
|
|
|
// Wiflix
|
|
axiosWiflixRequest,
|
|
|
|
// Streaming link utilities
|
|
mergeStreamingLinks,
|
|
migrateOldCacheFiles,
|
|
cleanupOldCacheFiles,
|
|
|
|
// FStream player filter
|
|
removeFsvidPlayers
|
|
};
|