Raspador de juegos para Steam
Hice un raspador para Steam que obtiene información diferente sobre un juego de Steam, como el precio, las especificaciones y las plataformas compatibles. La razón por la que hice esto fue porque tengo una computadora portátil súper lenta, por lo que mirar muchos juegos llevaría mucho tiempo :)
Algunas cosas que me gustaría mejorar son tener un mejor manejo de errores, ya que la web es un lugar desordenado y no todas las páginas van a ser iguales.
Otra cosa que estaba pensando hacer es tener una mejor gestión de datos, como usar clases y objetos para cada juego en lugar de almacenar todos los valores en un diccionario, lo que haría que el código fuera más simple y tal vez incluso más corto.
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')
Respuestas
Descomponer la lógica en funciones.
Tener funciones separadas para cada uno de los siguientes pasos hará que el código sea más fácil de leer.
- Obtener nombres de juegos
- Raspe la información del juego
- Mostrar información del juego
Proteja el punto de entrada de su script
Recomendaría mover el flujo de ejecución del script bajo un if __name__ == "__main__":
guardia. Hacer esto le permite importar las funciones de este archivo a otros archivos sin ejecutar el script.
Evite el uso sys.exit()
para controlar el flujo
La llamada sys.exit()
apaga el intérprete de Python, lo que dificulta la prueba de cualquier código que lo llame. En su lugar, debe refactorizar la secuencia de comandos para que finalice normalmente en todos los casos recuperables.
Por ejemplo, si el usuario no desea información sobre un juego con restricción de edad, sáltelo y continúe con el próximo juego de la lista. Creo que esto mejoraría la experiencia del usuario de todos modos, porque si no exit()
podemos procesar los otros juegos restantes en la lista.
Las plataformas compatibles deben ser una lista
Al determinar e imprimir las plataformas admitidas para un juego, tiene valores booleanos mac
que linux
finalmente se traducen en una cadena que toma uno 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
Creo que tiene más sentido modelar esto como una lista, por ejemplo, ["windows", "mac"]
para que sea más explícito qué plataformas son compatibles. Esto también le evitará escribir lógica adicional if/elif/else al imprimirlos.
Plano es mejor que anidado
Los bloques anidados try/except en la etapa de recuperación de precios son muy difíciles de leer.
Si delega la recuperación de precios a una función, puede estructurar la lógica para que sea plana en lugar de anidada, como en el siguiente pseudocódigo:
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'
Mal uso del manejo de excepciones
El script es atrapante KeyError
y Exception
maneja la impresión de tres tipos diferentes de precios: paquete, con descuento y estándar. Podría decirse que esto es un mal uso del manejo de excepciones, especialmente porque atrapar al general Exception
rara vez es una buena idea porque puede ocultar otros errores que no esperaba. Además, no es necesario aquí; solo podemos usar un 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
Gestión de datos
Mencionaste que estabas pensando en tener clases u objetos para cada juego en lugar de usar un diccionario. Creo que es una buena idea. Puede que no acorte el código, pero definitivamente mejoraría la legibilidad del código.
Un buen candidato para esto sería un contenedor de datos simple como typing.NamedTuple. Como sugirió @MaartenFabré en los comentarios, dataclasses.dataclasses otra buena opción para un contenedor de datos como este.
Casos extremos sin manejar
Las siguientes consultas de búsqueda darán como resultado que se agote el tiempo de espera del script:
Cualquier consulta de búsqueda que no devuelva sugerencias en Steam. Esto podría ser algo así como un juego que no existe (todavía), por ejemplo
"funkytown"
, o una cadena no vacía que consta solo de espacios en blanco, por ejemplo" "
.Cualquier consulta de búsqueda en la que la primera sugerencia sea un paquete de Steam, p
"the witcher 3 wild hunt game of the year"
.Cualquier consulta de búsqueda en la que la primera sugerencia sea un paquete de Steam, p
"monkey island collection"
.
Mencioné esto antes, pero el script asume incorrectamente que si no se muestra un precio en la página, entonces el juego es gratis. Pero hay juegos inéditos en Steam donde el desarrollador no ha fijado un precio, y al mismo tiempo no lo han clasificado como "gratis" o como un juego "free-to-play" ("Spirit of Glace" es uno ejemplo concreto que encontré). El precio a mostrar en este caso debería ser algo así como "TBD" o "Desconocido".
Afortunadamente, y como forma de presentar la siguiente sección, hay una API que podemos usar para distinguir entre gratis y no gratis. Esta API expone un is_free
campo que es true
cuando un juego es gratuito o free-to-play. Si salta al final de esta revisión, puede ver cómo se recupera en mi script de ejemplo.
Preferir las API al scraping
Con las API, la recuperación de datos es más rápida y, a menudo, mucho más rápida que raspar con Selenium. Con las API, la extracción de datos es más fácil ya que el formato de la respuesta suele ser JSON.
Siempre menciono esto cada vez que surge el raspado porque el ahorro potencial de tiempo y esfuerzo puede ser enorme. Dedique algún tiempo a buscar una API oficial o una API no oficial que esté documentada. Si no aparece nada, busque con un inspector de tráfico HTTP/S como Fiddler o Chrome DevTools y vea si puede encontrar alguna API no oficial prometedora. Si al final no puede encontrar nada, vuelva a raspar como último recurso.
En este caso, en realidad hay una API de Steam Store no oficial disponible. Para usarlo, necesitamos el ID de la aplicación de Steam o el ID del paquete de Steam del artículo que nos interesa, pero podemos obtenerlo de la API que activa el menú desplegable de sugerencias de búsqueda, https://store.steampowered.com/search/suggest
.
Ejemplo de secuencia de comandos usando API
El siguiente es un script de ejemplo que utiliza la API de Steam Store no oficial.
#!/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)