Game scraper cho Steam

Aug 17 2020

Tôi đã thực hiện một bản quét cho Steam để lấy thông tin khác nhau về một trò chơi Steam, chẳng hạn như giá cả, thông số kỹ thuật và các nền tảng được hỗ trợ. Lý do tôi làm điều này là vì tôi có một máy tính xách tay siêu chậm, vì vậy nhìn vào nhiều trò chơi sẽ mất nhiều thời gian :)

Một số điều tôi muốn cải thiện là xử lý lỗi tốt hơn, vì web là một nơi lộn xộn và không phải tất cả các trang đều giống nhau.

Một điều khác mà tôi đang nghĩ đến là quản lý dữ liệu tốt hơn, chẳng hạn như sử dụng các lớp và đối tượng cho mỗi trò chơi thay vì lưu trữ tất cả các giá trị trong từ điển, điều đó sẽ giúp cho mã đơn giản hơn và thậm chí có thể ngắn hơn.

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')

Trả lời

4 Setris Aug 29 2020 at 09:34

Chia logic thành các hàm

Có các chức năng riêng biệt cho từng bước sau đây sẽ làm cho mã dễ đọc hơn.

  • Nhận tên trò chơi
  • Thông tin trò chơi cóp nhặt
  • Hiển thị thông tin trò chơi

Bảo vệ điểm nhập kịch bản của bạn

Tôi khuyên bạn nên di chuyển luồng thực thi tập lệnh dưới sự if __name__ == "__main__":bảo vệ. Làm điều này cho phép bạn nhập các chức năng từ tệp này vào tệp khác mà không cần chạy tập lệnh.

Tránh sử dụng sys.exit()để kiểm soát luồng

Việc gọi sys.exit()sẽ tắt trình thông dịch Python, điều này làm cho bất kỳ mã nào gọi là khó kiểm tra. Thay vào đó, bạn nên cấu trúc lại tập lệnh để nó kết thúc bình thường đối với tất cả các trường hợp có thể khôi phục.

Ví dụ: nếu người dùng không muốn có thông tin về một trò chơi giới hạn độ tuổi, hãy bỏ qua và chuyển sang trò chơi tiếp theo trong danh sách. Tôi nghĩ rằng điều này sẽ tạo ra trải nghiệm người dùng tốt hơn dù sao, bởi vì nếu chúng exit()tôi không xử lý các trò chơi còn lại khác trong danh sách.

Các nền tảng được hỗ trợ phải là một danh sách

Trong việc xác định và in ấn các nền tảng được hỗ trợ cho một trò chơi, bạn có boolean maclinuxđược cuối cùng phiên dịch sang một chuỗi lấy 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

Tôi nghĩ sẽ hợp lý hơn nếu mô hình hóa điều này dưới dạng danh sách, ví dụ như ["windows", "mac"]vậy sẽ rõ ràng hơn những nền tảng nào được hỗ trợ. Điều này cũng sẽ giúp bạn không phải viết thêm logic if / elif / else khi in chúng ra.

Phẳng tốt hơn lồng nhau

Các khối thử / ngoại trừ lồng nhau trong giai đoạn truy xuất giá rất khó đọc.

Nếu bạn ủy quyền truy xuất giá cho một hàm, bạn có thể cấu trúc logic để nó phẳng thay vì lồng vào nhau, như trong mã giả sau:

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'

Lạm dụng xử lý ngoại lệ

Tập lệnh đang nắm bắt KeyErrorExceptionxử lý việc in ra ba loại giá khác nhau: gói, chiết khấu và tiêu chuẩn. Đây được cho là một cách xử lý ngoại lệ sai lầm, nhất là việc bắt tướng Exceptionhiếm khi là một ý kiến ​​hay vì nó có thể ẩn đi những lỗi khác mà bạn không ngờ tới. Thêm vào đó, nó không cần thiết ở đây; chúng ta chỉ có thể sử dụng 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

Quản lý dữ liệu

