- 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.
239 lines
9.3 KiB
Python
239 lines
9.3 KiB
Python
"""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&episode=46416"
|
|
data-fallback-source-url="/lecteur/1626/46416?lang=vostfr&source=169262&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&episode=46416"
|
|
data-fallback-source-url="/lecteur/1626/46416?lang=vostfr&source=169261&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"
|