diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f8be008 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +MAIL_TO= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b11e0f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +.venv/ diff --git a/main.py b/main.py new file mode 100644 index 0000000..93e9768 --- /dev/null +++ b/main.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +import os +import sys +import json +import time +import socket +import smtplib +from datetime import datetime, timezone +from email.message import EmailMessage + +from dotenv import load_dotenv +load_dotenv() + +import requests + +EPIC_ENDPOINTS = [ + # manchmal zuverlässiger als die ipv4-subdomain + "https://store-site-backend-static.ak.epicgames.com/freeGamesPromotions", + "https://store-site-backend-static-ipv4.ak.epicgames.com/freeGamesPromotions", +] + +def env(name: str, default: str | None = None, required: bool = False) -> str: + val = os.getenv(name, default) + if required and (val is None or val == ""): + raise SystemExit(f"Missing required env var: {name}") + return val + +def fetch_json_with_retry(locale="de-DE", country="DE", retries=3, timeout=20): + params = {"locale": locale, "country": country, "allowCountries": country} + headers = {"Accept": "application/json"} + + last_err = None + for base in EPIC_ENDPOINTS: + for attempt in range(1, retries + 1): + try: + r = requests.get(base, params=params, headers=headers, timeout=timeout) + r.raise_for_status() + return r.json() + except Exception as e: + last_err = e + # kleines Backoff + time.sleep(1.0 * attempt) + + raise RuntimeError(f"Epic fetch failed after retries. Last error: {last_err}") + +def is_active_window(window_obj: dict, now: datetime) -> bool: + try: + start = datetime.fromisoformat(window_obj["startDate"].replace("Z", "+00:00")) + end = datetime.fromisoformat(window_obj["endDate"].replace("Z", "+00:00")) + return start <= now <= end + except Exception: + return False + +def extract_free_games(epic_json: dict, locale="de-DE"): + # Pfad: data.Catalog.searchStore.elements + elements = ( + epic_json.get("data", {}) + .get("Catalog", {}) + .get("searchStore", {}) + .get("elements", []) + ) + + now = datetime.now(timezone.utc) + free_now = [] + + for e in elements: + promos = e.get("promotions") or {} + blocks = promos.get("promotionalOffers") or [] + windows = [] + for b in blocks: + windows.extend(b.get("promotionalOffers") or []) + + active = [w for w in windows if is_active_window(w, now)] + if not active: + continue + + slug = e.get("productSlug") or e.get("urlSlug") + store_url = None + if slug: + store_url = f"https://store.epicgames.com/{locale.lower()}/p/{slug}" + + free_now.append({ + "title": e.get("title"), + "until": active[0].get("endDate"), + "store_url": store_url, + }) + + # sortiere nach Titel (optional) + free_now.sort(key=lambda x: (x["title"] or "").lower()) + return free_now + +def format_email_body(free_games: list[dict]) -> tuple[str, str]: + now = datetime.now(timezone.utc).isoformat() + + if not free_games: + subject = "Epic Games: Keine aktuellen Gratis-Spiele" + text = f"Stand: {now}\n\nAktuell wurden keine Gratis-Spiele gefunden." + html = f"
Stand: {now}
Aktuell wurden keine Gratis-Spiele gefunden.
" + return subject, text, html + + subject = f"Epic Games: {len(free_games)} Gratis-Spiel(e) verfügbar" + lines = [f"Stand: {now}", "", "Aktuelle Gratis-Spiele:"] + for g in free_games: + lines.append(f"- {g['title']} (bis {g['until']})") + if g["store_url"]: + lines.append(f" {g['store_url']}") + text = "\n".join(lines) + + items_html = "" + for g in free_games: + title = (g["title"] or "").replace("&", "&").replace("<", "<").replace(">", ">") + until = (g["until"] or "") + if g["store_url"]: + items_html += f"Stand: {now}
+Aktuelle Gratis-Spiele:
+