Raspador de jogo para Steam
Eu fiz um scraper para o Steam que obtém informações diferentes sobre um jogo do Steam, como preço, especificações e plataformas suportadas. A razão pela qual fiz isso foi porque tenho um laptop super lento, então olhar para muitos jogos levaria muito tempo :)
Algumas coisas que eu gostaria de melhorar é ter um melhor tratamento de erros, já que a web é um lugar confuso e nem todas as páginas serão iguais.
Outra coisa que pensei em fazer é ter um melhor gerenciamento de dados, como usar classes e objetos para cada jogo em vez de armazenar todos os valores em um dicionário, o que tornaria o código mais simples e talvez até mais curto.
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')
Respostas
Divida a lógica em funções
Ter funções separadas para cada uma das etapas a seguir facilitará a leitura do código.
- Obter nomes de jogos
- Raspe as informações do jogo
- Exibir informações do jogo
Proteja o ponto de entrada do seu script
Eu recomendaria mover o fluxo de execução do script para um if __name__ == "__main__":
guarda. Isso permite importar as funções desse arquivo para outros arquivos sem executar o script.
Evite usar sys.exit()
para controle de fluxo
A chamada sys.exit()
desliga o interpretador Python, o que dificulta o teste de qualquer código que o chama. Em vez disso, você deve refatorar o script para que ele termine normalmente em todos os casos recuperáveis.
Por exemplo, se o usuário não quiser informações sobre um jogo com restrição de idade, ignore-o e vá para o próximo jogo da lista. Acho que isso melhoraria a experiência do usuário de qualquer maneira, porque se exit()
não conseguirmos processar os outros jogos restantes na lista.
As plataformas suportadas devem ser uma lista
Ao determinar e imprimir plataformas suportadas para um jogo, você tem booleanos mac
e linux
que são eventualmente traduzidos para uma string que leva um de 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
Acho que faz mais sentido modelar isso como uma lista, por exemplo, ["windows", "mac"]
para que seja mais explícito quais plataformas são suportadas. Isso também evitará que você escreva lógica extra if/elif/else ao imprimi-los.
Plana é melhor que aninhada
Os blocos try/except aninhados no estágio de recuperação de preço são muito difíceis de ler.
Se você delegar a recuperação do preço a uma função, poderá estruturar a lógica de modo que seja plana em vez de aninhada, como no pseudocódigo a seguir:
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'
Uso indevido do tratamento de exceções
O script é atraente KeyError
e Exception
lida com a impressão de três tipos diferentes de preços: pacote, com desconto e padrão. Isso é indiscutivelmente um uso indevido do tratamento de exceções, especialmente porque capturar o general Exception
raramente é uma boa ideia porque pode ocultar outros erros que você não esperava. Além disso, não é necessário aqui; podemos apenas usar um 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
Gestão de dados
Você mencionou que estava pensando em ter classes ou objetos para cada jogo ao invés de usar um dicionário. Eu acho que isso é uma boa idéia. Isso pode não tornar o código mais curto, mas definitivamente melhoraria a legibilidade do código.
Um bom candidato para isso seria um contêiner de dados simples como typing.NamedTuple. Como @MaartenFabré sugeriu nos comentários, dataclasses.dataclassé outra boa opção para um contêiner de dados como este.
Casos extremos não tratados
As seguintes consultas de pesquisa resultarão no tempo limite do script:
Qualquer consulta de pesquisa que não retorna sugestões no Steam. Isso pode ser algo como um jogo que não existe (ainda), por exemplo
"funkytown"
, ou uma string não vazia consistindo apenas de espaços em branco, por exemplo" "
.Qualquer consulta de pesquisa em que a primeira sugestão é um pacote Steam, por exemplo,
"the witcher 3 wild hunt game of the year"
.Qualquer consulta de pesquisa em que a primeira sugestão é um Steam Bundle, por exemplo,
"monkey island collection"
.
Mencionei isso anteriormente, mas o script assume incorretamente que, se um preço não for exibido na página, o jogo é gratuito. Mas existem jogos inéditos no Steam em que o desenvolvedor não definiu um preço e, ao mesmo tempo, não o classificou como "gratuito" ou como um jogo "free-to-play" ("Spirit of Glace" é um exemplo concreto que encontrei). O preço a ser exibido neste caso deve ser algo como "TBD" ou "Desconhecido".
Felizmente, e como forma de introduzir a próxima seção, existe uma API que podemos usar para distinguir entre gratuito e não gratuito. Essa API expõe um is_free
campo que indica true
quando um jogo é gratuito ou gratuito. Se você pular para o final desta revisão, poderá ver como ela está sendo recuperada em meu script de exemplo.
Prefira APIs a raspagem
Com APIs, a recuperação de dados é mais rápida - e muitas vezes ordens de magnitude mais rápidas do que a raspagem com o Selenium. Com APIs, a extração de dados é mais fácil, pois o formato da resposta geralmente é JSON.
Sempre faço questão de mencionar isso sempre que surge a raspagem, porque o potencial de economia de tempo e esforço pode ser enorme. Passe algum tempo procurando uma API oficial ou uma API não oficial que esteja documentada. Se nada aparecer, dê uma olhada com um inspetor de tráfego HTTP/S como Fiddler ou Chrome DevTools e veja se você pode encontrar alguma API não oficial promissora. Se finalmente você não conseguir encontrar nada, volte a raspar como último recurso.
Nesse caso, existe uma API não oficial da Steam Store disponível. Para usá-lo, precisamos do ID do aplicativo Steam ou do ID do pacote Steam do item em que estamos interessados, mas podemos obtê-lo na API que alimenta o menu suspenso de sugestões de pesquisa, https://store.steampowered.com/search/suggest
.
Exemplo de script usando API
O seguinte é um script de exemplo usando a API não oficial da 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)