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

@@ -11,10 +11,31 @@ ITAD_API_KEY=
# Requires ITAD_API_KEY when "itad" is included # Requires ITAD_API_KEY when "itad" is included
DEAL_SOURCES=cheapshark DEAL_SOURCES=cheapshark
# Deal filtering # ITAD countries: comma-separated ISO 3166-1 alpha-2 country codes
MIN_DEAL_RATING=8.0 # Deals are fetched for each country and merged (duplicates removed).
# Examples: US, CA, GB, DE, FR, AU, BR, JP
ITAD_COUNTRIES=US
# Currency display
# DEFAULT_CURRENCY is shown first; EXTRA_CURRENCIES are shown after it.
DEFAULT_CURRENCY=USD
EXTRA_CURRENCIES=CAD,EUR,GBP
# CheapShark-specific filtering
# (Falls back to shared MIN_DISCOUNT_PERCENT / MAX_PRICE if not set)
#CHEAPSHARK_MIN_DISCOUNT=50
#CHEAPSHARK_MIN_RATING=8.0
#CHEAPSHARK_MAX_PRICE=20
# ITAD-specific filtering
# (Falls back to shared MIN_DISCOUNT_PERCENT / MAX_PRICE if not set)
#ITAD_MIN_DISCOUNT=50
#ITAD_MAX_PRICE=20
#ITAD_DEALS_LIMIT=100
# Shared filter defaults (used when source-specific values are not set)
MIN_DISCOUNT_PERCENT=50 MIN_DISCOUNT_PERCENT=50
MAX_PRICE_USD=20 MAX_PRICE=20
# Send an intro message to the room on startup (true/false) # Send an intro message to the room on startup (true/false)
SEND_INTRO_MESSAGE=false SEND_INTRO_MESSAGE=false

View File

