beatport: fetch_top100 con curl_cffi + retry + cache 15min

This commit is contained in:
luciano committed 2026-07-14 16:24:43 +02:00
1 parent 13c8e60712
commit 3f31e240be
2 files changed
+142

No files matched your search

+65
View File
@@ -9,8 +9,11 @@ from __future__ import annotations
import json
import re
import time
from dataclasses import dataclass
from curl_cffi import requests as _cffi_requests
@dataclass(frozen=True)
class BeatportTrack:
@@ -166,3 +169,65 @@ def _parse_tracks(data: dict) -> list:
raise BeatportParseError(f"track[{i}] shape inattesa: {e}") from e
out.append(track)
return out
_IMPERSONATE = "chrome131" # aggiorna se CF rompe il fingerprint
_REQUEST_TIMEOUT = 15
_MAX_ATTEMPTS = 3
_BACKOFF_SEC = [1, 3] # attese fra tentativi
_CACHE_TTL_SEC = 15 * 60
# Cache in-memory: slug → (timestamp_epoch, list[BeatportTrack])
_cache: dict = {}
def _url_for(slug: str) -> str:
gid, _ = GENRES[slug]
return f"https://www.beatport.com/genre/{slug}/{gid}/top-100"
def _do_get(url: str) -> str:
"""GET con retry e backoff. Solleva BeatportUnreachableError su fallimento definitivo."""
last_exc = None
for attempt in range(_MAX_ATTEMPTS):
try:
resp = _cffi_requests.get(
url,
impersonate=_IMPERSONATE,
timeout=_REQUEST_TIMEOUT,
)
if resp.status_code >= 500 or resp.status_code == 403:
raise Exception(f"HTTP {resp.status_code}")
resp.raise_for_status()
return resp.text
except Exception as e:
last_exc = e
if attempt < _MAX_ATTEMPTS - 1:
time.sleep(_BACKOFF_SEC[attempt])
raise BeatportUnreachableError(f"Beatport irraggiungibile dopo {_MAX_ATTEMPTS} tentativi: {last_exc}")
def fetch_top100(slug: str, force_refresh: bool = False) -> list:
"""Fetches la Top 100 Beatport per il genere dato.
Cache in-memory 15 min. `force_refresh=True` bypassa la cache.
Raises:
ValueError: se slug non è in GENRES.
BeatportUnreachableError: rete/5xx dopo i retry.
BeatportParseError: HTML/JSON non conforme allo schema.
"""
if slug not in GENRES:
raise ValueError(f"slug genere non valido: {slug!r}")
now = time.time()
if not force_refresh:
cached = _cache.get(slug)
if cached and (now - cached[0]) < _CACHE_TTL_SEC:
return cached[1]
html = _do_get(_url_for(slug))
data = _extract_next_data(html)
tracks = _parse_tracks(data)
_cache[slug] = (now, tracks)
return tracks
+77
View File
@@ -111,3 +111,80 @@ class TestParseTracks:
def test_schema_missing_results_raises(self):
with pytest.raises(beatport.BeatportParseError, match="results"):
beatport._parse_tracks({"props": {"pageProps": {}}})
from unittest.mock import patch, MagicMock
from freezegun import freeze_time
def _mock_response(text: str, status_code: int = 200) -> MagicMock:
resp = MagicMock()
resp.text = text
resp.status_code = status_code
def _raise():
if status_code >= 400:
raise Exception(f"HTTP {status_code}")
resp.raise_for_status = _raise
return resp
class TestFetchTop100:
@pytest.fixture
def fixture_html(self, fixtures_dir):
return (fixtures_dir / "beatport_melodic_top100.html").read_text()
def test_success_returns_100_tracks(self, fixture_html):
beatport._cache.clear()
with patch("core.beatport._cffi_requests.get") as mock_get:
mock_get.return_value = _mock_response(fixture_html, 200)
tracks = beatport.fetch_top100("melodic-house-techno")
assert len(tracks) == 100
def test_invalid_slug_raises_value_error(self):
with pytest.raises(ValueError, match="slug"):
beatport.fetch_top100("not-a-real-genre")
def test_5xx_retries_and_raises_unreachable(self):
beatport._cache.clear()
with patch("core.beatport._cffi_requests.get") as mock_get:
mock_get.return_value = _mock_response("", 503)
with patch("core.beatport.time.sleep"): # skip backoff
with pytest.raises(beatport.BeatportUnreachableError):
beatport.fetch_top100("melodic-house-techno")
assert mock_get.call_count == 3 # 1 + 2 retry
def test_cache_hit_within_ttl(self, fixture_html):
beatport._cache.clear()
with patch("core.beatport._cffi_requests.get") as mock_get:
mock_get.return_value = _mock_response(fixture_html, 200)
beatport.fetch_top100("melodic-house-techno")
beatport.fetch_top100("melodic-house-techno")
assert mock_get.call_count == 1
def test_cache_expires_after_ttl(self, fixture_html):
beatport._cache.clear()
with patch("core.beatport._cffi_requests.get") as mock_get:
mock_get.return_value = _mock_response(fixture_html, 200)
with freeze_time("2026-01-01 10:00:00") as frozen:
beatport.fetch_top100("melodic-house-techno")
frozen.tick(delta=beatport._CACHE_TTL_SEC + 1)
beatport.fetch_top100("melodic-house-techno")
assert mock_get.call_count == 2
def test_force_refresh_bypasses_cache(self, fixture_html):
beatport._cache.clear()
with patch("core.beatport._cffi_requests.get") as mock_get:
mock_get.return_value = _mock_response(fixture_html, 200)
beatport.fetch_top100("melodic-house-techno")
beatport.fetch_top100("melodic-house-techno", force_refresh=True)
assert mock_get.call_count == 2
def test_uses_chrome_impersonation(self, fixture_html):
beatport._cache.clear()
with patch("core.beatport._cffi_requests.get") as mock_get:
mock_get.return_value = _mock_response(fixture_html, 200)
beatport.fetch_top100("melodic-house-techno")
call_kwargs = mock_get.call_args.kwargs
assert "impersonate" in call_kwargs
assert call_kwargs["impersonate"].startswith("chrome")