Bạn đã đề cập rằng bạn đang nghĩ đến việc có các lớp hoặc đối tượng cho mỗi trò chơi thay vì sử dụng từ điển. Tôi nghĩ rằng đây là một ý tưởng tốt. Nó có thể không làm cho mã ngắn hơn, nhưng nó chắc chắn sẽ cải thiện khả năng đọc của mã.

Một ứng cử viên tốt cho điều này sẽ là một vùng chứa dữ liệu đơn giản như typing.NamedTuple. Như @ MaartenFabré đã đề xuất trong các nhận xét, dataclasses.dataclasslà một lựa chọn tốt khác cho một vùng chứa dữ liệu như thế này.

Các trường hợp cạnh không xử lý

Các truy vấn tìm kiếm sau sẽ dẫn đến việc tập lệnh hết thời gian:

  • Bất kỳ truy vấn tìm kiếm nào không trả về đề xuất trên Steam. Đây có thể là một cái gì đó giống như một trò chơi mà không tồn tại (chưa), ví dụ "funkytown", hoặc một chuỗi không có sản phẩm nào bao gồm duy nhất của khoảng trắng, ví dụ " ".

  • Bất kỳ truy vấn tìm kiếm nơi gợi ý đầu tiên là một hơi trọn gói, ví dụ "the witcher 3 wild hunt game of the year".

  • Bất kỳ truy vấn tìm kiếm nơi gợi ý đầu tiên là một hơi Bundle, ví dụ "monkey island collection".

Tôi đã đề cập điều này trước đó, nhưng tập lệnh giả định không chính xác rằng nếu giá không được hiển thị trên trang, thì trò chơi là miễn phí. Nhưng có những trò chơi chưa được phát hành trên Steam mà nhà phát triển chưa đặt giá, đồng thời họ cũng không phân loại nó là "miễn phí" hay là một trò chơi "miễn phí" ("Spirit of Glace" là một ví dụ cụ thể tôi tìm thấy). Giá hiển thị trong trường hợp này phải là "TBD" hoặc "Không xác định".

May mắn thay, và như một cách để giới thiệu phần tiếp theo, có một API mà chúng ta có thể sử dụng để phân biệt giữa miễn phí và không miễn phí. API này hiển thị một is_freetrường truekhi một trò chơi là miễn phí hoặc miễn phí để chơi. Nếu bạn chuyển đến phần cuối của bài đánh giá này, bạn có thể thấy cách nó được truy xuất trong tập lệnh ví dụ của tôi.

Ưu tiên các API để cạo

Với API, việc truy xuất dữ liệu nhanh hơn - và thường là các đơn đặt hàng có quy mô nhanh hơn so với việc quét bằng Selenium. Với API, việc trích xuất dữ liệu dễ dàng hơn vì định dạng của phản hồi thường là JSON.

Tôi luôn đề cập đến vấn đề này bất cứ khi nào xuất hiện vì việc tiết kiệm thời gian và công sức tiềm năng có thể rất lớn. Dành một chút thời gian để tìm kiếm một API chính thức hoặc một API không chính thức được ghi lại. Nếu không có gì xuất hiện, hãy tham khảo ý kiến ​​của một trình kiểm tra lưu lượng HTTP / S như Fiddler hoặc Chrome DevTools và xem liệu bạn có thể tìm thấy bất kỳ API không chính thức đầy hứa hẹn nào không. Nếu cuối cùng bạn không thể tìm thấy bất cứ điều gì, hãy quay trở lại cạo như một phương sách cuối cùng.

Trong trường hợp này, thực sự có một API Cửa hàng Steam không chính thức có sẵn. Để sử dụng nó, chúng tôi cần ID ứng dụng Steam hoặc ID gói Steam của mặt hàng mà chúng tôi quan tâm, nhưng chúng tôi có thể lấy nó từ API cung cấp năng lượng cho menu thả xuống đề xuất tìm kiếm , https://store.steampowered.com/search/suggest.

Tập lệnh mẫu sử dụng API

Sau đây là một đoạn mã ví dụ sử dụng API Steam Store không chính thức.

#!/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)