MovixOpenSource/API/Mainapi/utils/proxyManager.js
Movix 8657a856f8 feat: live TV FCTV, fournisseur OAuth, sources J1F/Cinestream, modération IA & durcissement
feat: live TV FCTV, fournisseur OAuth, sources J1F/Cinestream, modération IA & durcissement

Gros lot de travail synchronisé depuis le repo de dev. Les grands axes :
migration de domaine (movix.date + miroirs), deux nouvelles sources films/séries
(1jour1film, Cinestream) et une n ouvelle source livetv, un fournisseur OAuth « Se connecter avec Movix », une
modération de contenu par IA revue, plus un lot de durcissement mémoire/réseau,
sécurité et perf côté API et front.

## 🌐 Domaines & miroirs

- Domaine canonique `movix.chat`/`movix.golf`/`movix.tax` → **movix.date** partout
  (front, allow-lists CORS/domaine, Discord RPC). Miroir `movix.health` →
  **movix.online** (Footer, Header, pages d'aide CaMarchePlus/Dns/Miroirs). Lien
  Telegram du footer → `mysticsaba.com`. ~10 clés i18n mises à jour.

## 🎬 Sources de streaming (VOD)

- **J1F (1jour1film)** — nouvelle source de bout en bout (`routes/j1f.js`, monté sur
  `/api/j1f`). Résout le domaine tournant via une page `/go/`, décode les blobs base64
  `J1F_SRV`/`j1fEpsData` (Dooplay/WP), ne garde que les players uniques, ignore les
  agrégateurs (frembed/vidsrc/videasy). Cache stale-while-revalidate dédié. Players 1J1F
  lus via l'extension/userscript.
- **Cinestream** — nouvelle source films (`routes/cinestream.js`, scrape Next.js
  `cinestream.info`, match par id TMDB autoritaire). Wiflix bascule ses **films** sur
  Cinestream (bot-shield flemmix pénible sur les films) ; la **TV** reste sur flemmix.
- **Wiflix** — `flemmix.farm` → **`flemmix.golf`** ; handshake cookie
  (`makeWiflixSearchRequest`, `h_check=25`) ; sélecteurs de template corrigés.
- **Cpasmal** — axios+proxy → `makeCpasmalRequest` (CycleTLS JA3) car Cloudflare 403 ;
  recherche GET → POST form (le WAF bloque le GET).
- **Fstream** — cookie anti-bot `fsschal` ; source épisodes JS statique
  (`/static/series/<id>.js`) avant l'AJAX `episodes_p.php` ; fallback `get_seasons.php`.
- **FranceTV** — `fetchSeasonEpisodesViaDeepPage` (pagination JSON « Afficher plus »).
- **Coflix** — `coflix.date` → `coflix.trade` ; logs 429 silencés en double.
- **proxyManager** — cinq helpers de contournement Cloudflare exportés
  (`makeCinestreamRequest`, `make1j1fRequest`, `makeCpasmalRequest`, handshake Wiflix
  `makeWiflixSearchRequest`/`wiflixHandshake`, `classifyCloudflare429` worker-vs-site) ;
  CycleTLS + rotation ProxyScrape, ne throw jamais sur statut HTTP. Coflix/LecteurVideo
  quittent les Cloudflare Workers pour ProxyScrape.

## 📺 Live TV — remplacement de Sosplay/Vavoo par FCTV33

- Source sport **Sosplay → FCTV33** (API RBTV, protobuf brut) réécrite de bout en bout
  dans `liveTvRoutes.js`, **Vavoo retiré** (manifest/catalogue/stream) — tout passe par
  `/api/livetv/*`.
- Pipeline neuf : décodeur protobuf maison, signature `sfver` (bs keys + MD5),
  auto-découverte des domaines API/player tournants (`hubu.ru/fctvlink` + Nuxt SSR /
  `params` rot47, cache 30 min), scraper de matchs (`scrapeFctvMatches` :
  équipes/logos, ligue, pays, statut live, tri live-first), résolution de flux (HLS
  natif d'abord, embed en secours), token CDN (XOR keystream + base64, gate
  `rb-session`).
- Route `GET /api/livetv/fctv/playlist` : proxy m3u8 intelligent (résout le token,
  réécrit les segments, retry sur token périmé, modes `raw` extension / `proxy` VIP).
- **Matchs plus réservés aux VIP** (seul `iptv_` le reste) : flux natif proxifié pour
  les VIP, stub `_fctvLocal` résolu côté client par
  l'extension/userscript (`RESOLVE_FCTV`), fallback embed sinon.
- Front `LiveTV.tsx` : `matches` → `fctv`, UI accordéon par match (logos, badges
  ligue/pays, score/timer live, favori), disclaimer, fix icône `music`. Rotations
  d'hôtes : `witv.team`→`witv.football`, `livetv876.me`→`livetv901.me`.

## 🧩 Extensions · Userscript · Discord RPC

- **FCTV en lecture native** : handlers `SETUP_FCTV_HEADERS`/`RESOLVE_FCTV` (règle DNR
  Referer/Origin/UA sur `/cfall/s*` + résolveur local IP-bindé : protobuf-lite + rot47
  + token XOR).
- **Vavoo retiré** , `cinepulse.lol` → `purstream.mx`.
- Fix extracteur **Sibnet** (`Range: bytes=0-0`) ; fix bug userscript `matchToRegex`
  (patterns ancrés `^…$` cassant `/cfall/s` → dé-ancrés) ; override dev
  `localhost:25565`.
- Bumps de version (Chrome 1.3.3→1.3.8, Firefox 1.5.1→1.5.5, userscript 1.4.0→1.4.7) ;
  PreMid nouveau `clientId` Discord + `regExp` élargi (1.0.3→1.0.4).

## 🔑 OAuth (fournisseur « Se connecter avec Movix ») & codes d'accès

- Refonte du flux d'autorisation OAuth (`OAuthAuthorizePage.tsx`) + routes
  (`routes/authRoutes.js`, `routes/oauth.js`) et stockage des clients OAuth étoffé
  (`utils/oauthClientsDb.js`, `routes/adminOauthApps.js` pour la gestion admin des apps).
- `AccessCodeForm.tsx` retouché en cohérence avec le nouveau flux ; `checkVip.js` ajusté.
- Session centralisée : `accountAuth.ts` expose `clearStoredAuthSession()` /
  `broadcastAuthChange()` (liste `AUTH_KEYS` unifiée), désormais utilisés partout à la
  déconnexion.

## 💬 Modération de contenu (IA)

- Modèle OpenRouter `google/gemini-2.5-flash-lite` → `deepseek/deepseek-v4-flash`
  (`commentsRoutes.js`, `sharedListsRoutes.js`) ; fonctions/logs renommés
  `moderateContentWithGemini` → `moderateContentWithAI` (découplés du nom du modèle).
- Prompt durci (critères INSULTES / DEMANDE_AJOUT resserrés) pour éviter les faux
  positifs sur questions, signalements de bug et plaintes de contenu manquant.
- `AdminComments.tsx` : libellé « par Gemini » → clé i18n générique.

## 🧊 Hydracker / Darkiworld

- **Sortie du debrid** : AllDebrid/RealDebrid supprimés (`utils/hydrackerLive.js` :
  branche provider retirée ; vars `ALLDEBRID_*`/`REALDEBRID_*` sorties du `.env`) —
  hydracker renvoie directement `rawUrl`/`directDL`. Liens servis depuis les snapshots
  SQLite locaux (`utils/darkiworldSqlite.js`, `darkino-backups/`).
- **Anti-scraping** sur `/decode/:id` (`routes/darkiworld.js`) : watchlist + alerte
  webhook Discord (`DISCORD_SCRAPER_WEBHOOK`) et empoisonnement/blocage de réponse pour
  les IP listées (`SCRAPER_BLOCKED_IPS`).
- **Mémoire** : cache d'agents proxy borné (`utils/lruMap.js`) qui détruit les sockets
  des agents évincés.

## 🛠️ Admin — propriété des liens & équipe

- **Propriété des liens** : les liens streaming gagnent l'attribution `added_by` et
  l'ownership est appliquée des deux côtés (uploader = ses liens, admin = tous). GET
  public masqué (`stripLinkOwner`), `GET /admin/streaming-links/:type/:id` authentifié
  conserve le tag ; `DELETE /admin/links` par URL unique ; `PUT /admin/links` masse
  admin-only.
- **Équipe** : `GET/POST/DELETE /admin/admins` + `GET /admin/team/history` (audit),
  identité via `adminIdentity.js` (option `preferProfile` → premier profil Movix). UI
  `AdminTeam.tsx`.
- **Privilèges** (`middleware/auth.js`) : `isAdmin` rejette `role !== 'admin'` (403) —
  les uploaders ne passent plus les routes admin-only. Migration
  `add_role_to_admins.sql`.

## 🎁 Wrapped v2

- Cartes de partage : logique canvas extraite (`utils/wrappedCanvas.ts`,
  `utils/wrappedShareCards.ts`), formats de partage supplémentaires + preview.
- Nouvelles slides : jour record, répartition par jour de semaine, champion de rewatch,
  âge ciné (année de sortie médiane pondérée par le temps de visionnage).
- Perf : données depuis le payload backend (zéro re-fetch TMDB), trailer lazy,
  percentile non bloquant (réchauffe en arrière-plan, lock Redis `NX`), shuffle seedé.

## 🔐 Auth · Profils · VIP

- **Pseudo forcé** : `validateProfileName` (1–32 chars, strip control/zero-width, +
  `maxLength={32}` côté UI Profile/WatchParty) ; les pseudos OAuth trop longs →
  `requiresUsernameChange` + modale bloquante `RequireUsernameChange.tsx`.
- **Sync durci** (`ProfileContext.tsx`, `syncPolicy.js`) : anti-replay cross-user,
  filtrage des ops à clés non-syncables (`access_code`/`access_code_expires` exclues),
  « wipe guard » qui préserve le local sur profil vide inattendu, dédupe des
  `loadProfiles()` concurrents. Logout centralisé (voir OAuth).
- **VIP** : `is_vip` préservé au chargement de profil (corrige un downgrade VIP
  inter-onglets qui relançait la popup pub) ; `vipDonations.js` poll de secours
  `payment-status.php` (via `ipn_token`, colonne `paygate_ipn_token`) → plus d'invoices
  bloquées.

## ▶️ Lecteur (HLS)

- Basse latence opt-in : `isLowLatencyEnabled('movies')` (scope movies/livetv,
  `lowLatencyPref.ts`) au lieu du mode forcé ; `FloatingPlayer` aligné.
- Sous-titres cinep : wrapper m3u8 synthétique pour les URI `.vtt`/`.srt` directes,
  `isSubtitleLoadError` (une piste cassée ne tue plus la vidéo), rendu `<i>/<b>/<u>`.
- Menu sources regroupé par langue (VF/VFQ/VFF/VOSTFR).

## 📡 Cast (AirPlay / Chromecast)

- Retrait du contournement proxy darkibox (`buildApiProxyUrl`) → cast direct de l'URL ;
  `runtime.ts` supprime `API_PROXY_BASE_URL`/`buildApiProxyUrl` devenus morts.
- Paramètre `streamType` (`BUFFERED` par défaut) propagé jusqu'à
  `loadMediaOnCastWithFallback`.

## 📢 Pubs

- Modes de popup (`adPopupMode.ts`) : `normal`, `auto`, `click-anywhere` ; toggle « +18 »
  séparé (`adAdultMode.ts`). `SCRIPT_AD_MODE_ENABLED` → `false`, URL du script changée.

## 🔎 Recherche & priorité des sources

- `searchUtils.ts` : le terme original contourne le filtre « trop générique » (fix
  zéro-résultat sur titres courts : Baki, Gantz).
- `animeSignals.ts` : détection donghua (CN/TW/HK ou `zh` + Animation, hors C-drama).
- `sourceAutoSelect.ts` : `VFF`/`VFQ` normalisés en `vf` (sinon reléguées derrière
  VOSTFR). `sourcePriorityPrefs.ts` : schéma v2 → v3 (nouvel ordre par défaut + `j1f`,
  migration non destructive des ordres personnalisés). `extractionPrefs.ts` : +daddylive.

## 🔒 Sécurité

- Sanitisation d'URL avant rendu en `href` : `CommentsSection.tsx` (liens markdown
  `http(s)://`) et `Greenlight/AdminLinkSubmissions.tsx` — bloque les `javascript:`.

