"""Source VoirAnime (voiranime.xyz) — plateforme VODSPHERE, animes VOSTFR. Faits structurels (vérifiés en live) : - Recherche : GET /catalogue?q= → `article.catalogue-card` (lien `a.catalogue-poster`, poster et titre dans `img[src]` / `img[alt]`). - Fiche : /catalogue/ — 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é `S1 · E1`. - Lecteur : /lecteur// — 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