Scraper game untuk Steam
Saya membuat scraper untuk Steam yang mendapatkan info berbeda tentang game Steam, seperti harga, spesifikasi dan platform yang didukung. Alasan saya membuat ini adalah karena saya memiliki laptop yang sangat lambat, jadi melihat banyak game akan memakan waktu lama :)
Beberapa hal yang ingin saya perbaiki adalah memiliki penanganan kesalahan yang lebih baik, karena web adalah tempat yang berantakan dan tidak semua halaman akan sama.
Hal lain yang saya pikirkan adalah memiliki manajemen data yang lebih baik, seperti menggunakan kelas dan objek untuk setiap game daripada menyimpan semua nilai dalam kamus, yang akan membuat kode menjadi lebih sederhana dan bahkan mungkin lebih pendek.
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')
Jawaban
Pisahkan logika menjadi beberapa fungsi
Memiliki fungsi terpisah untuk setiap langkah berikut akan membuat kode lebih mudah dibaca.
- Dapatkan nama game
- Mengikis informasi game
- Menampilkan informasi game
Jagalah titik masuk skrip Anda
Saya akan merekomendasikan untuk memindahkan aliran eksekusi skrip di bawah if __name__ == "__main__":
pelindung. Melakukan ini memungkinkan Anda untuk mengimpor fungsi dari file ini ke file lain tanpa menjalankan skrip.
Hindari menggunakan sys.exit()
untuk aliran kontrol
Memanggil sys.exit()
mematikan interpreter Python, yang membuat kode apa pun yang memanggilnya sulit untuk diuji. Anda sebaiknya melakukan refactor skrip sehingga berakhir secara normal untuk semua kasus yang dapat dipulihkan.
Misalnya, jika pengguna tidak menginginkan informasi untuk game yang dibatasi usia, lewati dan lanjutkan ke game berikutnya dalam daftar. Saya pikir ini akan membuat pengalaman pengguna yang lebih baik, karena jika kami exit()
tidak dapat memproses game lain yang tersisa dalam daftar.
Platform yang didukung harus berupa daftar
Dalam menentukan dan mencetak platform yang didukung untuk permainan, Anda memiliki boolean mac
dan linux
yang akhirnya diterjemahkan ke string mengambil salah satu 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
Saya pikir lebih masuk akal untuk memodelkan ini sebagai daftar, misalnya ["windows", "mac"]
jadi lebih eksplisit platform apa yang didukung. Ini juga akan menyelamatkan Anda dari menulis logika if / elif / else tambahan saat mencetaknya.
Datar lebih baik dari pada bersarang
Blok percobaan / pengecualian bersarang dalam tahap pengambilan harga sangat sulit dibaca.
Jika Anda mendelegasikan pengambilan harga ke suatu fungsi, Anda dapat menyusun logikanya menjadi datar, bukan bersarang, seperti dalam pseudocode berikut:
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'
Penyalahgunaan penanganan pengecualian
Naskahnya menarik KeyError
dan Exception
menangani pencetakan tiga jenis harga: bundel, diskon, dan standar. Ini bisa dibilang penyalahgunaan penanganan pengecualian, terutama karena menangkap jenderal Exception
jarang merupakan ide yang baik karena dapat menyembunyikan kesalahan lain yang tidak Anda harapkan. Ditambah itu tidak diperlukan di sini; kita bisa menggunakan 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
Manajemen data
Anda menyebutkan bahwa Anda berpikir tentang memiliki kelas atau objek untuk setiap permainan daripada menggunakan kamus. Saya pikir ini ide yang bagus. Ini mungkin tidak membuat kode lebih pendek, tetapi itu pasti akan meningkatkan keterbacaan kode.
Kandidat yang baik untuk ini adalah wadah data sederhana seperti typing.NamedTuple. Seperti yang disarankan @ MaartenFabré di komentar, dataclasses.dataclassadalah pilihan bagus lainnya untuk wadah data seperti ini.
Kasus tepi tidak tertangani
Kueri penelusuran berikut akan menghasilkan waktu skrip habis:
Permintaan pencarian apa pun yang tidak mengembalikan saran di Steam. Ini bisa menjadi sesuatu seperti permainan yang belum ada (belum), misalnya
"funkytown"
, atau string tidak kosong yang hanya terdiri dari spasi, misalnya" "
.Permintaan pencarian apa pun yang saran pertamanya adalah Paket Steam, mis
"the witcher 3 wild hunt game of the year"
.Permintaan pencarian apa pun yang saran pertamanya adalah Steam Bundle, mis
"monkey island collection"
.
Saya menyebutkan ini sebelumnya, tetapi skrip salah mengasumsikan bahwa jika harga tidak ditampilkan di halaman, maka game itu gratis. Tetapi ada game yang belum dirilis di Steam di mana pengembang belum menetapkan harga, dan pada saat yang sama mereka belum mengklasifikasikannya sebagai game "gratis" atau "gratis untuk dimainkan" ("Spirit of Glace" adalah satu contoh konkret yang saya temukan). Harga yang akan ditampilkan dalam kasus ini harus seperti "TBD" atau "Tidak diketahui".
Untungnya, dan sebagai cara untuk memperkenalkan bagian selanjutnya, ada API yang dapat kita gunakan untuk membedakan antara gratis dan tidak gratis. API ini memperlihatkan is_free
bidang yaitu true
saat game gratis atau gratis untuk dimainkan. Jika Anda melompat ke akhir tinjauan ini, Anda dapat melihat bagaimana itu diambil di skrip contoh saya.
Lebih suka API daripada scraping
Dengan API, pengambilan data lebih cepat - dan seringkali lipat lebih cepat daripada mengorek dengan Selenium. Dengan API, ekstraksi data lebih mudah karena format responsnya sering kali JSON.
Saya selalu membuat titik untuk menyebutkan ini setiap kali gesekan muncul karena potensi penghematan waktu dan tenaga bisa sangat besar. Luangkan waktu untuk mencari API resmi, atau API tidak resmi yang didokumentasikan. Jika tidak ada yang muncul, lihat-lihat dengan inspektur lalu lintas HTTP / S seperti Fiddler atau Chrome DevTools dan lihat apakah Anda dapat menemukan API tidak resmi yang menjanjikan. Jika akhirnya Anda tidak dapat menemukan apa pun, kembalilah ke mengorek sebagai upaya terakhir.
Dalam hal ini, sebenarnya ada API Toko Uap tidak resmi yang tersedia. Untuk menggunakannya, kami memerlukan ID Aplikasi Steam atau ID Paket Steam dari item yang kami minati, tetapi kami bisa mendapatkannya dari API yang mendukung menu drop-down saran pencarian , https://store.steampowered.com/search/suggest
.
Contoh script menggunakan API
Berikut ini adalah contoh skrip menggunakan Steam Store API tidak resmi.
#!/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)