metalfrom.eu/apps/crawler/tests/test_scraper_band.py
Nicolas Fryder 83ea8030e7
Some checks are pending
CI / javascript (push) Waiting to run
CI / python (push) Waiting to run
CI / mutation (push) Waiting to run
test: propriétés généralisées, scraper HTML et couche réseau couverts
BUG trouvé par un test de propriété
parse_location_text("0/0") produisait deux lignes identiques. band_locations
impose UNIQUE (ma_id, step_order, location_raw) : le doublon était absorbé par
le ON CONFLICT DO NOTHING de l'enqueue, mais il faussait les compteurs et n'a
aucun sens métier — une étape ne se déroule pas deux fois au même endroit.
Déduplication par dict.fromkeys (ordre préservé) + tests de régression.
Contre-exemple minimal trouvé en 150 tirages, aucun test par l'exemple ne
l'aurait deviné.

SCRAPER HTML (42 tests) — module le plus exposé du crawler, aucun test
jusqu'ici. C'est lui qui extrait genre, statut, thèmes, line-up et dates. Si
Metal Archives change son HTML, il renvoie des champs vides et le crawler
enregistre des fiches creuses SANS lever d'erreur : la panne invisible, donc
la plus coûteuse.
Couvre les trois orthographes de « Lyrical themes » observées chez MA, les
variantes de « label » et « formed in », le regroupement du line-up par
section, l'audit trail, et surtout la dégradation : une page vide ou
malformée ne doit jamais lever et doit rendre une structure complète.

COUCHE RÉSEAU (57 tests) — ma_http et flaresolverr, sans une seule requête
réelle. Ce qui est testé, c'est la logique AUTOUR du réseau : quand réessayer,
quand abandonner, comment extraire les données d'une réponse enveloppée par
FlareSolverr.
Enjeu concret : ces bornes conditionnent le nombre de requêtes envoyées à
Metal Archives — une boucle de retry mal bornée nous ferait bannir. Vérifié
que retries=2 donne exactement 3 tentatives, qu'un 500 n'est PAS réessayé
(contrairement à 403/429/503, qui justifient une session Chrome neuve), et
que les appels de préchauffage Cloudflare sont comptés à part.

PROPRIÉTÉS GÉNÉRALISÉES
- Python (Hypothesis) : parseur de localisations, requêtes de repli,
  centroïdes. Invariants — jamais d'exception, structure toujours complète,
  aucun lieu vide, aucun doublon, nombre de requêtes facturées borné.
- Frontend (fast-check) : échappement HTML (une faille XSS, pas un défaut
  cosmétique — tout le rendu passe par innerHTML), filtrage tri-état, tri
  sans effet de bord ni perte d'éléments, clés de coordonnées.

max_examples fixé à 150 côté Python : ces tests étaient devenus le chemin
critique de `check` (11 s). 150 tirages suffisaient à trouver « 0/0 ».

Tests : 621 JS + 61 intégration, 162 Python, 89 Playwright. check à 9 s.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 22:09:37 +02:00

204 lines
8.5 KiB
Python

