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:
Claude
2026-02-28 23:40:36 +00:00
parent da957f82ba
commit e4d90d0331
7 changed files with 307 additions and 59 deletions

View File

@@ -7,7 +7,7 @@ import httpx
from .cheapshark import CheapSharkDeal, fetch_deals as fetch_cheapshark_deals
from .config import Config
from .currency import refresh_rates
from .currency import configure as configure_currency, refresh_rates
from .database import Database
from .epic import EpicFreeGame, fetch_free_games
from .formatter import format_deal, format_epic_free, format_epic_upcoming, format_itad_deal
@@ -35,6 +35,9 @@ class DealsBot:
"""Initialize database, fetch exchange rates, and run first-run population."""
await self.db.connect()
# Configure currency display before fetching rates
configure_currency(self.config.default_currency, self.config.extra_currencies)
# Pre-fetch exchange rates so the first deal post has conversions
await refresh_rates(self._http)
@@ -69,9 +72,9 @@ class DealsBot:
if "cheapshark" in self.config.deal_sources:
deals = await fetch_cheapshark_deals(
self._http,
max_price=self.config.max_price_usd,
min_rating=self.config.min_deal_rating,
min_discount=self.config.min_discount_percent,
max_price=self.config.cheapshark_max_price,
min_rating=self.config.cheapshark_min_rating,
min_discount=self.config.cheapshark_min_discount,
)
for deal in deals:
await self.db.mark_posted(deal.dedup_id, "cheapshark", deal.title)
@@ -81,8 +84,10 @@ class DealsBot:
itad_deals = await fetch_itad_deals(
self._http,
self.config.itad_api_key,
max_price=self.config.max_price_usd,
min_discount=self.config.min_discount_percent,
countries=self.config.itad_countries,
max_price=self.config.itad_max_price,
min_discount=self.config.itad_min_discount,
limit=self.config.itad_deals_limit,
)
for deal in itad_deals:
await self.db.mark_posted(deal.dedup_id, "itad", deal.title)
@@ -106,9 +111,9 @@ class DealsBot:
logger.info("Checking CheapShark for deals...")
deals = await fetch_cheapshark_deals(
self._http,
max_price=self.config.max_price_usd,
min_rating=self.config.min_deal_rating,
min_discount=self.config.min_discount_percent,
max_price=self.config.cheapshark_max_price,
min_rating=self.config.cheapshark_min_rating,
min_discount=self.config.cheapshark_min_discount,
)
for deal in deals:
@@ -131,8 +136,10 @@ class DealsBot:
deals = await fetch_itad_deals(
self._http,
self.config.itad_api_key,
max_price=self.config.max_price_usd,
min_discount=self.config.min_discount_percent,
countries=self.config.itad_countries,
max_price=self.config.itad_max_price,
min_discount=self.config.itad_min_discount,
limit=self.config.itad_deals_limit,
)
for deal in deals:

View File

