9 Commits

Author SHA1 Message Date
Claude
c5ae8543e1 Fix Matrix formatting: build HTML directly instead of markdown conversion
The markdown library's conversion collapsed blockquote lines into a
single paragraph, causing all deal info to render on one line in Matrix
clients. Build HTML directly with <br> tags for reliable line breaks.
Also adds html.escape() for proper escaping of titles and prices, and
removes the unused markdown dependency.

https://claude.ai/code/session_01B7YPGrE3NatkwadCXVjv2j
2026-02-28 05:23:37 +00:00
Claude
949eceb983 Filter ITAD deals to only include games and DLC
Non-game content (courses, software bundles) was showing up in deal
posts. Filter on the API's type field to only include entries with
type "game" or "dlc".

https://claude.ai/code/session_01B7YPGrE3NatkwadCXVjv2j
2026-02-28 05:14:27 +00:00
Claude
d1a33e97c5 Update README to document ITAD as a deal source and DEAL_SOURCES config
https://claude.ai/code/session_01B7YPGrE3NatkwadCXVjv2j
2026-02-28 05:04:24 +00:00
Claude
34da126507 Add IsThereAnyDeal as a deal source alongside CheapShark
Users can now choose between CheapShark, ITAD, or both via the
DEAL_SOURCES env var (comma-separated, default: "cheapshark"). When
"itad" is included, the bot polls GET /deals/v2 for current deals with
discount/price filtering and posts them with the same formatting style.
ITAD deals include built-in historical low detection via the API's
deal flags. Preflight checks enforce ITAD_API_KEY when ITAD is a
configured deal source.

https://claude.ai/code/session_01B7YPGrE3NatkwadCXVjv2j
2026-02-28 04:59:27 +00:00
Claude
72d86fd4e5 Fix IsThereAnyDeal API: use POST for overview endpoint and lookup game UUIDs
The ITAD v2 API requires POST (not GET) for /games/overview/v2, and
expects ITAD game UUIDs in the JSON body rather than Steam app IDs.
Added a lookup step via GET /games/lookup/v1 to convert Steam app IDs
to ITAD UUIDs before calling the overview endpoint. Updated preflight
check to use the lookup endpoint for API key validation.

https://claude.ai/code/session_01B7YPGrE3NatkwadCXVjv2j
2026-02-28 04:47:00 +00:00
prosolis
a83f230291 Merge pull request #10 from prosolis/claude/fix-cheapshark-rating-bug-pxVJv
Post Epic free games on first run instead of silently recording them
2026-02-27 19:38:10 -08:00
prosolis
a334cb7bbe Merge pull request #9 from prosolis/claude/fix-cheapshark-rating-bug-pxVJv
Add SEND_INTRO_MESSAGE to .env.example
2026-02-27 19:13:29 -08:00
prosolis
66407c5a43 Merge pull request #8 from prosolis/claude/fix-cheapshark-rating-bug-pxVJv
More bugfixes
2026-02-27 19:05:26 -08:00
prosolis
19c070405e Merge pull request #7 from prosolis/claude/fix-cheapshark-rating-bug-pxVJv
Fix cheapshark rating and sorting issue
2026-02-27 18:50:37 -08:00
10 changed files with 385 additions and 109 deletions

View File

@@ -4,9 +4,13 @@ MATRIX_BOT_USER_ID=@dealsbot:example.com
MATRIX_BOT_ACCESS_TOKEN=syt_...
MATRIX_DEALS_ROOM_ID=!roomid:example.com
# IsThereAnyDeal API key (optional — enables historical low detection)
# IsThereAnyDeal API key (optional — enables historical low detection and ITAD deals)
ITAD_API_KEY=
# Deal sources: comma-separated list (cheapshark, itad, or both)
# Requires ITAD_API_KEY when "itad" is included
DEAL_SOURCES=cheapshark
# Deal filtering
MIN_DEAL_RATING=8.0
MIN_DISCOUNT_PERCENT=50

View File

@@ -7,8 +7,10 @@ Deals are sourced from PC/digital storefronts only (Steam, GOG, Humble Store, Gr
## Data Sources
- **CheapShark** — polled every 2 hours for top deals across Steam, GOG, Humble Store, and GreenManGaming
- **IsThereAnyDeal** — polled every 2 hours for deals across all tracked stores, with built-in historical low detection (requires API key)
- **Epic Games Store** — polled daily for free game promotions
- **IsThereAnyDeal** (optional) — flags deals that are at an all-time historical low price
CheapShark and IsThereAnyDeal can be used individually or together — configure via the `DEAL_SOURCES` variable. When used as a deal source, IsThereAnyDeal provides historical low flags directly. When only CheapShark is active, IsThereAnyDeal can still optionally enrich deals with historical low info via the `ITAD_API_KEY`.
## Quick Start
@@ -59,7 +61,8 @@ All configuration is via environment variables (see `.env.example`):
| `MATRIX_BOT_USER_ID` | Yes | — | Bot's Matrix user ID |
| `MATRIX_BOT_ACCESS_TOKEN` | Yes | — | Bot's access token |
| `MATRIX_DEALS_ROOM_ID` | Yes | — | Room ID to post deals in |
| `ITAD_API_KEY` | No | — | IsThereAnyDeal API key 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` |
| `MIN_DEAL_RATING` | No | 8.0 | Minimum CheapShark deal rating (0-10) |
| `MIN_DISCOUNT_PERCENT` | No | 50 | Minimum discount percentage |
| `MAX_PRICE_USD` | No | 20 | Maximum sale price in USD |
@@ -77,10 +80,10 @@ python -m gaming_deals_bot --check
This verifies:
- **Matrix** — authentication token is valid and bot has joined the target room
- **CheapShark** — API is reachable
- **CheapShark** — API is reachable (skipped if not in `DEAL_SOURCES`)
- **Epic Games Store** — API is reachable
- **Frankfurter** — exchange rate API is reachable
- **IsThereAnyDeal** — API key is valid (skipped if not configured)
- **IsThereAnyDeal** — API key is valid (required when `itad` is in `DEAL_SOURCES`, skipped otherwise if not configured)
The command exits with code 0 on success and 1 on failure, so it works in CI and Docker health-checks.

View File

@@ -48,14 +48,25 @@ async def main():
scheduler = AsyncIOScheduler()
# CheapShark: every 2 hours
scheduler.add_job(
bot.check_cheapshark,
"interval",
hours=2,
id="cheapshark",
name="CheapShark deals check",
)
# CheapShark: every 2 hours (if enabled)
if "cheapshark" in config.deal_sources:
scheduler.add_job(
bot.check_cheapshark,
"interval",
hours=2,
id="cheapshark",
name="CheapShark deals check",
)
# ITAD deals: every 2 hours (if enabled)
if "itad" in config.deal_sources:
scheduler.add_job(
bot.check_itad_deals,
"interval",
hours=2,
id="itad_deals",
name="ITAD deals check",
)
# Epic free games: once daily
scheduler.add_job(
@@ -67,12 +78,16 @@ async def main():
)
scheduler.start()
logger.info("Bot started — scheduler running")
logger.info(
"Bot started — scheduler running (deal sources: %s)",
", ".join(config.deal_sources),
)
await bot.send_intro()
# Run initial checks immediately (after first-run population is done)
await bot.check_cheapshark()
await bot.check_itad_deals()
await bot.check_epic_free_games()
# Keep running until signaled to stop

View File

@@ -5,13 +5,14 @@ import logging
import httpx
from .cheapshark import CheapSharkDeal, fetch_deals
from .cheapshark import CheapSharkDeal, fetch_deals as fetch_cheapshark_deals
from .config import Config
from .currency import refresh_rates
from .database import Database
from .epic import EpicFreeGame, fetch_free_games
from .formatter import format_deal, format_epic_free, format_epic_upcoming
from .formatter import format_deal, format_epic_free, format_epic_upcoming, format_itad_deal
from .itad import check_single_historical_low
from .itad_deals import ITADDeal, fetch_deals as fetch_itad_deals
from .matrix_client import MatrixDealsClient
logger = logging.getLogger(__name__)
@@ -53,23 +54,38 @@ class DealsBot:
await self.db.close()
async def _populate_initial_state(self):
"""Fetch current CheapShark deals and record them without posting (avoids spam on first run).
"""Fetch current deals and record them without posting (avoids spam on first run).
Epic free games are intentionally *not* recorded here so that the
subsequent ``check_epic_free_games`` call will post them. There are
only a handful at any time and they are time-limited, so users should
see them immediately rather than waiting for the next cycle.
"""
deals = await fetch_deals(
self._http,
max_price=self.config.max_price_usd,
min_rating=self.config.min_deal_rating,
min_discount=self.config.min_discount_percent,
)
for deal in deals:
await self.db.mark_posted(deal.dedup_id, "cheapshark", deal.title)
total = 0
logger.info("First run: recorded %d existing CheapShark deals", len(deals))
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,
)
for deal in deals:
await self.db.mark_posted(deal.dedup_id, "cheapshark", deal.title)
total += len(deals)
if "itad" in self.config.deal_sources and self.config.itad_api_key:
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,
)
for deal in itad_deals:
await self.db.mark_posted(deal.dedup_id, "itad", deal.title)
total += len(itad_deals)
logger.info("First run: recorded %d existing deals", total)
async def send_intro(self):
"""Send an intro message to the Matrix room if configured."""
@@ -81,9 +97,11 @@ class DealsBot:
"""Poll CheapShark for deals and post new ones."""
if not self._first_run_done:
return
if "cheapshark" not in self.config.deal_sources:
return
logger.info("Checking CheapShark for deals...")
deals = await fetch_deals(
deals = await fetch_cheapshark_deals(
self._http,
max_price=self.config.max_price_usd,
min_rating=self.config.min_deal_rating,
@@ -96,6 +114,29 @@ class DealsBot:
# Prune old records
await self.db.prune_old(days=30)
async def check_itad_deals(self):
"""Poll IsThereAnyDeal for deals and post new ones."""
if not self._first_run_done:
return
if "itad" not in self.config.deal_sources:
return
if not self.config.itad_api_key:
logger.warning("ITAD deal source enabled but ITAD_API_KEY is not set")
return
logger.info("Checking IsThereAnyDeal for deals...")
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,
)
for deal in deals:
await self._process_itad_deal(deal)
await self.db.prune_old(days=30)
async def _process_deal(self, deal: CheapSharkDeal):
"""Check dedup, check historical low, format, and post a single deal."""
if await self.db.has_been_posted(deal.dedup_id):
@@ -119,6 +160,20 @@ class DealsBot:
else:
logger.warning("Failed to post deal: %s — will retry next cycle", deal.title)
async def _process_itad_deal(self, deal: ITADDeal):
"""Check dedup, format, and post a single ITAD deal."""
if await self.db.has_been_posted(deal.dedup_id):
return
plain_text, html = format_itad_deal(deal)
success = await self.matrix.send_deal(plain_text, html)
if success:
await self.db.mark_posted(deal.dedup_id, "itad", deal.title)
logger.info("Posted ITAD deal: %s", deal.title)
else:
logger.warning("Failed to post ITAD deal: %s — will retry next cycle", deal.title)
async def check_epic_free_games(self):
"""Poll Epic Games Store for free games and post new ones."""
if not self._first_run_done:

View File

@@ -14,6 +14,12 @@ class Config:
# ITAD API key (optional — historical low checks disabled without it)
self.itad_api_key = os.environ.get("ITAD_API_KEY", "")
# Deal sources: comma-separated list of "cheapshark", "itad" (default: cheapshark)
raw_sources = os.environ.get("DEAL_SOURCES", "cheapshark")
self.deal_sources: list[str] = [
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"))

View File

@@ -1,23 +1,17 @@
"""Message formatting for Matrix deal posts.
Produces both a plain-text fallback and HTML formatted body for Matrix messages.
HTML is built directly using the Matrix-supported HTML subset rather than going
through a Markdown-to-HTML converter, which collapses line breaks.
"""
from datetime import datetime, timezone
import markdown
from html import escape
from .cheapshark import CheapSharkDeal
from .currency import format_price
from .epic import EpicFreeGame
_md = markdown.Markdown()
def _render_html(md_text: str) -> str:
"""Convert Markdown to HTML, resetting the parser between calls."""
_md.reset()
return _md.convert(md_text)
from .itad_deals import ITADDeal
def format_deal(deal: CheapSharkDeal, is_historical_low: bool = False) -> tuple[str, str]:
@@ -31,22 +25,18 @@ def format_deal(deal: CheapSharkDeal, is_historical_low: bool = False) -> tuple[
sale_multi = format_price(sale_price)
normal_display = format_price(normal_price)
title = escape(deal.title)
lines = [
f"**🎮 [DEAL] {deal.title}**",
f"> {discount}% off on {deal.store_name} ~~{normal_display}~~",
f"> 💰 **{sale_multi}**",
html_lines = [
f"<strong>🎮 [DEAL] {title}</strong>",
f"{discount}% off on {escape(deal.store_name)} <del>{escape(normal_display)}</del>",
f"💰 <strong>{escape(sale_multi)}</strong>",
]
if is_historical_low:
lines.append("> 🏆 _All-time low!_")
html_lines.append("🏆 <em>All-time low!</em>")
html_lines.append(f'🔗 <a href="{escape(deal.deal_url)}">View Deal</a>')
html = "<br>\n".join(html_lines)
lines.append(f"> 🔗 [View Deal]({deal.deal_url})")
md_text = "\n".join(lines)
html = _render_html(md_text)
# Plain text fallback — strip markdown syntax
plain_lines = [
f"🎮 [DEAL] {deal.title}",
f" {discount}% off on {deal.store_name} (was {normal_display})",
@@ -55,7 +45,38 @@ def format_deal(deal: CheapSharkDeal, is_historical_low: bool = False) -> tuple[
if is_historical_low:
plain_lines.append(" 🏆 All-time low!")
plain_lines.append(f" 🔗 {deal.deal_url}")
plain_text = "\n".join(plain_lines)
return plain_text, html
def format_itad_deal(deal: ITADDeal) -> tuple[str, str]:
"""Format an ITAD deal into (plain_text, html) for Matrix.
Returns (body, formatted_body).
"""
sale_multi = format_price(deal.sale_price)
normal_display = format_price(deal.normal_price)
title = escape(deal.title)
html_lines = [
f"<strong>🎮 [DEAL] {title}</strong>",
f"{deal.discount}% off on {escape(deal.shop_name)} <del>{escape(normal_display)}</del>",
f"💰 <strong>{escape(sale_multi)}</strong>",
]
if deal.is_historical_low:
html_lines.append("🏆 <em>All-time low!</em>")
html_lines.append(f'🔗 <a href="{escape(deal.url)}">View Deal</a>')
html = "<br>\n".join(html_lines)
plain_lines = [
f"🎮 [DEAL] {deal.title}",
f" {deal.discount}% off on {deal.shop_name} (was {normal_display})",
f" 💰 {sale_multi}",
]
if deal.is_historical_low:
plain_lines.append(" 🏆 All-time low!")
plain_lines.append(f" 🔗 {deal.url}")
plain_text = "\n".join(plain_lines)
return plain_text, html
@@ -66,26 +87,25 @@ def format_epic_free(game: EpicFreeGame) -> tuple[str, str]:
Returns (body, formatted_body).
"""
lines = [
f"**🆓 [FREE] {game.title}**",
"> Free on Epic Games Store",
title = escape(game.title)
html_lines = [
f"<strong>🆓 [FREE] {title}</strong>",
"Free on Epic Games Store",
]
if game.end_date:
try:
end_dt = datetime.fromisoformat(game.end_date.replace("Z", "+00:00"))
date_str = end_dt.strftime("%B %-d")
lines.append(f"> 📅 _Free until {date_str}_")
html_lines.append(f"📅 <em>Free until {date_str}</em>")
except (ValueError, TypeError):
pass
if game.url:
lines.append(f"> 🔗 [Claim Now]({game.url})")
html_lines.append(f'🔗 <a href="{escape(game.url)}">Claim Now</a>')
html = "<br>\n".join(html_lines)
md_text = "\n".join(lines)
html = _render_html(md_text)
# Plain text fallback
plain_lines = [
f"🆓 [FREE] {game.title}",
" Free on Epic Games Store",
@@ -99,7 +119,6 @@ def format_epic_free(game: EpicFreeGame) -> tuple[str, str]:
pass
if game.url:
plain_lines.append(f" 🔗 {game.url}")
plain_text = "\n".join(plain_lines)
return plain_text, html
@@ -107,24 +126,24 @@ def format_epic_free(game: EpicFreeGame) -> tuple[str, str]:
def format_epic_upcoming(game: EpicFreeGame) -> tuple[str, str]:
"""Format an upcoming Epic free game into (plain_text, html) for Matrix."""
lines = [
f"**📢 [UPCOMING FREE] {game.title}**",
"> Coming soon — Free on Epic Games Store",
title = escape(game.title)
html_lines = [
f"<strong>📢 [UPCOMING FREE] {title}</strong>",
"Coming soon — Free on Epic Games Store",
]
if game.end_date:
try:
end_dt = datetime.fromisoformat(game.end_date.replace("Z", "+00:00"))
date_str = end_dt.strftime("%B %-d")
lines.append(f"> 📅 _Free until {date_str}_")
html_lines.append(f"📅 <em>Free until {date_str}</em>")
except (ValueError, TypeError):
pass
if game.url:
lines.append(f"> 🔗 [Store Page]({game.url})")
md_text = "\n".join(lines)
html = _render_html(md_text)
html_lines.append(f'🔗 <a href="{escape(game.url)}">Store Page</a>')
html = "<br>\n".join(html_lines)
plain_lines = [
f"📢 [UPCOMING FREE] {game.title}",
@@ -132,7 +151,6 @@ def format_epic_upcoming(game: EpicFreeGame) -> tuple[str, str]:
]
if game.url:
plain_lines.append(f" 🔗 {game.url}")
plain_text = "\n".join(plain_lines)
return plain_text, html

View File

@@ -7,6 +7,29 @@ logger = logging.getLogger(__name__)
BASE_URL = "https://api.isthereanydeal.com"
async def _lookup_game_id(
client: httpx.AsyncClient,
api_key: str,
steam_app_id: str,
) -> str | None:
"""Look up the ITAD game UUID for a Steam app ID.
Returns the ITAD game UUID string, or None if not found.
"""
try:
resp = await client.get(
f"{BASE_URL}/games/lookup/v1",
params={"key": api_key, "appid": steam_app_id},
)
resp.raise_for_status()
data = resp.json()
if data.get("found") and data.get("game"):
return data["game"]["id"]
except (httpx.HTTPError, ValueError, KeyError) as exc:
logger.error("ITAD lookup error for app %s: %s", steam_app_id, exc)
return None
async def check_historical_lows(
client: httpx.AsyncClient,
api_key: str,
@@ -19,18 +42,22 @@ async def check_historical_lows(
if not api_key or not steam_app_ids:
return {}
# Build the list of ITAD game IDs from Steam app IDs
# ITAD uses the format "app/{steam_app_id}" for Steam games
game_ids = [f"app/{sid}" for sid in steam_app_ids]
# Look up ITAD game UUIDs for each Steam app ID
itad_id_to_steam: dict[str, str] = {}
for sid in steam_app_ids:
itad_id = await _lookup_game_id(client, api_key, sid)
if itad_id:
itad_id_to_steam[itad_id] = sid
if not itad_id_to_steam:
return {}
try:
resp = await client.get(
resp = await client.post(
f"{BASE_URL}/games/overview/v2",
params={"key": api_key, "shops[]": "steam"},
headers={"Content-Type": "application/json"},
params={"key": api_key, "shops": [61]},
json=list(itad_id_to_steam.keys()),
)
# The v2 endpoint may use POST with body — fall back to per-game lookup
# if batch doesn't work. For now, use the games/info endpoint pattern.
resp.raise_for_status()
data = resp.json()
except (httpx.HTTPError, ValueError) as exc:
@@ -38,19 +65,17 @@ async def check_historical_lows(
return {}
results: dict[str, bool] = {}
# Parse the response — structure depends on the ITAD API version
# The overview endpoint returns price overview with historical low data
if isinstance(data, dict):
for steam_id in steam_app_ids:
game_key = f"app/{steam_id}"
game_data = data.get(game_key) or data.get(steam_id, {})
if isinstance(game_data, dict):
lowest = game_data.get("lowest", {})
current = game_data.get("current", {})
if lowest and current:
lowest_price = lowest.get("price", float("inf"))
current_price = current.get("price", float("inf"))
results[steam_id] = current_price <= lowest_price
for price_entry in data.get("prices", []):
itad_id = price_entry.get("id")
steam_id = itad_id_to_steam.get(itad_id)
if not steam_id:
continue
lowest = price_entry.get("lowest")
current = price_entry.get("current")
if lowest and current:
lowest_price = lowest.get("price", {}).get("amount", float("inf"))
current_price = current.get("price", {}).get("amount", float("inf"))
results[steam_id] = current_price <= lowest_price
logger.info(
"ITAD: checked %d games, %d are at historical low",
@@ -69,13 +94,15 @@ async def check_single_historical_low(
if not api_key or not steam_app_id:
return False
itad_id = await _lookup_game_id(client, api_key, steam_app_id)
if not itad_id:
return False
try:
resp = await client.get(
resp = await client.post(
f"{BASE_URL}/games/overview/v2",
params={
"key": api_key,
"apps[]": f"app/{steam_app_id}",
},
params={"key": api_key},
json=[itad_id],
)
resp.raise_for_status()
data = resp.json()
@@ -83,10 +110,13 @@ async def check_single_historical_low(
logger.error("ITAD API error for app %s: %s", steam_app_id, exc)
return False
# Try to extract historical low info
if isinstance(data, dict) and "prices" in data:
for price_info in data["prices"]:
if price_info.get("isLowest"):
for price_entry in data.get("prices", []):
lowest = price_entry.get("lowest")
current = price_entry.get("current")
if lowest and current:
lowest_price = lowest.get("price", {}).get("amount", float("inf"))
current_price = current.get("price", {}).get("amount", float("inf"))
if current_price <= lowest_price:
return True
return False

View File

@@ -0,0 +1,133 @@
"""IsThereAnyDeal deals list — fetch current deals from the ITAD API."""
import logging
from dataclasses import dataclass
import httpx
from .itad import BASE_URL
logger = logging.getLogger(__name__)
# ITAD shop ID for Steam
STEAM_SHOP_ID = 61
@dataclass
class ITADDeal:
game_id: str # ITAD UUID
slug: str
title: str
sale_price: float # current deal price
normal_price: float # regular (non-sale) price
discount: int # percentage off (0-100)
currency: str
shop_name: str
shop_id: int
url: str # purchase redirect URL
is_historical_low: bool
timestamp: str # ISO datetime of deal
expiry: str | None # ISO datetime when deal expires
@property
def dedup_id(self) -> str:
return f"itad-{self.game_id}-{self.shop_id}-{self.discount}"
@property
def sale_price_usd(self) -> str:
return f"{self.sale_price:.2f}"
@property
def normal_price_usd(self) -> str:
return f"{self.normal_price:.2f}"
async def fetch_deals(
client: httpx.AsyncClient,
api_key: str,
*,
max_price: float = 20,
min_discount: float = 50,
limit: int = 20,
) -> list[ITADDeal]:
"""Fetch current deals from IsThereAnyDeal.
Uses GET /deals/v2 with sorting by highest discount.
"""
if not api_key:
return []
params: dict = {
"key": api_key,
"sort": "-cut",
"limit": min(limit, 200),
"nondeals": "false",
}
try:
resp = await client.get(f"{BASE_URL}/deals/v2", params=params)
resp.raise_for_status()
data = resp.json()
except (httpx.HTTPError, ValueError) as exc:
logger.error("ITAD deals API error: %s", exc)
return []
raw_list = data.get("list", [])
logger.info("ITAD returned %d raw deals (before filtering)", len(raw_list))
deals: list[ITADDeal] = []
for entry in raw_list:
deal_data = entry.get("deal", {})
if not deal_data:
continue
# Only include actual games and DLC — skip non-game content
# (courses, software bundles, etc.)
entry_type = entry.get("type")
title = entry.get("title", "?")
if entry_type not in ("game", "dlc"):
logger.debug("Filtered out %s: type=%s", title, entry_type)
continue
cut = deal_data.get("cut", 0)
price_amount = deal_data.get("price", {}).get("amount", 0)
regular_amount = deal_data.get("regular", {}).get("amount", 0)
currency = deal_data.get("price", {}).get("currency", "USD")
# Apply filters
if cut < min_discount:
logger.debug(
"Filtered out %s: discount %d%% < %d%%", title, cut, int(min_discount)
)
continue
if price_amount > max_price:
logger.debug(
"Filtered out %s: price %.2f > %.2f", title, price_amount, max_price
)
continue
flag = deal_data.get("flag")
is_historical_low = flag in ("H", "N")
shop = deal_data.get("shop", {})
deals.append(
ITADDeal(
game_id=entry.get("id", ""),
slug=entry.get("slug", ""),
title=title,
sale_price=price_amount,
normal_price=regular_amount,
discount=cut,
currency=currency,
shop_name=shop.get("name", "Unknown"),
shop_id=shop.get("id", 0),
url=deal_data.get("url", ""),
is_historical_low=is_historical_low,
timestamp=deal_data.get("timestamp", ""),
expiry=deal_data.get("expiry"),
)
)
logger.info("ITAD returned %d deals after filtering", len(deals))
return deals

View File

@@ -52,7 +52,10 @@ async def run_preflight(config: Config) -> bool:
# --- CheapShark ---
print(f"\n{_BOLD}CheapShark{_RESET}")
all_ok &= await _check_cheapshark(http)
if "cheapshark" in config.deal_sources:
all_ok &= await _check_cheapshark(http)
else:
_skip("Skipped", "not in DEAL_SOURCES")
# --- Epic Games Store ---
print(f"\n{_BOLD}Epic Games Store{_RESET}")
@@ -62,9 +65,10 @@ async def run_preflight(config: Config) -> bool:
print(f"\n{_BOLD}Frankfurter (exchange rates){_RESET}")
all_ok &= await _check_frankfurter(http)
# --- IsThereAnyDeal (optional) ---
# --- IsThereAnyDeal ---
itad_required = "itad" in config.deal_sources
print(f"\n{_BOLD}IsThereAnyDeal{_RESET}")
all_ok &= await _check_itad(http, config.itad_api_key)
all_ok &= await _check_itad(http, config.itad_api_key, required=itad_required)
# --- Summary ---
print()
@@ -164,20 +168,29 @@ async def _check_frankfurter(http: httpx.AsyncClient) -> bool:
return _fail("API reachable", str(exc))
async def _check_itad(http: httpx.AsyncClient, api_key: str) -> bool:
"""Verify the ITAD API key works (optional — skipped if no key is set)."""
async def _check_itad(http: httpx.AsyncClient, api_key: str, *, required: bool = False) -> bool:
"""Verify the ITAD API key works.
When *required* is True (ITAD is a deal source), a missing key is a failure.
Otherwise it's an optional skip.
"""
if not api_key:
if required:
return _fail("API key", "ITAD_API_KEY is required when 'itad' is in DEAL_SOURCES")
return _skip("Skipped", "no ITAD_API_KEY configured (optional)")
try:
# Use a lightweight endpoint to validate the key
# Use the lookup endpoint (GET) to validate the key with a known game
resp = await http.get(
f"{ITAD_URL}/games/overview/v2",
params={"key": api_key, "apps[]": "app/220"}, # Half-Life 2
f"{ITAD_URL}/games/lookup/v1",
params={"key": api_key, "appid": 220}, # Half-Life 2
)
if resp.status_code == 401 or resp.status_code == 403:
if resp.status_code in (401, 403):
return _fail("API key", "rejected by ITAD (401/403)")
resp.raise_for_status()
return _pass("API key valid", "ITAD responded successfully")
data = resp.json()
if data.get("found"):
return _pass("API reachable", "ITAD responded successfully")
return _pass("API reachable", "ITAD key valid (game not found)")
except Exception as exc:
return _fail("API reachable", str(exc))

View File

@@ -2,5 +2,4 @@ matrix-nio>=0.21.0
httpx>=0.25.0
aiosqlite>=0.19.0
apscheduler>=3.10.0
markdown>=3.5.0
python-dotenv>=1.0.0