"""
Parseur des pages Metal Archives.
Module le plus exposé du crawler : c'est lui qui extrait genre, statut, thèmes,
line-up et dates. Il n'avait AUCUN test. Si Metal Archives change son HTML, il
renvoie silencieusement des champs vides et le crawler enregistre des fiches
creuses sans lever la moindre erreur — la panne la plus coûteuse possible,
parce qu'elle est invisible.
Les fixtures reproduisent la structure réelle des pages (dl/dt/dd pour les
stats, table.lineupTable pour le line-up, #auditTrail pour les dates).
"""
import hashlib
import pytest
from src.scraper_band import page_hash, parse_band_page
def page(stats="", lineup="", extra=""):
rows = "".join(f"<dt>{k}:</dt><dd>{v}</dd>" for k, v in stats) if stats else ""
return f"""
<html><body>
<h1 class="band_name"><a href="/bands/Mayhem/67">Mayhem</a></h1>
<div id="band_stats"><dl>{rows}</dl></div>
{lineup}{extra}
</body></html>"""
FULL_STATS = [
("Country of origin", "Norway"),
("Location", "Oslo"),
("Status", "Active"),
("Formed in", "1984"),
("Genre", "Black Metal"),
("Lyrical themes", "Death, Satanism, Darkness"),
("Current label", "Century Media"),
("Years active", "1984-1993, 1995-present"),
]
class TestChampsPrincipaux:
def test_extrait_le_nom(self):
assert parse_band_page(page(FULL_STATS))["name"] == "Mayhem"
@pytest.mark.parametrize("champ,attendu", [
("status", "Active"),
("genre", "Black Metal"),
("formed_in", "1984"),
("location", "Oslo"),
("years_active", "1984-1993, 1995-present"),
("label", "Century Media"),
])
def test_extrait_chaque_champ_de_stats(self, champ, attendu):
assert parse_band_page(page(FULL_STATS))[champ] == attendu
def test_themes_est_un_alias_de_lyrical_themes(self):
"""upsert_band_enriched() lit data['themes'] : l'alias doit suivre."""
out = parse_band_page(page(FULL_STATS))
assert out["themes"] == out["lyrical_themes"] == "Death, Satanism, Darkness"
# Metal Archives n'est pas constant sur ce libellé : les trois orthographes
# ont été observées. En rater une revient à perdre les thèmes en silence.
@pytest.mark.parametrize("libelle", ["Lyrical themes", "Lyrical Themes", "Themes"])
def test_accepte_les_variantes_du_libelle_themes(self, libelle):
out = parse_band_page(page([(libelle, "War")]))
assert out["themes"] == "War"
@pytest.mark.parametrize("libelle", ["Current label", "Last label", "Label"])
def test_accepte_les_variantes_du_libelle_label(self, libelle):
assert parse_band_page(page([(libelle, "Peaceville")]))["label"] == "Peaceville"
@pytest.mark.parametrize("libelle", ["Formed in", "Formed"])
def test_accepte_les_variantes_de_formed(self, libelle):
assert parse_band_page(page([(libelle, "1991")]))["formed_in"] == "1991"
def test_info_raw_conserve_tout_en_minuscules(self):
raw = parse_band_page(page(FULL_STATS))["info_raw"]
assert raw["country of origin"] == "Norway"
assert all(k == k.lower() for k in raw)
class TestRobustesse:
"""Le parseur ne doit jamais lever : une page inattendue donne des champs vides."""
@pytest.mark.parametrize("html", [
"", "<html></html>", "<html><body></body></html>",
"pas du html du tout", "<div><span>fragment</span></div>",
"<html><body><h1>Sans stats</h1></body></html>",
])
def test_ne_leve_jamais_sur_une_page_degradee(self, html):
out = parse_band_page(html)
assert isinstance(out, dict)
assert "name" in out and "lineup" in out
def test_champs_absents_valent_none(self):
out = parse_band_page(page([]))
for champ in ("status", "genre", "formed_in", "location", "label", "themes"):
assert out[champ] is None, champ
def test_page_sans_h1_ne_plante_pas(self):
assert parse_band_page("<html><body><div id='band_stats'></div></body></html>")["name"] == ""
def test_dt_sans_dd_est_ignore(self):
html = "<html><body><div id='band_stats'><dl><dt>Genre:</dt></dl></div></body></html>"
assert parse_band_page(html)["genre"] is None
def test_retombe_sur_band_info_si_band_stats_absent(self):
html = """<html><body><div id="band_info"><dl>
<dt>Genre:</dt><dd>Doom</dd></dl></div></body></html>"""
assert parse_band_page(html)["genre"] == "Doom"
def test_la_structure_de_sortie_est_toujours_complete(self):
"""upsert_band_enriched lit ces clés sans les tester : elles doivent exister."""
out = parse_band_page("")
for cle in ("name", "info_raw", "status", "genre", "formed_in", "location",
"themes", "lyrical_themes", "label", "lineup", "links",
"discography_url"):
assert cle in out, cle
assert isinstance(out["lineup"], dict)
assert isinstance(out["links"], list)
LINEUP_HTML = """
<h2>Current lineup</h2>
<table class="lineupTable">
<tr><th>Nom</th><th>Rôle</th></tr>
<tr><td><a href="/artists/Necrobutcher/12">Necrobutcher</a></td><td>Bass</td></tr>
<tr><td><a href="/artists/Hellhammer/13">Hellhammer</a></td><td>Drums</td></tr>
</table>
<h2>Past members</h2>
<table class="lineupTable">
<tr><th>Nom</th><th>Rôle</th></tr>
<tr><td><a href="/artists/Dead/14">Dead</a></td><td>Vocals</td></tr>
</table>"""
class TestLineup:
def test_regroupe_par_section(self):
lineup = parse_band_page(page(FULL_STATS, LINEUP_HTML))["lineup"]
assert [m["name"] for m in lineup["current"]] == ["Necrobutcher", "Hellhammer"]
assert [m["name"] for m in lineup["past"]] == ["Dead"]
def test_conserve_role_et_url(self):
m = parse_band_page(page(FULL_STATS, LINEUP_HTML))["lineup"]["current"][0]
assert m["role"] == "Bass"
assert m["url"] == "/artists/Necrobutcher/12"
def test_ignore_la_ligne_den_tete(self):
lineup = parse_band_page(page(FULL_STATS, LINEUP_HTML))["lineup"]
assert all(m["name"] not in ("Nom", "Rôle") for m in lineup["current"])
def test_ignore_les_lignes_sans_lien_artiste(self):
html = """<h2>Current</h2><table class="lineupTable">
<tr><th>x</th></tr><tr><td>Texte sans lien</td><td>Bass</td></tr></table>"""
assert parse_band_page(page(FULL_STATS, html))["lineup"]["current"] == []
def test_section_inconnue_retombe_sur_current(self):
html = """<h2>Quelque chose d'imprévu</h2><table class="lineupTable">
<tr><th>x</th></tr><tr><td><a href="/artists/X/1">X</a></td><td>Bass</td></tr></table>"""
assert len(parse_band_page(page(FULL_STATS, html))["lineup"]["current"]) == 1
def test_les_quatre_sections_existent_meme_vides(self):
lineup = parse_band_page(page(FULL_STATS))["lineup"]
assert set(lineup) >= {"current", "past", "live", "session"}
class TestLiensEtDates:
def test_extrait_lurl_de_discographie(self):
extra = '<a href="/band/discography/id/67">Complete discography</a>'
assert parse_band_page(page(FULL_STATS, extra=extra))["discography_url"] == "/band/discography/id/67"
def test_discographie_absente_vaut_none(self):
assert parse_band_page(page(FULL_STATS))["discography_url"] is None
def test_extrait_les_liens_externes(self):
extra = '<div id="band_links"><a href="https://x.example">Site officiel</a></div>'
links = parse_band_page(page(FULL_STATS, extra=extra))["links"]
assert links == [{"text": "Site officiel", "url": "https://x.example"}]
def test_extrait_les_dates_de_laudit_trail(self):
extra = ('<div id="auditTrail">Added on: 2003-05-12 14:02 '
'Last modified on: 2026-08-01 09:31</div>')
out = parse_band_page(page(FULL_STATS, extra=extra))
assert out["ma_created_at"] == "2003-05-12 14:02"
assert out["ma_modified_at"] == "2026-08-01 09:31"
def test_audit_trail_absent_nintroduit_pas_les_cles(self):
out = parse_band_page(page(FULL_STATS))
assert out.get("ma_created_at") is None
class TestPageHash:
def test_stable_et_sensible(self):
assert page_hash("<html>a</html>") == page_hash("<html>a</html>")
assert page_hash("<html>a</html>") != page_hash("<html>b</html>")
def test_correspond_a_md5(self):
# upsert_band_enriched compare ce hash pour éviter les écritures inutiles :
# changer d'algorithme invaliderait tout le cache d'un coup.
assert page_hash("x") == hashlib.md5(b"x").hexdigest()
def test_accepte_lunicode(self):
assert len(page_hash("Mötley Crüe — ✝")) == 32