#!/usr/bin/env python3 """ Расчёт суммарного размера библиотеки Steam по данным из steam_library.json. Приоритет: api.steamcmd.net (реальные размеры депо). Запас: системные требования в магазине Steam (часто занижены). Результаты кэшируются в steam_app_sizes.json. """ import json import re import time import urllib.request from pathlib import Path from typing import Optional LIBRARY_FILE = "steam_library.json" SIZES_CACHE_FILE = "steam_app_sizes.json" OUTPUT_FILE = "steam_library_with_sizes.json" REQUEST_DELAY = 0.4 def get_app_size_from_steamcmd(appid: int) -> Optional[float]: """ Размер в ГБ из api.steamcmd.net (депо, реальный объём установки). Учитываются только депо для Windows, для языков — только english, чтобы не суммировать все локали. """ url = f"https://api.steamcmd.net/v1/info/{appid}" try: req = urllib.request.Request(url, headers={"Accept": "application/json"}) with urllib.request.urlopen(req, timeout=20) as resp: data = json.loads(resp.read().decode()) except Exception: return None if data.get("status") != "success" or "data" not in data: return None app_data = data["data"].get(str(appid)) if not app_data or "depots" not in app_data: return None depots = app_data["depots"] total_bytes = 0 for depot_id, depot in depots.items(): if depot_id in ("appmanagesdlc", "baselanguages", "branches", "privatebranches"): continue if not isinstance(depot, dict): continue config = depot.get("config") or {} oslist = config.get("oslist", "") if "windows" not in oslist.lower(): continue manifests = depot.get("manifests") if not manifests or "public" not in manifests: continue size_str = manifests["public"].get("size") if not size_str: continue try: size_bytes = int(size_str) except (TypeError, ValueError): continue language = config.get("language", "") if language and language != "english": continue total_bytes += size_bytes if total_bytes == 0: return None return total_bytes / (1024 ** 3) def parse_size_from_text(text: str) -> Optional[float]: """Извлекает размер в ГБ из строки системных требований.""" if not text: return None text = text.replace(",", ".") m = re.search(r"(\d+(?:\.\d+)?)\s*GB", text, re.IGNORECASE) if m: return float(m.group(1)) m = re.search(r"(\d+(?:\.\d+)?)\s*MB", text, re.IGNORECASE) if m: return float(m.group(1)) / 1024 return None def get_app_size_from_store(appid: int) -> Optional[float]: """Размер в ГБ из системных требований store.steampowered.com (часто занижен).""" url = f"https://store.steampowered.com/api/appdetails?appids={appid}&l=english" try: req = urllib.request.Request(url, headers={"Accept": "application/json"}) with urllib.request.urlopen(req, timeout=15) as resp: data = json.loads(resp.read().decode()) except Exception: return None block = data.get(str(appid)) if not block or not block.get("success") or "data" not in block: return None reqs = block["data"].get("pc_requirements") if not reqs: return None if isinstance(reqs, dict): for part in (reqs.get("minimum"), reqs.get("recommended")): size = parse_size_from_text(part) if size is not None: return size elif isinstance(reqs, str): return parse_size_from_text(reqs) return None def get_app_size(appid: int) -> Optional[float]: """Сначала steamcmd (реальный размер), при отсутствии — магазин.""" size = get_app_size_from_steamcmd(appid) if size is not None: return size return get_app_size_from_store(appid) def load_library(path: Path) -> list[dict]: with open(path, encoding="utf-8") as f: return json.load(f) def load_sizes_cache(path: Path) -> dict: if not path.exists(): return {} with open(path, encoding="utf-8") as f: return {int(k): v for k, v in json.load(f).items()} def save_sizes_cache(path: Path, cache: dict) -> None: with open(path, "w", encoding="utf-8") as f: json.dump(cache, f, indent=2) def progress_bar(current: int, total: int, width: int = 40) -> str: """Строка прогресс-бара: [=======> ] 45/289 15%""" if total <= 0: return "" pct = current / total filled = int(width * pct) bar = "=" * filled + ">" * (1 if filled < width else 0) + " " * (width - filled - 1) return f"[{bar}] {current}/{total} {pct*100:.0f}%" def main() -> None: base = Path(__file__).parent lib_path = base / LIBRARY_FILE cache_path = base / SIZES_CACHE_FILE out_path = base / OUTPUT_FILE if not lib_path.exists(): print(f"Сначала запусти steam-library-to-json.py — нужен файл {LIBRARY_FILE}") return library = load_library(lib_path) cache = load_sizes_cache(cache_path) total_gb = 0.0 unknown_count = 0 results = [] total_games = len(library) for i, game in enumerate(library): appid = game["appid"] name = game.get("name", "") if appid in cache: size_gb = cache[appid] else: size_gb = get_app_size(appid) cache[appid] = size_gb time.sleep(REQUEST_DELAY) if size_gb is not None: total_gb += size_gb else: unknown_count += 1 results.append({ "appid": appid, "name": name, "size_gb": round(size_gb, 2) if size_gb is not None else None, }) print(f"\r{progress_bar(i + 1, total_games)} {name[:35]:<35}", end="", flush=True) print() save_sizes_cache(cache_path, cache) with open(out_path, "w", encoding="utf-8") as f: json.dump(results, f, ensure_ascii=False, indent=2) print() print(f"Игр в библиотеке: {len(library)}") print(f"С известным размером: {len(library) - unknown_count}") print(f"Без размера: {unknown_count}") print(f"Суммарный размер: {total_gb:.1f} ГБ") print(f"Результат: {out_path}") print(f"Кэш: {cache_path}") if __name__ == "__main__": main()