Découvrir v2 : rails par type, exploration par genre

- Rails Nouveautés séparés : 🆕 animés (tri date Kitsu) et 🆕 séries & films
  (French-Stream) — chaque type garde son rail, plus d'écrasement mutuel
- Section 🎭 Explorer : chips type × genre — animés via catégories Kitsu,
  séries (/<genre>-series-/) et films (/films/<genre>/) via French-Stream ;
  état dans l'URL (/discover?t=serie&g=medical), partageable
- Scraper : browse(media_type, genre) + catalogue YAML surchargeable ;
  parsing div.short mutualisé avec les nouveautés
- Films réels retirés du mode « Animés » (ils suivent le rail séries)
- Endpoints /api/discover (latest_anime/latest_serie), /api/discover/genres,
  /api/discover/browse — parcours filtré par la préférence du compte
- Warmup démarrage : latest_by_type()
This commit is contained in:
Roman
2026-09-25 14:48:29 +00:00
parent 248ec60d35
commit d747c574b8
12 changed files with 526 additions and 84 deletions
+69 -3
View File
@@ -62,6 +62,42 @@ DEFAULT_CONFIG: dict = {
"link": "a.short-poster",
"image": "img",
},
# Parcours par genre (page Explorer) : type → clé → {path, label}.
# Chemins vérifiés en live : films = /films/<genre>/, séries = /<genre>-series-/.
"browse": {
"serie": {
"aventure": {"path": "/aventure-series-/", "label": "Aventure"},
"familles": {"path": "/familles-series-/", "label": "Famille"},
"fantastique": {"path": "/fantastique-series-/", "label": "Fantastique"},
"judiciaire": {"path": "/judiciare-series-/", "label": "Judiciaire"}, # coquille du site
"medical": {"path": "/medical-series-/", "label": "Médical"},
"romance": {"path": "/romance-series-/", "label": "Romance"},
"science-fiction": {"path": "/science-fiction-series-/", "label": "Science-Fiction"},
"thriller": {"path": "/thriller-series-/", "label": "Thriller"},
"western": {"path": "/western-series-/", "label": "Western"},
},
"film": {
"actions": {"path": "/films/actions/", "label": "Action"},
"animations": {"path": "/films/animations/", "label": "Animation"},
"aventures": {"path": "/films/aventures/", "label": "Aventure"},
"biopics": {"path": "/films/biopics/", "label": "Biopic"},
"comedies": {"path": "/films/comedies/", "label": "Comédie"},
"cultes": {"path": "/films/cultes/", "label": "Culte"},
"documentaires": {"path": "/films/documentaires/", "label": "Documentaire"},
"drames": {"path": "/films/drames/", "label": "Drame"},
"epouvante-horreurs": {"path": "/films/epouvante-horreurs/", "label": "Épouvante-Horreur"},
"espionnages": {"path": "/films/espionnages/", "label": "Espionnage"},
"familles": {"path": "/films/familles/", "label": "Famille"},
"fantastiques": {"path": "/films/fantastiques/", "label": "Fantastique"},
"guerres": {"path": "/films/guerres/", "label": "Guerre"},
"historiques": {"path": "/films/historiques/", "label": "Historique"},
"policiers": {"path": "/films/policiers/", "label": "Policier"},
"romances": {"path": "/films/romances/", "label": "Romance"},
"science-fictions": {"path": "/films/science-fictions/", "label": "Science-Fiction"},
"thrillers": {"path": "/films/thrillers/", "label": "Thriller"},
"westerns": {"path": "/films/westerns/", "label": "Western"},
},
},
}
_NEWSID_RE = re.compile(r"(?:newsid=|/)(\d+)(?:-[^/]*)?\.?html?$|(?:newsid=)(\d+)")
@@ -158,8 +194,31 @@ class FrenchStreamScraper(SourceScraper):
async def latest(self) -> list[SearchResult]:
"""Ajouts récents — page /series/ (mix films & séries)."""
config = _merged_config()
soup = await fetch_soup(f"{self.base_url}{config['latest']['path']}")
results = self._short_blocks(soup)
logger.info("french_stream : %d nouveautés récupérées", len(results))
return results
async def browse(self, media_type: str, category: str) -> list[SearchResult]:
"""Parcours par genre — pages /films/<genre>/ et /<genre>-series-/.
``media_type`` (« serie » | « film ») et ``category`` (clé du catalogue
YAML, ex. « thriller »). Le type est forcé sur les résultats : une page
genre séries ne liste que des séries, une page genre films que des films.
"""
catalog = _merged_config().get("browse", {}).get(media_type, {})
entry = catalog.get(category)
if entry is None:
raise ScrapeError(f"Catégorie inconnue : {media_type}/{category}")
soup = await fetch_soup(f"{self.base_url}{entry['path']}")
results = self._short_blocks(soup, force_type=media_type)
logger.info("french_stream : %d titres dans %s/%s", len(results), media_type, category)
return results
def _short_blocks(self, soup: BeautifulSoup, force_type: str | None = None) -> list[SearchResult]:
"""Bloc `div.short` → SearchResult (structure partagée nouveautés/genres)."""
config = _merged_config()
latest_cfg = config["latest"]
soup = await fetch_soup(f"{self.base_url}{latest_cfg['path']}")
results: list[SearchResult] = []
for block in soup.select(latest_cfg["item"]):
link = block.select_one(latest_cfg["link"])
@@ -179,12 +238,19 @@ class FrenchStreamScraper(SourceScraper):
title=title,
url=f"{self.base_url}{href}" if href.startswith("/") else href,
image_url=image.get("src") if image else None,
media_type=_media_type(title, href),
media_type=force_type or _media_type(title, href),
)
)
logger.info("french_stream : %d nouveautés récupérées", len(results))
return results
def browse_catalog(self) -> dict[str, dict[str, str]]:
"""Catalogue des genres parcourables : ``{type: {clé: libellé}}``."""
catalog = _merged_config().get("browse", {})
return {
media_type: {key: entry.get("label", key) for key, entry in genres.items()}
for media_type, genres in catalog.items()
}
# ------------------------------------------------------------- details
async def get_details(self, source_id: str) -> TitleDetails: