Merge pull request #14 from prosolis/claude/multi-country-currency-deals-2nZGh
Increased number of pulls by default / added multi-country deal pulling
This commit is contained in:
27
.env.example
27
.env.example
@@ -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=200
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|||||||
26
README.md
26
README.md
@@ -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 | 200 | 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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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", "200"))
|
||||||
|
|
||||||
# Intro message on startup
|
# Intro message on startup
|
||||||
self.send_intro_message = os.environ.get(
|
self.send_intro_message = os.environ.get(
|
||||||
|
|||||||
@@ -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": "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:
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,75 @@ 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 = 200,
|
||||||
) -> 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.
|
Defaults to the API maximum of 200 deals per country so that client-side
|
||||||
|
filtering (discount, price, type) has the widest possible pool to work
|
||||||
|
with — this avoids missing deals that wouldn't appear in a smaller window
|
||||||
|
sorted only by 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 = 200,
|
||||||
|
) -> 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 +126,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 +153,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 +182,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 +195,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
|
||||||
|
|||||||
@@ -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", {})
|
||||||
|
|||||||
Reference in New Issue
Block a user