- 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.
112 lines
4.5 KiB
Python
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
|