- ITAD deals can now be fetched from multiple countries simultaneously via ITAD_COUNTRIES (e.g. US,CA,GB,DE). Deals are merged across regions and deduplicated by game+shop, with the first country taking priority. - Prices from non-USD regions are normalised to USD using exchange rates so filtering (max price) works consistently across countries. - ITAD results are now sorted by newest deals first (client-side by timestamp) instead of only by highest discount, reducing missed deals. - Default fetch limit raised from 20 to 100 to capture more deals. - Display currency is now configurable: DEFAULT_CURRENCY sets the primary currency shown first, EXTRA_CURRENCIES sets additional ones. All Frankfurter-supported currencies are available with proper symbols. - Filtering is now per-source with dedicated env vars: CHEAPSHARK_MIN_DISCOUNT, CHEAPSHARK_MIN_RATING, CHEAPSHARK_MAX_PRICE, ITAD_MIN_DISCOUNT, ITAD_MAX_PRICE, ITAD_DEALS_LIMIT. Shared fallbacks (MIN_DISCOUNT_PERCENT, MAX_PRICE) are still supported. Legacy MAX_PRICE_USD and MIN_DEAL_RATING continue to work as fallbacks. https://claude.ai/code/session_01EfPjktyrF24DBNHWsY1KBP
190 lines
5.7 KiB
Python
190 lines
5.7 KiB
Python
"""Currency conversion using the Frankfurter API (ECB rates, no API key required).
|
|
|
|
Rates are cached in memory and refreshed at most twice per day.
|
|
Call ``configure()`` before ``refresh_rates()`` to set the display currencies.
|
|
"""
|
|
|
|
import logging
|
|
import time
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
FRANKFURTER_URL = "https://api.frankfurter.dev/v1/latest"
|
|
CACHE_TTL_SECONDS = 43200 # 12 hours — be nice to the free service
|
|
|
|
# Module-level state -------------------------------------------------------
|
|
_rates: dict[str, float] = {}
|
|
_last_fetched: float = 0.0
|
|
|
|
# Display preferences (set via configure())
|
|
_default_currency: str = "USD"
|
|
_extra_currencies: list[str] = ["CAD", "EUR", "GBP"]
|
|
|
|
# Currency display symbols
|
|
SYMBOLS: dict[str, str] = {
|
|
"USD": "$",
|
|
"CAD": "C$",
|
|
"EUR": "€",
|
|
"GBP": "£",
|
|
"AUD": "A$",
|
|
"BRL": "R$",
|
|
"CHF": "CHF ",
|
|
"CNY": "¥",
|
|
"CZK": "Kč",
|
|
"DKK": "kr",
|
|
"HUF": "Ft",
|
|
"INR": "₹",
|
|
"JPY": "¥",
|
|
"KRW": "₩",
|
|
"MXN": "MX$",
|
|
"NOK": "kr",
|
|
"NZD": "NZ$",
|
|
"PLN": "zł",
|
|
"SEK": "kr",
|
|
"TRY": "₺",
|
|
"ZAR": "R",
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Configuration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def configure(default_currency: str = "USD", extra_currencies: list[str] | None = None) -> None:
|
|
"""Set the primary display currency and additional currencies.
|
|
|
|
Must be called **before** ``refresh_rates()`` so the correct symbols are
|
|
requested from Frankfurter.
|
|
"""
|
|
global _default_currency, _extra_currencies
|
|
_default_currency = default_currency.upper()
|
|
if extra_currencies is not None:
|
|
_extra_currencies = [c.upper() for c in extra_currencies]
|
|
|
|
|
|
def _all_target_currencies() -> list[str]:
|
|
"""Return every non-USD currency we need exchange rates for."""
|
|
currencies: set[str] = set()
|
|
if _default_currency != "USD":
|
|
currencies.add(_default_currency)
|
|
for c in _extra_currencies:
|
|
if c != "USD":
|
|
currencies.add(c)
|
|
return sorted(currencies)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Rate fetching
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def refresh_rates(client: httpx.AsyncClient) -> bool:
|
|
"""Fetch latest USD-based exchange rates from Frankfurter.
|
|
|
|
Returns True if rates were successfully updated.
|
|
"""
|
|
global _rates, _last_fetched
|
|
|
|
needed = _all_target_currencies()
|
|
if not needed:
|
|
# Only USD display — no conversion needed
|
|
_last_fetched = time.monotonic()
|
|
return True
|
|
|
|
symbols = ",".join(needed)
|
|
try:
|
|
resp = await client.get(
|
|
FRANKFURTER_URL,
|
|
params={"base": "USD", "symbols": symbols},
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
except (httpx.HTTPError, ValueError) as exc:
|
|
logger.warning("Failed to fetch exchange rates: %s", exc)
|
|
return False
|
|
|
|
new_rates = data.get("rates", {})
|
|
if not new_rates:
|
|
logger.warning("Frankfurter returned empty rates")
|
|
return False
|
|
|
|
_rates = {k: float(v) for k, v in new_rates.items()}
|
|
_last_fetched = time.monotonic()
|
|
logger.info("Exchange rates updated: %s", _rates)
|
|
return True
|
|
|
|
|
|
async def _ensure_rates(client: httpx.AsyncClient) -> None:
|
|
"""Refresh rates if the cache is stale or empty."""
|
|
if _all_target_currencies() and (
|
|
not _rates or (time.monotonic() - _last_fetched) > CACHE_TTL_SECONDS
|
|
):
|
|
await refresh_rates(client)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Conversion helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _convert_from_usd(usd_amount: float, currency: str) -> float | None:
|
|
"""Convert a USD amount to *currency* using cached rates."""
|
|
if currency == "USD":
|
|
return usd_amount
|
|
rate = _rates.get(currency)
|
|
if rate is None:
|
|
return None
|
|
return round(usd_amount * rate, 2)
|
|
|
|
|
|
def convert_to_usd(amount: float, source_currency: str) -> float:
|
|
"""Convert *amount* from *source_currency* to USD using cached rates.
|
|
|
|
Falls back to a 1:1 conversion with a warning when rates are unavailable.
|
|
"""
|
|
if source_currency == "USD":
|
|
return amount
|
|
rate = _rates.get(source_currency)
|
|
if rate is None or rate == 0:
|
|
logger.warning("No rate for %s → USD, assuming 1:1", source_currency)
|
|
return amount
|
|
return round(amount / rate, 2)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Price formatting
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def format_price(usd_amount: float) -> str:
|
|
"""Return a formatted multi-currency price string.
|
|
|
|
The configured *default_currency* is shown first, followed by each
|
|
*extra_currency*. Example with defaults::
|
|
|
|
$14.99 · C$20.54 · €13.78 · £11.98
|
|
|
|
Falls back to USD-only if rates aren't available.
|
|
"""
|
|
display_order = [_default_currency] + [
|
|
c for c in _extra_currencies if c != _default_currency
|
|
]
|
|
|
|
parts: list[str] = []
|
|
for cur in display_order:
|
|
converted = _convert_from_usd(usd_amount, cur)
|
|
if converted is not None:
|
|
symbol = SYMBOLS.get(cur, f"{cur} ")
|
|
parts.append(f"{symbol}{converted:.2f}")
|
|
|
|
if not parts:
|
|
# Fallback when no rates are loaded yet
|
|
parts = [f"${usd_amount:.2f}"]
|
|
|
|
return " · ".join(parts)
|
|
|
|
|
|
async def format_price_async(client: httpx.AsyncClient, usd_amount: float) -> str:
|
|
"""Ensure rates are fresh, then format a multi-currency price string."""
|
|
await _ensure_rates(client)
|
|
return format_price(usd_amount)
|