feat: added data from How Long To Beat to GameCard

Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
This commit is contained in:
2025-07-14 13:15:17 +05:00
parent 233dab1269
commit d12b801191
2 changed files with 209 additions and 228 deletions

View File

@ -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()

View File

@ -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