"""Extracteur VoirAnime — endpoint « prepare » → URL lisible (directe ou proxy site). Chaque source d'un épisode voiranime.xyz se résout via `/lecteur/prepare/?content=..&episode=..` (AJAX : en-têtes Accept JSON et X-Requested-With obligatoires) qui renvoie : `{"success": true, "media_type": "mp4"|"hls", "stream_url": "/proxy/media?payload="}`. Le payload base64 est un JSON `{"url": ..., "referer": ..., "kind": ...}` où `url` est l'URL directe chez l'hébergeur d'origine. Deux cas (vérifiés en live) : - mp4 : l'URL d'origine est libre d'accès avec son Referer (Sibnet…) → on la retourne directement (téléchargement/Range natifs, sans double saut). - hls : les CDN utilisés (SmoothPre & co) signent leurs playlists pour le backend du site — accès direct 403. Le proxy `/proxy/media?payload=…` du site, lui, sert la playlist ET ses segments (chemins re-hostés) → on retourne l'URL proxy absolue. """ import base64 import binascii import json import logging from urllib.parse import parse_qs, urlparse from app.scrapers.base import HosterExtractor, ScrapeError, VideoLink, register_hoster from app.scrapers.http import fetch logger = logging.getLogger(__name__) @register_hoster class VoirAnimeExtractor(HosterExtractor): name = "voiranime" domains = ("voiranime.xyz",) async def extract(self, embed_url: str) -> VideoLink: origin = self._origin_of(embed_url) raw = await fetch( embed_url, referer=self._referer_for(embed_url), headers={ "Accept": "application/json", "X-Requested-With": "XMLHttpRequest", }, ) try: data = json.loads(raw) except json.JSONDecodeError as exc: raise ScrapeError(f"voiranime : réponse prepare invalide : {raw[:120]}") from exc if not data.get("success"): raise ScrapeError(f"voiranime : {data.get('error') or 'échec de préparation du lecteur'}") media_type = str(data.get("media_type") or "").lower() stream_url = str(data.get("stream_url") or "") if media_type == "hls": if not stream_url: raise ScrapeError(f"voiranime : flux HLS sans stream_url dans {embed_url}") return VideoLink( url=f"{origin}/{stream_url.lstrip('/')}", hoster=self.name, headers={"Referer": f"{origin}/"}, is_hls=True, ) payload = self._extract_payload(stream_url) url = str(payload.get("url") or "") if not url: raise ScrapeError(f"voiranime : payload sans URL de flux dans {embed_url}") referer = str(payload.get("referer") or "") return VideoLink( url=url, hoster=self.name, headers={"Referer": referer} if referer else {}, is_hls=url.split("?")[0].endswith(".m3u8"), ) @staticmethod def _origin_of(embed_url: str) -> str: parsed = urlparse(embed_url) return f"{parsed.scheme}://{parsed.netloc}" @staticmethod def _referer_for(embed_url: str) -> str: """Reconstruit la page lecteur d'où provient la requête prepare.""" origin = VoirAnimeExtractor._origin_of(embed_url) query = parse_qs(urlparse(embed_url).query) content = query.get("content", [""])[0] episode = query.get("episode", [""])[0] if content and episode: return f"{origin}/lecteur/{content}/{episode}" return f"{origin}/" @staticmethod def _extract_payload(stream_url: str) -> dict: payload_b64 = parse_qs(urlparse(stream_url).query).get("payload", [""])[0] if not payload_b64: raise ScrapeError(f"voiranime : pas de payload dans stream_url ({stream_url[:80]})") padded = payload_b64 + "=" * (-len(payload_b64) % 4) try: decoded = base64.b64decode(padded) except (binascii.Error, ValueError): try: decoded = base64.urlsafe_b64decode(padded) except (binascii.Error, ValueError) as exc: raise ScrapeError(f"voiranime : payload illisible : {payload_b64[:60]}") from exc try: data = json.loads(decoded) except (json.JSONDecodeError, UnicodeDecodeError) as exc: raise ScrapeError(f"voiranime : payload non-JSON : {payload_b64[:60]}") from exc if not isinstance(data, dict): raise ScrapeError("voiranime : payload inattendu (pas un objet JSON)") return data