## ⚙️ Infra & perf

- **server.js** : recyclage périodique des workers (12h) + `[memstats]` + hook
  `SIGUSR2` → heap-snapshot.
- **app.js** : monte `/api/j1f`, retire la route statique `/oauth-icons`, passe
  `makeCpasmalRequest` au router Cpasmal, `coflix.date` → `coflix.trade`.
- **lazyWithRetry.ts / main.tsx / ErrorBoundary.tsx** : récupération chunk-load réécrite
  (retry + reload budgété), garde `removeChild/insertBefore`, écran soft « Mise à jour »
  au lieu du crash-report.
- **UI** : `TopProgressBar` écrit `transform` directement sur un ref (compositor-only,
  plus de re-render) ; `blur-text` relâche `will-change` après l'animation.
- **proxiesembed/server.py** : `curl_cffi` (JA3) pour `cinep-proxy` + retry SOCKS5 sur
  403 ; lookup clé VIP réaligné sur `checkVip.js` ; log WARNING par défaut ; SOCKS5
  forcé IPv4 ; wrapper VTT/SRT pour hls.js ; re-label `application/zip` → `video/mp2t` ;
  `cinepulse.lol` → `purstream.mx`.

## 🧹 Nettoyage & suppressions

- **Front** : carte promo « Loadix » sur Download (remplace l'encart beta),
  « 1jour1film » ajouté à « C'est quoi Movix », fallback Darkino/Nightflix gutté en
  no-op (`api.movix.chat` décommissionné), classes CSS mortes retirées, fix
  `fetchPriority` → `fetchpriority`.
- **Backend** : scrapers/utilitaires morts retirés — `routes/dragiv.js`,
  `routes/topstream.js`, `routes/proxy.js`, `utils/cloudflareSession.js`,
  `utils/hydrackerBatch.js`, `utils/hydrackerQueue.js'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 18:22:13 +02:00

2462 lines
75 KiB
JavaScript

/**
* Proxy management utilities.
* Extracted from server.js — centralizes all proxy configuration, agent caching,
* Cloudflare Workers proxy rotation, CORS fallback, and site-specific request helpers.
*/
const axios = require("axios");
const http = require("http");
const https = require("https");
const { SocksProxyAgent } = require("socks-proxy-agent");
const { HttpProxyAgent } = require("http-proxy-agent");
const { HttpsProxyAgent } = require("https-proxy-agent");
const tough = require("tough-cookie");
const initCycleTLS = require("cycletls");
const { LruMap } = require("./lruMap");
const { redis } = require("../config/redis");
// === PROXY AGENT CACHES ===
// LRU-capped with onEvict that destroys the underlying agent (closes its
// keep-alive socket pool) so evicted entries do not leak sockets at the OS
// level. Without this, growing proxy rotation accumulated agents indefinitely
// — the root cause of multi-GB worker RSS.
const PROXY_AGENT_CACHE_MAX = 256;
function destroyHttpAgentLike(value) {
if (!value || typeof value !== "object") return;
// SocksProxyAgent / HttpProxyAgent / HttpsProxyAgent all extend http.Agent
// and expose a `destroy()` that closes pooled sockets.
if (typeof value.destroy === "function") {
try { value.destroy(); } catch (_) { /* ignore */ }
return;
}
// Darkino/Wiflix entries wrap two agents.
if (value.httpAgent && typeof value.httpAgent.destroy === "function") {
try { value.httpAgent.destroy(); } catch (_) { /* ignore */ }
}
if (
value.httpsAgent &&
value.httpsAgent !== value.httpAgent &&
typeof value.httpsAgent.destroy === "function"
) {
try { value.httpsAgent.destroy(); } catch (_) { /* ignore */ }
}
}
const proxyAgentCache = new LruMap({
max: PROXY_AGENT_CACHE_MAX,
onEvict: destroyHttpAgentLike,
});
const darkinoProxyAgentCache = new LruMap({
max: PROXY_AGENT_CACHE_MAX,
onEvict: destroyHttpAgentLike,
});
const proxyRotationState = new Map();
// Initialize global agent keep-alive with socket limits
http.globalAgent.keepAlive = true;
http.globalAgent.maxSockets = 128;
http.globalAgent.maxFreeSockets = 32;
https.globalAgent.keepAlive = true;
https.globalAgent.maxSockets = 128;
https.globalAgent.maxFreeSockets = 32;
// === PROXY CONFIGURATION FLAGS ===
const ENABLE_DARKINO_PROXY = true; // Passe \u00e0 false pour d\u00e9sactiver le proxy pour Darkino
const ENABLE_COFLIX_PROXY = true; // Passe \u00e0 false pour d\u00e9sactiver le proxy pour Coflix
const ENABLE_FRENCH_STREAM_PROXY = true; // Active/d\u00e9sactive le proxy pour French-Stream
const ENABLE_LECTEURVIDEO_PROXY = true; // Active/d\u00e9sactive le proxy pour LecteurVideo
const ENABLE_FSTREAM_PROXY = true; // Active/d\u00e9sactive le proxy pour FStream
const ENABLE_ANIME_PROXY = true; // Active/d\u00e9sactive le proxy pour AnimeSama (via Cloudflare Workers)
const ENABLE_WIFLIX_PROXY = true; // Active/d\u00e9sactive le proxy pour Wiflix
const MAX_PROXYSCRAPE_PROXY_ATTEMPTS = 2;
// Constante pour l'enhancement Darkino
const darkiworld_premium = false; // Passe \u00e0 false pour d\u00e9sactiver l'enhancement Darkino
// === DARKINO COOLDOWNS (cluster-wide via Redis) ===
// Cooldown de 5 minutes apr\u00e8s une erreur 403 (Cloudflare challenge), 429
// (Too Many Requests / Cloudflare Workers daily limit) ou 5xx (erreur
// serveur). Stock\u00e9 en Redis pour que les 6 workers partagent le signal :
// un seul worker doit hit l'erreur pour que tous arr\u00eatent de marteler.
const DARKINO_403_COOLDOWN_MS = 5 * 60 * 1000;
const DARKINO_429_COOLDOWN_MS = 5 * 60 * 1000;
const DARKINO_5XX_COOLDOWN_MS = 5 * 60 * 1000;
const DARKINO_NETERR_COOLDOWN_MS = 5 * 60 * 1000;
const DARKINO_COOLDOWN_KEYS = {
'403': 'darkino:cooldown:403',
'429': 'darkino:cooldown:429',
'5xx': 'darkino:cooldown:5xx',
'neterr': 'darkino:cooldown:neterr',
};
const DARKINO_COOLDOWN_TTL_MS = {
'403': DARKINO_403_COOLDOWN_MS,
'429': DARKINO_429_COOLDOWN_MS,
'5xx': DARKINO_5XX_COOLDOWN_MS,
'neterr': DARKINO_NETERR_COOLDOWN_MS,
};
// === DARKINO NETWORK-FAILURE CIRCUIT BREAKER ===
// Les cooldowns 403/429/5xx ne couvrent QUE les erreurs avec une reponse HTTP.
// Un timeout / erreur reseau (ECONNABORTED, ECONNRESET, ...) n'a pas de
// error.response : aucun cooldown ne s'armait, donc chaque requete continuait
// a marteler un upstream mort -> requetes en vol qui s'accumulent (RSS qui
// monte sans redescendre). Ce compteur d'echecs reseau CONSECUTIFs est
// cluster-wide via Redis ; au seuil il arme le cooldown 'neterr'.
const DARKINO_NETERR_THRESHOLD = 20; // echecs reseau consecutifs avant pause
const DARKINO_NETERR_WINDOW_MS = 2 * 60 * 1000; // fenetre glissante du compteur
const DARKINO_NETERR_COUNTER_KEY = 'darkino:netfail:count';
/**
* Returns remaining cooldown in ms (0 if not active or Redis unavailable).
* Fail-open: a Redis outage must not block Darkino traffic.
*/
async function getDarkinoCooldownRemainingMs(kind) {
const key = DARKINO_COOLDOWN_KEYS[kind];
if (!key) return 0;
try {
const pttl = await redis.pttl(key);
return pttl > 0 ? pttl : 0;
} catch (_) {
return 0;
}
}
/**
* Arms a Darkino cooldown. NX so concurrent 5xx responses across workers
* don't keep extending the window \u2014 first arm wins, rest are no-ops.
* Returns true if this caller actually set the key.
*/
async function armDarkinoCooldown(kind) {
const key = DARKINO_COOLDOWN_KEYS[kind];
const ttlMs = DARKINO_COOLDOWN_TTL_MS[kind];
if (!key || !ttlMs) return false;
try {
const ttlSec = Math.max(1, Math.ceil(ttlMs / 1000));
const armed = await redis.set(key, String(Date.now()), 'EX', ttlSec, 'NX');
return armed === 'OK';
} catch (_) {
return false;
}
}
/**
* Compte un echec reseau Darkino (timeout / erreur de connexion sans reponse
* HTTP). Cluster-wide via Redis INCR + fenetre glissante atomique. Au seuil,
* arme le cooldown 'neterr' et remet le compteur a zero. Fail-open : une
* panne Redis ne compte rien et ne bloque jamais le trafic.
* @returns {Promise<number>} compteur courant (0 si Redis indisponible)
*/
async function recordDarkinoNetFailure() {
try {
const execResult = await redis.multi()
.incr(DARKINO_NETERR_COUNTER_KEY)
.pexpire(DARKINO_NETERR_COUNTER_KEY, DARKINO_NETERR_WINDOW_MS)
.exec();
const count = Number(execResult?.[0]?.[1]) || 0;
if (count >= DARKINO_NETERR_THRESHOLD) {
const armed = await armDarkinoCooldown('neterr');
await redis.del(DARKINO_NETERR_COUNTER_KEY);
if (armed) {
console.log(`[DARKINO] ${count} echecs reseau consecutifs - cooldown ${Math.round(DARKINO_NETERR_COOLDOWN_MS / 60000)} min active`);
}
}
return count;
} catch (_) {
return 0;
}
}
/**
* Reinitialise le compteur d'echecs reseau Darkino apres une requete reussie
* (le seuil compte des echecs *consecutifs*). Fail-open.
*/
async function resetDarkinoNetFailures() {
try {
await redis.del(DARKINO_NETERR_COUNTER_KEY);
} catch (_) {
/* ignore */
}
}
// === CPASMAL CONFIGURATION ===
const CPASMAL_BASE_URL = "https://www.cpasmal.rip";
const cpasmalJar = new tough.CookieJar(null, { rejectPublicSuffixes: false });
// Cache pour les agents SOCKS5 Cpasmal (Keep-Alive)
const cpasmalAgentCache = new LruMap({
max: PROXY_AGENT_CACHE_MAX,
onEvict: destroyHttpAgentLike,
});
function getCpasmalAgent(proxy) {
if (!proxy) return null;
const cacheKey = `${proxy.type}:${proxy.host}:${proxy.port}:${proxy.auth}`;
if (cpasmalAgentCache.has(cacheKey)) {
return cpasmalAgentCache.get(cacheKey);
}
// Configuration Keep-Alive optimis\u00e9e
const agentOpts = {
keepAlive: true,
keepAliveMsecs: 15000, // 15s keep-alive
maxSockets: 50,
maxFreeSockets: 10,
timeout: 30000,
};
let agent;
if (proxy.type === "socks5" || proxy.type === "socks5h") {
const info = {
hostname: proxy.host,
port: proxy.port,
protocol: "socks:",
tls: { rejectUnauthorized: false },
...agentOpts,
};
// Auth handling
if (proxy.auth) {
const parts = proxy.auth.split(":");
info.username = parts[0]; // Correction: userId -> username
info.password = parts[1];
}
agent = new SocksProxyAgent(info);
}
if (agent) {
cpasmalAgentCache.set(cacheKey, agent);
}
return agent;
}
// Fonction pour faire des requ\u00eates vers Cpasmal avec rotation de proxies SOCKS5
async function axiosCpasmalRequest(config) {
const urlStr = config.url || "";
// Constante pour Cpasmal Base URL si relative
const baseURL = CPASMAL_BASE_URL;
// Headers par d\u00e9faut
const defaultHeaders = {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
Connection: "keep-alive",
...(config.headers || {}),
};
// Gestion des cookies (r\u00e9cup\u00e9ration manuelle pour compatibilit\u00e9 avec proxy agent)
if (urlStr || config.baseURL) {
try {
// Reconstitution approximative de l'URL pour les cookies
const targetUrl = urlStr.startsWith("http")
? urlStr
: (config.baseURL || baseURL) + urlStr;
const cookieString = await cpasmalJar.getCookieString(targetUrl);
if (cookieString) {
defaultHeaders["Cookie"] = cookieString;
}
} catch (err) {
console.error("[Cpasmal CookieJar] Error getting cookies:", err);
}
}
// Déterminer les agents proxy à utiliser :
// 1. _cpasmalAgents fournis (session scopée avec DARKINO_PROXIES HTTP)
// 2. _cpasmalProxy fourni (SOCKS5 override)
// 3. Sinon : proxy SOCKS5 aléatoire (legacy)
let httpAgent, httpsAgent, proxyLabel;
if (config._cpasmalAgents) {
httpAgent = config._cpasmalAgents.httpAgent;
httpsAgent = config._cpasmalAgents.httpsAgent;
proxyLabel = config._cpasmalAgents._label || "HTTP proxy";
} else {
const proxy =
config._cpasmalProxy !== undefined
? config._cpasmalProxy
: pickRandomProxyOrNone();
const agent = getCpasmalAgent(proxy);
httpAgent = agent;
httpsAgent = agent;
proxyLabel = proxy ? `${proxy.host}:${proxy.port}` : "Direct";
}
// Nettoyer les champs internes avant de passer à axios
const { _cpasmalProxy, _cpasmalAgents, ...cleanConfig } = config;
try {
if (process.env.DEBUG_CPASMAL === "true")
console.time(`[Cpasmal] Request ${urlStr} (Proxy: ${proxyLabel})`);
const response = await axios({
...cleanConfig,
headers: defaultHeaders,
httpAgent,
httpsAgent,
proxy: false,
timeout: config.timeout || 5000,
decompress: true,
});
if (process.env.DEBUG_CPASMAL === "true")
console.timeEnd(`[Cpasmal] Request ${urlStr} (Proxy: ${proxyLabel})`);
// Gestion des cookies (sauvegarde manuelle)
if (response.headers["set-cookie"]) {
const cookies = response.headers["set-cookie"];
const url = response.config.url;
try {
if (Array.isArray(cookies)) {
for (const cookie of cookies) {
await cpasmalJar.setCookie(cookie, url);
}
} else {
await cpasmalJar.setCookie(cookies, url);
}
} catch (err) {
console.error("[Cpasmal CookieJar] Error setting cookies:", err);
}
}
return response;
} catch (error) {
// Ajouter infos debug
error.cpasmalUrl = urlStr;
error.cpasmalProxy = proxyLabel;
// Propager l'erreur immédiatement
throw error;
}
}
// === CLOUDFLARE WORKERS PROXIES CONFIGURATION ===
const CLOUDFLARE_WORKERS_PROXIES = (
process.env.CLOUDFLARE_WORKERS_PROXIES || ""
)
.split(",")
.map((proxy) => proxy.trim())
.filter(Boolean);
// === CACHE POUR LES PROXIES CLOUDFLARE EN ERREUR ===
// Cache pour m\u00e9moriser les proxies en erreur (429, 500, timeout, etc.)
// Les proxies en erreur seront ignor\u00e9s pendant PROXY_ERROR_COOLDOWN_MS millisecondes
const proxyErrorCache = new Map(); // Map<proxyUrl, { errorTime: timestamp, errorCode: number|string, errorCount: number }>
const PROXY_ERROR_COOLDOWN_MS = 60000; // 60 secondes de cooldown pour un proxy en erreur
const PROXY_ERROR_COOLDOWN_429_MS = 120000; // 2 minutes de cooldown sp\u00e9cifique pour erreur 429 (rate limit)
const PROXY_ERROR_COOLDOWN_5XX_MS = 90000; // 1.5 minutes pour les erreurs serveur (500, 502, 503, 504)
const MAX_CONSECUTIVE_ERRORS = 3; // Nombre max d'erreurs cons\u00e9cutives avant cooldown prolong\u00e9
const PROXY_EXTENDED_COOLDOWN_MS = 300000; // 5 minutes de cooldown prolong\u00e9 si trop d'erreurs cons\u00e9cutives
/**
* Marque un proxy comme \u00e9tant en erreur
* @param {string} proxyUrl - URL du proxy
* @param {number|string} errorCode - Code d'erreur (429, 500, 'timeout', etc.)
*/
function markProxyAsErrored(proxyUrl, errorCode) {
const existing = proxyErrorCache.get(proxyUrl);
const errorCount = existing ? existing.errorCount + 1 : 1;
proxyErrorCache.set(proxyUrl, {
errorTime: Date.now(),
errorCode,
errorCount,
});
// Log seulement si DEBUG activ\u00e9
if (process.env.DEBUG_PROXY) {
console.log(
`[PROXY CACHE] Proxy marqu\u00e9 en erreur: ${proxyUrl} (code: ${errorCode}, count: ${errorCount})`,
);
}
}
/**
* V\u00e9rifie si un proxy est actuellement en cooldown (\u00e0 \u00e9viter)
* @param {string} proxyUrl - URL du proxy
* @returns {boolean} - true si le proxy doit \u00eatre ignor\u00e9
*/
function isProxyInCooldown(proxyUrl) {
const errorInfo = proxyErrorCache.get(proxyUrl);
if (!errorInfo) return false;
const now = Date.now();
const timeSinceError = now - errorInfo.errorTime;
// D\u00e9terminer le cooldown appropri\u00e9 selon le type d'erreur et le nombre d'erreurs
let cooldownMs;
if (errorInfo.errorCount >= MAX_CONSECUTIVE_ERRORS) {
cooldownMs = PROXY_EXTENDED_COOLDOWN_MS;
} else if (errorInfo.errorCode === 429) {
cooldownMs = PROXY_ERROR_COOLDOWN_429_MS;
} else if (errorInfo.errorCode >= 500 && errorInfo.errorCode < 600) {
cooldownMs = PROXY_ERROR_COOLDOWN_5XX_MS;
} else {
cooldownMs = PROXY_ERROR_COOLDOWN_MS;
}
// Si le cooldown est pass\u00e9, supprimer l'entr\u00e9e du cache
if (timeSinceError >= cooldownMs) {
proxyErrorCache.delete(proxyUrl);
return false;
}
return true;
}
/**
* Retourne la liste des proxies disponibles (non en cooldown)
* @param {string[]} allProxies - Liste de tous les proxies
* @returns {string[]} - Liste des proxies disponibles
*/
function getAvailableProxies(allProxies) {
const available = allProxies.filter((proxy) => !isProxyInCooldown(proxy));
// Si tous les proxies sont en cooldown, on r\u00e9initialise le cache et on retourne tous les proxies
// pour \u00e9viter de bloquer compl\u00e8tement le service
if (available.length === 0) {
if (process.env.DEBUG_PROXY) {
console.log(
"[PROXY CACHE] Tous les proxies sont en cooldown, r\u00e9initialisation du cache",
);
}
proxyErrorCache.clear();
return allProxies;
}
return available;
}
/**
* R\u00e9initialise le compteur d'erreurs d'un proxy apr\u00e8s un succ\u00e8s
* @param {string} proxyUrl - URL du proxy
*/
function markProxyAsHealthy(proxyUrl) {
proxyErrorCache.delete(proxyUrl);
}
// Nettoyage p\u00e9riodique du proxyErrorCache — supprime les entr\u00e9es dont le cooldown a expir\u00e9
setInterval(
() => {
const now = Date.now();
for (const [proxyUrl, errorInfo] of proxyErrorCache) {
const maxCooldown = PROXY_EXTENDED_COOLDOWN_MS; // 5 min = cooldown le plus long
if (now - errorInfo.errorTime > maxCooldown) {
proxyErrorCache.delete(proxyUrl);
}
}
},
5 * 60 * 1000,
).unref(); // Toutes les 5 minutes — unref to not prevent process exit
/**
* Construit l'URL proxy en fonction du type de proxy
* Les proxies se terminant par '/' attendent l'URL encod\u00e9e
* Les proxies se terminant par '?' attendent l'URL non-encod\u00e9e
* @param {string} proxyUrl - URL du proxy Cloudflare
* @param {string} targetUrl - URL cible \u00e0 proxyer
* @returns {string} - URL finale \u00e0 appeler
*/
function buildProxiedUrl(proxyUrl, targetUrl) {
if (proxyUrl.endsWith("/")) {
// Proxy qui attend l'URL encod\u00e9e (ex: cors-worker-1)
return proxyUrl + encodeURIComponent(targetUrl);
} else {
// Proxy qui attend l'URL en query string (ex: ?url=...)
return proxyUrl + targetUrl;
}
}
// Fonction pour faire une requ\u00eate avec fallback CORS en cas d'erreur 429
async function makeRequestWithCorsFallback(targetUrl, options = {}) {
const {
timeout = 7000,
headers = {},
decompress = true,
method: requestMethod,
data: requestData,
...otherOptions
} = options;
const method = (requestMethod || 'GET').toUpperCase();
const defaultHeaders = {
accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"accept-language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
priority: "u=1, i",
"sec-ch-ua":
'"Chromium";v="140", "Not=A?Brand";v="24", "Brave";v="140"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "cross-site",
"sec-gpc": "1",
"user-agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
...headers,
};
// Filtrer les proxies disponibles (non en cooldown)
const availableProxies = getAvailableProxies(CLOUDFLARE_WORKERS_PROXIES);
if (availableProxies.length === 0) {
return axios({
url: targetUrl,
method,
headers: defaultHeaders,
timeout,
decompress,
...(requestData !== undefined ? { data: requestData } : {}),
...otherOptions,
});
}
// Utiliser directement les proxies Cloudflare Workers disponibles
let lastError = null;
for (let i = 0; i < availableProxies.length; i++) {
const currentProxy = availableProxies[i];
try {
const finalProxyUrl = buildProxiedUrl(currentProxy, targetUrl);
const response = await axios({
url: finalProxyUrl,
method,
headers: defaultHeaders,
timeout,
decompress,
...(requestData !== undefined ? { data: requestData } : {}),
...otherOptions,
});
// Succ\u00e8s : marquer le proxy comme sain
markProxyAsHealthy(currentProxy);
return response;
} catch (proxyError) {
lastError = proxyError;
const statusCode = proxyError.response?.status;
const errorCode = statusCode || proxyError.code || "unknown";
// En cas d'erreur 400 ou 403, arr\u00eat imm\u00e9diat sans r\u00e9essayer avec d'autres proxies
if (statusCode === 400 || statusCode === 403) {
throw proxyError;
}
// Marquer le proxy en erreur pour les codes 429, 5xx, timeout, etc.
if (
statusCode === 429 ||
(statusCode >= 500 && statusCode < 600) ||
proxyError.code === "ECONNABORTED" ||
proxyError.code === "ETIMEDOUT"
) {
markProxyAsErrored(currentProxy, errorCode);
}
// Si c'est le dernier proxy et qu'on a une erreur, throw l'erreur
if (i === availableProxies.length - 1) {
throw proxyError;
}
// Sinon continuer avec le prochain proxy (429, etc.)
}
}
// Si on arrive ici, tous les proxies ont \u00e9chou\u00e9
throw lastError || new Error("Tous les proxies ont \u00e9chou\u00e9");
}
// Fonction pour faire une requ\u00eate Coflix avec rotation Cloudflare Workers.
// Classe un 429 recu via les Cloudflare Workers Coflix.
// - 'worker' : preuve POSITIVE d'un blocage Cloudflare du worker lui-meme
// (page "error code: 1015"/1027, "You are being rate limited", limite
// journaliere). Roter vers un autre worker (bucket de limite distinct) aide.
// - 'site' : tout le reste -> 429 forwarde depuis coflix.band (rate-limit
// global). Defaut volontaire : sans preuve d'un 1015, on ne rote pas et on
// ne martele pas l'upstream.
function classifyCloudflare429(body) {
const text = typeof body === "string" ? body : String(body || "");
return /error\s*code:?\s*(1015|1027|1029)|you are being rate limited|daily request limit/i.test(
text,
)
? "worker"
: "site";
}
// Coflix (recherche + pages film/serie) via proxies ProxyScrape (HTTP prefere,
// SOCKS5 fallback) au lieu des Cloudflare Workers. Sur 429/403/5xx/erreur reseau
// on rote vers l'IP suivante (2 max). Si tout est 429, le flag coflixSiteRateLimited
// coupe le spam de log par titre cote route (coflix.js).
async function makeCoflixRequest(targetUrl, options = {}) {
const {
timeout = 15000,
headers = {},
decompress = true,
...otherOptions
} = options;
// Nettoyer l'URL pour \u00e9viter les espaces ind\u00e9sirables
const cleanTargetUrl = targetUrl.trim();
// Headers pour les requ\u00eates Coflix via proxy
const coflixProxyHeaders = {
accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"accept-encoding": "gzip, deflate, br",
"accept-language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
"cache-control": "no-cache",
pragma: "no-cache",
"sec-ch-ua": '"Chromium";v="140", "Not=A?Brand";v="24", "Brave";v="140"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "cross-site",
"sec-gpc": "1",
"user-agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
...headers,
};
// 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 = { ...coflixProxyHeaders };
headersToRemove.forEach((header) => {
delete cleanHeaders[header];
delete cleanHeaders[header.toLowerCase()];
});
const { proxies, useSocks } = pickProxyscrapeCandidates();
let lastError = null;
// Aucun proxy ProxyScrape dispo -> tentative directe.
if (!proxies || proxies.length === 0) {
return axios({
url: cleanTargetUrl,
method: otherOptions.method || "GET",
headers: cleanHeaders,
timeout,
decompress,
responseType: "text",
responseEncoding: "utf8",
...otherOptions,
});
}
// Rotation sur 2 proxies ProxyScrape max (egress different = bucket 429 distinct).
for (let i = 0; i < proxies.length; i++) {
const proxy = proxies[i];
const auth = proxy.auth ? `${proxy.auth}@` : "";
// ponytail: agent non cache (<=2/req, keepAlive off -> pas de fuite socket).
const agent = useSocks
? getProxyAgent(proxy)
: new HttpsProxyAgent(`http://${auth}${proxy.host}:${proxy.port}`);
try {
const response = await axios({
url: cleanTargetUrl,
method: otherOptions.method || "GET",
headers: cleanHeaders,
timeout,
decompress,
responseType: "text",
responseEncoding: "utf8",
httpAgent: agent,
httpsAgent: agent,
...otherOptions,
});
return response;
} catch (error) {
lastError = error;
const statusCode = error.response?.status;
const errorCode = error.code || "unknown";
error.coflixUrl = cleanTargetUrl;
error.coflixProxy = `${proxy.host}:${proxy.port}`;
// 429 = coflix.band rate-limit. Une autre IP ProxyScrape = bucket distinct
// -> on rote. Le flag coupe le spam de log par titre si tout est limite.
if (statusCode === 429) {
error.coflixSiteRateLimited = true;
continue;
}
// 403 (Cloudflare), 5xx, ou erreur reseau -> proxy suivant.
if (
statusCode === 403 ||
(statusCode >= 500 && statusCode < 600) ||
errorCode === "ECONNABORTED" ||
errorCode === "ETIMEDOUT" ||
errorCode === "ECONNRESET" ||
errorCode === "EHOSTUNREACH" ||
errorCode === "ENETUNREACH"
) {
continue;
}
// Pour les erreurs fonctionnelles (400, 404, parsing, etc.), arr\u00eater imm\u00e9diatement.
throw error;
}
}
if (lastError) throw lastError;
throw new Error("Tous les proxies ProxyScrape ont echoue (Coflix)");
}
// Fonction pour faire une requete LecteurVideo avec CycleTLS.
// On limite volontairement la rotation a 2 proxies max pour eviter de balayer tout le pool.
// Instance CycleTLS partagee (singleton)
let cycleTLSInstance = null;
let cycleTLSInitializing = false;
const cycleTLSWaiters = [];
async function getCycleTLS() {
if (cycleTLSInstance) return cycleTLSInstance;
if (cycleTLSInitializing) {
return new Promise((resolve) => cycleTLSWaiters.push(resolve));
}
cycleTLSInitializing = true;
try {
cycleTLSInstance = await initCycleTLS();
cycleTLSInitializing = false;
for (const waiter of cycleTLSWaiters) waiter(cycleTLSInstance);
cycleTLSWaiters.length = 0;
return cycleTLSInstance;
} catch (err) {
cycleTLSInitializing = false;
// Reject all waiters so they don't leak forever
for (const waiter of cycleTLSWaiters) waiter(Promise.reject(err));
cycleTLSWaiters.length = 0;
throw err;
}
}
// Graceful shutdown: kill CycleTLS Go subprocess
async function shutdownCycleTLS() {
if (cycleTLSInstance) {
try {
await cycleTLSInstance.exit();
} catch {
/* ignore */
}
cycleTLSInstance = null;
}
}
// JA3 fingerprint Chrome 120+
const CHROME_JA3 =
"771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,0-23-65281-10-11-35-16-5-13-18-51-45-43-27-17513,29-23-24,0";
const CHROME_UA =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
function pickProxyscrapeCandidates() {
const httpPool =
HTTP_PROXIES.length > 0
? HTTP_PROXIES
: parseProxyPayload(process.env.HTTP_PROXIES, "http")
.map((entry) => sanitizeProxyEntry(entry, "http"))
.filter(Boolean);
const socksPool =
PROXIES.length > 0
? PROXIES
: parseProxyPayload(process.env.SOCKS5_PROXIES, "socks5")
.map((entry) => sanitizeProxyEntry(entry, "socks5"))
.filter(Boolean);
const preferredPool =
httpPool.length > 0
? { proxies: httpPool, useSocks: false, label: "HTTP_PROXIES" }
: { proxies: socksPool, useSocks: true, label: "SOCKS5_PROXIES" };
if (!preferredPool.proxies || preferredPool.proxies.length === 0) {
return {
proxies: [],
useSocks: false,
label: "aucun",
totalAvailable: 0,
};
}
const selectedProxies = [...preferredPool.proxies]
.sort(() => Math.random() - 0.5)
.slice(0, MAX_PROXYSCRAPE_PROXY_ATTEMPTS);
return {
proxies: selectedProxies,
useSocks: preferredPool.useSocks,
label: preferredPool.label,
totalAvailable: preferredPool.proxies.length,
};
}
async function makeLecteurVideoRequest(targetUrl, options = {}) {
const { headers = {} } = options;
const cleanTargetUrl = targetUrl.trim();
const {
proxies,
useSocks,
label: proxyPoolLabel,
totalAvailable,
} = pickProxyscrapeCandidates();
if (!proxies || proxies.length === 0) {
const cycleTLS = await getCycleTLS();
const response = await cycleTLS(
cleanTargetUrl,
{
body: "",
ja3: CHROME_JA3,
userAgent: CHROME_UA,
headers: {
Referer: "https://coflix.trade/",
Origin: "https://coflix.trade",
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "fr-FR,fr;q=0.9",
...headers,
},
timeout: 15,
},
"get",
);
const body =
typeof response.body === "string"
? response.body
: JSON.stringify(response.body);
return {
data: body,
status: response.status,
headers: response.headers || {},
};
}
const cycleTLS = await getCycleTLS();
let lastError = null;
console.log(
`[LECTEURVIDEO] ${proxies.length} proxy(s) retenu(s) sur ${totalAvailable} depuis ${proxyPoolLabel}`,
);
for (let i = 0; i < proxies.length; i++) {
const proxy = proxies[i];
const auth = proxy.auth ? `${proxy.auth}@` : "";
const proxyUrl = useSocks
? `socks5h://${auth}${proxy.host}:${proxy.port}`
: `http://${auth}${proxy.host}:${proxy.port}`;
console.log(
`[LECTEURVIDEO] tentative ${i + 1}/${proxies.length} via cycletls + ${proxy.host}:${proxy.port} (${proxyPoolLabel})`,
);
try {
const response = await cycleTLS(
cleanTargetUrl,
{
body: "",
ja3: CHROME_JA3,
userAgent: CHROME_UA,
proxy: proxyUrl,
headers: {
Referer: "https://coflix.trade/",
Origin: "https://coflix.trade",
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "fr-FR,fr;q=0.9",
...headers,
},
timeout: 15,
},
"get",
);
const body =
typeof response.body === "string"
? response.body
: JSON.stringify(response.body);
const title = body.match(/<title>(.*?)<\/title>/i)?.[1] || "no title";
console.log(
`[LECTEURVIDEO] ${proxy.host}:${proxy.port} -> ${response.status} | title: ${title} | ${body.length} chars`,
);
if (
response.status === 403 &&
(body.includes("cf-wrapper") || body.includes("cloudflare"))
) {
console.warn(
`[LECTEURVIDEO] proxy ${i + 1}/${proxies.length} (${proxy.host}) bloque par Cloudflare, retry...`,
);
continue;
}
return {
data: body,
status: response.status,
headers: response.headers || {},
};
} catch (error) {
console.error(
`[LECTEURVIDEO] proxy ${i + 1}/${proxies.length} (${proxy.host}) echoue: ${error.message?.substring(0, 100)}`,
);
lastError = error;
continue;
}
}
console.error(
`[LECTEURVIDEO] tous les proxies ont echoue pour ${cleanTargetUrl}`,
);
throw lastError || new Error("[LECTEURVIDEO] Tous les proxies ont echoue");
}
// CineStream (Cloudflare-fronted Next.js) via CycleTLS + ProxyScrape rotation,
// like LecteurVideo/AnimeSama. Plain axios + CF Workers gets hammered with 525
// (CF edge SSL errors under volume from one IP); a real Chrome JA3 over rotating
// low-volume proxy IPs survives. Returns an axios-like {data,status,headers}
// EVEN on 5xx/525 (never throws on HTTP status) so a flaky upstream degrades to
// "film not found" instead of a thrown error — the caller's regex just finds no
// tmdbid. Rotates proxies on 5xx/525/cf-block; returns 4xx immediately (a 400 on
// an exotic-script title is deterministic, not worth burning another proxy).
async function makeCinestreamRequest(targetUrl, options = {}) {
const { headers = {}, timeout = 15 } = options;
const cleanTargetUrl = targetUrl.trim();
const cycleHeaders = {
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8",
Referer: "https://cinestream.info/",
...headers,
};
const isCfBlock = (status, body) =>
status === 403 &&
typeof body === "string" &&
(body.includes("cf-wrapper") || body.includes("cloudflare"));
// 5xx (incl. Cloudflare 520-527 origin/SSL errors) + cf-block -> rotate proxy.
const shouldRotate = (status, body) =>
(status >= 500 && status < 600) || isCfBlock(status, body);
const cycleTLS = await getCycleTLS();
const asAxiosLike = (resp) => ({
data: typeof resp.body === "string" ? resp.body : JSON.stringify(resp.body),
status: resp.status,
headers: resp.headers || {},
});
const { proxies, useSocks } = pickProxyscrapeCandidates();
// No proxy pool -> single direct CycleTLS attempt.
if (!proxies || proxies.length === 0) {
const resp = await cycleTLS(
cleanTargetUrl,
{ body: "", ja3: CHROME_JA3, userAgent: CHROME_UA, headers: cycleHeaders, timeout },
"get",
);
return asAxiosLike(resp);
}
let lastResult = null;
let lastError = null;
for (let i = 0; i < proxies.length; i++) {
const proxy = proxies[i];
const auth = proxy.auth ? `${proxy.auth}@` : "";
const proxyUrl = useSocks
? `socks5h://${auth}${proxy.host}:${proxy.port}`
: `http://${auth}${proxy.host}:${proxy.port}`;
try {
const resp = await cycleTLS(
cleanTargetUrl,
{ body: "", ja3: CHROME_JA3, userAgent: CHROME_UA, proxy: proxyUrl, headers: cycleHeaders, timeout },
"get",
);
const result = asAxiosLike(resp);
if (shouldRotate(result.status, result.data)) {
lastResult = result;
continue;
}
return result; // 2xx success, or a definitive 4xx
} catch (err) {
lastError = err;
}
}
// All proxies 5xx/cf-block or errored: hand back the last response so the
// caller parses (and quietly finds nothing) instead of throwing.
if (lastResult) return lastResult;
throw lastError || new Error("[CINESTREAM] Tous les proxies ont echoue");
}
// 1jour1film (Dooplay theme, Cloudflare-fronted) via CycleTLS + ProxyScrape
// rotation — same mechanism as makeCinestreamRequest, but:
// - supports POST (the Dooplay `doo_player_ajax` admin-ajax call), and
// - takes a caller-supplied Referer/Origin in `headers` (the domain rotates,
// so there's no hardcoded referer here).
// Returns an axios-like {data,status,headers} and never throws on HTTP status;
// rotates proxies on 5xx / Cloudflare-block, returns 4xx as-is. A degraded
// response just yields HTML the caller's parser finds nothing in -> "not found".
// NOTE: the residential proxy pool is what gets the *full* page payload (player
// option labels, j1fEpsData, search nonce); datacenter IPs get a stripped
// variant. timeout is in SECONDS (CycleTLS), like the sibling helpers.
async function make1j1fRequest(targetUrl, options = {}) {
const { headers = {}, timeout = 15, method = "get", body = "" } = options;
const cleanTargetUrl = targetUrl.trim();
const lowerMethod = String(method).toLowerCase();
const cycleHeaders = {
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8",
...headers,
};
const isCfBlock = (status, b) =>
(status === 403 || status === 503) &&
typeof b === "string" &&
(b.includes("cf-wrapper") ||
b.includes("Just a moment") ||
b.includes("cloudflare"));
const shouldRotate = (status, b) =>
(status >= 500 && status < 600) || isCfBlock(status, b);
const cycleTLS = await getCycleTLS();
const asAxiosLike = (resp) => ({
data: typeof resp.body === "string" ? resp.body : JSON.stringify(resp.body),
status: resp.status,
headers: resp.headers || {},
});
const doRequest = (proxyUrl) =>
cycleTLS(
cleanTargetUrl,
{
body,
ja3: CHROME_JA3,
userAgent: CHROME_UA,
headers: cycleHeaders,
timeout,
...(proxyUrl ? { proxy: proxyUrl } : {}),
},
lowerMethod,
);
const { proxies, useSocks } = pickProxyscrapeCandidates();
// No proxy pool -> single direct CycleTLS attempt (works from a residential
// host; from a datacenter IP it returns the stripped variant).
if (!proxies || proxies.length === 0) {
return asAxiosLike(await doRequest(null));
}
let lastResult = null;
let lastError = null;
for (let i = 0; i < proxies.length; i++) {
const proxy = proxies[i];
const auth = proxy.auth ? `${proxy.auth}@` : "";
const proxyUrl = useSocks
? `socks5h://${auth}${proxy.host}:${proxy.port}`
: `http://${auth}${proxy.host}:${proxy.port}`;
try {
const result = asAxiosLike(await doRequest(proxyUrl));
if (shouldRotate(result.status, result.data)) {
lastResult = result;
continue;
}
return result; // 2xx success, or a definitive 4xx
} catch (err) {
lastError = err;
}
}
if (lastResult) return lastResult;
throw lastError || new Error("[1J1F] Tous les proxies ont echoue");
}
// Cpasmal (DLE, Cloudflare-fronted) via CycleTLS + ProxyScrape rotation.
// Same mechanism as make1j1fRequest: a real Chrome JA3 over rotating low-volume
// IPs beats the Cloudflare bot-challenge that plain axios + datacenter proxies
// hit (every GET/POST 403'd). Supports POST (DLE search form) and GET (detail /
// getxfield / Season.php pages). Caller supplies Referer/Origin/Content-Type in
// `headers`. timeout is in SECONDS (CycleTLS). Returns axios-like {data,status,headers}.
async function makeCpasmalRequest(targetUrl, options = {}) {
const { headers = {}, timeout = 15, method = "get", body = "" } = options;
const cleanTargetUrl = targetUrl.trim();
const lowerMethod = String(method).toLowerCase();
const cycleHeaders = {
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8",
...headers,
};
const isCfBlock = (status, b) =>
(status === 403 || status === 503) &&
typeof b === "string" &&
(b.includes("cf-wrapper") ||
b.includes("Just a moment") ||
b.includes("cloudflare"));
const shouldRotate = (status, b) =>
(status >= 500 && status < 600) || isCfBlock(status, b);
const cycleTLS = await getCycleTLS();
const asAxiosLike = (resp) => ({
data: typeof resp.body === "string" ? resp.body : JSON.stringify(resp.body),
status: resp.status,
headers: resp.headers || {},
});
const doRequest = (proxyUrl) =>
cycleTLS(
cleanTargetUrl,
{
body,
ja3: CHROME_JA3,
userAgent: CHROME_UA,
headers: cycleHeaders,
timeout,
...(proxyUrl ? { proxy: proxyUrl } : {}),
},
lowerMethod,
);
const { proxies, useSocks } = pickProxyscrapeCandidates();
// No proxy pool -> single direct CycleTLS attempt.
if (!proxies || proxies.length === 0) {
return asAxiosLike(await doRequest(null));
}
let lastResult = null;
let lastError = null;
for (let i = 0; i < proxies.length; i++) {
const proxy = proxies[i];
const auth = proxy.auth ? `${proxy.auth}@` : "";
const proxyUrl = useSocks
? `socks5h://${auth}${proxy.host}:${proxy.port}`
: `http://${auth}${proxy.host}:${proxy.port}`;
try {
const result = asAxiosLike(await doRequest(proxyUrl));
if (shouldRotate(result.status, result.data)) {
lastResult = result;
continue;
}
return result; // 2xx success, or a definitive 4xx
} catch (err) {
lastError = err;
}
}
if (lastResult) return lastResult;
throw lastError || new Error("[CPASMAL] Tous les proxies ont echoue");
}
// Fonction AnimeSama avec CycleTLS (cloudscraper-style : JA3 Chrome pour bypass Cloudflare).
// Utilise le SOCKS5_PROXIES env directement (proxies dedies) -- pas le pool ProxyScrape,
// pas de fallback direct sans proxy.
const MAX_ANIMESAMA_CYCLETLS_ATTEMPTS = 2;
const ANIMESAMA_SOCKS5_PROXIES = dedupeProxyEntries(
parseProxyPayload(process.env.SOCKS5_PROXIES, "socks5")
.map((entry) => sanitizeProxyEntry(entry, "socks5"))
.filter(Boolean),
);
async function makeAnimeSamaRequest(targetUrl, options = {}) {
const {
headers = {},
timeout = 30,
method = "get",
body = "",
} = options;
const cleanTargetUrl = targetUrl.trim();
const lowerMethod = String(method).toLowerCase();
const defaultHeaders = {
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "fr-FR,fr;q=0.6",
...headers,
};
const pool = [...ANIMESAMA_SOCKS5_PROXIES]
.sort(() => Math.random() - 0.5)
.slice(0, MAX_ANIMESAMA_CYCLETLS_ATTEMPTS);
if (pool.length === 0) {
throw new Error(
"[ANIMESAMA CYCLETLS] aucun proxy SOCKS5 defini dans SOCKS5_PROXIES",
);
}
const cycleTLS = await getCycleTLS();
const asAxiosLike = (response) => {
const data =
typeof response.body === "string"
? response.body
: JSON.stringify(response.body);
return { data, status: response.status, headers: response.headers || {} };
};
const isCloudflareBlock = (status, bodyStr) =>
status === 403 &&
typeof bodyStr === "string" &&
(bodyStr.includes("cf-wrapper") || bodyStr.includes("cloudflare"));
let lastError = null;
for (let i = 0; i < pool.length; i++) {
const proxy = pool[i];
const auth = proxy.auth ? `${proxy.auth}@` : "";
const proxyUrl = `socks5h://${auth}${proxy.host}:${proxy.port}`;
try {
const response = await cycleTLS(
cleanTargetUrl,
{
body,
ja3: CHROME_JA3,
userAgent: CHROME_UA,
proxy: proxyUrl,
headers: defaultHeaders,
timeout,
},
lowerMethod,
);
const result = asAxiosLike(response);
if (isCloudflareBlock(result.status, result.data)) {
lastError = new Error(
`[ANIMESAMA CYCLETLS] cloudflare block via ${proxy.host}:${proxy.port}`,
);
continue;
}
return result;
} catch (err) {
lastError = err;
}
}
throw lastError || new Error("[ANIMESAMA CYCLETLS] tous les proxies ont echoue");
}
// === SOCKS5 PROXIES (from env) ===
const parseJsonArrayEnv = (envName, fallback = []) => {
const rawValue = process.env[envName];
if (!rawValue) return fallback;
try {
const parsed = JSON.parse(rawValue);
return Array.isArray(parsed) ? parsed : fallback;
} catch {
return fallback;
}
};
const { acquireRedisLock } = require("./redisLock");
const PROXY_ROTATION_REDIS_PREFIX = "proxyscrape:rotation:v1";
const PROXY_RATE_LIMIT_REDIS_PREFIX = "proxyscrape:rate-limit:v1";
const proxyRateLimitState = new Map();
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function buildProxyRateLimitKey(poolName, proxy) {
if (!proxy || !proxy.host || !proxy.port) return null;
return `${PROXY_RATE_LIMIT_REDIS_PREFIX}:${poolName}:${proxy.type || "proxy"}:${proxy.host}:${proxy.port}:${proxy.auth || ""}`;
}
function reserveLocalRateLimitedProxy(proxyKey, minIntervalMs) {
if (!proxyKey) return false;
const now = Date.now();
const nextAllowedAt = proxyRateLimitState.get(proxyKey) || 0;
if (nextAllowedAt > now) {
return false;
}
proxyRateLimitState.set(proxyKey, now + minIntervalMs);
return true;
}
async function reserveRateLimitedProxy(poolName, proxy, minIntervalMs) {
if (!proxy) return false;
const safeIntervalMs = Math.max(0, Math.floor(Number(minIntervalMs) || 0));
if (safeIntervalMs <= 0) {
return true;
}
const proxyKey = buildProxyRateLimitKey(poolName, proxy);
if (!proxyKey) {
return false;
}
try {
const reserved = await redis.set(
proxyKey,
String(Date.now()),
"PX",
safeIntervalMs,
"NX",
);
if (reserved === "OK") {
return true;
}
return false;
} catch {
// Fallback local si Redis n'est pas disponible.
}
return reserveLocalRateLimitedProxy(proxyKey, safeIntervalMs);
}
setInterval(() => {
const now = Date.now();
for (const [proxyKey, nextAllowedAt] of proxyRateLimitState) {
if (!Number.isFinite(nextAllowedAt) || nextAllowedAt <= now) {
proxyRateLimitState.delete(proxyKey);
}
}
}, 30 * 1000).unref();
function reserveLocalProxyWindow(poolName, poolLength, batchSize) {
const existingCursor = proxyRotationState.get(poolName);
const startIndex = Number.isInteger(existingCursor)
? existingCursor % poolLength
: Math.floor(Math.random() * poolLength);
proxyRotationState.set(poolName, (startIndex + batchSize) % poolLength);
return startIndex;
}
async function reserveProxyWindow(poolName, poolLength, batchSize = 1) {
if (!Number.isFinite(poolLength) || poolLength <= 0) {
return 0;
}
const safeBatchSize = Math.min(
poolLength,
Math.max(1, Math.floor(Number(batchSize) || 1)),
);
try {
const rotationKey = `${PROXY_ROTATION_REDIS_PREFIX}:${poolName}`;
const nextValue = await redis.incrby(rotationKey, safeBatchSize);
const startIndex = Number(nextValue) - safeBatchSize;
return ((startIndex % poolLength) + poolLength) % poolLength;
} catch {
return reserveLocalProxyWindow(poolName, poolLength, safeBatchSize);
}
}
async function pickNextProxyBatch(pool, count = 1, poolName = "default") {
if (!Array.isArray(pool) || pool.length === 0) {
return [];
}
const safeCount = Math.min(
pool.length,
Math.max(1, Math.floor(Number(count) || 1)),
);
const startIndex = await reserveProxyWindow(poolName, pool.length, safeCount);
const selected = [];
for (let offset = 0; offset < safeCount; offset++) {
selected.push(pool[(startIndex + offset) % pool.length]);
}
return selected;
}
async function pickNextRateLimitedProxy(pool, options = {}) {
if (!Array.isArray(pool) || pool.length === 0) {
return null;
}
const {
poolName = "default",
minIntervalMs = 0,
waitTimeoutMs = 0,
retryDelayMs = 25,
} = options;
const safeMinIntervalMs = Math.max(0, Math.floor(Number(minIntervalMs) || 0));
if (safeMinIntervalMs <= 0) {
const [proxy] = await pickNextProxyBatch(pool, 1, poolName);
return proxy || null;
}
const deadline =
Date.now() + Math.max(0, Math.floor(Number(waitTimeoutMs) || 0));
while (true) {
const startIndex = await reserveProxyWindow(poolName, pool.length, 1);
for (let offset = 0; offset < pool.length; offset++) {
const proxy = pool[(startIndex + offset) % pool.length];
const reserved = await reserveRateLimitedProxy(
poolName,
proxy,
safeMinIntervalMs,
);
if (reserved) {
return proxy;
}
}
if (Date.now() >= deadline) {
return null;
}
const remainingMs = deadline - Date.now();
await sleep(Math.max(5, Math.min(retryDelayMs, remainingMs)));
}
}
async function pickNextSocks5Proxy(options = {}) {
const proxy = await pickNextRateLimitedProxy(PROXIES, {
poolName: "SOCKS5_PROXIES",
...options,
});
return proxy || null;
}
const splitCsvEnv = (envName, fallback = []) => {
const rawValue = process.env[envName];
if (!rawValue) return [...fallback];
return rawValue
.split(",")
.map((value) => value.trim())
.filter(Boolean);
};
const parsePositiveIntEnv = (envName, fallback) => {
const rawValue = process.env[envName];
const parsed = Number.parseInt(rawValue || "", 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};
function normalizeProxyType(type, fallback = "http") {
const normalized = String(type || fallback)
.toLowerCase()
.replace(/:$/, "")
.trim();
if (
normalized === "socks" ||
normalized === "socks5h" ||
normalized.startsWith("socks5")
) {
return "socks5";
}
if (normalized.startsWith("http")) {
return "http";
}
return fallback;
}
function updateArrayInPlace(target, nextValues) {
target.splice(0, target.length, ...nextValues);
return target;
}
function dedupeProxyEntries(entries = []) {
const seen = new Set();
return entries.filter((entry) => {
if (!entry || !entry.host || !entry.port) return false;
const key = `${entry.type}:${entry.host}:${entry.port}:${entry.auth || ""}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function sanitizeProxyEntry(entry, fallbackType = "http") {
if (!entry || !entry.host || !entry.port) return null;
const port = Number.parseInt(entry.port, 10);
if (!Number.isFinite(port) || port <= 0) return null;
return {
host: String(entry.host).trim(),
port,
auth: entry.auth ? String(entry.auth).trim() : undefined,
type: normalizeProxyType(entry.type, fallbackType),
};
}
function parseProxyString(value, defaultType = "http") {
if (!value || typeof value !== "string") return null;
const rawValue = value.trim();
if (!rawValue) return null;
if (/^[a-z]+:\/\//i.test(rawValue)) {
try {
const parsedUrl = new URL(rawValue);
return sanitizeProxyEntry(
{
host: parsedUrl.hostname,
port: parsedUrl.port,
auth: parsedUrl.username
? `${decodeURIComponent(parsedUrl.username)}${parsedUrl.password ? `:${decodeURIComponent(parsedUrl.password)}` : ""}`
: undefined,
type: parsedUrl.protocol.replace(":", ""),
},
defaultType,
);
} catch {
return null;
}
}
const authSplitIndex = rawValue.lastIndexOf("@");
let authPart = "";
let hostPortPart = rawValue;
if (authSplitIndex !== -1) {
authPart = rawValue.slice(0, authSplitIndex).trim();
hostPortPart = rawValue.slice(authSplitIndex + 1).trim();
}
const segments = hostPortPart
.split(":")
.map((segment) => segment.trim())
.filter(Boolean);
if (segments.length === 2) {
return sanitizeProxyEntry(
{
host: segments[0],
port: segments[1],
auth: authPart || undefined,
type: defaultType,
},
defaultType,
);
}
if (!authPart && segments.length === 4) {
const [a, b, c, d] = segments;
if (/^\d+$/.test(b)) {
return sanitizeProxyEntry(
{
host: a,
port: b,
auth: `${c}:${d}`,
type: defaultType,
},
defaultType,
);
}
if (/^\d+$/.test(d)) {
return sanitizeProxyEntry(
{
host: c,
port: d,
auth: `${a}:${b}`,
type: defaultType,
},
defaultType,
);
}
}
return null;
}
function parseProxyPayload(payload, defaultType = "http") {
if (!payload) return [];
if (Array.isArray(payload)) {
return dedupeProxyEntries(
payload
.flatMap((item) => parseProxyPayload(item, defaultType))
.filter(Boolean),
);
}
if (typeof payload === "string") {
const trimmed = payload.trim();
if (!trimmed) return [];
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
try {
return parseProxyPayload(JSON.parse(trimmed), defaultType);
} catch {
// Keep parsing as raw text below.
}
}
return dedupeProxyEntries(
trimmed
.split(/\r?\n/)
.map((line) => parseProxyString(line, defaultType))
.filter(Boolean),
);
}
if (typeof payload === "object") {
if (payload.host && payload.port) {
return dedupeProxyEntries(
[sanitizeProxyEntry(payload, defaultType)].filter(Boolean),
);
}
const candidateKeys = ["proxies", "data", "items", "results", "list"];
for (const key of candidateKeys) {
if (payload[key] !== undefined) {
const parsed = parseProxyPayload(payload[key], defaultType);
if (parsed.length > 0) return parsed;
}
}
const arrayValues = Object.values(payload).filter(Array.isArray);
for (const value of arrayValues) {
const parsed = parseProxyPayload(value, defaultType);
if (parsed.length > 0) return parsed;
}
}
return [];
}
function proxyToUrl(proxy, forcedType) {
const safeProxy = sanitizeProxyEntry(
proxy,
forcedType || proxy?.type || "http",
);
if (!safeProxy) return null;
const type = normalizeProxyType(forcedType || safeProxy.type, "http");
const auth = safeProxy.auth ? `${safeProxy.auth}@` : "";
return `${type}://${auth}${safeProxy.host}:${safeProxy.port}`;
}
const PROXY_POOL_REDIS_KEY = "proxyscrape:mainapi:pools:v1";
const PROXYSCRAPE_API_TOKEN = (
process.env.PROXYSCRAPE_API_TOKEN ||
process.env.PROXYSCRAPE_API_KEY ||
""
).trim();
const PROXYSCRAPE_ACCOUNT_ID = (
process.env.PROXYSCRAPE_ACCOUNT_ID || ""
).trim();
const PROXYSCRAPE_ENABLED = Boolean(
PROXYSCRAPE_API_TOKEN && PROXYSCRAPE_ACCOUNT_ID,
);
const PROXY_POOL_REDIS_TTL_SECONDS = 60 * 60;
const PROXYSCRAPE_ACCOUNT_BASE_URL = "https://api.proxyscrape.com";
const PROXYSCRAPE_ACCOUNT_PATH =
"/v4/account/{accountId}/datacenter_shared/proxy-list";
const PROXYSCRAPE_TIMEOUT_MS = 12000;
const PROXYSCRAPE_REFRESH_MS = 15 * 60 * 1000;
const PROXYSCRAPE_MAX_PER_PROTOCOL = 1000;
const PROXYSCRAPE_HTTP_PROTOCOLS = ["http"];
const PROXYSCRAPE_SOCKS_PROTOCOLS = ["socks"];
const PROXYSCRAPE_ALLOW_LEGACY_FALLBACK = false;
const PROXYSCRAPE_DEBUG = false;
const legacySocksEnv =
PROXYSCRAPE_ENABLED && !PROXYSCRAPE_ALLOW_LEGACY_FALLBACK
? []
: parseJsonArrayEnv("SOCKS5_PROXIES", []);
const legacyHttpEnv =
PROXYSCRAPE_ENABLED && !PROXYSCRAPE_ALLOW_LEGACY_FALLBACK
? []
: parseJsonArrayEnv("HTTP_PROXIES", []);
const PROXIES = dedupeProxyEntries(
(legacySocksEnv.length > 0
? legacySocksEnv
: parseProxyPayload(process.env.SOCKS5_PROXIES, "socks5")
)
.map((entry) => sanitizeProxyEntry(entry, "socks5"))
.filter(Boolean),
);
const COFLIX_SOCKS5_PROXIES = dedupeProxyEntries(
parseProxyPayload(process.env.SOCKS5_PROXIES, "socks5")
.map((entry) => sanitizeProxyEntry(entry, "socks5"))
.filter(Boolean),
);
const HTTP_PROXIES = dedupeProxyEntries(
(legacyHttpEnv.length > 0
? legacyHttpEnv
: parseProxyPayload(process.env.HTTP_PROXIES, "http")
)
.map((entry) => sanitizeProxyEntry(entry, "http"))
.filter(Boolean),
);
const DARKINO_HTTP_PROXIES = [];
const DARKINO_PROXIES = [];
function syncDerivedHttpProxyPools(httpEntries) {
const normalizedHttpEntries = dedupeProxyEntries(
httpEntries
.map((entry) => sanitizeProxyEntry(entry, "http"))
.filter(Boolean),
);
updateArrayInPlace(HTTP_PROXIES, normalizedHttpEntries);
updateArrayInPlace(
DARKINO_PROXIES,
normalizedHttpEntries.map((entry) => ({ ...entry, type: "http" })),
);
updateArrayInPlace(
DARKINO_HTTP_PROXIES,
DARKINO_PROXIES.map((proxy) => proxyToUrl(proxy, "http")).filter(Boolean),
);
}
syncDerivedHttpProxyPools(HTTP_PROXIES);
const proxyScrapeState = {
refreshPromise: null,
lastRefreshAt: 0,
lastRefreshError: null,
lastSuccessMeta: {
socks: null,
http: null,
},
};
function getProxyPoolAgeMs(updatedAt = proxyScrapeState.lastRefreshAt) {
const timestamp = Number(updatedAt);
if (!Number.isFinite(timestamp) || timestamp <= 0) {
return Number.POSITIVE_INFINITY;
}
return Date.now() - timestamp;
}
function isProxyPoolFresh(updatedAt = proxyScrapeState.lastRefreshAt) {
return getProxyPoolAgeMs(updatedAt) < PROXYSCRAPE_REFRESH_MS;
}
function buildProxyScrapeResult(source) {
return {
enabled: true,
socks: PROXIES.length,
http: HTTP_PROXIES.length,
meta: proxyScrapeState.lastSuccessMeta,
source,
};
}
function limitProxyEntries(entries) {
if (
!Number.isFinite(PROXYSCRAPE_MAX_PER_PROTOCOL) ||
PROXYSCRAPE_MAX_PER_PROTOCOL <= 0
) {
return entries;
}
return entries.slice(0, PROXYSCRAPE_MAX_PER_PROTOCOL);
}
function buildProxyScrapeCandidates(protocol) {
if (!PROXYSCRAPE_ACCOUNT_ID) {
return [];
}
const accountPath = PROXYSCRAPE_ACCOUNT_PATH.replace(
"{accountId}",
PROXYSCRAPE_ACCOUNT_ID,
);
return [
{
url: `${PROXYSCRAPE_ACCOUNT_BASE_URL}${accountPath}`,
headers: { "api-token": PROXYSCRAPE_API_TOKEN },
params: {
type: "getproxies",
protocol,
format: "normal",
},
label: `v4 account ${accountPath}`,
},
];
}
function applyStoredProxyPools(storedPools) {
if (!storedPools || typeof storedPools !== "object") return false;
const socks = dedupeProxyEntries(
(storedPools.socks || [])
.map((entry) => sanitizeProxyEntry(entry, "socks5"))
.filter(Boolean),
);
const httpEntries = dedupeProxyEntries(
(storedPools.http || [])
.map((entry) => sanitizeProxyEntry(entry, "http"))
.filter(Boolean),
);
if (socks.length === 0 || httpEntries.length === 0) {
return false;
}
updateArrayInPlace(
PROXIES,
socks.map((entry) => ({ ...entry, type: "socks5" })),
);
syncDerivedHttpProxyPools(httpEntries);
proxyAgentCache.clear();
darkinoProxyAgentCache.clear();
cpasmalAgentCache.clear();
return true;
}
async function loadProxyPoolsFromRedis() {
try {
const rawValue = await redis.get(PROXY_POOL_REDIS_KEY);
if (!rawValue) return false;
const parsed = JSON.parse(rawValue);
if (!applyStoredProxyPools(parsed)) return false;
proxyScrapeState.lastRefreshAt =
Number(parsed.updatedAt) || proxyScrapeState.lastRefreshAt;
proxyScrapeState.lastSuccessMeta =
parsed.meta || proxyScrapeState.lastSuccessMeta;
return true;
} catch {
return false;
}
}
async function saveProxyPoolsToRedis(payload) {
try {
await redis.set(
PROXY_POOL_REDIS_KEY,
JSON.stringify(payload),
"EX",
PROXY_POOL_REDIS_TTL_SECONDS,
);
} catch {
// ignore Redis persistence failures
}
}
async function fetchProxyScrapeProtocol(defaultType, protocols) {
const attemptErrors = [];
for (const protocol of protocols) {
const candidates = buildProxyScrapeCandidates(protocol);
for (const candidate of candidates) {
try {
if (PROXYSCRAPE_DEBUG) {
console.log(
`[PROXYSCRAPE DEBUG] tentative ${candidate.label} | protocol=${protocol} | url=${candidate.url} | params=${JSON.stringify(candidate.params)}`,
);
}
const response = await axios.get(candidate.url, {
headers: candidate.headers,
params: candidate.params,
timeout: PROXYSCRAPE_TIMEOUT_MS,
responseType: "text",
transformResponse: [(data) => data],
});
const proxies = limitProxyEntries(
dedupeProxyEntries(parseProxyPayload(response.data, defaultType)),
);
if (proxies.length === 0) {
const message = `[PROXYSCRAPE] ${candidate.label} (${protocol}) a retourne 0 proxy`;
attemptErrors.push(message);
if (PROXYSCRAPE_DEBUG) {
console.warn(
`${message} | body=${String(response.data || "").slice(0, 300)}`,
);
}
continue;
}
if (PROXYSCRAPE_DEBUG) {
console.log(
`[PROXYSCRAPE DEBUG] succes ${candidate.label} (${protocol}) -> ${proxies.length} proxies`,
);
}
return {
proxies,
meta: {
protocol,
endpoint: candidate.label,
count: proxies.length,
},
};
} catch (error) {
const status = error.response?.status;
const message = `[PROXYSCRAPE] ${candidate.label} (${protocol}) a echoue: ${status || error.code || error.message}`;
attemptErrors.push(message);
if (PROXYSCRAPE_DEBUG) {
console.warn(
`${message} | body=${String(error.response?.data || "").slice(0, 300)}`,
);
}
}
}
}
throw new Error(
attemptErrors.length > 0
? attemptErrors.join(" | ")
: "[PROXYSCRAPE] Aucun endpoint n a repondu",
);
}
async function refreshProxyScrapeProxies(options = {}) {
const { force = false, silent = false } = options;
if (!PROXYSCRAPE_ENABLED) {
return {
enabled: false,
socks: PROXIES.length,
http: HTTP_PROXIES.length,
};
}
if (!force && proxyScrapeState.refreshPromise) {
return proxyScrapeState.refreshPromise;
}
if (proxyScrapeState.refreshPromise) {
return proxyScrapeState.refreshPromise;
}
if (!force) {
const loadedFromRedis = await loadProxyPoolsFromRedis();
if (loadedFromRedis && isProxyPoolFresh()) {
if (!silent) {
console.log(
`[PROXYSCRAPE] Pools frais charges depuis Redis: ${PROXIES.length} SOCKS5, ${HTTP_PROXIES.length} HTTP`,
);
}
return buildProxyScrapeResult("redis-fresh");
}
}
proxyScrapeState.refreshPromise = (async () => {
const lock = await acquireRedisLock(PROXY_POOL_REDIS_KEY, {
ttl: Math.max(10, Math.ceil(PROXYSCRAPE_TIMEOUT_MS / 1000) * 2),
retries: 40,
retryDelay: 500,
});
if (!lock) {
const loadedFromRedis = await loadProxyPoolsFromRedis();
if (loadedFromRedis) {
if (!silent) {
console.log(
`[PROXYSCRAPE] Pools charges depuis Redis: ${PROXIES.length} SOCKS5, ${HTTP_PROXIES.length} HTTP`,
);
}
return buildProxyScrapeResult("redis");
}
}
try {
if (!force) {
const loadedFromRedis = await loadProxyPoolsFromRedis();
if (loadedFromRedis && isProxyPoolFresh()) {
if (!silent) {
console.log(
`[PROXYSCRAPE] Pools frais recharges depuis Redis: ${PROXIES.length} SOCKS5, ${HTTP_PROXIES.length} HTTP`,
);
}
return buildProxyScrapeResult("redis-fresh");
}
}
const socksResult = await fetchProxyScrapeProtocol(
"socks5",
PROXYSCRAPE_SOCKS_PROTOCOLS,
);
const httpResult = await fetchProxyScrapeProtocol(
"http",
PROXYSCRAPE_HTTP_PROTOCOLS,
);
updateArrayInPlace(
PROXIES,
socksResult.proxies.map((proxy) => ({ ...proxy, type: "socks5" })),
);
syncDerivedHttpProxyPools(httpResult.proxies);
proxyAgentCache.clear();
darkinoProxyAgentCache.clear();
cpasmalAgentCache.clear();
proxyScrapeState.lastRefreshAt = Date.now();
proxyScrapeState.lastRefreshError = null;
proxyScrapeState.lastSuccessMeta = {
socks: socksResult.meta,
http: httpResult.meta,
};
await saveProxyPoolsToRedis({
updatedAt: proxyScrapeState.lastRefreshAt,
socks: PROXIES,
http: HTTP_PROXIES,
meta: proxyScrapeState.lastSuccessMeta,
});
if (!silent) {
console.log(
`[PROXYSCRAPE] Refresh OK: ${PROXIES.length} SOCKS5 via ${socksResult.meta.endpoint}, ${HTTP_PROXIES.length} HTTP via ${httpResult.meta.endpoint}`,
);
}
return {
enabled: true,
socks: PROXIES.length,
http: HTTP_PROXIES.length,
meta: proxyScrapeState.lastSuccessMeta,
source: "api",
};
} catch (error) {
proxyScrapeState.lastRefreshError = error.message;
const loadedFromRedis = await loadProxyPoolsFromRedis();
if (loadedFromRedis) {
if (!silent) {
console.warn(
`[PROXYSCRAPE] Refresh API echoue, fallback Redis utilise: ${error.message}`,
);
}
return buildProxyScrapeResult("redis-fallback");
}
if (!silent) {
console.error(`[PROXYSCRAPE] Refresh echoue: ${error.message}`);
}
throw error;
} finally {
if (lock && typeof lock.release === "function") {
await lock.release();
}
}
})().finally(() => {
proxyScrapeState.refreshPromise = null;
});
return proxyScrapeState.refreshPromise;
}
function scheduleProxyScrapeRefresh() {
if (!PROXYSCRAPE_ENABLED) return;
setTimeout(() => {
loadProxyPoolsFromRedis().catch(() => {});
}, 0).unref();
setInterval(() => {
refreshProxyScrapeProxies({ silent: false }).catch(() => {});
}, PROXYSCRAPE_REFRESH_MS).unref();
}
scheduleProxyScrapeRefresh();
// === WIFLIX FREE PROXY LIST (ProxyScrape v4 free) ===
const WIFLIX_FREE_PROXY_URL =
"https://api.proxyscrape.com/v4/free-proxy-list/get?request=display_proxies&proxy_format=protocolipport&format=text";
const WIFLIX_FREE_PROXY_REFRESH_MS = 5 * 60 * 1000; // 5 minutes
const WIFLIX_FREE_PROXY_MAX_ATTEMPTS = 3; // Try up to 3 proxies per request
const WIFLIX_FREE_PROXY_TIMEOUT = 5000; // 5s per proxy attempt (fail fast)
// Cookie requis sur l'endpoint search (do=search) sinon réponse "Bot shield active.".
// Le site vérifie h_check==25 exactement (posé par son JS). Bump ici si le site change la valeur.
const WIFLIX_H_CHECK = "25";
const wiflixFreeProxies = []; // HTTP-only proxy URL strings
const wiflixProxyAgentCache = new LruMap({
max: PROXY_AGENT_CACHE_MAX,
onEvict: destroyHttpAgentLike,
});
const wiflixDeadProxies = new Set(); // Proxies who echoue — survit entre les requetes, reset au refresh
async function fetchWiflixFreeProxies() {
try {
const res = await axios.get(WIFLIX_FREE_PROXY_URL, { timeout: 15000 });
const text = typeof res.data === "string" ? res.data : "";
const lines = text
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
const parsed = [];
for (const line of lines) {
// Only keep http/https — socks4 free proxies are too unreliable
if (/^https?:\/\/.+:\d+$/.test(line)) {
parsed.push(line);
}
}
// Replace list in-place + reset dead list
wiflixFreeProxies.length = 0;
wiflixFreeProxies.push(...parsed);
wiflixProxyAgentCache.clear();
wiflixDeadProxies.clear();
console.log(
`[WIFLIX FREE PROXY] Refresh OK: ${wiflixFreeProxies.length} http proxies (${lines.length - parsed.length} socks ignores)`,
);
} catch (err) {
console.error(
`[WIFLIX FREE PROXY] Refresh echoue: ${err.message}`,
);
}
}
function getWiflixFreeProxyAgent(proxyUrl) {
if (wiflixProxyAgentCache.has(proxyUrl)) {
return wiflixProxyAgentCache.get(proxyUrl);
}
const agents = {
httpAgent: new HttpProxyAgent(proxyUrl),
httpsAgent: new HttpsProxyAgent(proxyUrl),
};
wiflixProxyAgentCache.set(proxyUrl, agents);
return agents;
}
/**
* Wiflix request — tries free HTTP proxies first, then falls back to Cloudflare Workers.
*/
async function makeWiflixRequest(targetUrl, options = {}) {
const {
timeout = 10000,
headers = {},
decompress = true,
method: requestMethod,
data: requestData,
...otherOptions
} = options;
const method = (requestMethod || "GET").toUpperCase();
// Cookie anti "Bot shield active." : l'endpoint search (do=search) l'exige.
// Inoffensif sur les GET de pages. Mergé avec un éventuel Cookie fourni par l'appelant.
const callerCookie = headers.cookie || headers.Cookie;
const mergedHeaders = { ...headers };
delete mergedHeaders.Cookie;
mergedHeaders.cookie = callerCookie
? `h_check=${WIFLIX_H_CHECK}; ${callerCookie}`
: `h_check=${WIFLIX_H_CHECK}`;
// Re-propage à toutes les phases (le fallback CF Workers relit options.headers).
options = { ...options, headers: mergedHeaders };
const defaultHeaders = {
accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"accept-language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
"user-agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
...mergedHeaders,
};
// --- Phase 1: free proxy list (skip already-dead ones) ---
const alive = wiflixFreeProxies.filter((p) => !wiflixDeadProxies.has(p));
if (alive.length > 0) {
const shuffled = alive.sort(() => Math.random() - 0.5);
const candidates = shuffled.slice(0, WIFLIX_FREE_PROXY_MAX_ATTEMPTS);
for (let i = 0; i < candidates.length; i++) {
const proxyUrl = candidates[i];
try {
const agents = getWiflixFreeProxyAgent(proxyUrl);
const response = await axios({
url: targetUrl,
method,
headers: defaultHeaders,
timeout: WIFLIX_FREE_PROXY_TIMEOUT,
decompress,
...agents,
...(requestData !== undefined ? { data: requestData } : {}),
...otherOptions,
});
// Verify we got a real response (not a proxy error page)
const body = typeof response.data === "string" ? response.data : "";
if (response.status === 200 && body.length > 100) {
console.log(
`[WIFLIX FREE PROXY] OK via ${proxyUrl} (tentative ${i + 1}/${candidates.length})`,
);
return response;
}
// Bad response — mark dead
wiflixDeadProxies.add(proxyUrl);
} catch {
wiflixDeadProxies.add(proxyUrl);
wiflixProxyAgentCache.delete(proxyUrl);
}
}
console.log(
`[WIFLIX FREE PROXY] ${candidates.length} echoues, ${alive.length - candidates.length} restants, fallback CF Workers`,
);
}
// --- Phase 2: fallback to Cloudflare Workers ---
return makeRequestWithCorsFallback(targetUrl, options);
}
// Set-Cookie[] -> "name=value; name2=value2" (drop path/expires/etc. attributes)
function extractCookieHeader(setCookie) {
if (!Array.isArray(setCookie) || setCookie.length === 0) return "";
return setCookie
.map((c) => String(c).split(";")[0].trim())
.filter(Boolean)
.join("; ");
}
// One GET (harvest the bot-shield session cookie) + one POST (search) over the
// given axios proxy agents. Returns the response if it's a real result page,
// else throws so the caller rotates to the next proxy.
async function wiflixHandshake(homeUrl, searchUrl, baseHeaders, pdata, agents, timeout) {
const home = await axios({
url: homeUrl,
method: "GET",
headers: { ...baseHeaders, referer: homeUrl },
timeout,
decompress: true,
...agents,
});
const harvested = extractCookieHeader(home.headers["set-cookie"]);
const cookie = harvested
? `${harvested}; h_check=${WIFLIX_H_CHECK}`
: `h_check=${WIFLIX_H_CHECK}`;
const res = await axios({
url: searchUrl,
method: "POST",
headers: { ...baseHeaders, cookie },
data: pdata,
timeout,
decompress: true,
...agents,
});
const body = typeof res.data === "string" ? res.data : "";
if (
res.status === 200 &&
body.length > 100 &&
!body.includes("Bot shield active")
) {
return res;
}
throw new Error(`wiflix search bad response (status ${res.status}, ${body.length}b)`);
}
/**
* Wiflix search — the bot shield rejects do=search unless the request carries a
* real session cookie issued by the homepage GET (plus h_check=25). The session
* cookie is IP-bound, so the GET (harvest) and the POST (search) MUST run on the
* same proxy. Port of vStream wiflix.py showMovies() handshake.
*/
async function makeWiflixSearchRequest(homeUrl, searchUrl, options = {}) {
const { timeout = 10000, headers = {}, data: pdata } = options;
const baseHeaders = {
accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"accept-language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
"user-agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
...headers,
};
// --- Phase 1: free public proxies (residential) — handshake on same proxy ---
const alive = wiflixFreeProxies.filter((p) => !wiflixDeadProxies.has(p));
if (alive.length > 0) {
const candidates = alive
.sort(() => Math.random() - 0.5)
.slice(0, WIFLIX_FREE_PROXY_MAX_ATTEMPTS);
for (let i = 0; i < candidates.length; i++) {
const proxyUrl = candidates[i];
try {
const res = await wiflixHandshake(
homeUrl, searchUrl, baseHeaders, pdata,
getWiflixFreeProxyAgent(proxyUrl), WIFLIX_FREE_PROXY_TIMEOUT,
);
console.log(
`[WIFLIX SEARCH] OK via free proxy ${proxyUrl} (${i + 1}/${candidates.length})`,
);
return res;
} catch {
wiflixDeadProxies.add(proxyUrl);
wiflixProxyAgentCache.delete(proxyUrl);
}
}
console.log(`[WIFLIX SEARCH] ${candidates.length} free proxies echoues, fallback CF Workers`);
}
// --- Phase 2: CF Workers fallback — stateless single POST (h_check only) ---
return makeRequestWithCorsFallback(searchUrl, {
method: "POST",
data: pdata,
headers: { ...headers, cookie: `h_check=${WIFLIX_H_CHECK}` },
timeout,
});
}
// Initial fetch + 5-min refresh
fetchWiflixFreeProxies();
setInterval(() => {
fetchWiflixFreeProxies();
}, WIFLIX_FREE_PROXY_REFRESH_MS).unref();
// Fonction utilitaire pour choisir aléatoirement un proxy (toujours utiliser un proxy)
function pickRandomProxyOrNone() {
// Sélectionner toujours un proxy aléatoire parmi la liste
if (!PROXIES || PROXIES.length === 0) return null;
const idx = Math.floor(Math.random() * PROXIES.length);
return PROXIES[idx] || null;
}
// Sélectionne un proxy aléatoire — retourne null si la liste est vide
function pickRandomProxy() {
if (!PROXIES || PROXIES.length === 0) return null;
const idx = Math.floor(Math.random() * PROXIES.length);
return PROXIES[idx];
}
// Fonction utilitaire pour cr\u00e9er un agent proxy SOCKS5 (avec cache)
function getProxyAgent(proxy) {
if (!proxy) return null;
const auth = proxy.auth ? `${proxy.auth}@` : "";
const proxyType = normalizeProxyType(proxy.type, "socks5");
const cacheKey = `${proxyType}:${proxy.host}:${proxy.port}:${auth}`;
// V\u00e9rifier le cache d'abord
if (proxyAgentCache.has(cacheKey)) {
return proxyAgentCache.get(cacheKey);
}
const protocol = proxyType === "socks5" ? "socks5h" : proxyType;
const proxyUrl = `${protocol}://${auth}${proxy.host}:${proxy.port}`;
const agent = new SocksProxyAgent(proxyUrl);
// Mettre en cache l'agent
proxyAgentCache.set(cacheKey, agent);
return agent;
}
// Fonction utilitaire pour cr\u00e9er un agent proxy (pour Darkino) - avec cache
function getDarkinoHttpProxyAgent(proxy) {
if (!proxy) return null;
const auth = proxy.auth ? `${proxy.auth}@` : "";
const proxyType = normalizeProxyType(proxy.type, "http");
const cacheKey = `${proxyType}:${proxy.host}:${proxy.port}:${auth}`;
// V\u00e9rifier le cache d'abord
if (darkinoProxyAgentCache.has(cacheKey)) {
return darkinoProxyAgentCache.get(cacheKey);
}
let agents;
if (proxyType === "socks5") {
const proxyUrl = `socks5://${auth}${proxy.host}:${proxy.port}`;
const agent = new SocksProxyAgent(proxyUrl);
agents = {
httpAgent: agent,
httpsAgent: agent,
};
} else {
const proxyUrl = `http://${auth}${proxy.host}:${proxy.port}`;
agents = {
httpAgent: new HttpProxyAgent(proxyUrl),
httpsAgent: new HttpsProxyAgent(proxyUrl),
};
}
// Mettre en cache les agents
darkinoProxyAgentCache.set(cacheKey, agents);
return agents;
}
module.exports = {
// Proxy agent caches
proxyAgentCache,
darkinoProxyAgentCache,
// Proxy configuration flags
ENABLE_DARKINO_PROXY,
ENABLE_COFLIX_PROXY,
ENABLE_FRENCH_STREAM_PROXY,
ENABLE_LECTEURVIDEO_PROXY,
ENABLE_FSTREAM_PROXY,
ENABLE_ANIME_PROXY,
ENABLE_WIFLIX_PROXY,
darkiworld_premium,
// Darkino cooldowns (Redis-backed, cluster-wide)
DARKINO_403_COOLDOWN_MS,
DARKINO_5XX_COOLDOWN_MS,
DARKINO_NETERR_COOLDOWN_MS,
getDarkinoCooldownRemainingMs,
armDarkinoCooldown,
recordDarkinoNetFailure,
resetDarkinoNetFailures,
// Cpasmal
CPASMAL_BASE_URL,
cpasmalJar,
getCpasmalAgent,
axiosCpasmalRequest,
// Cloudflare Workers proxies
CLOUDFLARE_WORKERS_PROXIES,
proxyErrorCache,
markProxyAsErrored,
isProxyInCooldown,
getAvailableProxies,
markProxyAsHealthy,
buildProxiedUrl,
// Request helpers
makeRequestWithCorsFallback,
makeWiflixRequest,
makeWiflixSearchRequest,
makeCoflixRequest,
classifyCloudflare429,
makeLecteurVideoRequest,
makeAnimeSamaRequest,
makeCinestreamRequest,
make1j1fRequest,
makeCpasmalRequest,
// SOCKS5 proxies
PROXIES,
HTTP_PROXIES,
DARKINO_HTTP_PROXIES,
DARKINO_PROXIES,
refreshProxyScrapeProxies,
// Agent helpers
pickRandomProxyOrNone,
pickRandomProxy,
pickNextSocks5Proxy,
getProxyAgent,
getDarkinoHttpProxyAgent,
// Cleanup
shutdownCycleTLS,
};