๐Ÿ›ก๏ธ Steam Game Protection Scanner โ€” Know the DRM Before You Download

:shield: 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.


:world_map: 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.


:magnifying_glass_tilted_left: 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:

:backhand_index_pointing_right: 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.


:high_voltage: 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
:locked: Denuvo The big one. Heavily restricts offline and manifest-based installs
:video_game: Steam DRM Standard Steamworks DRM โ€” usually manageable, but still noted
:rocket: Third-Party Launchers Rockstar, Ubisoft Connect, EA App, Battle.net, 2K, Bethesda โ€” the whole circus
:bust_in_silhouette: Account Required Needs a separate login beyond Steam โ€” extra friction
:globe_with_meridians: Always Online No internet = no game. Deal-breaker for offline setups
:shield: 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.


:bar_chart: 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 :white_check_mark: Excellent Clean install, minimal restrictions โ€” youโ€™re good
50โ€“79 :warning: Limited Some hurdles โ€” might need workarounds
0โ€“49 :cross_mark: 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.


:desktop_computer: 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.


:brain: 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.


:clipboard: 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 PCGamingWiki
  • PyQt6 โ€” the GUI framework

No API keys. No accounts. No config files. Just run it.


:wrench: 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())


:light_bulb: 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.


:bullseye: 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. :shield:

7 Likes

its work thank

1 Like

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

1 Like

Will there be any more updates to this program?

The next update is in a couple of days

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

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

1 Like

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

1 Like

I havenโ€™t installed games with Steam for a long time :grinning_face_with_smiling_eyes:
Iโ€™ve been playing ONLY cracked STEAM games for over 15 years and I HAVE NO PROBLEM! :flexed_biceps:Iโ€™ve also updated the games! MODs can be installed very easily!
I think you are misinformed and have never tried cracked Steam games! :enraged_face: TRIED IT AND WEโ€™LL TALK ABOUT IT AFTER THAT!!! :student:

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.

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.

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!

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

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.

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.

I see youโ€™ve released a good global update.