Show Metacritic scores for free games

This commit is contained in:
2026-05-28 16:49:22 +09:00
parent a7b6f3048c
commit 7d73ecd2b2
4 changed files with 123 additions and 4 deletions
+9
View File
@@ -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"<b>{title}</b> ({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)
+25
View File
@@ -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():
+62 -1
View File
@@ -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):
+27 -3
View File
@@ -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) => `<span class="ffg-genre">${escapeHtml(genre)}</span>`).join("");
const ratingHtml = item.rating && Number(item.rating) > 0
? `${Math.round(Number(item.rating))}`
? `<span class="ffg-steam-rating">Steam ${Math.round(Number(item.rating))}</span>`
: "";
const mcScore = Number(item.metacritic_score || 0);
const mcHtml = mcScore > 0
? `<span class="ffg-mc" title="Metacritic score">MC ${Math.round(mcScore)}</span>`
: "";
return `
@@ -239,7 +263,7 @@ function gameCard(item) {
<div class="ffg-genres">${genreHtml}</div>
<div class="ffg-meta">
<div class="ffg-countdown"><i class="fa fa-clock-o"></i><span>${escapeHtml(formatCountdown(item.free_end))}</span></div>
<div class="ffg-rating">${ratingHtml}</div>
<div class="ffg-rating">${mcHtml}${ratingHtml}</div>
</div>
</div>
</article>