- Episode : nouveau champ version (vf/vostfr) - French-Manga : épisodes listés pour chaque langue disponible (VOSTFR puis VF) au lieu d'un choix automatique, fragment #ep=<version>-<num> déjà résolu par extract_embed_links - Vostfree : langue déduite du titre h1 ou du slug de la fiche - UI : filtre VOSTFR/VF/Tout (défaut VOSTFR), badge par épisode, téléchargement de saison limité à la langue filtrée, compteur d'épisodes unique, version incluse dans le nom de fichier
193 lines
5.7 KiB
Python
193 lines
5.7 KiB
Python
"""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
|
|
version: str | None = None # langue ("vf" / "vostfr") quand la source la distingue
|
|
|
|
@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]
|