Ajout de la source VoirAnime (voiranime.xyz)

- Scraper catalogue : recherche /catalogue?q=, fiches + épisodes
  (cartes S·E, multi-saisons), chaîne de lecteurs prepare/fallback
  avec dédoublonnage par id de source, latest via carrousels accueil.
- Extracteur hoster : endpoint prepare (AJAX) → MP4 direct décodé du
  payload (Referer d'origine) ou HLS relayé par le proxy du site
  (CDN signés pour leur backend → accès direct en 403).
- fetch() accepte désormais des en-têtes additionnels (X-Requested-With).
- 9 tests sur fixtures HTML réelles ; README mis à jour.
This commit is contained in:
Roman
2026-09-23 10:51:29 +00:00
parent 5fedc4f9d7
commit 7399f4b781
5 changed files with 616 additions and 5 deletions
+238
View File
@@ -0,0 +1,238 @@
"""Tests du scraper VoirAnime (source + extracteur prepare) sur fixtures HTML réelles."""
import base64
import json
import pytest
from app.scrapers.base import ScrapeError, get_source, import_all_scrapers, resolve_hoster
from app.scrapers.hosters.voiranime import VoirAnimeExtractor
from app.scrapers.sources.voiranime import VoirAnimeScraper
# ------------------------------------------------------------ fixtures HTML
SEARCH_HTML = """
<div class="catalogue-grid">
<article class="catalogue-card catalogue-media-card">
<a href="/catalogue/one-piece" class="catalogue-poster">
<img src="https://img.example/one-piece.jpg" alt="one piece">
</a>
</article>
<article class="catalogue-card catalogue-media-card">
<a href="/catalogue/one-punch-man" class="catalogue-poster">
<img src="https://img.example/opm.jpg" alt="one punch man">
</a>
</article>
<article class="catalogue-card catalogue-media-card">
<a href="/catalogue/one-outs" class="catalogue-poster">
<img src="/assets/img/placeholders/content-placeholder.svg" alt="one outs">
</a>
</article>
</div>
"""
DETAILS_HTML = """
<h1>solo leveling</h1>
<div class="media-detail-meta"><span>26 épisodes</span></div>
<img src="https://img.example/solo.jpg" alt="Affiche de solo leveling">
<div class="media-detail-story">
<span class="media-detail-section-label">Synopsis</span>
<p> Sung Jinwoo, chasseur le plus faible. </p>
</div>
<a href="/lecteur/965/29054" class="detail-episode-card" data-season="1">
<small>S1 · E1</small><span>Episode 1</span>
</a>
<a href="/lecteur/965/29055" class="detail-episode-card" data-season="1">
<small>S1 · E2</small><span>Episode 2</span>
</a>
<a href="/lecteur/965/29060" class="detail-episode-card" data-season="2">
<small>S2 · E1</small><span>Episode 1</span>
</a>
<a href="/lecteur/965/29061" class="detail-episode-card" data-season="2">
<small>S2 · E2.5</small><span>Episode 2.5</span>
</a>
"""
PLAYER_PAGE_1 = """
<main class="tv-player" data-player
data-content-id="1626" data-episode-id="46416" data-source-id="169261"
data-prepare-url="/lecteur/prepare/169261?content=1626&amp;episode=46416"
data-fallback-source-url="/lecteur/1626/46416?lang=vostfr&amp;source=169262&amp;previous_source=169261">
</main>
"""
# fin de chaîne : le fallback repointe vers la source 1 (aller-retour observé en live)
PLAYER_PAGE_2 = """
<main class="tv-player" data-player
data-content-id="1626" data-episode-id="46416" data-source-id="169262"
data-prepare-url="/lecteur/prepare/169262?content=1626&amp;episode=46416"
data-fallback-source-url="/lecteur/1626/46416?lang=vostfr&amp;source=169261&amp;previous_source=169262">
</main>
"""
HOME_HTML = """
<div class="carousel" id="carousel-new">
<article class="content-card">
<a href="/catalogue/you-and-i-are-polar-opposites" class="card-poster">
<img src="https://img.example/polar.jpg" alt="you and i are polar opposites">
</a>
</article>
</div>
<div class="carousel" id="carousel-added">
<article class="content-card">
<a href="/catalogue/you-and-i-are-polar-opposites" class="card-poster">
<img src="https://img.example/polar.jpg" alt="you and i are polar opposites">
</a>
</article>
<article class="content-card">
<a href="/catalogue/solo-leveling" class="card-poster">
<img src="https://img.example/solo.jpg" alt="solo leveling">
</a>
</article>
</div>
"""
def _soup(html: str):
from bs4 import BeautifulSoup
return BeautifulSoup(html, "lxml")
def _prepare_payload(url: str, referer: str) -> str:
raw = json.dumps({"url": url, "referer": referer, "kind": "file"}).encode()
return base64.b64encode(raw).decode()
# ------------------------------------------------------------ source
async def test_search_parses_catalogue_cards(monkeypatch):
async def fake_fetch_soup(url, **kwargs):
assert "q=one+piece" in url
return _soup(SEARCH_HTML)
monkeypatch.setattr("app.scrapers.sources.voiranime.fetch_soup", fake_fetch_soup)
results = await VoirAnimeScraper().search("one piece")
assert [r.source_id for r in results] == ["one-piece", "one-punch-man", "one-outs"]
assert results[0].title == "one piece"
assert results[0].url == "https://voiranime.xyz/catalogue/one-piece"
assert results[0].image_url == "https://img.example/one-piece.jpg"
assert results[2].image_url == "/assets/img/placeholders/content-placeholder.svg"
async def test_get_details_and_episodes(monkeypatch):
async def fake_fetch_soup(url, **kwargs):
assert url.endswith("/catalogue/solo-leveling")
return _soup(DETAILS_HTML)
monkeypatch.setattr("app.scrapers.sources.voiranime.fetch_soup", fake_fetch_soup)
scraper = VoirAnimeScraper()
details = await scraper.get_details("solo-leveling")
assert details.title == "solo leveling"
assert details.episode_count == 26
assert details.image_url == "https://img.example/solo.jpg"
assert details.synopsis.startswith("Sung Jinwoo")
episodes = details.episodes
assert len(episodes) == 4
assert (episodes[0].season, episodes[0].number) == (1, 1.0)
assert (episodes[2].season, episodes[2].number) == (2, 1.0)
assert (episodes[3].season, episodes[3].number) == (2, 2.5)
assert episodes[0].url == "https://voiranime.xyz/lecteur/965/29054"
standalone = await scraper.list_episodes("solo-leveling")
assert len(standalone) == len(episodes)
async def test_extract_embed_links_follows_fallback_chain(monkeypatch):
pages = {
"https://voiranime.xyz/lecteur/1626/46416": PLAYER_PAGE_1,
"https://voiranime.xyz/lecteur/1626/46416?lang=vostfr&source=169262&previous_source=169261": PLAYER_PAGE_2,
}
async def fake_fetch_soup(url, **kwargs):
return _soup(pages[url])
monkeypatch.setattr("app.scrapers.sources.voiranime.fetch_soup", fake_fetch_soup)
urls = await VoirAnimeScraper().extract_embed_links("https://voiranime.xyz/lecteur/1626/46416")
assert urls == [
"https://voiranime.xyz/lecteur/prepare/169261?content=1626&episode=46416",
"https://voiranime.xyz/lecteur/prepare/169262?content=1626&episode=46416",
]
async def test_extract_embed_links_no_player_raises(monkeypatch):
async def fake_fetch_soup(url, **kwargs):
return _soup("<html><body>pas de lecteur</body></html>")
monkeypatch.setattr("app.scrapers.sources.voiranime.fetch_soup", fake_fetch_soup)
with pytest.raises(ScrapeError):
await VoirAnimeScraper().extract_embed_links("https://voiranime.xyz/lecteur/1/2")
async def test_latest_merges_carousels_dedup(monkeypatch):
async def fake_fetch_soup(url, **kwargs):
assert url == "https://voiranime.xyz/"
return _soup(HOME_HTML)
monkeypatch.setattr("app.scrapers.sources.voiranime.fetch_soup", fake_fetch_soup)
results = await VoirAnimeScraper().latest()
assert [r.source_id for r in results] == ["you-and-i-are-polar-opposites", "solo-leveling"]
# ------------------------------------------------------------ extracteur hoster
async def test_hoster_decodes_prepare_payload(monkeypatch):
payload = _prepare_payload(
"https://video.sibnet.ru/v/abc123/6235096.mp4", "https://video.sibnet.ru/"
)
async def fake_fetch(url, **kwargs):
assert "X-Requested-With" in kwargs.get("headers", {})
assert kwargs.get("referer") == "https://voiranime.xyz/lecteur/1626/46416"
return json.dumps(
{"success": True, "media_type": "mp4", "stream_url": f"/proxy/media?payload={payload}"}
)
monkeypatch.setattr("app.scrapers.hosters.voiranime.fetch", fake_fetch)
link = await VoirAnimeExtractor().extract(
"https://voiranime.xyz/lecteur/prepare/169261?content=1626&episode=46416"
)
assert link.url == "https://video.sibnet.ru/v/abc123/6235096.mp4"
assert link.hoster == "voiranime"
assert link.headers == {"Referer": "https://video.sibnet.ru/"}
assert link.is_hls is False
async def test_hoster_marks_hls(monkeypatch):
payload = _prepare_payload("https://cdn.example/hls/master.m3u8", "https://cdn.example/")
async def fake_fetch(url, **kwargs):
return json.dumps(
{"success": True, "media_type": "hls", "stream_url": f"/proxy/media?payload={payload}"}
)
monkeypatch.setattr("app.scrapers.hosters.voiranime.fetch", fake_fetch)
link = await VoirAnimeExtractor().extract("https://voiranime.xyz/lecteur/prepare/1?content=2&episode=3")
# HLS signé pour le backend du site → on sert l'URL proxy du site, pas le CDN
assert link.url == f"https://voiranime.xyz/proxy/media?payload={payload}"
assert link.is_hls is True
assert link.headers == {"Referer": "https://voiranime.xyz/"}
async def test_hoster_prepare_failure_raises(monkeypatch):
async def fake_fetch(url, **kwargs):
return json.dumps({"success": False, "error": "Flux extrait inaccessible"})
monkeypatch.setattr("app.scrapers.hosters.voiranime.fetch", fake_fetch)
with pytest.raises(ScrapeError, match="Flux extrait inaccessible"):
await VoirAnimeExtractor().extract("https://voiranime.xyz/lecteur/prepare/1?content=2&episode=3")
def test_source_and_hoster_registered():
import_all_scrapers()
assert get_source("voiranime").label == "VoirAnime"
extractor = resolve_hoster("https://voiranime.xyz/lecteur/prepare/169261?content=1&episode=2")
assert extractor is not None and extractor.name == "voiranime"