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.
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user