beatport: fetch_top100 con curl_cffi + retry + cache 15min
This commit is contained in:
1 parent
13c8e60712
commit
3f31e240be
2 files changed
+142
No files matched your search
@@ -9,8 +9,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from curl_cffi import requests as _cffi_requests
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class BeatportTrack:
|
class BeatportTrack:
|
||||||
@@ -166,3 +169,65 @@ def _parse_tracks(data: dict) -> list:
|
|||||||
raise BeatportParseError(f"track[{i}] shape inattesa: {e}") from e
|
raise BeatportParseError(f"track[{i}] shape inattesa: {e}") from e
|
||||||
out.append(track)
|
out.append(track)
|
||||||
return out
|
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
|
||||||
@@ -111,3 +111,80 @@ class TestParseTracks:
|
|||||||
def test_schema_missing_results_raises(self):
|
def test_schema_missing_results_raises(self):
|
||||||
with pytest.raises(beatport.BeatportParseError, match="results"):
|
with pytest.raises(beatport.BeatportParseError, match="results"):
|
||||||
beatport._parse_tracks({"props": {"pageProps": {}}})
|
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")
|
||||||
Reference in new issue
Block a user