@@ -55,6 +55,8 @@ python -m gaming_deals_bot
All configuration is via environment variables (see `.env.example`): All configuration is via environment variables (see `.env.example`):
### Core
| Variable | Required | Default | Description | | Variable | Required | Default | Description |
|---|---|---|---| |---|---|---|---|
| `MATRIX_HOMESERVER_URL` | Yes | — | Matrix homeserver URL | | `MATRIX_HOMESERVER_URL` | Yes | — | Matrix homeserver URL |
@@ -63,12 +65,27 @@ All configuration is via environment variables (see `.env.example`):
| `MATRIX_DEALS_ROOM_ID` | Yes | — | Room ID to post deals in | | `MATRIX_DEALS_ROOM_ID` | Yes | — | Room ID to post deals in |
| `ITAD_API_KEY` | No | — | IsThereAnyDeal API key (required when `itad` is in `DEAL_SOURCES`, optional otherwise for historical low detection) | | `ITAD_API_KEY` | No | — | IsThereAnyDeal API key (required when `itad` is in `DEAL_SOURCES`, optional otherwise for historical low detection) |
| `DEAL_SOURCES` | No | cheapshark | Comma-separated deal sources: `cheapshark`, `itad`, or `cheapshark,itad` | | `DEAL_SOURCES` | No | cheapshark | Comma-separated deal sources: `cheapshark`, `itad`, or `cheapshark,itad` |
| `MIN_DEAL_RATING` | No | 8.0 | Minimum CheapShark deal rating (0-10) | | `ITAD_COUNTRIES` | No | US | Comma-separated ISO 3166-1 alpha-2 country codes to fetch ITAD deals from (e.g. `US,CA,GB,DE`) |
| `MIN_DISCOUNT_PERCENT` | No | 50 | Minimum discount percentage | | `DEFAULT_CURRENCY` | No | USD | Primary display currency shown first in price strings |
| `MAX_PRICE_USD` | No | 20 | Maximum sale price in USD | | `EXTRA_CURRENCIES` | No | CAD,EUR,GBP | Additional currencies shown after the default (comma-separated) |
| `SEND_INTRO_MESSAGE` | No | false | Send "The deals must flow." to the room on startup | | `SEND_INTRO_MESSAGE` | No | false | Send "The deals must flow." to the room on startup |
| `DATABASE_PATH` | No | deals.db | Path to SQLite database file | | `DATABASE_PATH` | No | deals.db | Path to SQLite database file |
### Filtering
Each deal source has its own filter settings. Source-specific values take priority; when not set they fall back to the shared defaults.
| Variable | Source | Default | Description |
|---|---|---|---|
| `CHEAPSHARK_MIN_DISCOUNT` | CheapShark | 50 | Minimum discount percentage |
| `CHEAPSHARK_MIN_RATING` | CheapShark | 8.0 | Minimum deal rating (0-10, 0 = unrated allowed) |
| `CHEAPSHARK_MAX_PRICE` | CheapShark | 20 | Maximum sale price (USD) |
| `ITAD_MIN_DISCOUNT` | ITAD | 50 | Minimum discount percentage |
| `ITAD_MAX_PRICE` | ITAD | 20 | Maximum sale price (USD, prices from other regions are converted) |
| `ITAD_DEALS_LIMIT` | ITAD | 100 | Number of deals to fetch per country (max 200) |
| `MIN_DISCOUNT_PERCENT` | Shared | 50 | Fallback minimum discount when source-specific value is not set |
| `MAX_PRICE` | Shared | 20 | Fallback maximum price when source-specific value is not set |
## Preflight Check ## Preflight Check
Run `--check` to validate your configuration and test connectivity to all services before starting the bot: Run `--check` to validate your configuration and test connectivity to all services before starting the bot:
@@ -93,4 +110,5 @@ The command exits with code 0 on success and 1 on failure, so it works in CI and
- **Deduplication**: deals are tracked by game ID + timestamp; duplicates are never reposted - **Deduplication**: deals are tracked by game ID + timestamp; duplicates are never reposted
- **Pruning**: deals older than 30 days are pruned from the database so they can be reposted if they return - **Pruning**: deals older than 30 days are pruned from the database so they can be reposted if they return
- **One message per deal**: each deal is posted individually so messages are independently linkable and dismissible - **One message per deal**: each deal is posted individually so messages are independently linkable and dismissible
- **Multi-currency pricing**: deal prices are shown in USD, CAD, EUR, and GBP using live exchange rates from the [Frankfurter API](https://api.frankfurter.dev) (ECB data, no API key required). Rates are cached and refreshed twice daily. - **Multi-currency pricing**: deal prices are shown in your configured currencies (default: USD, CAD, EUR, GBP) using live exchange rates from the [Frankfurter API](https://api.frankfurter.dev) (ECB data, no API key required). Set `DEFAULT_CURRENCY` to change the primary display currency and `EXTRA_CURRENCIES` for additional ones. Rates are cached and refreshed twice daily.
- **Multi-country ITAD deals**: when using IsThereAnyDeal, deals can be fetched from multiple countries simultaneously via `ITAD_COUNTRIES` (e.g. `US,CA,GB,DE`). Deals are merged and deduplicated, with the first country in the list taking priority for duplicate games.

View File

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

View File

@@ -20,10 +20,47 @@ class Config:
s.strip().lower() for s in raw_sources.split(",") if s.strip() s.strip().lower() for s in raw_sources.split(",") if s.strip()
] ]
# Deal filtering # ITAD countries: comma-separated ISO 3166-1 alpha-2 country codes
self.min_deal_rating = float(os.environ.get("MIN_DEAL_RATING", "8.0")) # Deals are fetched for each country and merged (first country has priority
self.min_discount_percent = int(os.environ.get("MIN_DISCOUNT_PERCENT", "50")) # when the same game appears in multiple regions).
self.max_price_usd = float(os.environ.get("MAX_PRICE_USD", "20")) 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 # Intro message on startup
self.send_intro_message = os.environ.get( 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). """Currency conversion using the Frankfurter API (ECB rates, no API key required).
Rates are cached in memory and refreshed at most twice per day. 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 logging
@@ -11,13 +12,72 @@ import httpx
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
FRANKFURTER_URL = "https://api.frankfurter.dev/v1/latest" 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 CACHE_TTL_SECONDS = 43200 # 12 hours — be nice to the free service
# Module-level cache # Module-level state -------------------------------------------------------
_rates: dict[str, float] = {} _rates: dict[str, float] = {}
_last_fetched: float = 0.0 _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: async def refresh_rates(client: httpx.AsyncClient) -> bool:
"""Fetch latest USD-based exchange rates from Frankfurter. """Fetch latest USD-based exchange rates from Frankfurter.
@@ -26,7 +86,13 @@ async def refresh_rates(client: httpx.AsyncClient) -> bool:
""" """
global _rates, _last_fetched 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: try:
resp = await client.get( resp = await client.get(
FRANKFURTER_URL, FRANKFURTER_URL,
@@ -51,42 +117,69 @@ async def refresh_rates(client: httpx.AsyncClient) -> bool:
async def _ensure_rates(client: httpx.AsyncClient) -> None: async def _ensure_rates(client: httpx.AsyncClient) -> None:
"""Refresh rates if the cache is stale or empty.""" """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) 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) rate = _rates.get(currency)
if rate is None: if rate is None:
return None return None
return round(usd_amount * rate, 2) return round(usd_amount * rate, 2)
# Currency display symbols def convert_to_usd(amount: float, source_currency: str) -> float:
_SYMBOLS = { """Convert *amount* from *source_currency* to USD using cached rates.
"USD": "$",
"CAD": "C$",
"EUR": "",
"GBP": "£",
}
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: def format_price(usd_amount: float) -> str:
"""Return a formatted multi-currency price string. """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. 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: parts: list[str] = []
converted = _convert(usd_amount, cur) for cur in display_order:
converted = _convert_from_usd(usd_amount, cur)
if converted is not None: if converted is not None:
symbol = _SYMBOLS.get(cur, cur) symbol = SYMBOLS.get(cur, f"{cur} ")
parts.append(f"{symbol}{converted:.2f}") parts.append(f"{symbol}{converted:.2f}")
if not parts:
# Fallback when no rates are loaded yet
parts = [f"${usd_amount:.2f}"]
return " · ".join(parts) return " · ".join(parts)

View File

@@ -5,6 +5,7 @@ from dataclasses import dataclass
import httpx import httpx
from .currency import convert_to_usd
from .itad import BASE_URL from .itad import BASE_URL
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -18,8 +19,8 @@ class ITADDeal:
game_id: str # ITAD UUID game_id: str # ITAD UUID
slug: str slug: str
title: str title: str
sale_price: float # current deal price sale_price: float # current deal price (normalised to USD)
normal_price: float # regular (non-sale) price normal_price: float # regular (non-sale) price (normalised to USD)
discount: int # percentage off (0-100) discount: int # percentage off (0-100)
currency: str currency: str
shop_name: str shop_name: str
@@ -46,19 +47,70 @@ async def fetch_deals(
client: httpx.AsyncClient, client: httpx.AsyncClient,
api_key: str, api_key: str,
*, *,
countries: list[str] | None = None,
max_price: float = 20, max_price: float = 20,
min_discount: float = 50, min_discount: float = 50,
limit: int = 20, limit: int = 100,
) -> list[ITADDeal]: ) -> 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: if not api_key:
return [] 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 = { params: dict = {
"key": api_key, "key": api_key,
"country": country,
"sort": "-cut", "sort": "-cut",
"limit": min(limit, 200), "limit": min(limit, 200),
"nondeals": "false", "nondeals": "false",
@@ -69,11 +121,13 @@ async def fetch_deals(
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
except (httpx.HTTPError, ValueError) as exc: 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 [] return []
raw_list = data.get("list", []) 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] = [] deals: list[ITADDeal] = []
for entry in raw_list: for entry in raw_list:
@@ -94,15 +148,22 @@ async def fetch_deals(
regular_amount = deal_data.get("regular", {}).get("amount", 0) regular_amount = deal_data.get("regular", {}).get("amount", 0)
currency = deal_data.get("price", {}).get("currency", "USD") 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: if cut < min_discount:
logger.debug( 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 continue
if price_amount > max_price: if price_usd > max_price:
logger.debug( 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 continue
@@ -116,10 +177,10 @@ async def fetch_deals(
game_id=entry.get("id", ""), game_id=entry.get("id", ""),
slug=entry.get("slug", ""), slug=entry.get("slug", ""),
title=title, title=title,
sale_price=price_amount, sale_price=price_usd,
normal_price=regular_amount, normal_price=regular_usd,
discount=cut, discount=cut,
currency=currency, currency="USD", # normalised
shop_name=shop.get("name", "Unknown"), shop_name=shop.get("name", "Unknown"),
shop_id=shop.get("id", 0), shop_id=shop.get("id", 0),
url=deal_data.get("url", ""), 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 return deals

View File

@@ -8,7 +8,7 @@ from nio import AsyncClient, LoginError, RoomResolveAliasError
from .config import Config from .config import Config
from .cheapshark import BASE_URL as CHEAPSHARK_URL 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 .epic import FREE_GAMES_URL
from .itad import BASE_URL as ITAD_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") print(f"\n{_BOLD}Pastel — preflight checks{_RESET}\n")
all_ok = True 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: async with httpx.AsyncClient(timeout=15) as http:
# --- Matrix --- # --- Matrix ---
print(f"{_BOLD}Matrix{_RESET}") print(f"{_BOLD}Matrix{_RESET}")
@@ -63,7 +66,7 @@ async def run_preflight(config: Config) -> bool:
# --- Frankfurter (exchange rates) --- # --- Frankfurter (exchange rates) ---
print(f"\n{_BOLD}Frankfurter (exchange rates){_RESET}") print(f"\n{_BOLD}Frankfurter (exchange rates){_RESET}")
all_ok &= await _check_frankfurter(http) all_ok &= await _check_frankfurter(http, config)
# --- IsThereAnyDeal --- # --- IsThereAnyDeal ---
itad_required = "itad" in config.deal_sources 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)) 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.""" """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: try:
symbols = ",".join(TARGET_CURRENCIES) symbols = ",".join(needed)
resp = await http.get(FRANKFURTER_URL, params={"base": "USD", "symbols": symbols}) resp = await http.get(FRANKFURTER_URL, params={"base": "USD", "symbols": symbols})
resp.raise_for_status() resp.raise_for_status()
rates = resp.json().get("rates", {}) rates = resp.json().get("rates", {})