From d12b801191062a3bef0e7c6c4eb82d295605c1c1 Mon Sep 17 00:00:00 2001 From: Boris Yumankulov Date: Mon, 14 Jul 2025 13:15:17 +0500 Subject: [PATCH] feat: added data from How Long To Beat to GameCard Signed-off-by: Boris Yumankulov --- ...longtobeat-api.py => howlongtobeat_api.py} | 336 +++++++----------- portprotonqt/main_window.py | 101 +++++- 2 files changed, 209 insertions(+), 228 deletions(-) rename portprotonqt/{howlongtobeat-api.py => howlongtobeat_api.py} (54%) diff --git a/portprotonqt/howlongtobeat-api.py b/portprotonqt/howlongtobeat_api.py similarity index 54% rename from portprotonqt/howlongtobeat-api.py rename to portprotonqt/howlongtobeat_api.py index a62d016..03409d1 100644 --- a/portprotonqt/howlongtobeat-api.py +++ b/portprotonqt/howlongtobeat_api.py @@ -1,71 +1,37 @@ import orjson import re +import os from dataclasses import dataclass, field -from enum import Enum from typing import Any from difflib import SequenceMatcher - +from threading import Thread import requests from bs4 import BeautifulSoup, Tag from portprotonqt.config_utils import read_proxy_config - - -class SearchModifiers(Enum): - """Модификаторы поиска для фильтрации результатов.""" - NONE = "" - ONLY_DLC = "only_dlc" - ONLY_MODS = "only_mods" - ONLY_HACKS = "only_hacks" - HIDE_DLC = "hide_dlc" - +from portprotonqt.time_utils import format_playtime +from PySide6.QtCore import QObject, Signal @dataclass class GameEntry: """Информация об игре из HowLongToBeat.""" - # Основная информация game_id: int = -1 game_name: str | None = None - game_alias: str | None = None - game_type: str | None = None - game_image_url: str | None = None - game_web_link: str | None = None - review_score: float | None = None - developer: str | None = None - platforms: list[str] = field(default_factory=list) - release_year: int | None = None - similarity: float = -1.0 - - # Времена прохождения (в часах) main_story: float | None = None main_extra: float | None = None completionist: float | None = None - all_styles: float | None = None - coop_time: float | None = None - multiplayer_time: float | None = None - - # Флаги сложности - has_single_player: bool = False - has_coop: bool = False - has_multiplayer: bool = False - has_combined_complexity: bool = False - - # Исходные данные JSON + similarity: float = -1.0 raw_data: dict[str, Any] = field(default_factory=dict) - @dataclass class SearchConfig: """Конфигурация для поиска.""" api_key: str | None = None search_url: str | None = None - class APIKeyExtractor: """Извлекает API ключ и URL поиска из скриптов сайта.""" - @staticmethod def extract_from_script(script_content: str) -> SearchConfig: - """Извлекает конфигурацию из содержимого скрипта.""" config = SearchConfig() config.api_key = APIKeyExtractor._extract_api_key(script_content) config.search_url = APIKeyExtractor._extract_search_url(script_content, config.api_key) @@ -73,53 +39,40 @@ class APIKeyExtractor: @staticmethod def _extract_api_key(script_content: str) -> str | None: - """Извлекает API ключ из скрипта.""" - # Паттерн для поиска user ID user_id_pattern = r'users\s*:\s*{\s*id\s*:\s*"([^"]+)"' matches = re.findall(user_id_pattern, script_content) if matches: return ''.join(matches) - - # Паттерн для поиска конкатенированного API ключа concat_pattern = r'\/api\/\w+\/"(?:\.concat\("[^"]*"\))+' matches = re.findall(concat_pattern, script_content) if matches: parts = str(matches).split('.concat') cleaned_parts = [re.sub(r'["\(\)\[\]\']', '', part) for part in parts[1:]] return ''.join(cleaned_parts) - return None @staticmethod def _extract_search_url(script_content: str, api_key: str | None) -> str | None: - """Извлекает URL поиска из скрипта.""" if not api_key: return None - pattern = re.compile( r'fetch\(\s*["\'](\/api\/[^"\']*)["\']' r'((?:\s*\.concat\(\s*["\']([^"\']*)["\']\s*\))+)' r'\s*,', re.DOTALL ) - for match in pattern.finditer(script_content): endpoint = match.group(1) concat_calls = match.group(2) concat_strings = re.findall(r'\.concat\(\s*["\']([^"\']*)["\']\s*\)', concat_calls) concatenated_str = ''.join(concat_strings) - if concatenated_str == api_key: return endpoint - return None - class HTTPClient: """HTTP клиент для работы с API HowLongToBeat.""" - BASE_URL = 'https://howlongtobeat.com/' - GAME_URL = BASE_URL + "game" SEARCH_URL = BASE_URL + "api/s/" def __init__(self, timeout: int = 60): @@ -129,35 +82,23 @@ class HTTPClient: 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', 'referer': self.BASE_URL }) - # Apply proxy settings from config proxy_config = read_proxy_config() if proxy_config: self.session.proxies.update(proxy_config) def get_search_config(self, parse_all_scripts: bool = False) -> SearchConfig | None: - """Получает конфигурацию поиска с главной страницы.""" try: response = self.session.get(self.BASE_URL, timeout=self.timeout) response.raise_for_status() soup = BeautifulSoup(response.text, 'html.parser') scripts = soup.find_all('script', src=True) - - # Filter for Tag objects and ensure src is a string - if parse_all_scripts: - script_urls = [] - for script in scripts: - if isinstance(script, Tag): - src = script.get('src') - if src is not None and isinstance(src, str): + script_urls = [] + for script in scripts: + if isinstance(script, Tag): + src = script.get('src') + if src is not None and isinstance(src, str): + if parse_all_scripts or '_app-' in src: script_urls.append(src) - else: - script_urls = [] - for script in scripts: - if isinstance(script, Tag): - src = script.get('src') - if src is not None and isinstance(src, str) and '_app-' in src: - script_urls.append(src) - for script_url in script_urls: full_url = self.BASE_URL + script_url script_response = self.session.get(full_url, timeout=self.timeout) @@ -169,28 +110,21 @@ class HTTPClient: pass return None - def search_games(self, game_name: str, search_modifiers: SearchModifiers = SearchModifiers.NONE, - page: int = 1, config: SearchConfig | None = None) -> str | None: - """Выполняет поиск игр.""" + def search_games(self, game_name: str, page: int = 1, config: SearchConfig | None = None) -> str | None: if not config: config = self.get_search_config() if not config: config = self.get_search_config(parse_all_scripts=True) - if not config or not config.api_key: return None - search_url = self.SEARCH_URL if config.search_url: search_url = self.BASE_URL + config.search_url.lstrip('/') - - payload = self._build_search_payload(game_name, search_modifiers, page, config) + payload = self._build_search_payload(game_name, page, config) headers = { 'content-type': 'application/json', 'accept': '*/*' } - - # Попытка с API ключом в URL try: response = self.session.post( search_url + config.api_key, @@ -202,8 +136,6 @@ class HTTPClient: return response.text except requests.RequestException: pass - - # Попытка с API ключом в payload try: response = self.session.post( search_url, @@ -215,37 +147,14 @@ class HTTPClient: return response.text except requests.RequestException: pass - return None - def get_game_title(self, game_id: int) -> str | None: - """Получает название игры по ID.""" - try: - params = {'id': str(game_id)} - response = self.session.get(self.GAME_URL, params=params, timeout=self.timeout) - response.raise_for_status() - - soup = BeautifulSoup(response.text, 'html.parser') - title_tag = soup.title - - if title_tag and title_tag.string: - # Обрезаем стандартные части заголовка - title = title_tag.string[12:-17].strip() - return title - - except requests.RequestException: - pass - - return None - - def _build_search_payload(self, game_name: str, search_modifiers: SearchModifiers, - page: int, config: SearchConfig) -> dict[str, Any]: - """Строит payload для поискового запроса.""" + def _build_search_payload(self, game_name: str, page: int, config: SearchConfig) -> dict[str, Any]: payload = { 'searchType': "games", 'searchTerms': game_name.split(), 'searchPage': page, - 'size': 20, + 'size': 1, # Limit to 1 result 'searchOptions': { 'games': { 'userId': 0, @@ -260,7 +169,7 @@ class HTTPClient: "difficulty": "" }, 'rangeYear': {'max': "", 'min': ""}, - 'modifier': search_modifiers.value, + 'modifier': "" # Hardcoded to empty string for SearchModifiers.NONE }, 'users': {'sortCategory': "postcount"}, 'lists': {'sortCategory': "follows"}, @@ -268,194 +177,195 @@ class HTTPClient: 'sort': 0, 'randomizer': 0 }, - 'useCache': True + 'useCache': True, + 'fields': ["game_id", "game_name", "comp_main", "comp_plus", "comp_100"] # Request only needed fields } - if config.api_key: payload['searchOptions']['users']['id'] = config.api_key - return payload - class ResultParser: """Парсер результатов поиска.""" - - IMAGE_URL_PREFIX = "https://howlongtobeat.com/games/" - GAME_URL_PREFIX = "https://howlongtobeat.com/game/" - - def __init__(self, search_query: str, minimum_similarity: float = 0.4, - case_sensitive: bool = True, auto_filter_times: bool = False): + def __init__(self, search_query: str, minimum_similarity: float = 0.4, case_sensitive: bool = True): self.search_query = search_query self.minimum_similarity = minimum_similarity self.case_sensitive = case_sensitive - self.auto_filter_times = auto_filter_times self.search_numbers = self._extract_numbers(search_query) def parse_results(self, json_response: str, target_game_id: int | None = None) -> list[GameEntry]: - """Парсит JSON ответ и возвращает список игр.""" try: data = orjson.loads(json_response) games = [] - - for game_data in data.get("data", []): + # Only process the first result + if data.get("data"): + game_data = data["data"][0] game = self._parse_game_entry(game_data) - if target_game_id is not None: if game.game_id == target_game_id: games.append(game) elif self.minimum_similarity == 0.0 or game.similarity >= self.minimum_similarity: games.append(game) - return games - - except (orjson.JSONDecodeError, KeyError): + except (orjson.JSONDecodeError, KeyError, IndexError): return [] def _parse_game_entry(self, game_data: dict[str, Any]) -> GameEntry: - """Парсит данные одной игры.""" game = GameEntry() - - # Основная информация game.game_id = game_data.get("game_id", -1) game.game_name = game_data.get("game_name") - game.game_alias = game_data.get("game_alias") - game.game_type = game_data.get("game_type") - game.review_score = game_data.get("review_score") - game.developer = game_data.get("profile_dev") - game.release_year = game_data.get("release_world") game.raw_data = game_data - - # URL изображения - if "game_image" in game_data: - game.game_image_url = self.IMAGE_URL_PREFIX + game_data["game_image"] - - # Ссылка на игру - game.game_web_link = f"{self.GAME_URL_PREFIX}{game.game_id}" - - # Платформы - if "profile_platform" in game_data: - game.platforms = game_data["profile_platform"].split(", ") - - # Времена прохождения (конвертация из секунд в часы) time_fields = [ ("comp_main", "main_story"), ("comp_plus", "main_extra"), - ("comp_100", "completionist"), - ("comp_all", "all_styles"), - ("invested_co", "coop_time"), - ("invested_mp", "multiplayer_time") + ("comp_100", "completionist") ] - for json_field, attr_name in time_fields: if json_field in game_data: time_hours = round(game_data[json_field] / 3600, 2) setattr(game, attr_name, time_hours) - - # Флаги сложности - game.has_combined_complexity = bool(game_data.get("comp_lvl_combine", 0)) - game.has_single_player = bool(game_data.get("comp_lvl_sp", 0)) - game.has_coop = bool(game_data.get("comp_lvl_co", 0)) - game.has_multiplayer = bool(game_data.get("comp_lvl_mp", 0)) - - # Автофильтрация времен - if self.auto_filter_times: - if not game.has_single_player: - game.main_story = None - game.main_extra = None - game.completionist = None - game.all_styles = None - if not game.has_coop: - game.coop_time = None - if not game.has_multiplayer: - game.multiplayer_time = None - - # Вычисление similarity game.similarity = self._calculate_similarity(game) - return game def _calculate_similarity(self, game: GameEntry) -> float: - """Вычисляет similarity между поисковым запросом и игрой.""" - name_similarity = self._compare_strings(self.search_query, game.game_name) - alias_similarity = self._compare_strings(self.search_query, game.game_alias) - - return max(name_similarity, alias_similarity) + return self._compare_strings(self.search_query, game.game_name) def _compare_strings(self, a: str | None, b: str | None) -> float: - """Сравнивает две строки и возвращает коэффициент similarity.""" if not a or not b: return 0.0 - if self.case_sensitive: similarity = SequenceMatcher(None, a, b).ratio() else: similarity = SequenceMatcher(None, a.lower(), b.lower()).ratio() - - # Штраф за отсутствие чисел из оригинального запроса if self.search_numbers and not self._contains_numbers(b, self.search_numbers): similarity -= 0.1 - return max(0.0, similarity) @staticmethod def _extract_numbers(text: str) -> list[str]: - """Извлекает числа из текста.""" return [word for word in text.split() if word.isdigit()] @staticmethod def _contains_numbers(text: str, numbers: list[str]) -> bool: - """Проверяет, содержит ли текст указанные числа.""" if not numbers: return True - cleaned_text = re.sub(r'([^\s\w]|_)+', '', text) text_numbers = [word for word in cleaned_text.split() if word.isdigit()] - return any(num in text_numbers for num in numbers) +def get_cache_dir(): + """Возвращает путь к каталогу кэша, создаёт его при необходимости.""" + xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")) + cache_dir = os.path.join(xdg_cache_home, "PortProtonQt") + os.makedirs(cache_dir, exist_ok=True) + return cache_dir -class HowLongToBeat: +class HowLongToBeat(QObject): """Основной класс для работы с API HowLongToBeat.""" + searchCompleted = Signal(list) - def __init__(self, minimum_similarity: float = 0.4, auto_filter_times: bool = False, - timeout: int = 60): + def __init__(self, minimum_similarity: float = 0.4, timeout: int = 60, parent=None): + super().__init__(parent) self.minimum_similarity = minimum_similarity - self.auto_filter_times = auto_filter_times self.http_client = HTTPClient(timeout) + self.cache_dir = get_cache_dir() - def search(self, game_name: str, search_modifiers: SearchModifiers = SearchModifiers.NONE, - case_sensitive: bool = True) -> list[GameEntry] | None: - """Ищет игры по названию.""" + def _get_cache_file_path(self, game_name: str) -> str: + """Возвращает путь к файлу кэша для заданного имени игры.""" + safe_game_name = re.sub(r'[^\w\s-]', '', game_name).replace(' ', '_').lower() + cache_file = f"hltb_{safe_game_name}.json" + return os.path.join(self.cache_dir, cache_file) + + def _load_from_cache(self, game_name: str) -> str | None: + """Пытается загрузить данные из кэша, если они существуют.""" + cache_file = self._get_cache_file_path(game_name) + try: + if os.path.exists(cache_file): + with open(cache_file, 'rb') as f: + return f.read().decode('utf-8') + except (OSError, UnicodeDecodeError): + pass + return None + + def _save_to_cache(self, game_name: str, json_response: str): + """Сохраняет данные в кэш, храня только первую игру и необходимые поля.""" + cache_file = self._get_cache_file_path(game_name) + try: + # Парсим JSON и берем только первую игру + data = orjson.loads(json_response) + if data.get("data"): + first_game = data["data"][0] + simplified_data = { + "data": [{ + "game_id": first_game.get("game_id", -1), + "game_name": first_game.get("game_name"), + "comp_main": first_game.get("comp_main", 0), + "comp_plus": first_game.get("comp_plus", 0), + "comp_100": first_game.get("comp_100", 0) + }] + } + with open(cache_file, 'wb') as f: + f.write(orjson.dumps(simplified_data)) + except (OSError, orjson.JSONDecodeError, IndexError): + pass + + def search(self, game_name: str, case_sensitive: bool = True) -> list[GameEntry] | None: if not game_name or not game_name.strip(): return None - - json_response = self.http_client.search_games(game_name, search_modifiers) + # Проверяем кэш + cached_response = self._load_from_cache(game_name) + if cached_response: + try: + cached_data = orjson.loads(cached_response) + full_json = { + "data": [ + { + "game_id": game["game_id"], + "game_name": game["game_name"], + "comp_main": game["comp_main"], + "comp_plus": game["comp_plus"], + "comp_100": game["comp_100"] + } + for game in cached_data.get("data", []) + ] + } + parser = ResultParser( + game_name, + self.minimum_similarity, + case_sensitive + ) + return parser.parse_results(orjson.dumps(full_json).decode('utf-8')) + except orjson.JSONDecodeError: + pass + # Если нет в кэше, делаем запрос + json_response = self.http_client.search_games(game_name) if not json_response: return None - + # Сохраняем в кэш только первую игру + self._save_to_cache(game_name, json_response) parser = ResultParser( game_name, self.minimum_similarity, - case_sensitive, - self.auto_filter_times + case_sensitive ) - return parser.parse_results(json_response) - def search_by_id(self, game_id: int) -> GameEntry | None: - """Ищет игру по ID.""" - if not game_id or game_id <= 0: + def format_game_time(self, game_entry: GameEntry, time_field: str = "main_story") -> str | None: + time_value = getattr(game_entry, time_field, None) + if time_value is None: return None + time_seconds = int(time_value * 3600) + return format_playtime(time_seconds) - game_title = self.http_client.get_game_title(game_id) - if not game_title: - return None + def search_with_callback(self, game_name: str, case_sensitive: bool = True): + """Выполняет поиск игры в фоновом потоке и испускает сигнал с результатами.""" + def search_thread(): + try: + results = self.search(game_name, case_sensitive) + self.searchCompleted.emit(results if results else []) + except Exception as e: + print(f"Error in search_with_callback: {e}") + self.searchCompleted.emit([]) - json_response = self.http_client.search_games(game_title) - if not json_response: - return None - - parser = ResultParser(game_title, 0.0, False, self.auto_filter_times) - results = parser.parse_results(json_response, target_game_id=game_id) - - return results[0] if results else None + thread = Thread(target=search_thread) + thread.daemon = True + thread.start() diff --git a/portprotonqt/main_window.py b/portprotonqt/main_window.py index 9a62fcf..9c52662 100644 --- a/portprotonqt/main_window.py +++ b/portprotonqt/main_window.py @@ -31,6 +31,7 @@ from portprotonqt.config_utils import ( ) from portprotonqt.localization import _, get_egs_language, read_metadata_translations from portprotonqt.logger import get_logger +from portprotonqt.howlongtobeat_api import HowLongToBeat from portprotonqt.downloader import Downloader from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, QScrollArea, QSlider, @@ -1589,7 +1590,7 @@ class MainWindow(QMainWindow): badge_spacing = 5 top_y = 10 badge_y_positions = [] - badge_width = int(300 * 2/3) # 2/3 ширины обложки (300 px) + badge_width = int(300 * 2/3) # ProtonDB бейдж protondb_text = GameCard.getProtonDBText(protondb_tier) @@ -1678,11 +1679,6 @@ class MainWindow(QMainWindow): anticheat_visible = False # Расположение бейджей - right_margin = 8 - badge_spacing = 5 - top_y = 10 - badge_y_positions = [] - badge_width = int(300 * 2/3) if steam_visible: steam_x = 300 - badge_width - right_margin steamLabel.move(steam_x, top_y) @@ -1736,22 +1732,97 @@ class MainWindow(QMainWindow): descLabel.setStyleSheet(self.theme.DETAIL_PAGE_DESC_STYLE) detailsLayout.addWidget(descLabel) - infoLayout = QHBoxLayout() - infoLayout.setSpacing(10) + # Инициализация HowLongToBeat + hltb = HowLongToBeat(parent=self) + + # Создаем общий layout для всей игровой информации + gameInfoLayout = QVBoxLayout() + gameInfoLayout.setSpacing(10) + + # Первая строка: Last Launch и Play Time + firstRowLayout = QHBoxLayout() + firstRowLayout.setSpacing(10) + + # Last Launch lastLaunchTitle = QLabel(_("LAST LAUNCH")) lastLaunchTitle.setStyleSheet(self.theme.LAST_LAUNCH_TITLE_STYLE) lastLaunchValue = QLabel(last_launch) lastLaunchValue.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE) + firstRowLayout.addWidget(lastLaunchTitle) + firstRowLayout.addWidget(lastLaunchValue) + firstRowLayout.addSpacing(30) + + # Play Time playTimeTitle = QLabel(_("PLAY TIME")) playTimeTitle.setStyleSheet(self.theme.PLAY_TIME_TITLE_STYLE) playTimeValue = QLabel(formatted_playtime) playTimeValue.setStyleSheet(self.theme.PLAY_TIME_VALUE_STYLE) - infoLayout.addWidget(lastLaunchTitle) - infoLayout.addWidget(lastLaunchValue) - infoLayout.addSpacing(30) - infoLayout.addWidget(playTimeTitle) - infoLayout.addWidget(playTimeValue) - detailsLayout.addLayout(infoLayout) + firstRowLayout.addWidget(playTimeTitle) + firstRowLayout.addWidget(playTimeValue) + + gameInfoLayout.addLayout(firstRowLayout) + + # Создаем placeholder для второй строки (HLTB данные) + hltbLayout = QHBoxLayout() + hltbLayout.setSpacing(10) + + # Время прохождения (Main Story, Main + Sides, Completionist) + def on_hltb_results(results): + if results: + game = results[0] # Берем первый результат + main_story_time = hltb.format_game_time(game, "main_story") + main_extra_time = hltb.format_game_time(game, "main_extra") + completionist_time = hltb.format_game_time(game, "completionist") + + # Очищаем layout перед добавлением новых элементов + while hltbLayout.count(): + child = hltbLayout.takeAt(0) + if child.widget(): + child.widget().deleteLater() + + has_data = False + + if main_story_time is not None: + mainStoryTitle = QLabel(_("MAIN STORY")) + mainStoryTitle.setStyleSheet(self.theme.LAST_LAUNCH_TITLE_STYLE) + mainStoryValue = QLabel(main_story_time) + mainStoryValue.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE) + hltbLayout.addWidget(mainStoryTitle) + hltbLayout.addWidget(mainStoryValue) + hltbLayout.addSpacing(30) + has_data = True + + if main_extra_time is not None: + mainExtraTitle = QLabel(_("MAIN + SIDES")) + mainExtraTitle.setStyleSheet(self.theme.PLAY_TIME_TITLE_STYLE) + mainExtraValue = QLabel(main_extra_time) + mainExtraValue.setStyleSheet(self.theme.PLAY_TIME_VALUE_STYLE) + hltbLayout.addWidget(mainExtraTitle) + hltbLayout.addWidget(mainExtraValue) + hltbLayout.addSpacing(30) + has_data = True + + if completionist_time is not None: + completionistTitle = QLabel(_("COMPLETIONIST")) + completionistTitle.setStyleSheet(self.theme.LAST_LAUNCH_TITLE_STYLE) + completionistValue = QLabel(completionist_time) + completionistValue.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE) + hltbLayout.addWidget(completionistTitle) + hltbLayout.addWidget(completionistValue) + has_data = True + + # Если есть данные, добавляем layout во вторую строку + if has_data: + gameInfoLayout.addLayout(hltbLayout) + + # Подключаем сигнал searchCompleted к on_hltb_results + hltb.searchCompleted.connect(on_hltb_results) + + # Запускаем поиск в фоновом потоке + hltb.search_with_callback(name, case_sensitive=False) + + # Добавляем общий layout с игровой информацией + detailsLayout.addLayout(gameInfoLayout) if controller_support: cs = controller_support.lower() @@ -1769,7 +1840,7 @@ class MainWindow(QMainWindow): detailsLayout.addStretch(1) - # Определяем текущий идентификатор игры по exec_line для корректного отображения кнопки + # Определяем текущий идентификатор игры по exec_line entry_exec_split = shlex.split(exec_line) if not entry_exec_split: return