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 인터프리터가 종료되어 호출 하는 코드를 테스트하기 어렵게 만듭니다. 대신 모든 복구 가능한 경우에 대해 정상적으로 종료되도록 스크립트를 리팩터링해야합니다.
예를 들어 사용자가 연령 제한 게임에 대한 정보를 원하지 않는 경우 해당 정보를 건너 뛰고 목록에서 다음 게임으로 이동합니다. 어쨌든 exit()
목록에 남아있는 다른 게임을 처리하지 않으면 더 나은 사용자 경험을 제공 할 것이라고 생각 합니다.
지원되는 플랫폼은 목록이어야합니다.
결정하고 게임을 지원하는 플랫폼을 인쇄, 당신은 논리 값을 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"
또는 공백으로 만 구성된 비어 있지 않은 문자열 (예 :" "
.첫 번째 제안이 Steam 패키지 인 검색어 (예 :
"the witcher 3 wild hunt game of the year"
.첫 번째 제안이 Steam 번들 인 검색어 (예 :
"monkey island collection"
.
앞서 언급했지만 스크립트는 페이지에 가격이 표시되지 않으면 게임이 무료라고 잘못 가정합니다. 하지만 Steam에는 개발자가 가격을 정하지 않고 동시에 "무료"또는 "무료"게임으로 분류하지 않은 미출시 게임이 있습니다 ( "Spirit of Glace"는 내가 찾은 구체적인 예). 이 경우 표시 할 가격은 "미정"또는 "알 수 없음"과 같은 형식이어야합니다.
다행히 다음 섹션을 소개하는 방법으로 무료와 무료가 아닌 것을 구분하는 데 사용할 수있는 API가 있습니다. 이 API는 게임이 무료이거나 무료 인 경우 is_free
필드를 노출합니다 true
. 이 리뷰의 끝으로 이동하면 예제 스크립트에서 어떻게 검색되는지 확인할 수 있습니다.
스크래핑보다 API 선호
API를 사용하면 데이터 검색이 더 빠르고 종종 Selenium으로 스크래핑하는 것보다 훨씬 빠릅니다. API를 사용하면 응답 형식이 종종 JSON이기 때문에 데이터 추출이 더 쉽습니다.
잠재적 인 시간과 노력 절약이 엄청날 수 있기 때문에 스크래핑이 나올 때마다 항상 이것을 언급합니다. 공식 API 또는 문서화 된 비공식 API를 검색하는 데 시간을 할애하십시오. 아무것도 나타나지 않으면 Fiddler 또는 Chrome DevTools와 같은 HTTP / S 트래픽 검사기를 살펴보고 유망한 비공식 API를 찾을 수 있는지 확인하십시오. 마침내 아무것도 찾을 수 없다면 최후의 수단으로 스크래핑으로 돌아가십시오.
이 경우 실제로 사용할 수 있는 비공식 Steam Store API 가 있습니다. 이를 사용하려면 관심있는 항목의 Steam 앱 ID 또는 Steam 패키지 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)