เครื่องขูดเกมสำหรับ Steam

Aug 17 2020

ฉันสร้างมีดโกนสำหรับ 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')

คำตอบ

4 Setris Aug 29 2020 at 09:34

แยกตรรกะออกเป็นฟังก์ชัน

การมีฟังก์ชันแยกกันสำหรับแต่ละขั้นตอนต่อไปนี้จะทำให้อ่านโค้ดได้ง่ายขึ้น

  • รับชื่อเกม
  • ขูดข้อมูลเกม
  • แสดงข้อมูลเกม

ปกป้องจุดเริ่มต้นของสคริปต์ของคุณ

ฉันขอแนะนำให้ย้ายขั้นตอนการดำเนินการสคริปต์ภายใต้การif __name__ == "__main__":ป้องกัน การทำเช่นนี้ช่วยให้คุณสามารถนำเข้าฟังก์ชันจากไฟล์นี้ไปยังไฟล์อื่นได้โดยไม่ต้องเรียกใช้สคริปต์

หลีกเลี่ยงการใช้sys.exit()เพื่อควบคุมโฟลว์

การโทรsys.exit()จะปิดตัวแปล Python ซึ่งทำให้โค้ดใด ๆ ที่เรียกว่าทดสอบได้ยาก คุณควร refactor สคริปต์แทนดังนั้นจึงจะยุติตามปกติสำหรับกรณีที่กู้คืนได้ทั้งหมด

ตัวอย่างเช่นหากผู้ใช้ไม่ต้องการข้อมูลของเกมที่ จำกัด อายุให้ข้ามไปและไปยังเกมถัดไปในรายการ ฉันคิดว่าสิ่งนี้จะทำให้ประสบการณ์ผู้ใช้ดีขึ้นอยู่ดีเพราะถ้าเราexit()ไม่ดำเนินการกับเกมอื่น ๆ ที่เหลือในรายการ

แพลตฟอร์มที่รองรับควรเป็นรายการ

ในการกำหนดและการพิมพ์แพลตฟอร์มที่สนับสนุนสำหรับการเล่นเกมที่คุณต้อง booleans 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"" "

  • ค้นหาใด ๆ "the witcher 3 wild hunt game of the year"ที่คำแนะนำแรกเป็นแพคเกจไอน้ำเช่น

  • ค้นหาใด ๆ ที่คำแนะนำแรกเป็นไอน้ำ Bundle "monkey island collection"เช่น

ฉันพูดถึงสิ่งนี้ก่อนหน้านี้ แต่สคริปต์ไม่ถูกต้องสันนิษฐานว่าหากราคาไม่แสดงบนหน้าเกมก็เล่นได้ฟรี แต่มีเกมที่ยังไม่เปิดตัวบน Steam ที่ผู้พัฒนาไม่ได้กำหนดราคาและในขณะเดียวกันพวกเขายังไม่ได้จัดประเภทเป็นเกม "ฟรี" หรือเป็นเกม "เล่นฟรี" ("Spirit of Glace" เป็นเกมหนึ่ง ตัวอย่างที่เป็นรูปธรรมที่ฉันพบ) ราคาที่จะแสดงในกรณีนี้ควรเป็น "TBD" หรือ "Unknown"

โชคดีและเพื่อเป็นการแนะนำในส่วนถัดไปมี API ที่เราสามารถใช้เพื่อแยกความแตกต่างระหว่างฟรีและไม่ฟรี API นี้แสดงis_freeฟิลด์ที่เป็นtrueช่วงเวลาที่เกมนั้นเล่นฟรีหรือเล่นฟรี หากคุณข้ามไปที่ส่วนท้ายของบทวิจารณ์นี้คุณจะเห็นวิธีการดึงข้อมูลในสคริปต์ตัวอย่างของฉัน

ต้องการ API ที่ขูด

ด้วย API การดึงข้อมูลจะเร็วกว่า - และมักจะเรียงลำดับขนาดได้เร็วกว่าการขูดด้วยซีลีเนียม ด้วย API การแยกข้อมูลทำได้ง่ายกว่าเนื่องจากรูปแบบของการตอบกลับมักเป็น JSON

ฉันมักจะพูดถึงสิ่งนี้ทุกครั้งที่มีการขูดขึ้นมาเพราะการประหยัดเวลาและความพยายามที่เป็นไปได้นั้นมีมาก ใช้เวลาค้นหา API อย่างเป็นทางการหรือ API ที่ไม่เป็นทางการที่มีการบันทึกไว้ หากไม่มีอะไรเกิดขึ้นให้ใช้ตัวตรวจสอบการรับส่งข้อมูล HTTP / S เช่น Fiddler หรือ Chrome DevTools และดูว่าคุณสามารถหา API ที่ไม่เป็นทางการที่มีแนวโน้มได้หรือไม่ หากสุดท้ายแล้วคุณไม่พบสิ่งใดให้ถอยกลับไปที่การขูดเป็นทางเลือกสุดท้าย

ในกรณีนี้มีSteam Store APIที่ไม่เป็นทางการที่พร้อมใช้งาน ที่จะใช้มันเราต้องอบไอน้ำแอปประจำตัวประชาชนหรือไอแพคเกจ ID ของรายการที่เรากำลังอยู่ในความสนใจใน แต่เราสามารถที่จะได้รับจาก API https://store.steampowered.com/search/suggestที่อำนาจข้อเสนอแนะการค้นหาแบบเลื่อนลงเมนู

ตัวอย่างสคริปต์โดยใช้ API

ต่อไปนี้เป็นสคริปต์ตัวอย่างที่ใช้ Steam Store API ที่ไม่เป็นทางการ

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