Парсер игры для Steam
Я сделал парсер для Steam, который получал различную информацию об игре Steam, такую как цена, характеристики и поддерживаемые платформы. Причина, по которой я сделал это, заключалась в том, что у меня очень медленный ноутбук, поэтому просмотр многих игр занял бы много времени :)
Некоторые вещи, которые я хотел бы улучшить, - это улучшить обработку ошибок, поскольку Интернет - беспорядочное место, и не все страницы будут одинаковыми.
Еще я думал о том, чтобы улучшить управление данными, например, использовать классы и объекты для каждой игры вместо хранения всех значений в словаре, что упростило бы и, возможно, даже короче код.
from selenium import webdriver
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support.ui import Select
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import NoSuchElementException
from selenium.webdriver.firefox.options import Options
from sys import exit
games = {}
x = 0
# ask for games
while True:
if x == 0:
game = input('Type the game you want to find here: ')
else:
game = input('Type the game you want to find here (or enter nothing to continue): ')
if not game:
break
games[game] = {}
x += 1
# configure browser
print('Starting Browser')
firefox_options = Options()
firefox_options.headless = True
browser = webdriver.Firefox(options=firefox_options, service_log_path='/tmp/geckodriver.log')
print('Retrieving website')
browser.get('https://store.steampowered.com/')
for a_game in games:
print('Finding info for "' + a_game + '"')
# input & click
print('Waiting for page to load')
WebDriverWait(browser, 20).until(EC.element_to_be_clickable((By.CSS_SELECTOR, "input#store_nav_search_term"))).send_keys(a_game)
WebDriverWait(browser, 20).until(EC.element_to_be_clickable((By.CSS_SELECTOR, "div#search_suggestion_contents>a"))).click()
print('Navigating to game page')
# if age-restricted:
try:
browser.find_element_by_css_selector('.agegate_birthday_selector')
age_query = input('"' + a_game + '" is age-restricted, do you want to continue? y/n ')
if age_query != 'y':
print('Abort')
exit()
select = Select(browser.find_element_by_id('ageYear'))
select.select_by_value('2000')
browser.find_element_by_css_selector('a.btnv6_blue_hoverfade:nth-child(1)').click()
except NoSuchElementException:
pass
print('Waiting for game page to load')
# name of game
games[a_game]['name'] = WebDriverWait(browser, 20).until(EC.element_to_be_clickable((By.CSS_SELECTOR, '.apphub_AppName'))).text
# supported platforms
print('Retrieving supported platforms')
mac = False
linux = False
try:
browser.find_element_by_css_selector('div.game_area_purchase_game_wrapper:nth-child(1) > div:nth-child(1) > div:nth-child(2) > '
'span:nth-child(2)')
mac = True
except NoSuchElementException:
pass
try:
browser.find_element_by_css_selector('div.game_area_purchase_game_wrapper:nth-child(1) > div:nth-child(1) > div:nth-child(2) > '
'span:nth-child(3)')
linux = True
except NoSuchElementException:
pass
if mac and linux:
games[a_game]['platform'] = 'all'
elif mac:
games[a_game]['platform'] = 'mac'
elif linux:
games[a_game]['platform'] = 'linux'
else:
games[a_game]['platform'] = 'windows'
# price
print('Retrieving price')
discounted = False
try:
games[a_game]['price'] = browser.find_element_by_css_selector('div.game_purchase_action:nth-child(4) > div:nth-child(1) > div:nth-child(1)').text
except NoSuchElementException:
try:
games[a_game]['before_price'] = browser.find_element_by_class_name('discount_original_price').text
games[a_game]['after_price'] = browser.find_element_by_class_name('discount_final_price').text
except NoSuchElementException:
try:
games[a_game]['price'] = 'FREE'
except NoSuchElementException:
games[a_game]['bundle_price'] = browser.find_element_by_css_selector('div.game_purchase_action_bg:nth-child(2) > div:nth-child(1)')
except Exception:
games[a_game]['price'] = 'Error: Unable to get price'
# system requirements
print('Retrieving system requirements')
games[a_game]['specs'] = browser.find_element_by_css_selector('.game_area_sys_req').text
# close browser
print('Finished Retrieving data, closing browser \n')
print('********************************************')
browser.close()
for each_game in games.keys():
print('GAME: ' + games[each_game]['name'].upper())
# printing supported platforms
if games[each_game]['platform'] == 'all':
print('Supported Platforms: Windows, Mac and Linux')
elif games[each_game]['platform'] == 'mac':
print('Supported Platforms: Windows and Mac')
elif games[each_game]['platform'] == 'linux':
print('Supported Platforms: Windows and Linux')
else:
print('Supported Platforms: Windows Only')
print('\n')
# printing price
try:
print('Price: Discounted ' + games[each_game]['after_price'] + ' from ' + games[each_game]['before_price'])
except KeyError:
print('Price: ' + games[each_game]['price'])
except Exception:
print('Bundled Price: ' + games[each_game]['bundle_price'])
print('\n')
# printing system requirements
print('System Requirements: \n')
print('-------------------------------- \n')
print(games[each_game]['specs'])
print('--------------------------------')
input('Press enter to continue ')
print('Finished Successfully')
Ответы
Разбейте логику на функции
Наличие отдельных функций для каждого из следующих шагов упростит чтение кода.
- Получите названия игр
- Очистить информацию об игре
- Отображение информации об игре
Защитите точку входа вашего скрипта
Я бы рекомендовал переместить поток выполнения скрипта под if __name__ == "__main__":
охрану. Это позволяет импортировать функции из этого файла в другие файлы без запуска сценария.
Избегайте использования sys.exit()
для потока управления
Вызов sys.exit()
завершает работу интерпретатора Python, что затрудняет тестирование любого вызывающего кода. Вместо этого вам следует провести рефакторинг скрипта, чтобы он завершился нормально для всех восстанавливаемых случаев.
Например, если пользователю не нужна информация для игры с возрастным ограничением, пропустите ее и перейдите к следующей игре в списке. Я думаю, что это в любом случае улучшит пользовательский опыт, потому что, если мы не exit()
сможем обработать другие оставшиеся в списке игры.
Поддерживаемые платформы должны быть перечислены
При определении и печати поддерживаемых платформ для игры, у вас есть булевы mac
и linux
которые в конечном счете переводятся в строку , взяв одно из all
, mac
, linux
, windows
:
if mac and linux:
games[a_game]['platform'] = 'all' # windows, mac, linux
elif mac:
games[a_game]['platform'] = 'mac' # windows, mac
elif linux:
games[a_game]['platform'] = 'linux' # windows, linux
else:
games[a_game]['platform'] = 'windows' # windows
Я думаю, что имеет смысл смоделировать это как список, например, ["windows", "mac"]
чтобы было более ясно, какие платформы поддерживаются. Это также избавит вас от написания дополнительной логики if / elif / else при их распечатке.
Квартира лучше, чем вложенная
Вложенные блоки try / except на этапе получения цены очень трудно читать.
Если вы делегируете извлечение цены функции, вы можете структурировать логику так, чтобы она была плоской, а не вложенной, как в следующем псевдокоде:
def get_price() -> str:
element = find_element_for_bundle_price()
if element:
return element.text
element = find_element_for_non_discounted_price()
if element:
return element.text
element = find_element_for_discounted_price()
if element:
return element.text
# If we don't find a price on the page, it's free?
# Actually this is not always true, but for this example
# we'll assume this is the case.
return 'FREE'
Неправильное использование обработки исключений
Скрипт ловкий KeyError
и Exception
обрабатывает распечатку трех разных типов цен: пакетная, со скидкой и стандартная. Возможно, это неправильное использование обработки исключений, особенно потому, что перехват общего Exception
редко бывает хорошей идеей, потому что он может скрыть другие ошибки, которых вы не ожидали. Плюс здесь это не нужно; мы можем просто использовать if / elif / else:
game_dict = games[each_game]
if 'bundle_price' in game_dict:
# print bundle price
elif 'before_price' in game_dict and 'after_price' in game_dict:
# print discounted price
else:
# print standard price
Управление данными
Вы упомянули, что думаете о наличии классов или объектов для каждой игры вместо использования словаря. Думаю, это хорошая идея. Возможно, это не сделает код короче, но определенно улучшит читаемость кода.
Хорошим кандидатом для этого может быть простой контейнер данных, например typing.NamedTuple. Как предложил @ MaartenFabré в комментариях, dataclasses.dataclassэто еще один хороший выбор для такого контейнера данных.
Необработанные крайние случаи
Следующие поисковые запросы приведут к тайм-ауту скрипта:
Любой поисковый запрос, который не дает предложений в Steam. Это может быть что-то вроде игры, которой пока нет, например
"funkytown"
, или непустая строка, состоящая только из пробелов, например" "
.Любой поисковый запрос, в котором первым предложением является пакет Steam, например
"the witcher 3 wild hunt game of the year"
.Любой поисковый запрос, в котором первым предложением является Steam Bundle, например
"monkey island collection"
.
Я уже упоминал об этом ранее, но скрипт неправильно предполагает, что если цена не отображается на странице, то игра бесплатна. Но в Steam есть неизданные игры, для которых разработчик не установил цену, и в то же время они не классифицировали ее как «бесплатную» или «бесплатную» («Spirit of Glace» - одна из конкретный пример я нашел). Цена, отображаемая в этом случае, должна иметь вид «TBD» или «Unknown».
К счастью, в качестве введения в следующий раздел есть API, который мы можем использовать, чтобы различать бесплатные и платные. Этот API предоставляет is_free
поле, true
в котором используется бесплатная или бесплатная игра. Если вы перейдете к концу этого обзора, вы увидите, как это получается в моем примере сценария.
Предпочитайте API парсеру
С API извлечение данных происходит быстрее - и часто на несколько порядков быстрее, чем очистка с помощью Selenium. С API извлечение данных упрощается, так как ответ часто имеет формат JSON.
Я всегда говорю об этом всякий раз, когда возникает очистка, потому что потенциальная экономия времени и усилий может быть огромной. Потратьте некоторое время на поиск официального API или неофициального API, который задокументирован. Если ничего не появляется, поищите инспектор трафика HTTP / S, например Fiddler или Chrome DevTools, и посмотрите, сможете ли вы найти какие-либо многообещающие неофициальные API. Если, наконец, вы ничего не можете найти, в крайнем случае вернитесь к соскабливанию.
В этом случае на самом деле доступен неофициальный API Steam Store . Для того, чтобы использовать его нам нужен стим App ID или Паровой пакет ID элемента мы находимся интересует, но мы можем получить , что из API , что полномочия поиска предложение в раскрывающемся меню, https://store.steampowered.com/search/suggest
.
Пример скрипта с использованием API
Ниже приведен пример сценария с использованием неофициального API Steam Store.
#!/usr/bin/env python3
import re
import json
import requests
from enum import Enum
from bs4 import BeautifulSoup # type: ignore
from typing import Any, Dict, List, Optional, NamedTuple, Union
SEARCH_SUGGEST_API = "https://store.steampowered.com/search/suggest"
APP_DETAILS_API = "https://store.steampowered.com/api/appdetails"
PACKAGE_DETAILS_API = "https://store.steampowered.com/api/packagedetails"
class Platform(Enum):
WINDOWS = "windows"
MAC = "mac"
LINUX = "linux"
def __str__(self) -> str:
return str(self.value)
class Price(NamedTuple):
initial: int # price in cents
final: int # price in cents
class SteamGame(NamedTuple):
app_id: int
name: str
platforms: List[Platform]
is_released: bool
is_free: bool
price: Optional[Price]
pc_requirements: str
def __str__(self) -> str:
if self.is_free:
price = "Free"
elif self.price:
final = f"${self.price.final / 100}" if self.price.initial == self.price.final: price = final else: price = f"{final} (previously ${self.price.initial / 100})"
else:
price = "TBD"
platforms = ", ".join(str(p) for p in self.platforms)
is_released = "Yes" if self.is_released else "No"
return "\n".join(
(
f"Name: {self.name}",
f"Released: {is_released}",
f"Supported Platforms: {platforms}",
f"Price: {price}",
"",
"PC Requirements:",
self.pc_requirements,
)
)
class SteamBundle(NamedTuple):
bundle_id: int
name: str
price: Price
application_names: List[str]
def __str__(self) -> str:
final = f"${self.price.final / 100}" if self.price.initial == self.price.final: price = final else: price = f"{final} (without bundle: ${self.price.initial / 100})"
return "\n".join(
(
f"Name: {self.name}",
f"Price: {price}",
"",
"Items included in this bundle:",
*(f" - {name}" for name in self.application_names),
)
)
class SteamPackage(NamedTuple):
package_id: int
name: str
platforms: List[Platform]
is_released: bool
price: Optional[Price]
application_names: List[str]
def __str__(self) -> str:
if self.price:
final = f"${self.price.final / 100}" if self.price.initial == self.price.final: price = final else: initial = f"${self.price.initial / 100}"
price = f"{final} (without package: {initial})"
else:
price = "TBD"
platforms = ", ".join(str(p) for p in self.platforms)
is_released = "Yes" if self.is_released else "No"
return "\n".join(
(
f"Name: {self.name}",
f"Released: {is_released}",
f"Supported Platforms: {platforms}",
f"Price: {price}",
"",
"Items included in this package:",
*(f" - {name}" for name in self.application_names),
)
)
SteamItem = Union[SteamGame, SteamBundle, SteamPackage]
def deserialize_bundle_data(encoded_bundle_json: str) -> Any:
return json.loads(re.sub(r""", '"', encoded_bundle_json))
def extract_app_ids(bundle_data: Dict[str, Any]) -> List[int]:
return [
app_id
for item in bundle_data["m_rgItems"]
for app_id in item["m_rgIncludedAppIDs"]
]
def lookup_app_names(
session: requests.Session, app_ids: List[int]
) -> List[str]:
app_names = []
for app_id in app_ids:
params = {"appids": app_id, "filters": "basic"}
response = session.get(APP_DETAILS_API, params=params)
response.raise_for_status()
app_names.append(response.json()[str(app_id)]["data"]["name"])
return app_names
def extract_bundle_price(bundle_data: Dict[str, Any]) -> Price:
total_price = sum(
item["m_nFinalPriceInCents"] for item in bundle_data["m_rgItems"]
)
total_price_with_bundle_discount = sum(
item["m_nFinalPriceWithBundleDiscount"]
for item in bundle_data["m_rgItems"]
)
return Price(total_price, total_price_with_bundle_discount)
def extract_package_information(
package_id: int, package_data: Dict[str, Any]
) -> SteamPackage:
return SteamPackage(
package_id=package_id,
name=package_data["name"],
platforms=[p for p in Platform if package_data["platforms"][str(p)]],
is_released=not package_data["release_date"]["coming_soon"],
price=Price(
package_data["price"]["initial"], package_data["price"]["final"]
),
application_names=[app["name"] for app in package_data["apps"]],
)
def get_package(session: requests.Session, package_id: str) -> SteamPackage:
params = {"packageids": package_id}
response = session.get(PACKAGE_DETAILS_API, params=params)
response.raise_for_status()
return extract_package_information(
int(package_id), response.json()[package_id]["data"]
)
def extract_requirements_text(requirements_html: str) -> str:
soup = BeautifulSoup(requirements_html, "html.parser")
return "\n".join(tag.get_text() for tag in soup.find_all("li"))
def extract_game_information(game_data: Dict[str, Any]) -> SteamGame:
price_overview = game_data.get("price_overview")
price = (
Price(price_overview["initial"], price_overview["final"])
if price_overview
else None
)
requirements = game_data["pc_requirements"]
minimum = extract_requirements_text(requirements["minimum"])
recommended_html = requirements.get("recommended")
recommended = (
extract_requirements_text(recommended_html)
if recommended_html
else None
)
minimum_requirements = f"[Minimum]\n{minimum}"
if recommended:
recommended_requirements = f"[Recommended]\n{recommended}"
pc_requirements = (
minimum_requirements + "\n\n" + recommended_requirements
)
else:
pc_requirements = minimum_requirements
return SteamGame(
app_id=game_data["steam_appid"],
name=game_data["name"],
platforms=[p for p in Platform if game_data["platforms"][str(p)]],
is_released=not game_data["release_date"]["coming_soon"],
is_free=game_data["is_free"],
price=price,
pc_requirements=pc_requirements,
)
def get_game(session: requests.Session, app_id: str) -> SteamGame:
params = {"appids": app_id}
response = session.get(APP_DETAILS_API, params=params)
response.raise_for_status()
return extract_game_information(response.json()[app_id]["data"])
def get_game_information(games: List[str]) -> Dict[str, Optional[SteamItem]]:
game_to_info = {}
with requests.Session() as session:
for game in games:
params = {"term": game, "f": "games", "cc": "US", "l": "english"}
response = session.get(SEARCH_SUGGEST_API, params=params)
response.raise_for_status()
# get first search suggestion
result = BeautifulSoup(response.text, "html.parser").find("a")
if result:
bundle_id = result.get("data-ds-bundleid")
package_id = result.get("data-ds-packageid")
app_id = result.get("data-ds-appid")
if bundle_id:
name = result.find("div", class_="match_name").get_text()
bundle_data = deserialize_bundle_data(
result["data-ds-bundle-data"]
)
app_ids = extract_app_ids(bundle_data)
app_names = lookup_app_names(session, app_ids)
price = extract_bundle_price(bundle_data)
info: Optional[SteamItem] = SteamBundle(
bundle_id=int(bundle_id),
name=name,
price=price,
application_names=app_names,
)
elif package_id:
info = get_package(session, package_id)
elif app_id:
info = get_game(session, app_id)
else:
info = None
else:
info = None
game_to_info[game] = info
return game_to_info
def display_game_information(
game_information: Dict[str, Optional[SteamItem]]
) -> None:
arrow = " =>"
for game_query, game_info in game_information.items():
result_header = f"{game_query}{arrow}"
query_result = (
game_info if game_info else f"No results found for {game_query!r}."
)
result = "\n".join(
(
result_header,
"-" * (len(result_header) - len(arrow)),
"",
str(query_result),
"\n",
)
)
print(result)
if __name__ == "__main__":
games = [
"slay the spire",
"civ 6",
"funkytown",
"path of exile",
"bless unleashed",
"the witcher 3 wild hunt game of the year",
"divinity source",
"monkey island collection",
"star wars squadrons",
"spirit of glace",
]
game_info = get_game_information(games)
display_game_information(game_info)