들어가며
지난 편에서 Apple 리퍼비시 재고 감시를 만들었습니다. 이번엔 범위를 넓혀 게임 가격 감시를 추가했습니다.
목표는 세 가지입니다.
- Steam: 원하는 게임이 목표가 이하로 세일할 때 알림
- GOG: 동일한 가격 감시
- Epic: 무료 게임 배포 이벤트 감지
각 플랫폼마다 API 전략이 달랐고, 예상치 못한 장애물도 있었습니다. 그 과정을 공유합니다.
가격 감시 모델 설계
재고 감시는 단순합니다. “있다/없다”의 변화만 보면 됩니다. 가격 감시는 조금 다릅니다.
- 사용자마다 목표 가격이 다릅니다
- “현재 가격 ≤ 목표 가격”일 때만 알림을 보내야 합니다
- 세일이 지속되는 동안 매번 알림하면 안 됩니다 (24시간 쿨다운)
이를 위해 PriceResult 모델과 compare_price 로직을 추가했습니다.
class PriceResult(BaseModel):
site: str
product_id: str
name: str
original_price: float
current_price: float
currency: str
url: str
@property
def is_free(self) -> bool:
return self.current_price == 0
def should_notify(self, target_price: float) -> bool:
if self.is_free:
return True
return self.current_price <= target_price
# config/games.yaml
targets:
- site: steam
type: price
interval_hours: 3
currency: KRW
products:
- app_id: "1245620"
name: "Elden Ring"
target_price: 33000
Steam 크롤러: 세 가지 제품 유형
Steam에는 세 가지 제품 유형이 있습니다.
| 유형 | 설명 | API |
|---|---|---|
| App | 단일 게임 | /api/appdetails?appids= |
| Package (Sub) | 묶음 패키지 | /api/packagedetails?packageids= |
| Bundle | 번들 (묶음 할인) | ??? |
App과 Package는 공식 API가 있습니다.
# App
resp = await client.get(
"https://store.steampowered.com/api/appdetails",
params={"appids": app_id, "cc": cc, "filters": "price_overview"}
)
price_data = data[app_id]["data"]["price_overview"]
# Package
resp = await client.get(
"https://store.steampowered.com/api/packagedetails",
params={"packageids": package_id, "cc": cc}
)
price_data = data[package_id]["data"]["price"]
price_overview vs price
App API는 price_overview 키를, Package API는 price 키를 사용합니다. 처음에 Package 응답에서 price_overview를 찾다가 항상 None이 나와 당황했습니다.
Bundle: API가 없다
문제는 번들입니다. Monkey Island Collection(bundle_id: 6588)을 추적하려 했는데, /api/bundledetails 엔드포인트를 호출하자 403 Forbidden이 떨어졌습니다.
공식적으로 공개된 API가 아닌 겁니다. 방법을 찾아야 했습니다.
번들 스토어 페이지(store.steampowered.com/bundle/6588/)의 HTML을 분석했더니 이런 구조가 있었습니다.
<div data-ds-bundleid="6588"
data-ds-bundle-data='{"m_rgItems": [
{"m_nBasePriceInCents": 1100000, ...},
{"m_nBasePriceInCents": 1100000, ...},
...
]}'>
<div class="game_purchase_discount"
data-price-final="5814000"
data-bundlediscount="10">
</div>
</div>
data-ds-bundle-data JSON에 개별 게임 원가가, data-price-final에 번들 최종 가격이 있습니다. BeautifulSoup으로 파싱합니다.
from bs4 import BeautifulSoup
import json
async def _fetch_bundle(self, bundle_id, name, currency, cc):
resp = await self._client.get(
f"https://store.steampowered.com/bundle/{bundle_id}/",
params={"cc": cc, "l": "english"}
)
soup = BeautifulSoup(resp.text, "html.parser")
bundle_div = soup.find("div", {"data-ds-bundleid": bundle_id})
if bundle_div is None:
return None
# 개별 게임 원가 합산
bundle_data = json.loads(str(bundle_div.get("data-ds-bundle-data", "{}")))
items = bundle_data.get("m_rgItems", [])
original_cents = sum(item.get("m_nBasePriceInCents", 0) for item in items)
# 번들 최종 가격
price_div = bundle_div.find(attrs={"data-price-final": True})
final_cents = int(str(price_div["data-price-final"]))
return self._build_price_result(bundle_id, name,
{"initial": original_cents, "final": final_cents}, currency)
App → Package Fallback
사용자가 app_id로 입력했지만 실제로는 패키지인 경우도 있습니다. App API가 success: false를 반환하면 자동으로 Package API로 재시도합니다.
if app_id:
result = await self._fetch_app(app_id_str, name, currency, cc)
if result is None:
# App 조회 실패 → Package로 fallback
result = await self._fetch_package(app_id_str, name, currency, cc)
GOG 크롤러: 비공식 REST API
GOG는 공식 API를 제공하지 않지만, 스토어가 내부적으로 사용하는 REST 엔드포인트가 알려져 있습니다.
GET https://api.gog.com/products/{product_id}/prices?countryCode=KR
응답 구조가 깔끔합니다.
{
"_embedded": {
"prices": [{
"currency": {"code": "KRW"},
"basePrice": "4990 KRW",
"finalPrice": "0 KRW"
}]
}
}
가격 형식이 특이합니다. "4990 KRW" — 정수 + 통화 코드 문자열로, GOG는 1/100 단위 표기를 씁니다. (4990 → 49.90)
@staticmethod
def _parse_price(price_str: str) -> float:
"""'4990 KRW' → 49.90"""
amount_cents = int(price_str.split()[0])
return round(amount_cents / 100, 2)
Epic 크롤러: 무료 게임 감지
Epic Games는 가격 API가 없습니다. 대신 PRD에서 Epic의 핵심 가치는 무료 게임 배포 이벤트 감지였습니다.
Epic은 현재 무료 배포 중인 게임 목록을 공개 엔드포인트로 제공합니다.
GET https://store-site-backend-static.ak.epicgames.com/freeGamesPromotions
?locale=ko&country=KR&allowCountries=KR
GraphQL 대신 프로모션 API를 선택한 이유
Epic Store는 내부적으로 GraphQL을 사용하며, 이를 통해 특정 게임 가격도 조회할 수 있습니다. 하지만 게임마다 namespace와 offerId를 사전에 파악해야 하고, 비공식이라 언제든 변경될 수 있습니다. 프로모션 API는 공개 엔드포인트이고, 우리 목적(무료 게임 감지)에 정확히 맞습니다.
현재 무료인지 판별하는 조건은 promotionalOffers에 discountPercentage: 0인 항목이 있는지입니다. (Epic에서 0%는 “원가의 0%”, 즉 완전 무료를 의미합니다.)
@staticmethod
def _is_currently_free(item: dict) -> bool:
for offer_group in (item.get("promotions") or {}).get("promotionalOffers") or []:
for offer in offer_group.get("promotionalOffers") or []:
setting = offer.get("discountSetting") or {}
if (setting.get("discountType") == "PERCENTAGE"
and setting.get("discountPercentage") == 0):
return True
return False
products에 slug를 입력하면 관심 있는 게임만 watchlist로 필터링합니다. 비워두면 모든 무료 게임에 알림이 옵니다.
- site: epic
type: price
interval_hours: 6
products:
- slug: "baldurs-gate-2-enhanced-edition"
name: "Baldur's Gate II: Enhanced Edition"
target_price: 0
실제 운영 중 마주친 오류
배포 후 로그를 보다가 이런 에러를 발견했습니다.
ERROR - [epic] 크롤링 실패 (3회 연속) — 'NoneType' object has no attribute 'get'
코드를 보면 이런 부분이 있었습니다.
# 문제 코드
elements = (
data.get("data", {})
.get("Catalog", {})
...
)
Python의 dict.get(key, default)는 키가 없을 때만 기본값을 반환합니다. 키가 존재하지만 값이 null이면 None을 반환합니다. Epic API가 {"data": null} 형태로 응답한 것입니다.
# 수정 코드 — or {} 패턴으로 null-safe 처리
catalog = (data.get("data") or {}).get("Catalog") or {}
search_store = catalog.get("searchStore") or {}
elements = search_store.get("elements") or []
or {} 패턴은 값이 None이든 키가 없든 모두 {}로 처리합니다. API 응답의 어느 레벨에서든 null이 오더라도 안전합니다.
dict.get(key, default)의 함정
get(key, default)는 키 부재 시에만 동작합니다. 외부 API 응답처럼 null이 명시적으로 올 수 있는 경우엔 or default 패턴을 사용하세요.
# 안전하지 않음
data.get("key", {}).get("nested") # key가 null이면 AttributeError
# 안전함
(data.get("key") or {}).get("nested") 설정 파일 분리
처음엔 config/targets.yaml 하나에 모든 타겟을 관리했습니다. 크롤러가 늘어나면서 문제가 생겼습니다.
- 재고 감시(apple_refurb)와 가격 감시(steam, gog, epic)가 한 파일에 섞임
- Amazon, 쿠팡이 추가될수록 파악이 어려워짐
--type stock|price플래그로 필터링하는 구조가 직관적이지 않음
도메인별로 파일을 분리했습니다.
config/
stock.yaml # 재고 감시 (apple_refurb)
games.yaml # 게임 가격/무료 감시 (steam, gog, epic)
shopping.yaml # 쇼핑몰 가격 감시 (amazon, 쿠팡 — 예정)
run.py는 config/*.yaml을 자동으로 탐색합니다.
config_files = [args.config] if args.config else sorted(glob.glob("config/*.yaml"))
for config_path in config_files:
await run(config_path)
새 도메인을 추가할 때 기존 파일을 건드리지 않고 새 파일만 추가하면 됩니다.
GitHub Actions 단일화
기존엔 재고/가격 워크플로우가 분리되어 있었습니다. 설정 파일을 분리하면서 워크플로우도 하나로 합쳤습니다.
# .github/workflows/crawl.yml
on:
schedule:
- cron: "0 * * * *" # 매시간 한 번, interval_hours가 실제 주기 제어
Playwright는 apple_refurb에만 필요하지만, 통합 워크플로우에서 항상 설치합니다. 개인 프로젝트 기준으로 설치 시간(약 2분)은 허용 범위입니다.
결과
Phase 1-B 완료 후 동작 중인 크롤러 목록입니다.
| 크롤러 | 방식 | 주기 |
|---|---|---|
| apple_refurb | Playwright + Bootstrap JSON | 4시간 |
| steam | 공식 API (app/package/bundle) | 3시간 |
| gog | 비공식 REST API | 6시간 |
| epic | 무료 프로모션 API | 6시간 |
전체 테스트 34개가 통과하고 있으며, 실제로 세일 알림과 무료 게임 알림을 받고 있습니다.
다음 편에서는
Phase 2-A에서는 AWS DynamoDB를 도입해 가격 이력을 쌓습니다. 현재는 “지금 가격이 목표가 이하인가”만 판단하지만, 이력이 쌓이면 “역대 최저가인가”도 판단할 수 있게 됩니다.
쿠팡(파트너스 API)과 Amazon(Playwright) 크롤러도 추가할 예정입니다.
소스 코드는 GitHub에서 확인하실 수 있습니다.