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
+32
View File
@@ -26,3 +26,35 @@ latest:
item: "div.short"
link: "a.short-poster" # href = fiche (/index.php?newsid=N), alt = titre
image: "img"
browse: # parcours par genre (page Explorer) — chemins vérifiés en live
serie: # pages /<genre>-series-/ (9 genres exposés par le site)
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: # pages /films/<genre>/
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"}
+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: