Raspador de jogo para Steam

Aug 17 2020

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

4 Setris Aug 29 2020 at 09:34

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 mace linuxque 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 KeyErrore Exceptionlida 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 Exceptionraramente é 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_freecampo que indica truequando 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)