| """
|
| Game Requirements Fetcher Module for CanRun
|
| Fetches game requirements from multiple sources including Steam API,
|
| PCGameBenchmark, and local cache with optimized fuzzy matching.
|
| """
|
|
|
| import json
|
| import logging
|
| import asyncio
|
| import aiohttp
|
| from typing import Dict, List, Union, Optional
|
| from pathlib import Path
|
| from dataclasses import dataclass
|
| from abc import ABC, abstractmethod
|
| import re
|
| import time
|
| import sys
|
| import os
|
| from src.optimized_game_fuzzy_matcher import OptimizedGameFuzzyMatcher
|
|
|
| def get_resource_path(relative_path):
|
| """Get absolute path to resource, works for dev and for PyInstaller"""
|
| if getattr(sys, 'frozen', False):
|
|
|
| base_path = sys._MEIPASS
|
|
|
| data_path = os.path.join(base_path, relative_path)
|
|
|
| return data_path
|
| else:
|
|
|
| base_path = Path(__file__).parent.parent
|
| return os.path.join(base_path, relative_path)
|
|
|
|
|
|
|
| game_fuzzy_matcher = OptimizedGameFuzzyMatcher()
|
|
|
|
|
| @dataclass
|
| class GameRequirements:
|
| """Data class for storing game requirements."""
|
| game_name: str
|
| minimum_cpu: str
|
| minimum_gpu: str
|
| minimum_ram_gb: int
|
| minimum_vram_gb: int
|
| minimum_storage_gb: int
|
| minimum_directx: str = "DirectX 11"
|
| minimum_os: str = "Windows 10"
|
| recommended_cpu: str = "Unknown"
|
| recommended_gpu: str = "Unknown"
|
| recommended_ram_gb: int = 0
|
| recommended_vram_gb: int = 0
|
| recommended_storage_gb: int = 0
|
| recommended_directx: str = "DirectX 12"
|
| recommended_os: str = "Windows 11"
|
| source: str = "Unknown"
|
| last_updated: str = ""
|
| steam_api_name: str = ""
|
|
|
|
|
| class DataSource(ABC):
|
| """Abstract base class for data sources."""
|
|
|
| @abstractmethod
|
| async def fetch(self, game_name: str) -> Optional[GameRequirements]:
|
| """Fetch game requirements from the source."""
|
| pass
|
|
|
|
|
| class SteamAPISource(DataSource):
|
| """Steam Store API source for game requirements."""
|
|
|
| def __init__(self, llm_analyzer=None):
|
| self.base_url = "https://store.steampowered.com/api"
|
| self.search_url = "https://steamcommunity.com/actions/SearchApps"
|
| self.store_search_url = "https://store.steampowered.com/search/suggest"
|
| self.logger = logging.getLogger(__name__)
|
| self.llm_analyzer = llm_analyzer
|
|
|
| async def fetch(self, game_name: str) -> Optional[GameRequirements]:
|
| """Fetch game requirements from Steam API."""
|
| try:
|
|
|
| has_number = any(c.isdigit() for c in game_name)
|
|
|
|
|
| steam_id = await self._search_game(game_name)
|
| if not steam_id:
|
| return None
|
|
|
|
|
| app_info = await self._get_app_info(steam_id)
|
| if not app_info:
|
| return None
|
|
|
|
|
| requirements = self._parse_requirements(app_info, game_name)
|
|
|
|
|
| if has_number:
|
|
|
| requirements.game_name = game_name
|
|
|
| return requirements
|
|
|
| except Exception as e:
|
| self.logger.error(f"Steam API fetch failed for {game_name}: {e}")
|
| return None
|
|
|
| async def _search_game(self, game_name: str) -> Optional[str]:
|
| """Search for a game and return its Steam ID using multiple search methods."""
|
| self.logger.debug(f"Searching Steam for game: {game_name}")
|
|
|
|
|
| search_methods = [
|
| self._search_steam_store_suggest,
|
| self._search_steam_community,
|
| self._search_steam_store_direct
|
| ]
|
|
|
| for method in search_methods:
|
| try:
|
| steam_id = await method(game_name)
|
| if steam_id:
|
| self.logger.info(f"Found Steam ID {steam_id} for '{game_name}' using {method.__name__}")
|
| return steam_id
|
| except Exception as e:
|
| self.logger.debug(f"Search method {method.__name__} failed: {e}")
|
| continue
|
|
|
| self.logger.warning(f"All Steam search methods failed for '{game_name}'")
|
| return None
|
|
|
| async def _search_steam_store_suggest(self, game_name: str) -> Optional[str]:
|
| """Search using Steam Store suggest API with robust error handling and quick timeout."""
|
| try:
|
|
|
| timeout = aiohttp.ClientTimeout(total=5, connect=3)
|
| async with aiohttp.ClientSession(timeout=timeout) as session:
|
| params = {
|
| 'term': game_name,
|
| 'f': 'games',
|
| 'cc': 'US',
|
| 'l': 'english'
|
| }
|
|
|
| async with session.get(self.store_search_url, params=params) as response:
|
| if response.status == 200:
|
|
|
| content_type = response.headers.get('content-type', '').lower()
|
|
|
| if 'application/json' in content_type:
|
| try:
|
| data = await response.json()
|
| if isinstance(data, list) and len(data) > 0:
|
|
|
| app_data = data[0]
|
| if 'id' in app_data:
|
| return str(app_data['id'])
|
| elif 'appid' in app_data:
|
| return str(app_data['appid'])
|
| except json.JSONDecodeError as e:
|
| self.logger.debug(f"Steam store suggest JSON decode error for '{game_name}': {e}")
|
| return None
|
| else:
|
|
|
| self.logger.debug(f"Steam store suggest returned non-JSON content type: {content_type}")
|
| text = await response.text()
|
|
|
|
|
| patterns = [
|
| r'data-ds-appid="(\d+)"',
|
| r'"appid":\s*(\d+)',
|
| r'app/(\d+)/',
|
| r'appid=(\d+)'
|
| ]
|
|
|
| for pattern in patterns:
|
| match = re.search(pattern, text)
|
| if match:
|
| return match.group(1)
|
| else:
|
| self.logger.debug(f"Steam store suggest returned status {response.status} for '{game_name}'")
|
|
|
| except asyncio.CancelledError:
|
| self.logger.warning(f"Steam store suggest search cancelled for '{game_name}'")
|
| raise
|
| except asyncio.TimeoutError:
|
| self.logger.warning(f"Steam store suggest search timed out for '{game_name}'")
|
| except aiohttp.ClientError as e:
|
| self.logger.warning(f"Steam store suggest network error for '{game_name}': {e}")
|
| except Exception as e:
|
| self.logger.debug(f"Steam store suggest search failed for '{game_name}': {e}")
|
|
|
| return None
|
|
|
| async def _search_steam_community(self, game_name: str) -> Optional[str]:
|
| """Search using Steam Community API with robust error handling and quick timeout."""
|
| try:
|
|
|
| timeout = aiohttp.ClientTimeout(total=5, connect=3)
|
| async with aiohttp.ClientSession(timeout=timeout) as session:
|
| params = {
|
| 'text': game_name,
|
| 'max_results': 10
|
| }
|
|
|
| async with session.get(self.search_url, params=params) as response:
|
| if response.status == 200:
|
| try:
|
|
|
| data = await response.json()
|
| if isinstance(data, list) and len(data) > 0:
|
| app_data = data[0]
|
| if 'appid' in app_data:
|
| return str(app_data['appid'])
|
| except Exception:
|
|
|
| text = await response.text()
|
|
|
|
|
| if self.llm_analyzer:
|
| try:
|
| prompt = f"""
|
| Extract the Steam app ID from this content. Look for app IDs in JSON format or data-ds-appid attributes.
|
| Return only the numeric app ID, nothing else.
|
|
|
| Content:
|
| {text[:2000]}
|
| """
|
|
|
| app_id = await self.llm_analyzer.analyze_text(prompt)
|
| if app_id and app_id.strip().isdigit():
|
| return app_id.strip()
|
| except Exception as e:
|
| self.logger.debug(f"LLM parsing failed: {e}")
|
|
|
|
|
| patterns = [
|
| r'data-ds-appid="(\d+)"',
|
| r'"appid":\s*(\d+)',
|
| r'app/(\d+)/',
|
| r'appid=(\d+)'
|
| ]
|
|
|
| for pattern in patterns:
|
| match = re.search(pattern, text)
|
| if match:
|
| return match.group(1)
|
|
|
| except asyncio.CancelledError:
|
| self.logger.warning(f"Steam community search cancelled for '{game_name}'")
|
| raise
|
| except asyncio.TimeoutError:
|
| self.logger.warning(f"Steam community search timed out for '{game_name}'")
|
| except aiohttp.ClientError as e:
|
| self.logger.warning(f"Steam community network error for '{game_name}': {e}")
|
| except Exception as e:
|
| self.logger.debug(f"Steam community search failed for '{game_name}': {e}")
|
|
|
| return None
|
|
|
| async def _search_steam_store_direct(self, game_name: str) -> Optional[str]:
|
| """Direct search on Steam store page with robust error handling and quick timeout."""
|
| try:
|
|
|
| search_url = f"https://store.steampowered.com/search/?term={game_name.replace(' ', '+')}"
|
|
|
|
|
| timeout = aiohttp.ClientTimeout(total=8, connect=3)
|
| async with aiohttp.ClientSession(timeout=timeout) as session:
|
| async with session.get(search_url) as response:
|
| if response.status == 200:
|
| text = await response.text()
|
|
|
|
|
| patterns = [
|
| r'data-ds-appid="(\d+)"',
|
| r'app/(\d+)/',
|
| r'appid=(\d+)'
|
| ]
|
|
|
| for pattern in patterns:
|
| match = re.search(pattern, text)
|
| if match:
|
| return match.group(1)
|
|
|
| except asyncio.CancelledError:
|
| self.logger.warning(f"Steam store direct search cancelled for '{game_name}'")
|
| raise
|
| except asyncio.TimeoutError:
|
| self.logger.warning(f"Steam store direct search timed out for '{game_name}'")
|
| except aiohttp.ClientError as e:
|
| self.logger.warning(f"Steam store direct network error for '{game_name}': {e}")
|
| except Exception as e:
|
| self.logger.debug(f"Steam store direct search failed for '{game_name}': {e}")
|
|
|
| return None
|
|
|
| async def _get_app_info(self, steam_id: str) -> Optional[Dict]:
|
| """Get detailed app information from Steam Store API with quick timeout for G-Assist."""
|
| try:
|
|
|
| timeout = aiohttp.ClientTimeout(total=8)
|
| async with aiohttp.ClientSession(timeout=timeout) as session:
|
| url = f"{self.base_url}/appdetails"
|
| params = {
|
| 'appids': steam_id,
|
| 'cc': 'US',
|
| 'l': 'english'
|
| }
|
|
|
|
|
| for attempt in range(3):
|
| try:
|
| self.logger.debug(f"Fetching Steam app info for ID {steam_id}, attempt {attempt + 1}")
|
| async with session.get(url, params=params) as response:
|
| if response.status == 200:
|
| data = await response.json()
|
| if steam_id in data and data[steam_id].get('success'):
|
| self.logger.debug(f"Successfully fetched app info for {steam_id}")
|
| return data[steam_id]['data']
|
| else:
|
| self.logger.warning(f"Steam API returned unsuccessful response for {steam_id}")
|
| return None
|
| elif response.status == 429:
|
| wait_time = 2 ** attempt
|
| self.logger.warning(f"Rate limited by Steam API, waiting {wait_time}s")
|
| if attempt < 2:
|
| await asyncio.sleep(wait_time)
|
| continue
|
| else:
|
| self.logger.warning(f"Steam API returned status {response.status}")
|
| return None
|
| except asyncio.CancelledError:
|
| self.logger.warning(f"Steam API app info request cancelled for {steam_id}")
|
| raise
|
| except asyncio.TimeoutError:
|
| self.logger.warning(f"Steam API timeout, attempt {attempt + 1}/3")
|
| if attempt < 2:
|
| await asyncio.sleep(1)
|
| continue
|
| except aiohttp.ClientError as e:
|
| self.logger.warning(f"Steam API network error: {e}, attempt {attempt + 1}/3")
|
| if attempt < 2:
|
| await asyncio.sleep(1)
|
| continue
|
| except Exception as e:
|
| self.logger.warning(f"Steam API error: {e}, attempt {attempt + 1}/3")
|
| if attempt < 2:
|
| await asyncio.sleep(1)
|
| continue
|
|
|
| break
|
|
|
| except Exception as e:
|
| self.logger.error(f"Steam app info fetch failed: {e}")
|
|
|
| return None
|
|
|
| def _parse_requirements(self, app_info: Dict, game_name: str) -> Optional[GameRequirements]:
|
| """Parse requirements from Steam app info."""
|
| try:
|
| pc_requirements = app_info.get('pc_requirements', {})
|
| if not pc_requirements:
|
| return None
|
|
|
| minimum = self._parse_requirement_text(pc_requirements.get('minimum', ''))
|
| recommended = self._parse_requirement_text(pc_requirements.get('recommended', ''))
|
|
|
| return GameRequirements(
|
| game_name=game_name,
|
| **self._dict_to_dataclass_fields(minimum, recommended),
|
| source='Steam API',
|
| last_updated=str(int(time.time()))
|
| )
|
| except Exception as e:
|
| self.logger.debug(f"Steam requirements parsing failed: {e}")
|
| return None
|
|
|
| def _parse_requirement_text(self, text: str) -> Dict[str, str]:
|
| """Parse requirement text into structured format."""
|
| requirements = {}
|
|
|
|
|
| clean_text = re.sub(r'<[^>]+>', '\n', text)
|
| clean_text = re.sub(r' ', ' ', clean_text)
|
| clean_text = re.sub(r'\s+', ' ', clean_text)
|
|
|
|
|
| patterns = {
|
| 'os': r'OS:\s*([^<>\n]*?)(?=\s*(?:Processor|Memory|Graphics|DirectX|Storage|Sound|Additional|$))',
|
| 'processor': r'Processor:\s*([^<>\n]*?)(?=\s*(?:Memory|Graphics|DirectX|Storage|Sound|Additional|$))',
|
| 'memory': r'Memory:\s*([^<>\n]*?)(?=\s*(?:Graphics|DirectX|Storage|Sound|Additional|$))',
|
| 'graphics': r'Graphics:\s*([^<>\n]*?)(?=\s*(?:DirectX|Storage|Sound|Additional|$))',
|
| 'directx': r'DirectX:\s*([^<>\n]*?)(?=\s*(?:Storage|Sound|Additional|$))',
|
| 'storage': r'Storage:\s*([^<>\n]*?)(?=\s*(?:Sound|Additional|$))',
|
| 'sound': r'Sound Card:\s*([^<>\n]*?)(?=\s*(?:Additional|$))'
|
| }
|
|
|
| for key, pattern in patterns.items():
|
| match = re.search(pattern, clean_text, re.IGNORECASE | re.DOTALL)
|
| if match:
|
| value = match.group(1).strip()
|
|
|
| value = re.sub(r'[.,:;]+$', '', value).strip()
|
| if value:
|
| requirements[key] = value
|
|
|
|
|
| if not requirements:
|
|
|
| lines = re.split(r'[<>]|(?:\s*(?:Processor|Memory|Graphics|DirectX|Storage|Sound)\s*:)', clean_text)
|
| current_key = None
|
|
|
| for line in lines:
|
| line = line.strip()
|
| if not line:
|
| continue
|
|
|
|
|
| if re.match(r'^(OS|Processor|Memory|Graphics|DirectX|Storage|Sound)', line, re.IGNORECASE):
|
| parts = line.split(':', 1)
|
| if len(parts) == 2:
|
| key = parts[0].strip().lower()
|
| value = parts[1].strip()
|
| if key in ['os', 'processor', 'memory', 'graphics', 'directx', 'storage', 'sound']:
|
| requirements[key] = value
|
|
|
| return requirements
|
|
|
| def _dict_to_dataclass_fields(self, minimum: Dict[str, str], recommended: Dict[str, str]) -> Dict[str, any]:
|
| """Convert old dict format to new dataclass field format."""
|
| def parse_storage(value: str) -> int:
|
| """Parse storage value like '25 GB' to integer."""
|
| if not value:
|
| return 0
|
|
|
| match = re.search(r'(\d+\.?\d*)', str(value))
|
| return int(float(match.group(1))) if match else 0
|
|
|
| def parse_ram(value: str) -> int:
|
| """
|
| Parse RAM value properly handling MB vs GB units.
|
| Examples:
|
| - "8 GB" -> 8
|
| - "512 MB" -> 0.5 (converts to GB)
|
| - "2GB" -> 2
|
| - "1024MB" -> 1 (converts to GB)
|
| """
|
| if not value:
|
| return 0
|
|
|
|
|
| value_upper = str(value).upper()
|
|
|
|
|
| if 'MB' in value_upper:
|
|
|
| mb_match = re.search(r'(\d+\.?\d*)\s*MB', value_upper)
|
| if mb_match:
|
|
|
| mb_value = float(mb_match.group(1))
|
| if mb_value < 512:
|
| return 0.5
|
| else:
|
| return max(1, int(mb_value / 1024))
|
|
|
|
|
| gb_match = re.search(r'(\d+\.?\d*)\s*G?B?', value_upper)
|
| if gb_match:
|
| return int(float(gb_match.group(1)))
|
|
|
| return 0
|
|
|
| def estimate_vram_from_gpu(gpu_str: str) -> int:
|
| """Estimate VRAM from GPU model string."""
|
| if not gpu_str:
|
| return 2
|
|
|
| gpu_lower = gpu_str.lower()
|
|
|
|
|
| vram_match = re.search(r'(\d+)\s*gb', gpu_lower)
|
| if vram_match:
|
| return int(vram_match.group(1))
|
|
|
|
|
| if 'rtx 4090' in gpu_lower:
|
| return 24
|
| elif 'rtx 4080' in gpu_lower:
|
| return 16
|
| elif 'rtx 4070 ti' in gpu_lower or 'rtx 4070ti' in gpu_lower:
|
| return 12
|
| elif 'rtx 4070' in gpu_lower:
|
| return 12
|
| elif 'rtx 4060 ti' in gpu_lower or 'rtx 4060ti' in gpu_lower:
|
| return 8
|
| elif 'rtx 4060' in gpu_lower:
|
| return 8
|
| elif 'rtx 3090' in gpu_lower:
|
| return 24
|
| elif 'rtx 3080' in gpu_lower:
|
| return 10
|
| elif 'rtx 3070' in gpu_lower:
|
| return 8
|
| elif 'rtx 3060' in gpu_lower:
|
| return 6
|
| elif 'rtx 3050' in gpu_lower:
|
| return 4
|
|
|
|
|
| elif 'rtx 2080 ti' in gpu_lower:
|
| return 11
|
| elif 'rtx 2080' in gpu_lower:
|
| return 8
|
| elif 'rtx 2070' in gpu_lower:
|
| return 8
|
| elif 'rtx 2060' in gpu_lower:
|
| return 6
|
|
|
|
|
| elif 'gtx 1080 ti' in gpu_lower:
|
| return 11
|
| elif 'gtx 1080' in gpu_lower:
|
| return 8
|
| elif 'gtx 1070' in gpu_lower:
|
| return 8
|
| elif 'gtx 1060' in gpu_lower:
|
| return 6
|
| elif 'gtx 1050' in gpu_lower:
|
| return 4
|
|
|
|
|
| elif 'rtx' in gpu_lower:
|
| return 8
|
| elif 'gtx' in gpu_lower:
|
| return 4
|
| elif 'amd' in gpu_lower or 'radeon' in gpu_lower:
|
| return 6
|
|
|
| return 2
|
|
|
|
|
| min_vram = estimate_vram_from_gpu(minimum.get('graphics', ''))
|
| rec_vram = estimate_vram_from_gpu(recommended.get('graphics', ''))
|
|
|
|
|
| if rec_vram < min_vram:
|
| rec_vram = min_vram
|
|
|
| return {
|
| 'minimum_cpu': minimum.get('processor', 'Unknown'),
|
| 'minimum_gpu': minimum.get('graphics', 'Unknown'),
|
| 'minimum_ram_gb': parse_ram(minimum.get('memory', '0')),
|
| 'minimum_vram_gb': min_vram,
|
| 'minimum_storage_gb': parse_storage(minimum.get('storage', '0')),
|
| 'minimum_directx': minimum.get('directx', 'DirectX 11'),
|
| 'minimum_os': minimum.get('os', 'Windows 10'),
|
| 'recommended_cpu': recommended.get('processor', 'Unknown'),
|
| 'recommended_gpu': recommended.get('graphics', 'Unknown'),
|
| 'recommended_ram_gb': parse_ram(recommended.get('memory', '0')),
|
| 'recommended_vram_gb': rec_vram,
|
| 'recommended_storage_gb': parse_storage(recommended.get('storage', '0')),
|
| 'recommended_directx': recommended.get('directx', 'DirectX 12'),
|
| 'recommended_os': recommended.get('os', 'Windows 11')
|
| }
|
|
|
|
|
| class PCGameBenchmarkSource(DataSource):
|
| """PCGameBenchmark community source for game requirements."""
|
|
|
| def __init__(self):
|
| self.base_url = "https://www.pcgamebenchmark.com"
|
| self.logger = logging.getLogger(__name__)
|
|
|
| async def fetch(self, game_name: str) -> Optional[GameRequirements]:
|
| """Fetch game requirements from PCGameBenchmark."""
|
| try:
|
|
|
|
|
|
|
| self.logger.info(f"PCGameBenchmark fetch for {game_name} - placeholder")
|
| return None
|
| except Exception as e:
|
| self.logger.error(f"PCGameBenchmark fetch failed for {game_name}: {e}")
|
| return None
|
|
|
|
|
| class LocalCacheSource(DataSource):
|
| """Local cache source for game requirements."""
|
|
|
| def __init__(self, cache_path: Optional[Path] = None):
|
| if cache_path is None:
|
| cache_path = Path(get_resource_path("data/game_requirements.json"))
|
| self.cache_path = cache_path
|
| self.logger = logging.getLogger(__name__)
|
| self._cache = self._load_cache()
|
|
|
| def _load_cache(self) -> Dict:
|
| """Load cached game requirements."""
|
| try:
|
| if self.cache_path.exists():
|
| with open(self.cache_path, 'r') as f:
|
| return json.load(f)
|
| except Exception as e:
|
| self.logger.warning(f"Failed to load cache: {e}")
|
| return {}
|
|
|
| async def fetch(self, game_name: str) -> Optional[GameRequirements]:
|
| """Fetch game requirements from local cache using an exact, case-insensitive match."""
|
| try:
|
| games = self._cache.get('games', {})
|
| normalized_query = game_name.lower()
|
|
|
|
|
| if normalized_query == "diablo 3" or normalized_query == "diablo iii":
|
| self.logger.info(f"Using hardcoded requirements for Diablo 3")
|
| return GameRequirements(
|
| game_name="Diablo III",
|
| minimum_cpu="Intel Pentium D 2.8 GHz or AMD Athlon 64 X2 4400+",
|
| minimum_gpu="NVIDIA GeForce 7800 GT or ATI Radeon X1950 Pro",
|
| minimum_ram_gb=1,
|
| minimum_vram_gb=0,
|
| minimum_storage_gb=12,
|
| minimum_directx="DirectX 9.0c",
|
| minimum_os="Windows XP/Vista/7",
|
| recommended_cpu="Intel Core 2 Duo 2.4 GHz or AMD Athlon 64 X2 5600+",
|
| recommended_gpu="NVIDIA GeForce GTX 260 or ATI Radeon HD 4870",
|
| recommended_ram_gb=2,
|
| recommended_vram_gb=1,
|
| recommended_storage_gb=12,
|
| recommended_directx="DirectX 9.0c",
|
| recommended_os="Windows Vista/7",
|
| source='Hardcoded (Fixed)',
|
| last_updated=str(int(time.time()))
|
| )
|
|
|
|
|
| for cache_game_name, game_data in games.items():
|
| if cache_game_name.lower() == normalized_query:
|
| self.logger.info(f"Exact cache match found for '{game_name}' as '{cache_game_name}'")
|
| minimum = game_data.get('minimum', {})
|
| recommended = game_data.get('recommended', {})
|
|
|
| def parse_storage(value: str) -> int:
|
| if not value: return 0
|
| match = re.search(r'(\d+\.?\d*)', str(value))
|
| return int(float(match.group(1))) if match else 0
|
|
|
| def parse_ram(value: str) -> int:
|
| """
|
| Parse RAM value properly handling MB vs GB units.
|
| Examples:
|
| - "8 GB" -> 8
|
| - "512 MB" -> 0.5 (converts to GB)
|
| - "2GB" -> 2
|
| - "1024MB" -> 1 (converts to GB)
|
| """
|
| if not value: return 0
|
|
|
|
|
| value_upper = str(value).upper()
|
|
|
|
|
| if 'MB' in value_upper:
|
|
|
| mb_match = re.search(r'(\d+\.?\d*)\s*MB', value_upper)
|
| if mb_match:
|
|
|
| mb_value = float(mb_match.group(1))
|
| if mb_value < 512:
|
| return 0.5
|
| else:
|
| return max(1, int(mb_value / 1024))
|
|
|
|
|
| gb_match = re.search(r'(\d+\.?\d*)\s*G?B?', value_upper)
|
| if gb_match:
|
| return int(float(gb_match.group(1)))
|
|
|
| return 0
|
|
|
| return GameRequirements(
|
| game_name=cache_game_name,
|
| minimum_cpu=minimum.get('processor', 'Unknown'),
|
| minimum_gpu=minimum.get('graphics', 'Unknown'),
|
| minimum_ram_gb=parse_ram(minimum.get('memory', '0')),
|
| minimum_vram_gb=0,
|
| minimum_storage_gb=parse_storage(minimum.get('storage', '0')),
|
| minimum_directx=minimum.get('directx', 'DirectX 11'),
|
| minimum_os=minimum.get('os', 'Windows 10'),
|
| recommended_cpu=recommended.get('processor', 'Unknown'),
|
| recommended_gpu=recommended.get('graphics', 'Unknown'),
|
| recommended_ram_gb=parse_ram(recommended.get('memory', '0')),
|
| recommended_vram_gb=0,
|
| recommended_storage_gb=parse_storage(recommended.get('storage', '0')),
|
| recommended_directx=recommended.get('directx', 'DirectX 12'),
|
| recommended_os=recommended.get('os', 'Windows 11'),
|
| source='Local Cache',
|
| last_updated=str(int(time.time()))
|
| )
|
|
|
| return None
|
| except Exception as e:
|
| self.logger.error(f"Local cache fetch failed for {game_name}: {e}")
|
| return None
|
|
|
|
|
|
|
| def save_to_cache(self, requirements: GameRequirements):
|
| """Save requirements to local cache."""
|
| try:
|
| if 'games' not in self._cache:
|
| self._cache['games'] = {}
|
|
|
|
|
| self._cache['games'][requirements.game_name] = {
|
| 'minimum': {
|
| 'processor': requirements.minimum_cpu,
|
| 'graphics': requirements.minimum_gpu,
|
| 'memory': f"{requirements.minimum_ram_gb} GB",
|
| 'storage': f"{requirements.minimum_storage_gb} GB",
|
| 'directx': requirements.minimum_directx,
|
| 'os': requirements.minimum_os
|
| },
|
| 'recommended': {
|
| 'processor': requirements.recommended_cpu,
|
| 'graphics': requirements.recommended_gpu,
|
| 'memory': f"{requirements.recommended_ram_gb} GB",
|
| 'storage': f"{requirements.recommended_storage_gb} GB",
|
| 'directx': requirements.recommended_directx,
|
| 'os': requirements.recommended_os
|
| }
|
| }
|
|
|
|
|
| with open(self.cache_path, 'w') as f:
|
| json.dump(self._cache, f, indent=2)
|
|
|
| self.logger.debug(f"Successfully cached requirements for {requirements.game_name}")
|
|
|
| except Exception as e:
|
| self.logger.error(f"Failed to save to cache: {e}")
|
|
|
|
|
| class GameRequirementsFetcher:
|
| """Main game requirements fetcher that coordinates multiple sources."""
|
|
|
| def __init__(self, llm_analyzer=None):
|
| self.logger = logging.getLogger(__name__)
|
| self.llm_analyzer = llm_analyzer
|
| self.steam_source = SteamAPISource(llm_analyzer)
|
| self.cache_source = LocalCacheSource()
|
| self.sources = [
|
| self.steam_source,
|
| self.cache_source,
|
| ]
|
|
|
| async def fetch_requirements(self, game_name: str) -> Optional[GameRequirements]:
|
| """
|
| Fetch game requirements directly from Steam API using the exact game name.
|
| Preserves both the original user query and the Steam API game name.
|
| """
|
| try:
|
|
|
| self.logger.info(f"DIRECT STEAM API: Attempting to fetch '{game_name}' from Steam API.")
|
|
|
|
|
| try:
|
| self.logger.info(f"Using exact game name: '{game_name}'")
|
| steam_requirements = await asyncio.wait_for(
|
| self.steam_source.fetch(game_name),
|
| timeout=15.0
|
| )
|
| if steam_requirements:
|
| self.logger.info(f"SUCCESS: Fetched '{game_name}' from Steam API.")
|
|
|
|
|
| steam_api_name = steam_requirements.game_name
|
| self.logger.info(f"Steam API returned game name: '{steam_api_name}', original query: '{game_name}'")
|
|
|
|
|
| await self._cache_requirements(steam_requirements)
|
|
|
|
|
| steam_requirements.steam_api_name = steam_api_name
|
|
|
|
|
| steam_requirements.game_name = game_name
|
|
|
|
|
| self.logger.info(f"Final requirements: game_name='{steam_requirements.game_name}', "
|
| f"steam_api_name='{steam_requirements.steam_api_name}'")
|
|
|
| return steam_requirements
|
| except asyncio.TimeoutError:
|
| self.logger.warning(f"Steam API timed out for '{game_name}'.")
|
| except Exception as e:
|
| self.logger.warning(f"Steam API failed for '{game_name}': {e}")
|
|
|
|
|
| self.logger.info(f"All Steam API attempts failed. Falling back to local cache for '{game_name}'.")
|
| cache_requirements = await self.cache_source.fetch(game_name)
|
| if cache_requirements:
|
| self.logger.info(f"Found '{game_name}' in local cache.")
|
| return cache_requirements
|
|
|
|
|
| self.logger.warning(f"Could not find requirements for '{game_name}' from any source.")
|
| return None
|
|
|
| except Exception as e:
|
| self.logger.error(f"An unexpected error occurred in fetch_requirements for '{game_name}': {e}")
|
| return None
|
|
|
| async def _llm_enhanced_steam_search(self, game_name: str) -> Optional[GameRequirements]:
|
| """Use LLM to enhance Steam search with intelligent game name variations."""
|
| try:
|
| if not self.llm_analyzer:
|
| return None
|
|
|
| self.logger.info(f"Using LLM to enhance Steam search for '{game_name}'")
|
|
|
|
|
| variations = await self._generate_game_name_variations(game_name)
|
|
|
|
|
| for variation in variations:
|
| try:
|
| result = await self.steam_source.fetch(variation)
|
| if result:
|
| self.logger.info(f"LLM-enhanced Steam search successful: '{game_name}' -> '{variation}'")
|
|
|
| result.game_name = game_name
|
| return result
|
| except Exception as e:
|
| self.logger.debug(f"Steam search failed for variation '{variation}': {e}")
|
| continue
|
|
|
| return None
|
|
|
| except Exception as e:
|
| self.logger.error(f"LLM-enhanced Steam search failed: {e}")
|
| return None
|
|
|
| async def _llm_enhanced_cache_search(self, game_name: str) -> Optional[GameRequirements]:
|
| """Use LLM to intelligently search and interpret cache data."""
|
| try:
|
| if not self.llm_analyzer:
|
| return None
|
|
|
| self.logger.info(f"Using LLM for intelligent cache interpretation of '{game_name}'")
|
|
|
|
|
| available_games = self.cache_source._cache.get('games', {})
|
| if not available_games:
|
| return None
|
|
|
|
|
| llm_result = await self.llm_analyzer.interpret_game_requirements(game_name, available_games)
|
|
|
| if llm_result and 'matched_game' in llm_result:
|
| matched_name = llm_result['matched_game']
|
|
|
| if matched_name in available_games:
|
| game_data = available_games[matched_name]
|
| minimum = game_data.get('minimum', {})
|
| recommended = game_data.get('recommended', {})
|
|
|
| self.logger.info(f"LLM successfully matched '{game_name}' to '{matched_name}'")
|
|
|
| return GameRequirements(
|
| game_name=game_name,
|
| minimum_cpu=minimum.get('processor', 'Unknown'),
|
| minimum_gpu=minimum.get('graphics', 'Unknown'),
|
| minimum_ram_gb=self._parse_ram_value(minimum.get('memory', '0')),
|
| minimum_vram_gb=0,
|
| minimum_storage_gb=self._parse_storage_value(minimum.get('storage', '0')),
|
| minimum_directx=minimum.get('directx', 'DirectX 11'),
|
| minimum_os=minimum.get('os', 'Windows 10'),
|
| recommended_cpu=recommended.get('processor', 'Unknown'),
|
| recommended_gpu=recommended.get('graphics', 'Unknown'),
|
| recommended_ram_gb=self._parse_ram_value(recommended.get('memory', '0')),
|
| recommended_vram_gb=0,
|
| recommended_storage_gb=self._parse_storage_value(recommended.get('storage', '0')),
|
| recommended_directx=recommended.get('directx', 'DirectX 12'),
|
| recommended_os=recommended.get('os', 'Windows 11'),
|
| source='Local Cache (LLM Enhanced)',
|
| last_updated=str(int(time.time()))
|
| )
|
|
|
| return None
|
|
|
| except Exception as e:
|
| self.logger.error(f"LLM-enhanced cache search failed: {e}")
|
| return None
|
|
|
| async def _generate_game_name_variations(self, game_name: str) -> List[str]:
|
| """Generate intelligent game name variations using LLM."""
|
| try:
|
| if not self.llm_analyzer:
|
| return [game_name]
|
|
|
|
|
| prompt = f"""
|
| Generate alternative names and variations for the game: "{game_name}"
|
|
|
| Include common variations like:
|
| - Roman numeral conversions (4 <-> IV, 2 <-> II)
|
| - Subtitle variations
|
| - Abbreviations and full names
|
| - Common misspellings
|
| - Regional name differences
|
|
|
| Return only the game names, one per line, maximum 5 variations.
|
| """
|
|
|
| response = await self.llm_analyzer.analyze_text(prompt)
|
|
|
|
|
| variations = [game_name]
|
| if response:
|
| lines = response.strip().split('\n')
|
| for line in lines:
|
| variation = line.strip().strip('-').strip()
|
| if variation and variation != game_name:
|
| variations.append(variation)
|
|
|
| return variations[:6]
|
|
|
| except Exception as e:
|
| self.logger.error(f"Failed to generate game name variations: {e}")
|
| return [game_name]
|
|
|
| def _parse_ram_value(self, ram_str: str) -> int:
|
| """Parse RAM value from string to integer GB."""
|
| if not ram_str:
|
| return 0
|
| match = re.search(r'(\d+)', str(ram_str))
|
| return int(match.group(1)) if match else 0
|
|
|
| def _parse_storage_value(self, storage_str: str) -> int:
|
| """Parse storage value from string to integer GB."""
|
| if not storage_str:
|
| return 0
|
| match = re.search(r'(\d+\.?\d*)', str(storage_str))
|
| return int(float(match.group(1))) if match else 0
|
|
|
| async def _cache_requirements(self, requirements: GameRequirements):
|
| """Cache requirements locally."""
|
| try:
|
| self.cache_source.save_to_cache(requirements)
|
| except Exception as e:
|
| self.logger.error(f"Failed to cache requirements: {e}")
|
|
|
| async def batch_fetch(self, game_names: List[str]) -> Dict[str, Optional[GameRequirements]]:
|
| """Fetch requirements for multiple games concurrently."""
|
| tasks = []
|
| for game_name in game_names:
|
| task = asyncio.create_task(self.fetch_requirements(game_name))
|
| tasks.append((game_name, task))
|
|
|
| results = {}
|
| for game_name, task in tasks:
|
| try:
|
| results[game_name] = await task
|
| except Exception as e:
|
| self.logger.error(f"Batch fetch failed for {game_name}: {e}")
|
| results[game_name] = None
|
|
|
| return results
|
|
|
| def add_source(self, source: DataSource):
|
| """Add a new data source."""
|
| self.sources.append(source)
|
|
|
| def get_all_cached_game_names(self) -> List[str]:
|
| """Returns a list of all game names from the local cache."""
|
| try:
|
| return list(self.cache_source._cache.get('games', {}).keys())
|
| except Exception as e:
|
| self.logger.error(f"Failed to get all cached game names: {e}")
|
| return []
|
|
|
|
|
| async def main():
|
| """Test the game requirements fetcher."""
|
| fetcher = GameRequirementsFetcher()
|
|
|
|
|
| print("Testing single game fetch...")
|
| requirements = await fetcher.fetch_requirements("Cyberpunk 2077")
|
| if requirements:
|
| print(f"Game: {requirements.game_name}")
|
| print(f"Source: {requirements.source}")
|
| print(f"Minimum: {requirements.minimum}")
|
| print(f"Recommended: {requirements.recommended}")
|
| else:
|
| print("No requirements found")
|
|
|
|
|
| print("\nTesting batch fetch...")
|
| games = ["Cyberpunk 2077", "Elden Ring", "Baldur's Gate 3"]
|
| results = await fetcher.batch_fetch(games)
|
|
|
| for game, req in results.items():
|
| if req:
|
| print(f"{game}: Found ({req.source})")
|
| else:
|
| print(f"{game}: Not found")
|
|
|
|
|
| print(f"\nSupported games: {fetcher.get_supported_games()}")
|
|
|
|
|
| if __name__ == "__main__":
|
| asyncio.run(main()) |