"""Contrats et registres des scrapers. Architecture à 2 registres : - sources (sites de catalogues animes/séries) : recherche, détails, épisodes, liens embed - hébergeurs vidéo : résolution d'une URL embed → URL directe du fichier Ajouter une source = écrire une classe qui implémente le contrat et la décorer @register_source / @register_hoster. """ import logging from abc import ABC, abstractmethod from dataclasses import dataclass, field logger = logging.getLogger(__name__) class ScrapeError(Exception): """Échec de scraping/extraction — jamais silencieux, toujours journalisé et remonté.""" # ---------------------------------------------------------------- modèles @dataclass class SearchResult: source: str source_id: str # identifiant stable chez la source (slug, id…) title: str url: str image_url: str | None = None media_type: str = "anime" # anime | serie | film @dataclass class Episode: number: float # 1, 2, 2.5 pour les OAV intermédiaires title: str | None url: str # page de l'épisode chez la source season: int = 1 @dataclass class TitleDetails: source: str source_id: str title: str url: str synopsis: str | None = None image_url: str | None = None banner_url: str | None = None genres: list[str] = field(default_factory=list) rating: float | None = None year: int | None = None episode_count: int | None = None episodes: list[Episode] = field(default_factory=list) media_type: str = "anime" @dataclass class VideoLink: """Lien vidéo résolu (URL directe du fichier, lisible par un lecteur).""" url: str hoster: str quality: str | None = None headers: dict[str, str] = field(default_factory=dict) # ex. Referer obligatoire is_hls: bool = False # ---------------------------------------------------------------- contrats class SourceScraper(ABC): """Contrat d'un site catalogue (animes, séries…).""" name: str # identifiant unique, ex. "vostfree" label: str # nom affiché base_url: str media_types: tuple[str, ...] = ("anime",) @abstractmethod async def search(self, query: str) -> list[SearchResult]: ... @abstractmethod async def get_details(self, source_id: str) -> TitleDetails: ... @abstractmethod async def list_episodes(self, source_id: str) -> list[Episode]: ... @abstractmethod async def extract_embed_links(self, episode_url: str) -> list[str]: """URLs des lecteurs embarqués trouvés sur la page d'un épisode.""" ... async def latest(self) -> list[SearchResult]: """Ajouts récents du catalogue (découverte). Vide si la source ne le supporte pas.""" return [] class HosterExtractor(ABC): """Contrat d'un hébergeur vidéo : embed URL → URL directe.""" name: str domains: tuple[str, ...] = () def can_handle(self, url: str) -> bool: return any(d in url for d in self.domains) @abstractmethod async def extract(self, embed_url: str) -> VideoLink: ... # ---------------------------------------------------------------- registres _source_registry: dict[str, type[SourceScraper]] = {} _hoster_registry: list[type[HosterExtractor]] = [] def register_source(cls: type[SourceScraper]) -> type[SourceScraper]: if cls.name in _source_registry: raise ValueError(f"Source déjà enregistrée : {cls.name}") _source_registry[cls.name] = cls logger.debug("Source enregistrée : %s", cls.name) return cls def register_hoster(cls: type[HosterExtractor]) -> type[HosterExtractor]: _hoster_registry.append(cls) logger.debug("Hébergeur enregistré : %s", cls.name) return cls _source_instances: dict[str, SourceScraper] = {} _hoster_instances: list[HosterExtractor] | None = None def get_source(name: str) -> SourceScraper: if name not in _source_instances: cls = _source_registry.get(name) if cls is None: raise ScrapeError(f"Source inconnue : {name}") _source_instances[name] = cls() return _source_instances[name] def all_sources() -> list[SourceScraper]: return [get_source(name) for name in _source_registry] def resolve_hoster(url: str) -> HosterExtractor | None: """Trouve l'extracteur capable de traiter une URL embed (None → générique).""" global _hoster_instances if _hoster_instances is None: _hoster_instances = [cls() for cls in _hoster_registry] for extractor in _hoster_instances: if extractor.can_handle(url): return extractor return None def import_all_scrapers() -> None: """Importe tous les modules pour déclencher les décorateurs d'enregistrement.""" import importlib import pkgutil import app.scrapers.hosters as hosters_pkg import app.scrapers.sources as sources_pkg for pkg in (sources_pkg, hosters_pkg): for mod in pkgutil.iter_modules(pkg.__path__): importlib.import_module(f"{pkg.__name__}.{mod.name}") # ---------------------------------------------------------------- format interne INTERNAL_SEP = "|" def encode_internal_url(video_url: str, page_url: str, title: str) -> str: """Format interne `video_url|page_url|titre` pour transporter le contexte.""" for part in (video_url, page_url, title): if INTERNAL_SEP in part: raise ValueError(f"Caractère interdit '{INTERNAL_SEP}' dans : {part!r}") return INTERNAL_SEP.join([video_url, page_url, title]) def decode_internal_url(value: str) -> tuple[str, str, str]: parts = value.split(INTERNAL_SEP) if len(parts) != 3 or not parts[0]: raise ValueError(f"URL interne invalide : {value!r}") return parts[0], parts[1], parts[2]