@@ -20,10 +20,47 @@ class Config:
s.strip().lower() for s in raw_sources.split(",") if s.strip()
]
# Deal filtering
self.min_deal_rating = float(os.environ.get("MIN_DEAL_RATING", "8.0"))
self.min_discount_percent = int(os.environ.get("MIN_DISCOUNT_PERCENT", "50"))
self.max_price_usd = float(os.environ.get("MAX_PRICE_USD", "20"))
# ITAD countries: comma-separated ISO 3166-1 alpha-2 country codes
# Deals are fetched for each country and merged (first country has priority
# when the same game appears in multiple regions).
raw_countries = os.environ.get("ITAD_COUNTRIES", "US")
self.itad_countries: list[str] = [
c.strip().upper() for c in raw_countries.split(",") if c.strip()
]
# Currency display
self.default_currency = os.environ.get("DEFAULT_CURRENCY", "USD").upper()
raw_extra = os.environ.get("EXTRA_CURRENCIES", "CAD,EUR,GBP")
self.extra_currencies: list[str] = [
c.strip().upper() for c in raw_extra.split(",") if c.strip()
]
# Shared filter fallbacks (support legacy env vars)
_max_price = os.environ.get(
"MAX_PRICE", os.environ.get("MAX_PRICE_USD", "20")
)
_min_discount = os.environ.get("MIN_DISCOUNT_PERCENT", "50")
_min_rating = os.environ.get("MIN_DEAL_RATING", "8.0")
# CheapShark-specific filtering (falls back to shared values)
self.cheapshark_min_discount = int(
os.environ.get("CHEAPSHARK_MIN_DISCOUNT", _min_discount)
)
self.cheapshark_min_rating = float(
os.environ.get("CHEAPSHARK_MIN_RATING", _min_rating)
)
self.cheapshark_max_price = float(
os.environ.get("CHEAPSHARK_MAX_PRICE", _max_price)
)
# ITAD-specific filtering (falls back to shared values)
self.itad_min_discount = int(
os.environ.get("ITAD_MIN_DISCOUNT", _min_discount)
)
self.itad_max_price = float(
os.environ.get("ITAD_MAX_PRICE", _max_price)
)
self.itad_deals_limit = int(os.environ.get("ITAD_DEALS_LIMIT", "100"))
# Intro message on startup
self.send_intro_message = os.environ.get(

View File

@@ -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": "",
"DKK": "kr",
"HUF": "Ft",
"INR": "",
"JPY": "¥",
"KRW": "",
"MXN": "MX$",
"NOK": "kr",
"NZD": "NZ$",
"PLN": "",
"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)

View File

@@ -5,6 +5,7 @@ from dataclasses import dataclass
import httpx
from .currency import convert_to_usd
from .itad import BASE_URL
logger = logging.getLogger(__name__)
@@ -18,8 +19,8 @@ class ITADDeal:
game_id: str # ITAD UUID
slug: str
title: str
sale_price: float # current deal price
normal_price: float # regular (non-sale) price
sale_price: float # current deal price (normalised to USD)
normal_price: float # regular (non-sale) price (normalised to USD)
discount: int # percentage off (0-100)
currency: str
shop_name: str
@@ -46,19 +47,70 @@ async def fetch_deals(
client: httpx.AsyncClient,
api_key: str,
*,
countries: list[str] | None = None,
max_price: float = 20,
min_discount: float = 50,
limit: int = 20,
limit: int = 100,
) -> list[ITADDeal]:
"""Fetch current deals from IsThereAnyDeal.
"""Fetch current deals from IsThereAnyDeal across one or more countries.
Uses GET /deals/v2 with sorting by highest discount.
Deals are fetched per-country, merged (first country in the list wins
when the same game+shop appears in multiple regions), and finally sorted
by timestamp so that the newest deals appear first.
"""
if not api_key:
return []
if countries is None:
countries = ["US"]
seen: set[str] = set() # game_id-shop_id keys for cross-country dedup
all_deals: list[ITADDeal] = []
for country in countries:
country_deals = await _fetch_country_deals(
client,
api_key,
country=country,
max_price=max_price,
min_discount=min_discount,
limit=limit,
)
for deal in country_deals:
key = f"{deal.game_id}-{deal.shop_id}"
if key not in seen:
seen.add(key)
all_deals.append(deal)
else:
logger.debug(
"Skipping duplicate %s from country %s", deal.title, country
)
# Sort newest first — the API only supports sorting by discount or price,
# so we sort client-side by the deal timestamp.
all_deals.sort(key=lambda d: d.timestamp, reverse=True)
logger.info(
"ITAD: %d deals across %d country/countries after dedup",
len(all_deals),
len(countries),
)
return all_deals
async def _fetch_country_deals(
client: httpx.AsyncClient,
api_key: str,
*,
country: str = "US",
max_price: float = 20,
min_discount: float = 50,
limit: int = 100,
) -> list[ITADDeal]:
"""Fetch deals for a single country from the ITAD ``/deals/v2`` endpoint."""
params: dict = {
"key": api_key,
"country": country,
"sort": "-cut",
"limit": min(limit, 200),
"nondeals": "false",
@@ -69,11 +121,13 @@ async def fetch_deals(
resp.raise_for_status()
data = resp.json()
except (httpx.HTTPError, ValueError) as exc:
logger.error("ITAD deals API error: %s", exc)
logger.error("ITAD deals API error for country %s: %s", country, exc)
return []
raw_list = data.get("list", [])
logger.info("ITAD returned %d raw deals (before filtering)", len(raw_list))
logger.info(
"ITAD returned %d raw deals for %s (before filtering)", len(raw_list), country
)
deals: list[ITADDeal] = []
for entry in raw_list:
@@ -94,15 +148,22 @@ async def fetch_deals(
regular_amount = deal_data.get("regular", {}).get("amount", 0)
currency = deal_data.get("price", {}).get("currency", "USD")
# Apply filters
# Normalise to USD so prices are comparable across regions
price_usd = convert_to_usd(price_amount, currency)
regular_usd = convert_to_usd(regular_amount, currency)
# Apply ITAD-specific filters
if cut < min_discount:
logger.debug(
"Filtered out %s: discount %d%% < %d%%", title, cut, int(min_discount)
"Filtered out %s: discount %d%% < %d%%",
title,
cut,
int(min_discount),
)
continue
if price_amount > max_price:
if price_usd > max_price:
logger.debug(
"Filtered out %s: price %.2f > %.2f", title, price_amount, max_price
"Filtered out %s: price $%.2f > $%.2f", title, price_usd, max_price
)
continue
@@ -116,10 +177,10 @@ async def fetch_deals(
game_id=entry.get("id", ""),
slug=entry.get("slug", ""),
title=title,
sale_price=price_amount,
normal_price=regular_amount,
sale_price=price_usd,
normal_price=regular_usd,
discount=cut,
currency=currency,
currency="USD", # normalised
shop_name=shop.get("name", "Unknown"),
shop_id=shop.get("id", 0),
url=deal_data.get("url", ""),
@@ -129,5 +190,7 @@ async def fetch_deals(
)
)
logger.info("ITAD returned %d deals after filtering", len(deals))
logger.info(
"ITAD returned %d deals for %s after filtering", len(deals), country
)
return deals

View File

@@ -8,7 +8,7 @@ from nio import AsyncClient, LoginError, RoomResolveAliasError
from .config import Config
from .cheapshark import BASE_URL as CHEAPSHARK_URL
from .currency import FRANKFURTER_URL, TARGET_CURRENCIES
from .currency import FRANKFURTER_URL, configure as configure_currency, _all_target_currencies
from .epic import FREE_GAMES_URL
from .itad import BASE_URL as ITAD_URL
@@ -45,6 +45,9 @@ async def run_preflight(config: Config) -> bool:
print(f"\n{_BOLD}Pastel — preflight checks{_RESET}\n")
all_ok = True
# Configure currency display so the Frankfurter check fetches the right symbols
configure_currency(config.default_currency, config.extra_currencies)
async with httpx.AsyncClient(timeout=15) as http:
# --- Matrix ---
print(f"{_BOLD}Matrix{_RESET}")
@@ -63,7 +66,7 @@ async def run_preflight(config: Config) -> bool:
# --- Frankfurter (exchange rates) ---
print(f"\n{_BOLD}Frankfurter (exchange rates){_RESET}")
all_ok &= await _check_frankfurter(http)
all_ok &= await _check_frankfurter(http, config)
# --- IsThereAnyDeal ---
itad_required = "itad" in config.deal_sources
@@ -153,10 +156,16 @@ async def _check_epic(http: httpx.AsyncClient) -> bool:
return _fail("API reachable", str(exc))
async def _check_frankfurter(http: httpx.AsyncClient) -> bool:
async def _check_frankfurter(http: httpx.AsyncClient, config: Config) -> bool:
"""Fetch exchange rates to confirm Frankfurter is reachable."""
needed = _all_target_currencies()
if not needed:
return _pass(
"Skipped",
f"only {config.default_currency} configured — no conversion needed",
)
try:
symbols = ",".join(TARGET_CURRENCIES)
symbols = ",".join(needed)
resp = await http.get(FRANKFURTER_URL, params={"base": "USD", "symbols": symbols})
resp.raise_for_status()
rates = resp.json().get("rates", {})