Add multi-country ITAD deals, configurable currency, and per-source filtering
- 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
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""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
|
||||
@@ -11,13 +12,72 @@ import httpx
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FRANKFURTER_URL = "https://api.frankfurter.dev/v1/latest"
|
||||
TARGET_CURRENCIES = ("CAD", "EUR", "GBP")
|
||||
CACHE_TTL_SECONDS = 43200 # 12 hours — be nice to the free service
|
||||
|
||||
# Module-level cache
|
||||
# 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.
|
||||
@@ -26,7 +86,13 @@ async def refresh_rates(client: httpx.AsyncClient) -> bool:
|
||||
"""
|
||||
global _rates, _last_fetched
|
||||
|
||||
symbols = ",".join(TARGET_CURRENCIES)
|
||||
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,
|
||||
@@ -51,42 +117,69 @@ async def refresh_rates(client: httpx.AsyncClient) -> bool:
|
||||
|
||||
async def _ensure_rates(client: httpx.AsyncClient) -> None:
|
||||
"""Refresh rates if the cache is stale or empty."""
|
||||
if not _rates or (time.monotonic() - _last_fetched) > CACHE_TTL_SECONDS:
|
||||
if _all_target_currencies() and (
|
||||
not _rates or (time.monotonic() - _last_fetched) > CACHE_TTL_SECONDS
|
||||
):
|
||||
await refresh_rates(client)
|
||||
|
||||
|
||||
def _convert(usd_amount: float, currency: str) -> float | None:
|
||||
"""Convert a USD amount to the target currency using cached rates."""
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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)
|
||||
|
||||
|
||||
# Currency display symbols
|
||||
_SYMBOLS = {
|
||||
"USD": "$",
|
||||
"CAD": "C$",
|
||||
"EUR": "€",
|
||||
"GBP": "£",
|
||||
}
|
||||
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.
|
||||
|
||||
Example: ``$14.99 · C$20.54 · €13.78 · £11.98``
|
||||
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.
|
||||
"""
|
||||
parts = [f"${usd_amount:.2f}"]
|
||||
display_order = [_default_currency] + [
|
||||
c for c in _extra_currencies if c != _default_currency
|
||||
]
|
||||
|
||||
for cur in TARGET_CURRENCIES:
|
||||
converted = _convert(usd_amount, cur)
|
||||
parts: list[str] = []
|
||||
for cur in display_order:
|
||||
converted = _convert_from_usd(usd_amount, cur)
|
||||
if converted is not None:
|
||||
symbol = _SYMBOLS.get(cur, cur)
|
||||
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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user