From 7d73ecd2b201d811ab1b63d4c27fb1a292209b29 Mon Sep 17 00:00:00 2001 From: javara999 Date: Thu, 28 May 2026 16:49:22 +0900 Subject: [PATCH] Show Metacritic scores for free games --- logic.py | 9 ++++ model.py | 25 +++++++++++ scraper.py | 63 +++++++++++++++++++++++++++- templates/ff_freegame_main_list.html | 30 +++++++++++-- 4 files changed, 123 insertions(+), 4 deletions(-) diff --git a/logic.py b/logic.py index 603a7d6..c1c40ce 100644 --- a/logic.py +++ b/logic.py @@ -59,7 +59,10 @@ def _discord_send(webhook_url, games): title = game.get("title") or "Unknown" platform = SOURCE_LABELS.get(game.get("platform"), game.get("platform")) store_url = game.get("store_url") or "" + score = int(game.get("metacritic_score") or 0) line = f"**{title}** ({platform})" + if score > 0: + line += f" · MC {score}" if store_url: line += f"\n{store_url}" lines.append(line) @@ -73,7 +76,10 @@ def _telegram_send(bot_token, chat_id, games): title = game.get("title") or "Unknown" platform = SOURCE_LABELS.get(game.get("platform"), game.get("platform")) store_url = game.get("store_url") or "" + score = int(game.get("metacritic_score") or 0) line = f"{title} ({platform})" + if score > 0: + line += f" · MC {score}" if store_url: line += f"\n{store_url}" lines.append(line) @@ -109,6 +115,7 @@ class Logic(PluginModuleBase): super().__init__(PM, name="main", first_menu="setting") def plugin_load(self): + ModelFreeGameItem.ensure_schema() if _truthy(ModelSetting.get("auto_start")): self.scheduler_start() @@ -141,6 +148,7 @@ class Logic(PluginModuleBase): threading.Thread(target=self.scheduler_function, daemon=True).start() return jsonify({"ret": "success"}) if sub == "web_list": + ModelFreeGameItem.ensure_schema() return jsonify(ModelFreeGameItem.web_list(req)) if sub == "platform_counts": return jsonify({"ret": "success", "data": ModelFreeGameItem.get_platform_counts()}) @@ -171,6 +179,7 @@ class Logic(PluginModuleBase): logger.info("FreeGame fetch skipped: already running") return try: + ModelFreeGameItem.ensure_schema() enabled_sources = set(_enabled_sources()) results = scraper.fetch_all() grouped = _split_source_payload(results) diff --git a/model.py b/model.py index 2c7ef97..a3448e4 100644 --- a/model.py +++ b/model.py @@ -3,6 +3,7 @@ import json from datetime import datetime from sqlalchemy import desc +from sqlalchemy import text from .setup import * @@ -30,6 +31,8 @@ class ModelFreeGameItem(ModelBase): free_end = db.Column(db.String) rating = db.Column(db.Float) rating_count = db.Column(db.Integer) + metacritic_score = db.Column(db.Integer) + metacritic_url = db.Column(db.String) genres_json = db.Column(db.Text) def __init__(self): @@ -60,6 +63,8 @@ class ModelFreeGameItem(ModelBase): "free_end": self.free_end, "rating": self.rating, "rating_count": self.rating_count, + "metacritic_score": self.metacritic_score, + "metacritic_url": self.metacritic_url, "genres": self.genres, "updated_time": self.updated_time.strftime("%Y-%m-%d %H:%M:%S") if self.updated_time else "", } @@ -85,10 +90,30 @@ class ModelFreeGameItem(ModelBase): row.free_end = str(data.get("free_end") or "") row.rating = float(data.get("rating") or 0) row.rating_count = int(data.get("rating_count") or 0) + row.metacritic_score = int(data.get("metacritic_score") or 0) + row.metacritic_url = str(data.get("metacritic_url") or "") row.genres_json = json.dumps(data.get("genres") or [], ensure_ascii=False) row.updated_time = datetime.now() return row + @classmethod + def ensure_schema(cls): + with F.app.app_context(): + try: + try: + engine = F.db.get_engine(F.app, bind=cls.__bind_key__) + except TypeError: + engine = F.db.engines[cls.__bind_key__] + with engine.begin() as conn: + rows = conn.execute(text(f"PRAGMA table_info({cls.__tablename__})")).fetchall() + columns = {row[1] for row in rows} + if "metacritic_score" not in columns: + conn.execute(text(f"ALTER TABLE {cls.__tablename__} ADD COLUMN metacritic_score INTEGER")) + if "metacritic_url" not in columns: + conn.execute(text(f"ALTER TABLE {cls.__tablename__} ADD COLUMN metacritic_url VARCHAR")) + except Exception: + P.logger.exception("ff_freegame schema migration failed") + @classmethod def delete_not_in_sources(cls, sources): with F.app.app_context(): diff --git a/scraper.py b/scraper.py index 599905b..32c0759 100644 --- a/scraper.py +++ b/scraper.py @@ -47,6 +47,48 @@ _STORE_MAP = { "30": "indiegala", } _CS_DEDICATED_STORE_IDS = {"1", "7", "30"} +_MC_CACHE = {} + + +def _metacritic_from_deal(d): + try: + score = int(float(d.get("metacriticScore") or 0)) + except Exception: + score = 0 + link = d.get("metacriticLink") or "" + if link and link.startswith("/"): + link = "https://www.metacritic.com" + link + return score, link + + +def _cheapshark_metacritic_lookup(title="", steam_appid=None): + key = f"{steam_appid or ''}:{title or ''}".lower() + if key in _MC_CACHE: + return _MC_CACHE[key] + params = {"pageSize": 5} + if steam_appid: + params["steamAppID"] = steam_appid + elif title: + params["title"] = title + else: + _MC_CACHE[key] = (0, "") + return _MC_CACHE[key] + try: + deals = _get(f"{_CS_BASE}/deals", params=params).json() + normalized = re.sub(r"\s+", " ", str(title or "").strip()).lower() + best = None + for deal in deals or []: + deal_title = re.sub(r"\s+", " ", str(deal.get("title") or "").strip()).lower() + if steam_appid or deal_title == normalized: + best = deal + break + if best is None and deals: + best = deals[0] + _MC_CACHE[key] = _metacritic_from_deal(best or {}) + except Exception as e: + log.debug("CheapShark metacritic lookup failed title=%s appid=%s: %s", title, steam_appid, e) + _MC_CACHE[key] = (0, "") + return _MC_CACHE[key] def _cs_deal(d, override_platform=None): @@ -75,6 +117,7 @@ def _cs_deal(d, override_platform=None): deal_id = d.get("dealID", "") game_id = d.get("gameID", "") title = d.get("title", "") + mc_score, mc_url = _metacritic_from_deal(d) is_free_period = curr == 0.0 and orig > 0.0 return { "external_id": f"cs_{game_id}_{store_id}", @@ -91,6 +134,8 @@ def _cs_deal(d, override_platform=None): "genres": [], "rating": rating, "rating_count": rc, + "metacritic_score": mc_score, + "metacritic_url": mc_url, } @@ -223,6 +268,7 @@ def _steam_game_dict(appid: int, detail: dict): is_free_period = (original > 0.0 and (current == 0.0 or discount == 100)) or free_weekend if not is_free_period: return None + mc_score, mc_url = _cheapshark_metacritic_lookup(detail.get("name", ""), steam_appid=appid) return { "external_id": f"steam_{appid}", @@ -237,6 +283,8 @@ def _steam_game_dict(appid: int, detail: dict): "free_start": None, "free_end": None, "genres": genres, "rating": 0.0, "rating_count": rec.get("total", 0), + "metacritic_score": mc_score, + "metacritic_url": mc_url, } @@ -965,8 +1013,18 @@ def _fetch_stove_deals_legacy(): return results +def _enrich_metacritic(items): + for item in items or []: + if int(item.get("metacritic_score") or 0) > 0: + continue + score, url = _cheapshark_metacritic_lookup(item.get("title") or "") + item["metacritic_score"] = score + item["metacritic_url"] = url + return items + + def fetch_all(): - return { + data = { "epic": fetch_epic_free(), "steam": fetch_steam_free(), "cheapshark": fetch_cheapshark_deals(), @@ -975,6 +1033,9 @@ def fetch_all(): "indiegala_free": fetch_indiegala_free(), "stove": fetch_stove_deals(), } + for items in data.values(): + _enrich_metacritic(items) + return data def _parse_dt(s): diff --git a/templates/ff_freegame_main_list.html b/templates/ff_freegame_main_list.html index db6c73f..8aa4418 100644 --- a/templates/ff_freegame_main_list.html +++ b/templates/ff_freegame_main_list.html @@ -145,11 +145,31 @@ font-weight: 600; } .ffg-rating { - min-width: 54px; + display: inline-flex; + flex-direction: column; + align-items: flex-end; + gap: 3px; + min-width: 74px; text-align: right; color: #8e96c4; font-size: 12px; } + .ffg-mc { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 7px; + border-radius: 8px; + background: rgba(250, 204, 21, .14); + border: 1px solid rgba(250, 204, 21, .32); + color: #fde68a; + font-weight: 800; + line-height: 1; + } + .ffg-steam-rating { + color: #8e96c4; + line-height: 1; + } .ffg-empty { padding: 48px 16px; text-align: center; @@ -223,7 +243,11 @@ function gameCard(item) { const genres = Array.isArray(item.genres) ? item.genres.slice(0, 2) : []; const genreHtml = genres.map((genre) => `${escapeHtml(genre)}`).join(""); const ratingHtml = item.rating && Number(item.rating) > 0 - ? `${Math.round(Number(item.rating))}` + ? `Steam ${Math.round(Number(item.rating))}` + : ""; + const mcScore = Number(item.metacritic_score || 0); + const mcHtml = mcScore > 0 + ? `MC ${Math.round(mcScore)}` : ""; return ` @@ -239,7 +263,7 @@ function gameCard(item) {
${genreHtml}
${escapeHtml(formatCountdown(item.free_end))}
-
${ratingHtml}
+
${mcHtml}${ratingHtml}