- 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.
259 lines
9.8 KiB
Python
259 lines
9.8 KiB
Python
"""Source VoirAnime (voiranime.xyz) — plateforme VODSPHERE, animes VOSTFR.
|
|
|
|
Faits structurels (vérifiés en live) :
|
|
- Recherche : GET /catalogue?q=<q> → `article.catalogue-card` (lien `a.catalogue-poster`,
|
|
poster et titre dans `img[src]` / `img[alt]`).
|
|
- Fiche : /catalogue/<slug> — titre `h1`, poster `img[alt^="Affiche de"]`, compteur
|
|
d'épisodes dans `.media-detail-meta span` (« 1192 épisodes »), synopsis `.media-detail-story p`.
|
|
- Épisodes : `a.detail-episode-card` avec `data-season` et libellé `<small>S1 · E1</small>`.
|
|
- Lecteur : /lecteur/<content>/<episode> — attributs data-prepare-url (résolution AJAX)
|
|
et data-fallback-source-url (source suivante) ; en fin de chaîne le fallback repointe
|
|
vers une source déjà vue → dédoublonnage par id de source obligatoire.
|
|
- Accueil : carrousels #carousel-new (nouveautés) et #carousel-added (ajouts), 24 cartes.
|
|
- La résolution d'un lien direct se fait dans l'extracteur hoster dédié (voir
|
|
app/scrapers/hosters/voiranime.py) : cette source ne renvoie que les URLs prepare.
|
|
"""
|
|
|
|
import logging
|
|
import re
|
|
from copy import deepcopy
|
|
from urllib.parse import quote_plus, urljoin
|
|
|
|
from bs4 import BeautifulSoup, Tag
|
|
|
|
from app.scrapers.base import (
|
|
Episode,
|
|
ScrapeError,
|
|
SearchResult,
|
|
SourceScraper,
|
|
TitleDetails,
|
|
register_source,
|
|
)
|
|
from app.scrapers.config_loader import load_scraper_config
|
|
from app.scrapers.http import fetch_soup
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEFAULT_CONFIG: dict = {
|
|
"search": {
|
|
"endpoint": "/catalogue?q={query}",
|
|
"result": "article.catalogue-card",
|
|
"link": "a.catalogue-poster",
|
|
},
|
|
"details": {
|
|
"title": "h1",
|
|
"poster": 'img[alt^="Affiche de"]',
|
|
"meta_count": ".media-detail-meta span",
|
|
"synopsis": ".media-detail-story p",
|
|
},
|
|
"episodes": {
|
|
"card": "a.detail-episode-card",
|
|
},
|
|
"player": {
|
|
"root": "[data-prepare-url]",
|
|
"prepare_attr": "data-prepare-url",
|
|
"fallback_attr": "data-fallback-source-url",
|
|
"max_sources": 4,
|
|
},
|
|
"latest": {
|
|
"path": "/",
|
|
"carousels": ("#carousel-new", "#carousel-added"),
|
|
"item": "article.content-card",
|
|
"link": "a.card-poster",
|
|
},
|
|
}
|
|
|
|
_SLUG_RE = re.compile(r"/catalogue/([a-z0-9-]+)", re.IGNORECASE)
|
|
_EPISODE_LABEL_RE = re.compile(r"S\s*(\d+)\s*·\s*E\s*(\d+(?:[.,]\d+)?)", re.IGNORECASE)
|
|
_EPISODE_COUNT_RE = re.compile(r"(\d+)\s*épisodes", re.IGNORECASE)
|
|
_SOURCE_ID_RE = re.compile(r"/lecteur/prepare/(\d+)")
|
|
|
|
_FALLBACK_SOURCE_RE = re.compile(r"[?&]source=(\d+)")
|
|
def _merged_config() -> dict:
|
|
merged = deepcopy(DEFAULT_CONFIG)
|
|
for key, values in load_scraper_config("voiranime").items():
|
|
if isinstance(values, dict) and isinstance(merged.get(key), dict):
|
|
merged[key].update(values)
|
|
else:
|
|
merged[key] = values
|
|
return merged
|
|
|
|
|
|
@register_source
|
|
class VoirAnimeScraper(SourceScraper):
|
|
name = "voiranime"
|
|
label = "VoirAnime"
|
|
base_url = "https://voiranime.xyz"
|
|
media_types = ("anime",)
|
|
|
|
# ------------------------------------------------------------- helpers
|
|
|
|
def _title_url(self, source_id: str) -> str:
|
|
return f"{self.base_url}/catalogue/{source_id}"
|
|
|
|
@staticmethod
|
|
def _slug_from_url(url: str) -> str | None:
|
|
match = _SLUG_RE.search(url)
|
|
return match.group(1) if match else None
|
|
|
|
@staticmethod
|
|
def _text(element: Tag | None) -> str:
|
|
return element.get_text(" ", strip=True) if element else ""
|
|
|
|
def _result_from_card(self, link: Tag) -> SearchResult | None:
|
|
href = link.get("href")
|
|
if not href:
|
|
return None
|
|
source_id = self._slug_from_url(str(href))
|
|
if not source_id:
|
|
return None
|
|
img = link.select_one("img")
|
|
title = (img.get("alt") if img else None) or source_id.replace("-", " ")
|
|
image = img.get("src") if img else None
|
|
return SearchResult(
|
|
source=self.name,
|
|
source_id=source_id,
|
|
title=str(title).strip(),
|
|
url=self._title_url(source_id),
|
|
image_url=str(image) if image else None,
|
|
)
|
|
|
|
# ------------------------------------------------------------- search
|
|
|
|
async def search(self, query: str) -> list[SearchResult]:
|
|
config = _merged_config()
|
|
search_cfg = config["search"]
|
|
url = self.base_url + search_cfg["endpoint"].format(query=quote_plus(query))
|
|
soup = await fetch_soup(url)
|
|
results: list[SearchResult] = []
|
|
seen: set[str] = set()
|
|
for card in soup.select(search_cfg["result"]):
|
|
link = card.select_one(search_cfg["link"])
|
|
if link is None:
|
|
logger.warning("voiranime : carte de résultat sans lien, ignorée")
|
|
continue
|
|
result = self._result_from_card(link)
|
|
if result and result.source_id not in seen:
|
|
seen.add(result.source_id)
|
|
results.append(result)
|
|
return results
|
|
|
|
# ------------------------------------------------------------- détails
|
|
|
|
async def get_details(self, source_id: str) -> TitleDetails:
|
|
config = _merged_config()
|
|
details_cfg = config["details"]
|
|
url = self._title_url(source_id)
|
|
soup = await fetch_soup(url)
|
|
|
|
title = self._text(soup.select_one(details_cfg["title"])) or source_id.replace("-", " ")
|
|
poster = soup.select_one(details_cfg["poster"])
|
|
image = str(poster["src"]) if poster and poster.get("src") else None
|
|
synopsis = self._text(soup.select_one(details_cfg["synopsis"])) or None
|
|
|
|
episode_count: int | None = None
|
|
count_el = soup.select_one(details_cfg["meta_count"])
|
|
if count_el:
|
|
match = _EPISODE_COUNT_RE.search(self._text(count_el))
|
|
if match:
|
|
episode_count = int(match.group(1))
|
|
|
|
episodes = self._parse_episodes(soup, config)
|
|
return TitleDetails(
|
|
source=self.name,
|
|
source_id=source_id,
|
|
title=title,
|
|
url=url,
|
|
synopsis=synopsis,
|
|
image_url=image,
|
|
episode_count=episode_count if episode_count is not None else len(episodes),
|
|
episodes=episodes,
|
|
)
|
|
|
|
# ------------------------------------------------------------- épisodes
|
|
|
|
async def list_episodes(self, source_id: str) -> list[Episode]:
|
|
config = _merged_config()
|
|
soup = await fetch_soup(self._title_url(source_id))
|
|
return self._parse_episodes(soup, config)
|
|
|
|
def _parse_episodes(self, soup: BeautifulSoup, config: dict) -> list[Episode]:
|
|
episodes: list[Episode] = []
|
|
for card in soup.select(config["episodes"]["card"]):
|
|
href = card.get("href")
|
|
if not href:
|
|
continue
|
|
label = self._text(card)
|
|
match = _EPISODE_LABEL_RE.search(label)
|
|
if not match:
|
|
logger.warning("voiranime : libellé d'épisode illisible : %r", label[:60])
|
|
continue
|
|
episodes.append(
|
|
Episode(
|
|
number=float(match.group(2).replace(",", ".")),
|
|
title=None,
|
|
url=urljoin(self.base_url + "/", str(href)),
|
|
season=int(match.group(1)),
|
|
)
|
|
)
|
|
return episodes
|
|
|
|
# ------------------------------------------------------------- lecteurs
|
|
|
|
async def extract_embed_links(self, episode_url: str) -> list[str]:
|
|
"""Parcourt la chaîne Lecteur 1 → 2 → … et renvoie les URLs prepare absolues."""
|
|
config = _merged_config()
|
|
player_cfg = config["player"]
|
|
prepare_urls: list[str] = []
|
|
seen_sources: set[str] = set()
|
|
page_url = episode_url
|
|
for _ in range(int(player_cfg["max_sources"])):
|
|
soup = await fetch_soup(page_url)
|
|
root = soup.select_one(player_cfg["root"])
|
|
if root is None:
|
|
break
|
|
prepare = root.get(player_cfg["prepare_attr"])
|
|
if not prepare:
|
|
break
|
|
prepare_url = urljoin(self.base_url + "/", str(prepare))
|
|
match = _SOURCE_ID_RE.search(prepare_url)
|
|
source_key = match.group(1) if match else prepare_url
|
|
if source_key in seen_sources:
|
|
break
|
|
seen_sources.add(source_key)
|
|
prepare_urls.append(prepare_url)
|
|
fallback = root.get(player_cfg["fallback_attr"])
|
|
if not fallback:
|
|
break
|
|
page_url = urljoin(self.base_url + "/", str(fallback))
|
|
next_source = _FALLBACK_SOURCE_RE.search(page_url)
|
|
if next_source and next_source.group(1) in seen_sources:
|
|
break
|
|
if not prepare_urls:
|
|
raise ScrapeError(f"voiranime : aucun lecteur trouvé sur {episode_url}")
|
|
return prepare_urls
|
|
|
|
# ------------------------------------------------------------- découverte
|
|
|
|
async def latest(self) -> list[SearchResult]:
|
|
"""Nouveautés + ajouts récents — carrousels de la page d'accueil."""
|
|
config = _merged_config()
|
|
latest_cfg = config["latest"]
|
|
soup = await fetch_soup(self.base_url + latest_cfg["path"])
|
|
results: list[SearchResult] = []
|
|
seen: set[str] = set()
|
|
for carousel in latest_cfg["carousels"]:
|
|
section = soup.select_one(carousel)
|
|
if section is None:
|
|
logger.debug("voiranime : carrousel %s absent de l'accueil", carousel)
|
|
continue
|
|
for card in section.select(latest_cfg["item"]):
|
|
link = card.select_one(latest_cfg["link"])
|
|
if link is None:
|
|
continue
|
|
result = self._result_from_card(link)
|
|
if result and result.source_id not in seen:
|
|
seen.add(result.source_id)
|
|
results.append(result)
|
|
return results
|