Know What Youโre Getting Into โ Before You Hit Install Update 17.03.2026
Ever downloaded a game only to find out it wants three launchers, a blood sample, and a constant internet connection just to load the main menu? Yeah. That.
What This Actually Does
A lightweight Python tool that scans any Steam game and tells you exactly whatโs standing between you and actually playing it offline.
Plug in a Steam AppID โ get a full protection breakdown in seconds.
Why This Exists
Not Every Game Plays Nice โ Here's the Problem
You already know how to install games using Steam manifests from the previous guide:
Steam Manifest Generators โ Full Toolkit: Download Any Game from Steamโs Own Servers
But hereโs the thing โ not every game cooperates. Some titles are wrapped in so many layers of DRM and third-party garbage that manifest installs either break or become pointless. Denuvo, always-online requirements, forced launcher redirects โ the list goes on.
So instead of downloading 80GB and then finding out it wonโt launch, this tool tells you upfront what youโre dealing with.
Think of it as a pre-flight check before you commit your bandwidth.
What It Scans For
Full Detection Breakdown
The scanner pulls data from three sources simultaneously โ Steam Store API, SteamDB, and PCGamingWiki โ then cross-references everything to minimize false positives.
Hereโs what gets flagged:
| Protection Layer | What It Means for You |
|---|---|
| The big one. Heavily restricts offline and manifest-based installs | |
| Standard Steamworks DRM โ usually manageable, but still noted | |
| Rockstar, Ubisoft Connect, EA App, Battle.net, 2K, Bethesda โ the whole circus | |
| Needs a separate login beyond Steam โ extra friction | |
| No internet = no game. Deal-breaker for offline setups | |
| Easy Anti-Cheat, BattlEye, PunkBuster โ relevant for modding and compatibility |
Every detection includes evidence keywords so you can see exactly what triggered each flag. No guessing.
The Offline Readiness Score
0โ100 Scale โ Instant Verdict
After scanning, the tool spits out a score from 0 to 100:
| Score | Verdict | What It Means |
|---|---|---|
| 80โ100 | Clean install, minimal restrictions โ youโre good | |
| 50โ79 | Some hurdles โ might need workarounds | |
| 0โ49 | Stacked protections โ expect problems with offline/manifest installs |
How scoring works:
- Start at 100 (clean slate)
- Denuvo detected โ โ40
- Always online โ โ40
- Third-party launcher โ โ25
- Account required โ โ20
- Steam DRM โ โ10
Penalties stack. A game with Denuvo + always-online + a forced launcher is already sitting at 0 before you even look at the rest.
What It Looks Like
The GUI is minimal โ intentionally. One input field, one button, full results.
Punch in any Steam AppID (that number in the store URL), hit Analyze, and the full report drops in seconds.
How It Works Under the Hood
Smart Detection โ Not Just Keyword Spam
This isnโt a dumb string matcher. The scanner uses negation-aware context checking โ meaning if a page says โdoes not use Denuvoโ or โno longer requires always online,โ it wonโt false-flag it.
A 30-character window before each keyword match gets checked for negation patterns like โno,โ โnot,โ โwithout,โ โdoes not,โ โdoesnโt,โ and โno longer.โ
Data pipeline:
- Steam Store API โ pulls DRM notices, system requirements, and descriptions
- SteamDB โ deeper metadata and historical protection info (auto-falls back to proxy if blocked)
- PCGamingWiki โ community-maintained protection and compatibility data
All three sources get analyzed independently, then merged. If any source flags a protection, it shows up โ along with which keywords triggered it.
Requirements & Setup
Getting It Running
You need Python with two packages:
pip install requests PyQt6
Save the script, run it, enter an AppID. Thatโs the whole setup.
Dependencies:
requestsโ handles all the HTTP calls to Steam, SteamDB, and PCGamingWikiPyQt6โ the GUI framework
No API keys. No accounts. No config files. Just run it.
The Full Script
Complete Python Source โ Copy and Go
#!/usr/bin/env python3
"""Steam Protection Analyzer Pro.
Desktop utility that aggregates multiple public sources to estimate:
- DRM / protection technologies
- offline readiness score
- top Steam games in near real-time
- news feed about DRM, anti-cheat and gaming industry updates
- download of manifest-like metadata snapshots from multiple open endpoints
"""
from __future__ import annotations
import json
import re
import sys
import threading
import time
import webbrowser
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from email.utils import parsedate_to_datetime
from pathlib import Path
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
from urllib.parse import quote
from xml.etree import ElementTree as ET
import requests
from PySide6 import QtCore, QtGui, QtWidgets
HEADERS = {"User-Agent": "Mozilla/5.0 (SteamProtectionAnalyzer/2.0)"}
REQUEST_TIMEOUT = 12
LAUNCHERS = {
"rockstar": "Rockstar Games Launcher",
"social club": "Rockstar Games Launcher",
"ubisoft": "Ubisoft Connect",
"uplay": "Ubisoft Connect",
"ea app": "EA App",
"origin": "EA App",
"battle.net": "Battle.net",
"2k launcher": "2K Launcher",
"bethesda.net": "Bethesda Launcher",
"paradox launcher": "Paradox Launcher",
"2k account": "2K Launcher",
}
ACCOUNT_SIGNS = [
"requires third-party account",
"requires account",
"online activation",
"social club account",
"ea account",
"ubisoft account",
"requires 3rd-party account",
"requires internet connection for first-time activation",
]
ANTICHEAT = {
"easy anti-cheat": "Easy Anti-Cheat",
"battleye": "BattlEye",
"punkbuster": "PunkBuster",
"xigncode": "XIGNCODE",
"ricochet anti-cheat": "RICOCHET",
"vanguard": "Riot Vanguard",
}
PROTECTION_SIGNS = {
"denuvo": "Denuvo",
"vmprotect": "VMProtect",
"securom": "SecuROM",
"arxan": "Arxan",
"themida": "Themida",
"origin drm": "Origin DRM",
}
STEAM_DRM_SIGNS = ["steam drm", "steamworks drm", "steamworks (drm)"]
ALWAYS_ONLINE_SIGNS = ["always online", "permanent internet connection", "internet required"]
NEGATION_PATTERNS = [
"no ",
"not ",
"without ",
"does not ",
"doesn't ",
"no longer ",
"removed ",
"was removed",
]
NEWS_FEEDS = [
"https://store.steampowered.com/feeds/news.xml",
"https://www.pcgamer.com/rss/",
"https://www.rockpapershotgun.com/feed",
"https://www.gamespot.com/feeds/mashup/",
"https://kotaku.com/rss",
"https://www.ign.com/rss.xml",
"https://www.eurogamer.net/feed",
"https://www.gamesradar.com/rss/",
"https://www.polygon.com/rss/index.xml",
"https://www.destructoid.com/feed/",
"https://www.vg247.com/feed",
"https://www.gematsu.com/feed",
"https://www.nintendolife.com/feeds/latest",
"https://www.purexbox.com/feeds/latest",
"https://www.pushsquare.com/feeds/latest",
"https://www.dualshockers.com/feed/",
"https://www.shacknews.com/feed",
"https://www.mmorpg.com/rss/news.xml",
"https://www.pcgamesn.com/rss",
"https://www.gamesindustry.biz/feed",
"https://www.si.com/videogames/.rss/full/",
"https://www.engadget.com/gaming/rss.xml",
"https://venturebeat.com/category/games/feed/",
"https://www.dexerto.com/gaming/feed/",
"https://www.techradar.com/gaming/rss",
"https://feeds.feedburner.com/steam_database",
"https://store.steampowered.com/oldnews/?feed=steam_community_announcements",
]
NEWS_KEYWORDS = [
"denuvo",
"drm",
"anti-cheat",
"steam",
"protection",
"offline",
"review",
]
NEWS_MAX_AGE_HOURS = 72
GITHUB_API = "https://api.github.com"
GITHUB_MANIFEST_QUERIES = [
'manifest depot appid {app_id} extension:json',
'depot manifest "{app_id}" extension:txt',
'steam manifest "{app_id}" extension:vdf',
]
@dataclass
class SteamStoreEntry:
name: str
text: str
@dataclass
class TopGame:
app_id: int
name: str
players: int
review_percent: Optional[int]
review_text: str
opencritic: str
metacritic: str
ratings_note: str
@dataclass
class NewsItem:
title: str
link: str
source: str
published: str
@dataclass
class NewReleaseItem:
app_id: int
name: str
discount: str
final_price: str
@dataclass
class SteamworksBuildConfig:
app_id: str
depot_id: str
content_root: str
branch: str
description: str
class BackgroundTask(QtCore.QObject):
completed = QtCore.Signal(object)
failed = QtCore.Signal(str)
finished = QtCore.Signal()
def __init__(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> None:
super().__init__()
self._fn = fn
self._args = args
self._kwargs = kwargs
@QtCore.Slot()
def run(self) -> None:
try:
result = self._fn(*self._args, **self._kwargs)
self.completed.emit(result)
except Exception as exc: # noqa: BLE001
self.failed.emit(str(exc))
finally:
self.finished.emit()
class DataProvider:
def __init__(self) -> None:
self.session = requests.Session()
self._cache: Dict[str, Tuple[float, Any]] = {}
self._cache_lock = threading.Lock()
def _cache_key(self, url: str, params: Optional[dict], as_json: bool) -> str:
params_part = json.dumps(params or {}, sort_keys=True, ensure_ascii=False)
return f"{'json' if as_json else 'text'}::{url}::{params_part}"
def _cache_get(self, key: str, ttl: int) -> Optional[Any]:
now = time.time()
with self._cache_lock:
data = self._cache.get(key)
if not data:
return None
ts, value = data
if now - ts > ttl:
self._cache.pop(key, None)
return None
return value
def _cache_set(self, key: str, value: Any) -> None:
with self._cache_lock:
self._cache[key] = (time.time(), value)
def _get_text(self, url: str) -> str:
key = self._cache_key(url, None, as_json=False)
cached = self._cache_get(key, ttl=300)
if cached is not None:
return str(cached)
response = self.session.get(url, headers=HEADERS, timeout=REQUEST_TIMEOUT)
response.raise_for_status()
text = response.text
self._cache_set(key, text)
return text
def _get_json(self, url: str, params: Optional[dict] = None) -> dict:
key = self._cache_key(url, params, as_json=True)
cached = self._cache_get(key, ttl=300)
if cached is not None and isinstance(cached, dict):
return cached
response = self.session.get(url, headers=HEADERS, timeout=REQUEST_TIMEOUT, params=params)
response.raise_for_status()
payload = response.json()
self._cache_set(key, payload)
return payload
def steam_store_data(self, app_id: str) -> Optional[SteamStoreEntry]:
url = "https://store.steampowered.com/api/appdetails"
data = self._get_json(url, {"appids": app_id, "cc": "us", "l": "en"})
node = data.get(str(app_id), {})
if not node.get("success"):
return None
details = node.get("data", {})
name = details.get("name", "Unknown title")
text = (
str(details.get("drm_notice", ""))
+ "\n"
+ str(details.get("pc_requirements", ""))
+ "\n"
+ details.get("short_description", "")
+ "\n"
+ details.get("detailed_description", "")
).lower()
return SteamStoreEntry(name=name, text=text)
def steamdb_text(self, app_id: str) -> str:
candidates = [
f"https://steamdb.info/app/{app_id}/",
f"https://r.jina.ai/http://steamdb.info/app/{app_id}/",
]
last_error: Optional[Exception] = None
for url in candidates:
try:
return self._get_text(url).lower()
except requests.RequestException as exc:
last_error = exc
raise requests.RequestException(f"SteamDB unavailable: {last_error}")
def pcgamingwiki_text(self, game_name: str) -> str:
encoded = quote(game_name.replace(" ", "_"), safe="_")
candidates = [
f"https://www.pcgamingwiki.com/wiki/{encoded}",
f"https://r.jina.ai/http://www.pcgamingwiki.com/wiki/{encoded}",
]
for url in candidates:
try:
return self._get_text(url).lower()
except requests.RequestException:
continue
# Fallback to MediaWiki API extract (usually more permissive than direct page fetch).
api_url = "https://www.pcgamingwiki.com/w/api.php"
payload = {
"action": "query",
"prop": "extracts",
"titles": game_name,
"format": "json",
"explaintext": 1,
"redirects": 1,
}
data = self._get_json(api_url, payload)
pages = data.get("query", {}).get("pages", {})
extract_parts = [str(v.get("extract", "")) for v in pages.values()]
extract = "\n".join(extract_parts).strip()
if not extract:
raise requests.RequestException("PCGamingWiki empty response")
return extract.lower()
def fetch_top_games(self, limit: int = 20) -> List[TopGame]:
steamspy = self._get_json("https://steamspy.com/api.php", {"request": "all"})
entries = sorted(
[v for v in steamspy.values() if isinstance(v, dict) and v.get("appid")],
key=lambda x: int(x.get("ccu", 0)),
reverse=True,
)[:limit]
def build_row(entry: dict) -> TopGame:
app_id = int(entry["appid"])
name = str(entry.get("name", f"App {app_id}"))
players = int(entry.get("ccu", 0))
review_percent, review_text = self._review_snapshot(app_id)
external = self._external_ratings(app_id, name, review_percent)
return TopGame(
app_id=app_id,
name=name,
players=players,
review_percent=review_percent,
review_text=review_text,
opencritic=external.get("opencritic", "0/100"),
metacritic=external.get("metacritic", "0/100"),
ratings_note=external.get("note", ""),
)
result: List[TopGame] = []
workers = min(8, max(2, len(entries)))
with ThreadPoolExecutor(max_workers=workers) as pool:
future_map = {pool.submit(build_row, entry): idx for idx, entry in enumerate(entries)}
ordered: Dict[int, TopGame] = {}
for future in as_completed(future_map):
idx = future_map[future]
try:
ordered[idx] = future.result()
except Exception:
continue
for idx in range(len(entries)):
if idx in ordered:
result.append(ordered[idx])
return result
def _review_snapshot(self, app_id: int) -> Tuple[Optional[int], str]:
data = self._get_json(
f"https://store.steampowered.com/appreviews/{app_id}",
{"json": 1, "language": "all", "purchase_type": "all", "num_per_page": 0},
)
summary = data.get("query_summary", {})
total = int(summary.get("total_reviews", 0))
positive = int(summary.get("total_positive", 0))
if total <= 0:
return None, "No review data"
percent = int(round((positive / total) * 100))
text = f"{percent}% positive ({total:,} reviews)"
return percent, text
def _external_ratings(self, app_id: int, game_name: str, steam_percent: Optional[int]) -> Dict[str, str]:
fallback_score = f"{steam_percent}/100" if steam_percent is not None else "0/100"
ratings: Dict[str, str] = {"opencritic": fallback_score, "metacritic": fallback_score, "note": ""}
query = quote(game_name)
notes: List[str] = []
# 1) OpenCritic API (if reachable)
for endpoint in (
"https://api.opencritic.com/api/meta/search",
"https://api.opencritic.com/api/game/search",
):
try:
data = self._get_json(endpoint, {"criteria": game_name})
if isinstance(data, list) and data:
candidate = data[0]
score = candidate.get("score")
if score is not None:
ratings["opencritic"] = f"{float(score):.0f}/100"
break
except (requests.RequestException, ValueError, TypeError):
continue
# 2) OpenCritic HTML fallback through text proxy
if ratings["opencritic"] == fallback_score:
try:
html_text = self._get_text(f"https://r.jina.ai/http://opencritic.com/search/{query}")
score_match = re.search(r"(\d{2})\s*Top Critic Average", html_text, re.I)
if score_match:
ratings["opencritic"] = f"{score_match.group(1)}/100"
else:
notes.append("OpenCritic fallback to Steam score")
except requests.RequestException:
notes.append("OpenCritic fallback to Steam score")
# 3) Metacritic from Steam Store appdetails (usually stable)
try:
app_data = self._get_json(
"https://store.steampowered.com/api/appdetails",
{"appids": str(app_id), "cc": "us", "l": "en"},
)
node = app_data.get(str(app_id), {})
details = node.get("data", {}) if node.get("success") else {}
meta_info = details.get("metacritic", {}) if isinstance(details, dict) else {}
score = meta_info.get("score") if isinstance(meta_info, dict) else None
if score is not None:
ratings["metacritic"] = f"{int(score)}/100"
except (requests.RequestException, ValueError, TypeError):
notes.append("Steam metacritic field unavailable")
# 4) Metacritic page fallback
if ratings["metacritic"] == fallback_score:
meta_candidates = [
f"https://www.metacritic.com/search/{query}/?category=13",
f"https://r.jina.ai/http://www.metacritic.com/search/{query}/?category=13",
]
for url in meta_candidates:
try:
html_text = self._get_text(url)
except requests.RequestException:
continue
match = re.search(r'metascore_w\s+large\s+game\s+[a-z_]+">(\d{1,3})<', html_text, re.I)
if match:
ratings["metacritic"] = f"{match.group(1)}/100"
break
if ratings["metacritic"] == fallback_score:
notes.append("Metacritic not parsed")
ratings["note"] = "; ".join(notes)
return ratings
def fetch_game_rating(self, app_id: str) -> Dict[str, str]:
store = self.steam_store_data(app_id)
if not store:
return {"error": "Game not found"}
review_percent, review_text = self._review_snapshot(int(app_id))
external = self._external_ratings(int(app_id), store.name, review_percent)
ccu = 0
try:
spy = self._get_json("https://steamspy.com/api.php", {"request": "appdetails", "appid": app_id})
ccu = int(spy.get("ccu", 0) or 0)
except (requests.RequestException, ValueError, TypeError):
ccu = 0
return {
"app_id": app_id,
"name": store.name,
"ccu": f"{ccu:,}" if ccu > 0 else "โ",
"steam_reviews": review_text,
"opencritic": external.get("opencritic", "0/100"),
"metacritic": external.get("metacritic", "0/100"),
"note": external.get("note", ""),
}
def fetch_new_releases(self, limit: int = 50) -> List[NewReleaseItem]:
data = self._get_json("https://store.steampowered.com/api/featuredcategories", {"cc": "us", "l": "en"})
items = data.get("new_releases", {}).get("items", [])
rows: List[NewReleaseItem] = []
for item in items[:limit]:
app_id = int(item.get("id", 0) or 0)
name = str(item.get("name", f"App {app_id}"))
discount = f"{int(item.get('discount_percent', 0))}%"
final = item.get("final_price", 0)
final_price = f"${(int(final) / 100):.2f}" if isinstance(final, int) else str(final)
rows.append(NewReleaseItem(app_id=app_id, name=name, discount=discount, final_price=final_price))
return rows
def fetch_news(self, limit: int = 50) -> List[NewsItem]:
collected: List[NewsItem] = []
fallback_collected: List[NewsItem] = []
def fetch_feed(feed_url: str) -> Tuple[List[NewsItem], List[NewsItem]]:
local_collected: List[NewsItem] = []
local_fallback: List[NewsItem] = []
try:
xml_payload = self._get_text(feed_url)
except requests.RequestException:
return local_collected, local_fallback
try:
root = ET.fromstring(xml_payload)
except ET.ParseError:
return local_collected, local_fallback
source = self._source_name(feed_url)
for title, link, pub in self._iter_feed_items(root):
blob = f"{title} {link}".lower()
if any(word in blob for word in NEWS_KEYWORDS):
local_collected.append(NewsItem(title=title, link=link, source=source, published=pub))
else:
local_fallback.append(NewsItem(title=title, link=link, source=source, published=pub))
return local_collected, local_fallback
workers = min(16, max(4, len(NEWS_FEEDS)))
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = [pool.submit(fetch_feed, url) for url in NEWS_FEEDS]
for future in as_completed(futures):
try:
local_collected, local_fallback = future.result()
except Exception:
continue
collected.extend(local_collected)
fallback_collected.extend(local_fallback)
if len(collected) < max(10, limit // 2):
collected.extend(fallback_collected)
dedup: Dict[str, NewsItem] = {}
for row in collected:
dedup[row.link or row.title] = row
now = datetime.now(timezone.utc)
max_age = timedelta(hours=NEWS_MAX_AGE_HOURS)
fresh_rows: List[Tuple[datetime, NewsItem]] = []
for row in dedup.values():
published_at = self._parse_published_datetime(row.published)
if not published_at:
continue
age = now - published_at
if age < timedelta(hours=-2):
continue
if age <= max_age:
fresh_rows.append((published_at, row))
fresh_rows.sort(key=lambda x: x[0], reverse=True)
return [row for _, row in fresh_rows[:limit]]
def _parse_published_datetime(self, value: str) -> Optional[datetime]:
text = value.strip()
if not text:
return None
parsed: Optional[datetime] = None
try:
parsed = parsedate_to_datetime(text)
except (TypeError, ValueError):
parsed = None
if parsed is None:
iso_candidate = text.replace("Z", "+00:00")
try:
parsed = datetime.fromisoformat(iso_candidate)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
else:
parsed = parsed.astimezone(timezone.utc)
return parsed
def _source_name(self, feed_url: str) -> str:
if "steampowered" in feed_url:
return "Steam"
if "steam_database" in feed_url:
return "SteamDB"
if "pcgamer" in feed_url:
return "PC Gamer"
if "rockpapershotgun" in feed_url:
return "Rock Paper Shotgun"
if "gamespot" in feed_url:
return "GameSpot"
if "kotaku" in feed_url:
return "Kotaku"
if "ign" in feed_url:
return "IGN"
if "eurogamer" in feed_url:
return "Eurogamer"
if "gamesradar" in feed_url:
return "GamesRadar"
if "polygon" in feed_url:
return "Polygon"
if "destructoid" in feed_url:
return "Destructoid"
if "vg247" in feed_url:
return "VG247"
if "gematsu" in feed_url:
return "Gematsu"
if "nintendolife" in feed_url:
return "Nintendo Life"
if "purexbox" in feed_url:
return "Pure Xbox"
if "pushsquare" in feed_url:
return "Push Square"
if "dualshockers" in feed_url:
return "DualShockers"
if "shacknews" in feed_url:
return "Shacknews"
if "mmorpg" in feed_url:
return "MMORPG.com"
if "pcgamesn" in feed_url:
return "PCGamesN"
if "gamesindustry" in feed_url:
return "GamesIndustry"
if "si.com/videogames" in feed_url:
return "Sports Illustrated Gaming"
if "engadget" in feed_url:
return "Engadget Gaming"
if "venturebeat" in feed_url:
return "VentureBeat Games"
if "dexerto" in feed_url:
return "Dexerto Gaming"
if "techradar" in feed_url:
return "TechRadar Gaming"
return "RSS"
def _iter_feed_items(self, root: ET.Element) -> List[Tuple[str, str, str]]:
rows: List[Tuple[str, str, str]] = []
# RSS
for item in root.findall(".//item"):
title = (item.findtext("title") or "Untitled").strip()
link = (item.findtext("link") or "").strip()
pub = (item.findtext("pubDate") or item.findtext("published") or "").strip()
if title and link:
rows.append((title, link, pub))
# Atom with namespaces
for entry in root.findall(".//{*}entry"):
title = (entry.findtext("{*}title") or "Untitled").strip()
pub = (entry.findtext("{*}published") or entry.findtext("{*}updated") or "").strip()
link_node = entry.find("{*}link[@rel='alternate']") or entry.find("{*}link")
link = ""
if link_node is not None:
link = (link_node.attrib.get("href") or link_node.text or "").strip()
if title and link:
rows.append((title, link, pub))
return rows
def _extract_manifest_entries(self, text: str) -> List[Dict[str, str]]:
entries: Dict[str, Dict[str, str]] = {}
patterns = [
re.compile(r"depot\s*(\d{4,})[^\n]{0,200}?manifest\s*(\d{6,})", re.I),
re.compile(r"(\d{4,})[^\n]{0,200}?gid\s*(\d{6,})", re.I),
]
for pattern in patterns:
for depot_id, manifest_id in pattern.findall(text):
key = f"{depot_id}:{manifest_id}"
entries[key] = {"depot_id": depot_id, "manifest_id": manifest_id}
return sorted(entries.values(), key=lambda row: (int(row["depot_id"]), int(row["manifest_id"])))
def _try_save_json(self, out_dir: Path, name: str, url: str, params: Optional[dict]) -> Path:
data = self._get_json(url, params)
output = out_dir / name
output.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
return output
def _try_save_text(self, out_dir: Path, name: str, url: str) -> Path:
text = self._get_text(url)
output = out_dir / name
output.write_text(text, encoding="utf-8")
return output
def _steamcmd_download_commands(self, app_id: str, entries: List[Dict[str, str]]) -> List[str]:
commands: List[str] = []
for row in entries:
commands.append(
f"+download_depot {app_id} {row['depot_id']} {row['manifest_id']}"
)
return commands
def _github_headers(self) -> Dict[str, str]:
headers = dict(HEADERS)
headers["Accept"] = "application/vnd.github+json"
return headers
def _search_github_manifest_sources(self, app_id: str, limit: int = 50) -> List[Tuple[str, str]]:
found: Dict[str, str] = {}
for query_template in GITHUB_MANIFEST_QUERIES:
query = query_template.format(app_id=app_id)
url = f"{GITHUB_API}/search/code"
try:
response = self.session.get(
url,
headers=self._github_headers(),
params={"q": query, "per_page": min(limit, 100)},
timeout=REQUEST_TIMEOUT,
)
response.raise_for_status()
data = response.json()
except (requests.RequestException, ValueError):
continue
for item in data.get("items", []):
repo = item.get("repository", {})
full_name = repo.get("full_name")
default_branch = repo.get("default_branch", "master")
path = item.get("path")
if not full_name or not path:
continue
raw_url = f"https://raw.githubusercontent.com/{full_name}/{default_branch}/{path}"
label = f"github:{full_name}/{path}"
found[raw_url] = label
if len(found) >= limit:
break
if len(found) >= limit:
break
return [(url, label) for url, label in found.items()]
def download_manifest_bundle(self, app_id: str, out_dir: Path) -> Tuple[List[Path], List[str]]:
out_dir.mkdir(parents=True, exist_ok=True)
stamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
generated: List[Path] = []
errors: List[str] = []
manifest_entries: List[Dict[str, str]] = []
json_sources = [
(
f"appdetails_{app_id}_{stamp}.json",
"https://store.steampowered.com/api/appdetails",
{"appids": app_id, "cc": "us", "l": "en"},
),
(
f"steamspy_{app_id}_{stamp}.json",
"https://steamspy.com/api.php",
{"request": "appdetails", "appid": app_id},
),
(
f"steamcmd_info_{app_id}_{stamp}.json",
f"https://api.steamcmd.net/v1/info/{app_id}",
None,
),
]
for name, url, params in json_sources:
try:
generated.append(self._try_save_json(out_dir, name, url, params))
except requests.RequestException as exc:
errors.append(f"{url}: {exc}")
steamdb_candidates = [
f"https://steamdb.info/app/{app_id}/depots/",
f"https://r.jina.ai/http://steamdb.info/app/{app_id}/depots/",
]
for idx, url in enumerate(steamdb_candidates, start=1):
try:
text_path = self._try_save_text(out_dir, f"steamdb_depots_source{idx}_{app_id}_{stamp}.txt", url)
generated.append(text_path)
manifest_entries.extend(self._extract_manifest_entries(text_path.read_text(encoding="utf-8", errors="ignore")))
except requests.RequestException as exc:
errors.append(f"{url}: {exc}")
github_candidates = self._search_github_manifest_sources(app_id, limit=80)
for idx, (url, label) in enumerate(github_candidates, start=1):
try:
text_path = self._try_save_text(
out_dir,
f"github_manifest_source{idx}_{app_id}_{stamp}.txt",
url,
)
generated.append(text_path)
text = text_path.read_text(encoding="utf-8", errors="ignore")
entries = self._extract_manifest_entries(text)
if entries:
manifest_entries.extend(entries)
except requests.RequestException as exc:
errors.append(f"{label} ({url}): {exc}")
unique: Dict[str, Dict[str, str]] = {}
for row in manifest_entries:
unique[f"{row['depot_id']}:{row['manifest_id']}"] = row
manifest_entries = sorted(unique.values(), key=lambda row: (int(row["depot_id"]), int(row["manifest_id"])))
index_payload = {
"app_id": app_id,
"generated_at_utc": datetime.now(timezone.utc).isoformat(),
"manifest_count": len(manifest_entries),
"entries": manifest_entries,
}
index_path = out_dir / f"manifest_index_{app_id}_{stamp}.json"
index_path.write_text(json.dumps(index_payload, ensure_ascii=False, indent=2), encoding="utf-8")
generated.append(index_path)
links_path = out_dir / f"manifest_links_{app_id}_{stamp}.txt"
if manifest_entries:
link_rows = [
f"https://steamdb.info/depot/{row['depot_id']}/manifests/?utm_source=analyzer#manifest_{row['manifest_id']}"
for row in manifest_entries
]
links_path.write_text("\n".join(link_rows), encoding="utf-8")
steamcmd_cmds = self._steamcmd_download_commands(app_id, manifest_entries)
commands_txt = out_dir / f"steamcmd_download_commands_{app_id}_{stamp}.txt"
commands_txt.write_text(
"\n".join(
[
"# Login first with account that owns app/depot access:",
"steamcmd +login <steam_user> <password>",
"",
"# Then run each command:",
]
+ [f"steamcmd +login <steam_user> <password> {cmd} +quit" for cmd in steamcmd_cmds]
),
encoding="utf-8",
)
generated.append(commands_txt)
script_sh = out_dir / f"download_manifests_{app_id}_{stamp}.sh"
script_sh.write_text(
"#!/usr/bin/env bash\n"
"set -euo pipefail\n"
"if [ $# -lt 2 ]; then\n"
" echo \"Usage: $0 <steam_user> <steam_password>\"\n"
" exit 1\n"
"fi\n"
"USER=\"$1\"\n"
"PASS=\"$2\"\n"
+ "\n".join([f"steamcmd +login \"$USER\" \"$PASS\" {cmd} +quit" for cmd in steamcmd_cmds])
+ "\n",
encoding="utf-8",
)
generated.append(script_sh)
script_bat = out_dir / f"download_manifests_{app_id}_{stamp}.bat"
script_bat.write_text(
"@echo off\r\n"
"if \"%~2\"==\"\" (\r\n"
" echo Usage: %~nx0 ^<steam_user^> ^<steam_password^>\r\n"
" exit /b 1\r\n"
")\r\n"
"set USER=%~1\r\n"
"set PASS=%~2\r\n"
+ "\r\n".join([f"steamcmd +login \"%USER%\" \"%PASS%\" {cmd} +quit" for cmd in steamcmd_cmds])
+ "\r\n",
encoding="utf-8",
)
generated.append(script_bat)
else:
links_path.write_text(
"No manifest IDs parsed from open sources for this app at this time.\n"
"Saved source dumps can be inspected manually.",
encoding="utf-8",
)
generated.append(links_path)
return generated, errors
def generate_steamworks_build_files(config: SteamworksBuildConfig, out_dir: Path) -> List[Path]:
out_dir.mkdir(parents=True, exist_ok=True)
safe_desc = config.description.replace('"', "'")
app_build = (
'"appbuild"\n'
'{\n'
f' "appid" "{config.app_id}"\n'
f' "desc" "{safe_desc}"\n'
f' "buildoutput" "{(out_dir / "output").as_posix()}"\n'
f' "contentroot" "{config.content_root}"\n'
f' "setlive" "{config.branch}"\n'
' "depots"\n'
' {\n'
f' "{config.depot_id}" "depot_build_{config.depot_id}.vdf"\n'
' }\n'
'}\n'
)
depot_build = (
'"DepotBuildConfig"\n'
'{\n'
f' "DepotID" "{config.depot_id}"\n'
' "FileMapping"\n'
' {\n'
' "LocalPath" "*"\n'
' "DepotPath" "."\n'
' "recursive" "1"\n'
' }\n'
' "FileExclusion" "*.pdb"\n'
'}\n'
)
app_path = out_dir / f"app_build_{config.app_id}.vdf"
depot_path = out_dir / f"depot_build_{config.depot_id}.vdf"
readme_path = out_dir / "steamcmd_publish_instructions.txt"
app_path.write_text(app_build, encoding="utf-8")
depot_path.write_text(depot_build, encoding="utf-8")
readme_path.write_text(
"Generated for your own Steamworks app/depot only.\n"
"1) Place your game build files under contentroot.\n"
"2) Login: steamcmd +login <publisher_user>\n"
f"3) Run: steamcmd +run_app_build {app_path.as_posix()} +quit\n"
"4) Verify build in Steamworks dashboard.\n",
encoding="utf-8",
)
return [app_path, depot_path, readme_path]
def _has_term(text: str, term: str) -> bool:
for match in re.finditer(re.escape(term), text):
start = max(match.start() - 40, 0)
context = text[start:match.start()]
if any(neg in context for neg in NEGATION_PATTERNS):
continue
return True
return False
def _gather_terms(text: str, terms: Iterable[str]) -> List[str]:
return [term for term in terms if _has_term(text, term)]
def analyze_text(text: str) -> Dict[str, object]:
result = {
"protections": set(),
"steam_drm": False,
"launchers": set(),
"account_required": False,
"always_online": False,
"anticheats": set(),
"evidence": {
"protections": [],
"steam_drm": [],
"launchers": [],
"account_required": [],
"always_online": [],
"anticheats": [],
},
}
for key, name in PROTECTION_SIGNS.items():
if _has_term(text, key):
result["protections"].add(name)
result["evidence"]["protections"].append(name)
steam_drm_hits = _gather_terms(text, STEAM_DRM_SIGNS)
if steam_drm_hits:
result["steam_drm"] = True
result["evidence"]["steam_drm"].extend(steam_drm_hits)
for key, launcher in LAUNCHERS.items():
if _has_term(text, key):
result["launchers"].add(launcher)
result["evidence"]["launchers"].append(launcher)
account_hits = _gather_terms(text, ACCOUNT_SIGNS)
if account_hits:
result["account_required"] = True
result["evidence"]["account_required"].extend(account_hits)
online_hits = _gather_terms(text, ALWAYS_ONLINE_SIGNS)
if online_hits:
result["always_online"] = True
result["evidence"]["always_online"].extend(online_hits)
for key, anticheat in ANTICHEAT.items():
if _has_term(text, key):
result["anticheats"].add(anticheat)
result["evidence"]["anticheats"].append(anticheat)
return result
def merge_results(*results: Optional[Dict[str, object]]) -> Dict[str, object]:
final = {
"protections": set(),
"steam_drm": False,
"launchers": set(),
"account_required": False,
"always_online": False,
"anticheats": set(),
"evidence": {
"protections": [],
"steam_drm": [],
"launchers": [],
"account_required": [],
"always_online": [],
"anticheats": [],
},
}
for row in results:
if not row:
continue
final["protections"].update(row.get("protections", set()))
final["launchers"].update(row.get("launchers", set()))
final["anticheats"].update(row.get("anticheats", set()))
final["steam_drm"] = final["steam_drm"] or bool(row.get("steam_drm"))
final["account_required"] = final["account_required"] or bool(row.get("account_required"))
final["always_online"] = final["always_online"] or bool(row.get("always_online"))
for key, values in row.get("evidence", {}).items():
final["evidence"].setdefault(key, []).extend(values)
return final
def offline_score(result: Dict[str, object]) -> int:
score = 100
score -= min(45, len(result["protections"]) * 18)
if result["launchers"]:
score -= 20
if result["account_required"]:
score -= 20
if result["always_online"]:
score -= 35
if result["steam_drm"]:
score -= 10
return max(score, 0)
def _list_text(items: Iterable[str]) -> str:
values = sorted(set(items))
return ", ".join(values) if values else "NO"
class AnalyzerTab(QtWidgets.QWidget):
def __init__(self, provider: DataProvider) -> None:
super().__init__()
self.provider = provider
layout = QtWidgets.QVBoxLayout(self)
header = QtWidgets.QHBoxLayout()
header.addWidget(QtWidgets.QLabel("Steam AppID:"))
self.input = QtWidgets.QLineEdit()
self.input.setPlaceholderText("Example: 1174180")
header.addWidget(self.input)
self.analyze_btn = QtWidgets.QPushButton("Analyze")
self.analyze_btn.clicked.connect(self.run_check)
header.addWidget(self.analyze_btn)
layout.addLayout(header)
self.game_name_label = QtWidgets.QLabel("Game: โ")
layout.addWidget(self.game_name_label)
self.output = QtWidgets.QTextEdit()
self.output.setReadOnly(True)
layout.addWidget(self.output)
self._thread: Optional[QtCore.QThread] = None
self._worker: Optional[BackgroundTask] = None
def run_check(self) -> None:
app_id = self.input.text().strip()
if not app_id.isdigit():
self.output.setText("Invalid AppID.")
return
self.analyze_btn.setEnabled(False)
self.output.setText("Analyzing...\n")
self._start_task(self._analyze_job, app_id)
def _start_task(self, fn: Callable[..., Any], *args: Any) -> None:
thread = QtCore.QThread(self)
worker = BackgroundTask(fn, *args)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.completed.connect(self._on_analyze_done)
worker.failed.connect(self._on_analyze_failed)
worker.finished.connect(thread.quit)
worker.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
self._thread = thread
self._worker = worker
thread.start()
def _analyze_job(self, app_id: str) -> Dict[str, str]:
store = self.provider.steam_store_data(app_id)
if not store:
return {"error": "Game not found.", "name": ""}
errors: List[str] = []
steamdb_text = ""
wiki_text = ""
try:
steamdb_text = self.provider.steamdb_text(app_id)
except requests.RequestException as exc:
errors.append(f"SteamDB unavailable ({exc})")
try:
wiki_text = self.provider.pcgamingwiki_text(store.name)
except requests.RequestException as exc:
errors.append(f"PCGamingWiki unavailable ({exc})")
final = merge_results(analyze_text(store.text), analyze_text(steamdb_text), analyze_text(wiki_text))
score = offline_score(final)
message = [
f"Game: {store.name}",
"",
"Protection summary:",
f"- Detected protections: {_list_text(final['protections'])}",
f"- Steam DRM: {'YES' if final['steam_drm'] else 'NO'}",
f"- Third-party launcher: {_list_text(final['launchers'])}",
f"- Account required: {'YES' if final['account_required'] else 'NO'}",
f"- Always online: {'YES' if final['always_online'] else 'NO'}",
f"- Anti-cheat: {_list_text(final['anticheats'])}",
"",
f"Offline readiness score: {score}/100",
]
if score >= 80:
message.append("โ
Excellent for offline play")
elif score >= 50:
message.append("โ Limited offline usability")
else:
message.append("โ Poor offline experience")
if errors:
message += ["", "Warnings:"] + [f"- {e}" for e in errors]
evidence = final["evidence"]
message += [
"",
"Evidence:",
f"- Protections: {_list_text(evidence['protections'])}",
f"- Steam DRM: {_list_text(evidence['steam_drm'])}",
f"- Launchers: {_list_text(evidence['launchers'])}",
f"- Accounts: {_list_text(evidence['account_required'])}",
f"- Always-online: {_list_text(evidence['always_online'])}",
f"- Anti-cheat: {_list_text(evidence['anticheats'])}",
]
return {"name": store.name, "message": "\n".join(message)}
@QtCore.Slot(object)
def _on_analyze_done(self, result: object) -> None:
payload = result if isinstance(result, dict) else {}
error = str(payload.get("error", ""))
if error:
self.output.setText(error)
self.game_name_label.setText("Game: โ")
self.analyze_btn.setEnabled(True)
return
name = str(payload.get("name", "โ"))
self.game_name_label.setText(f"Game: {name}")
self.output.setText(str(payload.get("message", "No data.")))
self.analyze_btn.setEnabled(True)
@QtCore.Slot(str)
def _on_analyze_failed(self, error: str) -> None:
self.output.setText(f"Analysis failed: {error}")
self.analyze_btn.setEnabled(True)
class TopGamesTab(QtWidgets.QWidget):
def __init__(self, provider: DataProvider) -> None:
super().__init__()
self.provider = provider
layout = QtWidgets.QVBoxLayout(self)
controls = QtWidgets.QHBoxLayout()
self.limit_spin = QtWidgets.QSpinBox()
self.limit_spin.setRange(5, 2000)
self.limit_spin.setValue(20)
self.refresh_btn = QtWidgets.QPushButton("Refresh top games")
self.refresh_btn.clicked.connect(self.refresh)
controls.addWidget(QtWidgets.QLabel("Rows:"))
controls.addWidget(self.limit_spin)
self.search_id_input = QtWidgets.QLineEdit()
self.search_id_input.setPlaceholderText("Search by AppID")
controls.addWidget(self.search_id_input)
self.search_btn = QtWidgets.QPushButton("Find rating")
self.search_btn.clicked.connect(self.search_rating)
controls.addWidget(self.search_btn)
controls.addStretch(1)
controls.addWidget(self.refresh_btn)
layout.addLayout(controls)
self.search_result = QtWidgets.QLabel("Game rating lookup: โ")
layout.addWidget(self.search_result)
self.table = QtWidgets.QTableWidget(0, 8)
self.table.setHorizontalHeaderLabels(
["#", "Game", "CCU", "Steam Reviews", "OpenCritic", "Metacritic", "AppID", "Ratings status"]
)
self.table.horizontalHeader().setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.Stretch)
layout.addWidget(self.table)
self._thread: Optional[QtCore.QThread] = None
self._worker: Optional[BackgroundTask] = None
def refresh(self) -> None:
self.refresh_btn.setEnabled(False)
self._start_task(self.provider.fetch_top_games, self.limit_spin.value())
def _start_task(self, fn: Callable[..., Any], *args: Any) -> None:
thread = QtCore.QThread(self)
worker = BackgroundTask(fn, *args)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.completed.connect(self._on_rows)
worker.failed.connect(self._on_failed)
worker.finished.connect(thread.quit)
worker.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
self._thread = thread
self._worker = worker
thread.start()
def search_rating(self) -> None:
app_id = self.search_id_input.text().strip()
if not app_id.isdigit():
self.search_result.setText("Game rating lookup: invalid AppID")
return
self.search_btn.setEnabled(False)
self._start_task(self.provider.fetch_game_rating, app_id)
@QtCore.Slot(object)
def _on_rows(self, result: object) -> None:
if isinstance(result, dict) and ("name" in result or "error" in result):
if result.get("error"):
self.search_result.setText(f"Game rating lookup: {result['error']}")
else:
note = str(result.get("note", ""))
suffix = f" ({note})" if note else ""
self.search_result.setText(
f"Game rating lookup: {result['name']}{suffix}"
)
# Show search result directly in the main table (single row).
self.table.setRowCount(1)
self.table.setItem(0, 0, QtWidgets.QTableWidgetItem("1"))
self.table.setItem(0, 1, QtWidgets.QTableWidgetItem(str(result.get("name", ""))))
self.table.setItem(0, 2, QtWidgets.QTableWidgetItem(str(result.get("ccu", "โ"))))
self.table.setItem(0, 3, QtWidgets.QTableWidgetItem(str(result.get("steam_reviews", "No review data"))))
self.table.setItem(0, 4, QtWidgets.QTableWidgetItem(str(result.get("opencritic", "0/100"))))
self.table.setItem(0, 5, QtWidgets.QTableWidgetItem(str(result.get("metacritic", "0/100"))))
self.table.setItem(0, 6, QtWidgets.QTableWidgetItem(str(result.get("app_id", ""))))
self.table.setItem(0, 7, QtWidgets.QTableWidgetItem(note or "ok"))
self.search_btn.setEnabled(True)
return
rows = result if isinstance(result, list) else []
self.table.setRowCount(len(rows))
for idx, game in enumerate(rows, start=1):
self.table.setItem(idx - 1, 0, QtWidgets.QTableWidgetItem(str(idx)))
self.table.setItem(idx - 1, 1, QtWidgets.QTableWidgetItem(game.name))
self.table.setItem(idx - 1, 2, QtWidgets.QTableWidgetItem(f"{game.players:,}"))
self.table.setItem(idx - 1, 3, QtWidgets.QTableWidgetItem(game.review_text))
self.table.setItem(idx - 1, 4, QtWidgets.QTableWidgetItem(game.opencritic))
self.table.setItem(idx - 1, 5, QtWidgets.QTableWidgetItem(game.metacritic))
self.table.setItem(idx - 1, 6, QtWidgets.QTableWidgetItem(str(game.app_id)))
self.table.setItem(idx - 1, 7, QtWidgets.QTableWidgetItem(game.ratings_note or "ok"))
self.refresh_btn.setEnabled(True)
@QtCore.Slot(str)
def _on_failed(self, error: str) -> None:
QtWidgets.QMessageBox.warning(self, "Top Games", f"Failed: {error}")
self.refresh_btn.setEnabled(True)
self.search_btn.setEnabled(True)
class NewsTab(QtWidgets.QWidget):
def __init__(self, provider: DataProvider) -> None:
super().__init__()
self.provider = provider
self.items: List[NewsItem] = []
layout = QtWidgets.QVBoxLayout(self)
controls = QtWidgets.QHBoxLayout()
self.refresh_btn = QtWidgets.QPushButton("Refresh news")
self.refresh_btn.clicked.connect(self.refresh)
controls.addWidget(self.refresh_btn)
controls.addStretch(1)
layout.addLayout(controls)
self.list = QtWidgets.QListWidget()
self.list.itemDoubleClicked.connect(self.open_selected)
layout.addWidget(self.list)
self.open_btn = QtWidgets.QPushButton("Open source link")
self.open_btn.clicked.connect(self.open_selected)
layout.addWidget(self.open_btn)
self._thread: Optional[QtCore.QThread] = None
self._worker: Optional[BackgroundTask] = None
def refresh(self) -> None:
self.refresh_btn.setEnabled(False)
thread = QtCore.QThread(self)
worker = BackgroundTask(self.provider.fetch_news)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.completed.connect(self._on_news)
worker.failed.connect(self._on_news_failed)
worker.finished.connect(thread.quit)
worker.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
self._thread = thread
self._worker = worker
thread.start()
@QtCore.Slot(object)
def _on_news(self, result: object) -> None:
self.items = result if isinstance(result, list) else []
self.list.clear()
if not self.items:
self.list.addItem(f"No fresh news found for last {NEWS_MAX_AGE_HOURS} hours.")
self.refresh_btn.setEnabled(True)
return
for row in self.items:
label = f"[{row.source}] {row.title}"
if row.published:
label += f" โ {row.published}"
self.list.addItem(label)
self.refresh_btn.setEnabled(True)
@QtCore.Slot(str)
def _on_news_failed(self, error: str) -> None:
QtWidgets.QMessageBox.warning(self, "News", f"Failed: {error}")
self.refresh_btn.setEnabled(True)
def open_selected(self) -> None:
index = self.list.currentRow()
if index < 0 or index >= len(self.items):
return
webbrowser.open(self.items[index].link)
class NewReleasesTab(QtWidgets.QWidget):
def __init__(self, provider: DataProvider) -> None:
super().__init__()
self.provider = provider
layout = QtWidgets.QVBoxLayout(self)
controls = QtWidgets.QHBoxLayout()
self.refresh_btn = QtWidgets.QPushButton("Refresh new releases")
self.refresh_btn.clicked.connect(self.refresh)
controls.addWidget(self.refresh_btn)
controls.addStretch(1)
layout.addLayout(controls)
self.table = QtWidgets.QTableWidget(0, 4)
self.table.setHorizontalHeaderLabels(["AppID", "Title", "Discount", "Final Price"])
self.table.horizontalHeader().setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.Stretch)
layout.addWidget(self.table)
self._thread: Optional[QtCore.QThread] = None
self._worker: Optional[BackgroundTask] = None
self._timer = QtCore.QTimer(self)
self._timer.setInterval(60_000)
self._timer.timeout.connect(self.refresh)
self._timer.start()
QtCore.QTimer.singleShot(250, self.refresh)
def refresh(self) -> None:
self.refresh_btn.setEnabled(False)
thread = QtCore.QThread(self)
worker = BackgroundTask(self.provider.fetch_new_releases, 100)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.completed.connect(self._on_rows)
worker.failed.connect(self._on_failed)
worker.finished.connect(thread.quit)
worker.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
self._thread = thread
self._worker = worker
thread.start()
@QtCore.Slot(object)
def _on_rows(self, result: object) -> None:
rows = result if isinstance(result, list) else []
self.table.setRowCount(len(rows))
for i, row in enumerate(rows):
self.table.setItem(i, 0, QtWidgets.QTableWidgetItem(str(row.app_id)))
self.table.setItem(i, 1, QtWidgets.QTableWidgetItem(row.name))
self.table.setItem(i, 2, QtWidgets.QTableWidgetItem(row.discount))
self.table.setItem(i, 3, QtWidgets.QTableWidgetItem(row.final_price))
self.refresh_btn.setEnabled(True)
@QtCore.Slot(str)
def _on_failed(self, error: str) -> None:
self.refresh_btn.setEnabled(True)
class SteamAnalyzerWindow(QtWidgets.QMainWindow):
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("Steam Protection Analyzer Pro")
self.resize(980, 700)
provider = DataProvider()
tabs = QtWidgets.QTabWidget()
tabs.addTab(AnalyzerTab(provider), "Protection Analyzer")
tabs.addTab(TopGamesTab(provider), "Top Games + Reviews")
tabs.addTab(NewsTab(provider), "Industry News")
tabs.addTab(NewReleasesTab(provider), "Steam New Releases")
self.setCentralWidget(tabs)
if __name__ == "__main__":
app = QtWidgets.QApplication(sys.argv)
app.setStyle("Fusion")
window = SteamAnalyzerWindow()
window.show()
sys.exit(app.exec())
Quick Usage Example
Step-by-Step โ 30 Seconds Flat
- Open the app
- Go to any Steam store page โ grab the number from the URL (e.g.,
1174180for Red Dead Redemption 2) - Paste it in the AppID field
- Hit Analyze
- Read your report โ Denuvo? Launcher hell? Always-online nonsense? Itโs all there
Thatโs it. Now you know whether to bother with the manifest install or save yourself the headache.
Who This Is For
- You use Steam manifest generators and want to check compatibility before downloading
- You grab games from other sources and want to know what DRM landmines are waiting
- You play offline a lot and need to know which games will actually cooperate
- Youโre just curious about what protections your library is running
One AppID. Full x-ray. No surprises. ![]()




!