# 🛡️ Steam Game Protection Scanner — Know the DRM Before You Download

**URL:** <https://onehack.st/t/steam-game-protection-scanner-know-the-drm-before-you-download/317803>\
**Category:** Tutorials & Methods\
**Tags:** tips-tricks, gaming\
**Created:** [February 3, 2026, 8:34am UTC](https://onehack.st/t/steam-game-protection-scanner-know-the-drm-before-you-download/317803 "2026-02-03T08:34:14Z")\
**Posts on this page:** 17\
**Page:** 1

<div class="post-metadata">

**Author:** ![Edgar](https://onehack.st/user_avatar/onehack.st/edgar/32/167100_2.png) [@Edgar](https://onehack.st/u/Edgar)\
**Post date:** [February 3, 2026, 8:34am UTC](https://onehack.st/t/steam-game-protection-scanner-know-the-drm-before-you-download/317803/1 "2026-02-03T08:34:15Z")

</div>

# 🛡 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](https://onehack.st/t/steam-manifest-generators-full-toolkit-download-any-game-from-steams-own-servers/317050)
> 
> 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 |
> | --- | --- |
> | 🔒 **Denuvo** | The big one. Heavily restricts offline and manifest-based installs |
> | 🎮 **Steam DRM** | Standard Steamworks DRM — usually manageable, but still noted |
> | 🚀 **Third-Party Launchers** | Rockstar, Ubisoft Connect, EA App, [Battle.net](http://Battle.net), 2K, Bethesda — the whole circus |
> | 👤 **Account Required** | Needs a separate login beyond Steam — extra friction |
> | 🌐 **Always Online** | No internet = no game. Deal-breaker for offline setups |
> | 🛡 **Anti-Cheat** | 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** | ✅ Excellent | Clean install, minimal restrictions — you’re good |
> | **50–79** | ⚠ Limited | Some hurdles — might need workarounds |
> | **0–49** | ❌ Poor | 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.

 ![image](https://onehack.st/uploads/default/original/3X/4/1/410c018f175fc6d4112b6b77382cadf1424cf4b9.png)

 ![image](https://onehack.st/uploads/default/original/3X/a/5/a55535469707a414564c0774604c84a65c7796b6.png)

 ![image](https://onehack.st/uploads/default/original/3X/1/b/1b2cdf4144a2aa252792fe325c784febe35835a6.png)

 ![image](https://onehack.st/uploads/default/original/3X/3/c/3c03be8617c548099fe0cdac512b8c67920f5c91.png)

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:**
> 
> 1. **Steam Store API** → pulls DRM notices, system requirements, and descriptions
> 2. **SteamDB** → deeper metadata and historical protection info (auto-falls back to proxy if blocked)
> 3. **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:
> 
> ```auto
> 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 PCGamingWiki
> - `PyQt6` — the GUI framework
> 
> No API keys. No accounts. No config files. Just run it.

* * *

## 🔧 The Full Script

> **Complete Python Source — Copy and Go**
>
> ```python
> #!/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**
>
> 1. Open the app
> 2. Go to any Steam store page — grab the number from the URL (e.g., `1174180` for Red Dead Redemption 2)
> 3. Paste it in the AppID field
> 4. Hit **Analyze**
> 5. 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.** 🛡

---

<div class="post-metadata">

**Author:** ![nikitin.sergey1998](https://onehack.st/user_avatar/onehack.st/nikitin.sergey1998/32/167665_2.png) [@nikitin.sergey1998](https://onehack.st/u/nikitin.sergey1998)\
**Post date:** [February 3, 2026, 8:57am UTC](https://onehack.st/t/steam-game-protection-scanner-know-the-drm-before-you-download/317803/2 "2026-02-03T08:57:44Z")

</div>

its work thank

---

<div class="post-metadata">

**Author:** ![Edgar](https://onehack.st/user_avatar/onehack.st/edgar/32/167100_2.png) [@Edgar](https://onehack.st/u/Edgar)\
**Post date:** [February 3, 2026, 10:34am UTC](https://onehack.st/t/steam-game-protection-scanner-know-the-drm-before-you-download/317803/3 "2026-02-03T10:34:38Z")

</div>

There’s a lot of interesting things ahead; the gaming theme will be actively developing.

---

<div class="post-metadata">

**Author:** ![nikitin.sergey1998](https://onehack.st/user_avatar/onehack.st/nikitin.sergey1998/32/167665_2.png) [@nikitin.sergey1998](https://onehack.st/u/nikitin.sergey1998)\
**Post date:** [March 15, 2026, 9:25am UTC](https://onehack.st/t/steam-game-protection-scanner-know-the-drm-before-you-download/317803/4 "2026-03-15T09:25:43Z")

</div>

Will there be any more updates to this program?

---

<div class="post-metadata">

**Author:** ![Edgar](https://onehack.st/user_avatar/onehack.st/edgar/32/167100_2.png) [@Edgar](https://onehack.st/u/Edgar)\
**Post date:** [March 15, 2026, 9:26am UTC](https://onehack.st/t/steam-game-protection-scanner-know-the-drm-before-you-download/317803/5 "2026-03-15T09:26:15Z")

</div>

The next update is in a couple of days

---

<div class="post-metadata">

**Author:** ![teodor11](https://onehack.st/user_avatar/onehack.st/teodor11/32/158489_2.png) [@teodor11](https://onehack.st/u/teodor11)\
**Post date:** [March 15, 2026, 10:28am UTC](https://onehack.st/t/steam-game-protection-scanner-know-the-drm-before-you-download/317803/6 "2026-03-15T10:28:52Z")

</div>

For Windows if you download the games from [https://repack-games.com/](https://repack-games.com/) you will not have problems with STEAM!  
Same with  
[https://pcgamestorrents.com/](https://pcgamestorrents.com/)  
[https://www.skidrowreloaded.com](https://www.skidrowreloaded.com)  
[https://fitgirl-repacks.site/](https://fitgirl-repacks.site/)  
[https://thelastgame.ru/](https://thelastgame.ru/)  
I have been downloading for several years and I have had no problems!

---

<div class="post-metadata">

**Author:** ![StupidUsername](https://onehack.st/user_avatar/onehack.st/stupidusername/32/170634_2.png) [@StupidUsername](https://onehack.st/u/StupidUsername)\
**Post date:** [March 15, 2026, 11:03am UTC](https://onehack.st/t/steam-game-protection-scanner-know-the-drm-before-you-download/317803/7 "2026-03-15T11:03:40Z")

</div>

Some of the game receive latest update, so it is normal some people still prefer to download from Steam

---

<div class="post-metadata">

**Author:** ![Edgar](https://onehack.st/user_avatar/onehack.st/edgar/32/167100_2.png) [@Edgar](https://onehack.st/u/Edgar)\
**Post date:** [March 15, 2026, 12:16pm UTC](https://onehack.st/t/steam-game-protection-scanner-know-the-drm-before-you-download/317803/8 "2026-03-15T12:16:36Z")

</div>

This is a program designed for people who play through steam manifest and want a stable process with a clean build from Steam servers.

---

<div class="post-metadata">

**Author:** ![teodor11](https://onehack.st/user_avatar/onehack.st/teodor11/32/158489_2.png) [@teodor11](https://onehack.st/u/teodor11)\
**Post date:** [March 16, 2026, 9:17am UTC](https://onehack.st/t/steam-game-protection-scanner-know-the-drm-before-you-download/317803/9 "2026-03-16T09:17:24Z")

</div>

I haven’t installed games with Steam for a long time 😄  
I’ve been playing ONLY cracked STEAM games for over 15 years and I HAVE NO PROBLEM! 💪I’ve also updated the games! MODs can be installed very easily!  
I think you are misinformed and have never tried cracked Steam games! 😡 TRIED IT AND WE’LL TALK ABOUT IT AFTER THAT!!! 🧑‍🎓

---

<div class="post-metadata">

**Author:** ![Edgar](https://onehack.st/user_avatar/onehack.st/edgar/32/167100_2.png) [@Edgar](https://onehack.st/u/Edgar)\
**Post date:** [March 16, 2026, 9:33am UTC](https://onehack.st/t/steam-game-protection-scanner-know-the-drm-before-you-download/317803/10 "2026-03-16T09:33:07Z")

</div>

I am well acquainted with hacks and mods, but this topic is not about hacks and mods, but about the Steam manifest and installing a clean build from steam servers. If I wanted to write about mods and game hacks, I would do it like I did before, devoting 4 topics to it.

---

<div class="post-metadata">

**Author:** ![nikitin.sergey1998](https://onehack.st/user_avatar/onehack.st/nikitin.sergey1998/32/167665_2.png) [@nikitin.sergey1998](https://onehack.st/u/nikitin.sergey1998)\
**Post date:** [March 16, 2026, 9:43am UTC](https://onehack.st/t/steam-game-protection-scanner-know-the-drm-before-you-download/317803/11 "2026-03-16T09:43:29Z")

</div>

You have 15 years of experience, Edgar has 20 years of experience, of which he has been using linux and Windows in parallel for 15 years, so I don’t understand what you are trying to explain.

---

<div class="post-metadata">

**Author:** ![teodor11](https://onehack.st/user_avatar/onehack.st/teodor11/32/158489_2.png) [@teodor11](https://onehack.st/u/teodor11)\
**Post date:** [March 16, 2026, 12:07pm UTC](https://onehack.st/t/steam-game-protection-scanner-know-the-drm-before-you-download/317803/12 "2026-03-16T12:07:55Z")

</div>

**Hey Mister nikitin.sergey1998**

It’s your and everyone else’s problem if you want to complicate things using Steam!  
I don’t want to argue with YOU!  
As for my experience, I’ve been an IT engineer for over 40 years, since the first computers appeared!

**Goodbye forever!**

---

<div class="post-metadata">

**Author:** ![nikitin.sergey1998](https://onehack.st/user_avatar/onehack.st/nikitin.sergey1998/32/167665_2.png) [@nikitin.sergey1998](https://onehack.st/u/nikitin.sergey1998)\
**Post date:** [March 16, 2026, 12:41pm UTC](https://onehack.st/t/steam-game-protection-scanner-know-the-drm-before-you-download/317803/14 "2026-03-16T12:41:49Z")

</div>

I am glad for you that you have been working in this field for 40 years, but this is not a reason to devalue the experience of other people. Which approach to choose is a personal choice of each person for you, it’s a pain in the ass for others, it’s growth, because they are looking for solutions, developing programs and growing at the same time

---

<div class="post-metadata">

**Author:** ![Edgar](https://onehack.st/user_avatar/onehack.st/edgar/32/167100_2.png) [@Edgar](https://onehack.st/u/Edgar)\
**Post date:** [March 16, 2026, 12:58pm UTC](https://onehack.st/t/steam-game-protection-scanner-know-the-drm-before-you-download/317803/15 "2026-03-16T12:58:51Z")

</div>

Downloading the steam manifest using the manifest generator takes less than a minute, installing steam takes 5 minutes, and checking the protection of the game using this program takes 1 minute, so I don’t understand what difficulties you are talking about. You will get a clean game quickly. Everything else depends on the Internet speed when downloading the game.

---

<div class="post-metadata">

**Author:** ![teodor11](https://onehack.st/user_avatar/onehack.st/teodor11/32/158489_2.png) [@teodor11](https://onehack.st/u/teodor11)\
**Post date:** [March 16, 2026, 3:10pm UTC](https://onehack.st/t/steam-game-protection-scanner-know-the-drm-before-you-download/317803/16 "2026-03-16T15:10:38Z")

</div>

> **[The FBI is investigating malware hidden inside games hosted on Steam |...](https://techcrunch.com/2026/03/13/valve-steam-malware-games-fbi/)**
>
> The FBI believes a series of video games published on Steam in the last two years were embedded with malware by the same hacker.

---

<div class="post-metadata">

**Author:** ![Edgar](https://onehack.st/user_avatar/onehack.st/edgar/32/167100_2.png) [@Edgar](https://onehack.st/u/Edgar)\
**Post date:** [March 16, 2026, 3:31pm UTC](https://onehack.st/t/steam-game-protection-scanner-know-the-drm-before-you-download/317803/17 "2026-03-16T15:31:47Z")

</div>

The FBI is comfortable spreading all sorts of rumors about organizations in order to introduce their contractors into private organizations in this way, supposedly to ensure security. Personally, I have not encountered any for decades.  
And even if that were true, it does not concern me in any way. This is a matter for Steam employees. I am simply a person who enjoys challenges and complexity and likes creating free, open-source programs. Most importantly, my programs do not cause any harm.

---

<div class="post-metadata">

**Author:** ![nikitin.sergey1998](https://onehack.st/user_avatar/onehack.st/nikitin.sergey1998/32/167665_2.png) [@nikitin.sergey1998](https://onehack.st/u/nikitin.sergey1998)\
**Post date:** [March 17, 2026, 11:02am UTC](https://onehack.st/t/steam-game-protection-scanner-know-the-drm-before-you-download/317803/18 "2026-03-17T11:02:23Z")

</div>

I see you’ve released a good global update.
