เครื่องขูดเกมสำหรับ Steam
ฉันสร้างมีดโกนสำหรับ 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')
คำตอบ
แยกตรรกะออกเป็นฟังก์ชัน
การมีฟังก์ชันแยกกันสำหรับแต่ละขั้นตอนต่อไปนี้จะทำให้อ่านโค้ดได้ง่ายขึ้น
- รับชื่อเกม
- ขูดข้อมูลเกม
- แสดงข้อมูลเกม
ปกป้องจุดเริ่มต้นของสคริปต์ของคุณ
ฉันขอแนะนำให้ย้ายขั้นตอนการดำเนินการสคริปต์ภายใต้การ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)