forked from Boria138/PortProtonQt
feat: added module for work with howlongtobeat.com
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
This commit is contained in:
13
LICENSE
13
LICENSE
@ -73,6 +73,19 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
|
|
||||||
|
===============================
|
||||||
|
= HowLongToBeat-Python-API : =
|
||||||
|
===============================
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2020 JaeguKim
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
==============
|
==============
|
||||||
= legendary: =
|
= legendary: =
|
||||||
==============
|
==============
|
||||||
|
@ -6,7 +6,7 @@ arch=('any')
|
|||||||
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
|
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
|
||||||
license=('GPL-3.0')
|
license=('GPL-3.0')
|
||||||
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
|
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
|
||||||
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils')
|
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4')
|
||||||
makedepends=('python-'{'build','installer','setuptools','wheel'})
|
makedepends=('python-'{'build','installer','setuptools','wheel'})
|
||||||
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt#tag=v$pkgver")
|
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt#tag=v$pkgver")
|
||||||
sha256sums=('SKIP')
|
sha256sums=('SKIP')
|
||||||
|
@ -6,7 +6,7 @@ arch=('any')
|
|||||||
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
|
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
|
||||||
license=('GPL-3.0')
|
license=('GPL-3.0')
|
||||||
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
|
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
|
||||||
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils')
|
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4')
|
||||||
makedepends=('python-'{'build','installer','setuptools','wheel'})
|
makedepends=('python-'{'build','installer','setuptools','wheel'})
|
||||||
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt.git")
|
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt.git")
|
||||||
sha256sums=('SKIP')
|
sha256sums=('SKIP')
|
||||||
|
@ -44,6 +44,7 @@ Requires: python3-pefile
|
|||||||
Requires: python3-pillow
|
Requires: python3-pillow
|
||||||
Requires: perl-Image-ExifTool
|
Requires: perl-Image-ExifTool
|
||||||
Requires: xdg-utils
|
Requires: xdg-utils
|
||||||
|
Requires: python3-beautifulsoup4
|
||||||
|
|
||||||
%description -n python3-%{pypi_name}-git
|
%description -n python3-%{pypi_name}-git
|
||||||
This application provides a sleek, intuitive graphical interface for managing and launching games from PortProton, Steam, and Epic Games Store. It consolidates your game libraries into a single, user-friendly hub for seamless navigation and organization. Its lightweight structure and cross-platform support deliver a cohesive gaming experience, eliminating the need for multiple launchers. Unique PortProton integration enhances Linux gaming, enabling effortless play of Windows-based titles with minimal setup.
|
This application provides a sleek, intuitive graphical interface for managing and launching games from PortProton, Steam, and Epic Games Store. It consolidates your game libraries into a single, user-friendly hub for seamless navigation and organization. Its lightweight structure and cross-platform support deliver a cohesive gaming experience, eliminating the need for multiple launchers. Unique PortProton integration enhances Linux gaming, enabling effortless play of Windows-based titles with minimal setup.
|
||||||
|
@ -41,6 +41,7 @@ Requires: python3-pefile
|
|||||||
Requires: python3-pillow
|
Requires: python3-pillow
|
||||||
Requires: perl-Image-ExifTool
|
Requires: perl-Image-ExifTool
|
||||||
Requires: xdg-utils
|
Requires: xdg-utils
|
||||||
|
Requires: python3-beautifulsoup4
|
||||||
|
|
||||||
%description -n python3-%{pypi_name}
|
%description -n python3-%{pypi_name}
|
||||||
This application provides a sleek, intuitive graphical interface for managing and launching games from PortProton, Steam, and Epic Games Store. It consolidates your game libraries into a single, user-friendly hub for seamless navigation and organization. Its lightweight structure and cross-platform support deliver a cohesive gaming experience, eliminating the need for multiple launchers. Unique PortProton integration enhances Linux gaming, enabling effortless play of Windows-based titles with minimal setup.
|
This application provides a sleek, intuitive graphical interface for managing and launching games from PortProton, Steam, and Epic Games Store. It consolidates your game libraries into a single, user-friendly hub for seamless navigation and organization. Its lightweight structure and cross-platform support deliver a cohesive gaming experience, eliminating the need for multiple launchers. Unique PortProton integration enhances Linux gaming, enabling effortless play of Windows-based titles with minimal setup.
|
||||||
|
461
portprotonqt/howlongtobeat-api.py
Normal file
461
portprotonqt/howlongtobeat-api.py
Normal file
@ -0,0 +1,461 @@
|
|||||||
|
import orjson
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
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)
|
||||||
|
return config
|
||||||
|
|
||||||
|
@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):
|
||||||
|
self.timeout = timeout
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update({
|
||||||
|
'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.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)
|
||||||
|
if script_response.status_code == 200:
|
||||||
|
config = APIKeyExtractor.extract_from_script(script_response.text)
|
||||||
|
if config.api_key:
|
||||||
|
return config
|
||||||
|
except requests.RequestException:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def search_games(self, game_name: str, search_modifiers: SearchModifiers = SearchModifiers.NONE,
|
||||||
|
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)
|
||||||
|
headers = {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'accept': '*/*'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Попытка с API ключом в URL
|
||||||
|
try:
|
||||||
|
response = self.session.post(
|
||||||
|
search_url + config.api_key,
|
||||||
|
headers=headers,
|
||||||
|
data=orjson.dumps(payload),
|
||||||
|
timeout=self.timeout
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
return response.text
|
||||||
|
except requests.RequestException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Попытка с API ключом в payload
|
||||||
|
try:
|
||||||
|
response = self.session.post(
|
||||||
|
search_url,
|
||||||
|
headers=headers,
|
||||||
|
data=orjson.dumps(payload),
|
||||||
|
timeout=self.timeout
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
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 для поискового запроса."""
|
||||||
|
payload = {
|
||||||
|
'searchType': "games",
|
||||||
|
'searchTerms': game_name.split(),
|
||||||
|
'searchPage': page,
|
||||||
|
'size': 20,
|
||||||
|
'searchOptions': {
|
||||||
|
'games': {
|
||||||
|
'userId': 0,
|
||||||
|
'platform': "",
|
||||||
|
'sortCategory': "popular",
|
||||||
|
'rangeCategory': "main",
|
||||||
|
'rangeTime': {'min': 0, 'max': 0},
|
||||||
|
'gameplay': {
|
||||||
|
'perspective': "",
|
||||||
|
'flow': "",
|
||||||
|
'genre': "",
|
||||||
|
"difficulty": ""
|
||||||
|
},
|
||||||
|
'rangeYear': {'max': "", 'min': ""},
|
||||||
|
'modifier': search_modifiers.value,
|
||||||
|
},
|
||||||
|
'users': {'sortCategory': "postcount"},
|
||||||
|
'lists': {'sortCategory': "follows"},
|
||||||
|
'filter': "",
|
||||||
|
'sort': 0,
|
||||||
|
'randomizer': 0
|
||||||
|
},
|
||||||
|
'useCache': True
|
||||||
|
}
|
||||||
|
|
||||||
|
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):
|
||||||
|
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", []):
|
||||||
|
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):
|
||||||
|
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")
|
||||||
|
]
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class HowLongToBeat:
|
||||||
|
"""Основной класс для работы с API HowLongToBeat."""
|
||||||
|
|
||||||
|
def __init__(self, minimum_similarity: float = 0.4, auto_filter_times: bool = False,
|
||||||
|
timeout: int = 60):
|
||||||
|
self.minimum_similarity = minimum_similarity
|
||||||
|
self.auto_filter_times = auto_filter_times
|
||||||
|
self.http_client = HTTPClient(timeout)
|
||||||
|
|
||||||
|
def search(self, game_name: str, search_modifiers: SearchModifiers = SearchModifiers.NONE,
|
||||||
|
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)
|
||||||
|
if not json_response:
|
||||||
|
return None
|
||||||
|
|
||||||
|
parser = ResultParser(
|
||||||
|
game_name,
|
||||||
|
self.minimum_similarity,
|
||||||
|
case_sensitive,
|
||||||
|
self.auto_filter_times
|
||||||
|
)
|
||||||
|
|
||||||
|
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:
|
||||||
|
return None
|
||||||
|
|
||||||
|
game_title = self.http_client.get_game_title(game_id)
|
||||||
|
if not game_title:
|
||||||
|
return None
|
||||||
|
|
||||||
|
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
|
@ -27,6 +27,7 @@ classifiers = [
|
|||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"babel>=2.17.0",
|
"babel>=2.17.0",
|
||||||
|
"beautifulsoup4>=4.13.4",
|
||||||
"evdev>=1.9.1",
|
"evdev>=1.9.1",
|
||||||
"icoextract>=0.1.6",
|
"icoextract>=0.1.6",
|
||||||
"numpy>=2.2.4",
|
"numpy>=2.2.4",
|
||||||
|
Reference in New Issue
Block a user