Files
ohm_streaming/app/scrapers/hosters/voiranime.py
T
Roman 7399f4b781 Ajout de la source VoirAnime (voiranime.xyz)
- Scraper catalogue : recherche /catalogue?q=, fiches + épisodes
  (cartes S·E, multi-saisons), chaîne de lecteurs prepare/fallback
  avec dédoublonnage par id de source, latest via carrousels accueil.
- Extracteur hoster : endpoint prepare (AJAX) → MP4 direct décodé du
  payload (Referer d'origine) ou HLS relayé par le proxy du site
  (CDN signés pour leur backend → accès direct en 403).
- fetch() accepte désormais des en-têtes additionnels (X-Requested-With).
- 9 tests sur fixtures HTML réelles ; README mis à jour.
2026-09-23 10:51:29 +00:00

112 lines
4.5 KiB
Python

"""Extracteur VoirAnime — endpoint « prepare » → URL lisible (directe ou proxy site).
Chaque source d'un épisode voiranime.xyz se résout via
`/lecteur/prepare/<source_id>?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=<b64>"}`.
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