epic-awesome-gamer/src/services/agents/epic_games.py

288 lines
11 KiB
Python

# -*- coding: utf-8 -*-
# Time : 2023/8/14 23:16
# Author : QIN2DIM
# Github : https://github.com/QIN2DIM
# Description:
from __future__ import annotations
import json
from contextlib import suppress
from dataclasses import dataclass, field
from json import JSONDecodeError
from pathlib import Path
from typing import List, Dict
import httpx
from hcaptcha_challenger.agents.playwright.control import AgentT
from loguru import logger
from playwright.async_api import BrowserContext, expect, TimeoutError, Page
from services.models import EpicPlayer
from utils import from_dict_to_model
# fmt:off
URL_CLAIM = "https://store.epicgames.com/en-US/free-games"
URL_LOGIN = f"https://www.epicgames.com/id/login?lang=en-US&noHostRedirect=true&redirectUrl={URL_CLAIM}"
URL_PROMOTIONS = "https://store-site-backend-static.ak.epicgames.com/freeGamesPromotions"
URL_PRODUCT_PAGE = "https://store.epicgames.com/en-US/p/"
URL_ORDER_HISTORY = "https://www.epicgames.com/account/v2/payment/ajaxGetOrderHistory"
URL_CART = "https://store.epicgames.com/en-US/cart"
URL_CART_SUCCESS = "https://store.epicgames.com/en-US/cart/success"
# -----
URL_STORE_EXPLORER = "https://store.epicgames.com/en-US/browse?sortBy=releaseDate&sortDir=DESC&priceTier=tierFree&count=40"
URL_STORE_EXPLORER_GRAPHQL = (
"https://store.epicgames.com/graphql?operationName=searchStoreQuery"
'&variables={"category":"games/edition/base","comingSoon":false,"count":80,"freeGame":true,"keywords":"","sortBy":"releaseDate","sortDir":"DESC","start":0,"tag":"","withPrice":true}'
'&extensions={"persistedQuery":{"version":1,"sha256Hash":"13a2b6787f1a20d05c75c54c78b1b8ac7c8bf4efc394edf7a5998fdf35d1adb0"}}'
)
# fmt:on
@dataclass
class CompletedOrder:
offerId: str
namespace: str
@dataclass
class Game:
url: str
namespace: str
title: str
thumbnail: str
in_library = None
@dataclass
class EpicGames:
player: EpicPlayer
"""
Agent control
"""
_solver: AgentT = None
"""
Module for anti-captcha
"""
_promotions: List[Game] = field(default_factory=list)
"""
Free promotional items for the week,
considered metadata for task sequence of the agent
"""
@classmethod
def from_player(
cls, player: EpicPlayer, *, page: Page, tmp_dir: Path | None = None, **solver_opt
):
"""尽可能早地实例化,用于部署 captcha 事件监听器"""
return cls(
player=player, _solver=AgentT.from_page(page=page, tmp_dir=tmp_dir, **solver_opt)
)
@property
def promotions(self) -> List[Game]:
self._promotions = self._promotions or get_promotions()
return self._promotions
async def _login(self, page: Page) -> str | None:
await page.goto(URL_CLAIM, wait_until="domcontentloaded")
while await page.locator('a[role="button"]:has-text("Sign In")').count() > 0:
await page.goto(URL_LOGIN, wait_until="domcontentloaded")
logger.info("login", url=page.url)
await page.click("#login-with-epic")
logger.info("login-with-epic", url=page.url)
await page.fill("#email", self.player.email)
await page.type("#password", self.player.password)
await page.click("#sign-in")
fall_in_challenge = False
for _ in range(15):
if not fall_in_challenge:
with suppress(TimeoutError):
await page.wait_for_url(URL_CART_SUCCESS, timeout=3000)
break
logger.debug("claim_weekly_games", action="handle challenge")
fall_in_challenge = True
result = await self._solver(window="login", recur_url=URL_CLAIM)
logger.debug("handle challenge", result=result)
match result:
case self._solver.status.CHALLENGE_BACKCALL:
await page.click("//a[@class='talon_close_button']")
await page.wait_for_timeout(1000)
await page.click("#sign-in", delay=200)
case self._solver.status.CHALLENGE_RETRY:
continue
# await page.reload()
# await page.fill("#email", self.player.email)
# await page.type("#password", self.player.password)
# await page.click("#sign-in")
case self._solver.status.CHALLENGE_SUCCESS:
with suppress(TimeoutError):
await page.wait_for_url(URL_CLAIM)
break
return
logger.success("login", result="token has not expired")
return self._solver.status.CHALLENGE_SUCCESS
async def authorize(self, page: Page):
for _ in range(3):
match await self._login(page):
case self._solver.status.CHALLENGE_SUCCESS:
return True
case _:
continue
logger.critical("Failed to flush token", agent=self.__class__.__name__)
async def flush_token(self, context: BrowserContext) -> Dict[str, str] | None:
page = context.pages[0]
await page.goto("https://www.epicgames.com/account/personal", wait_until="networkidle")
await page.goto(
"https://store.epicgames.com/zh-CN/p/orwell-keeping-an-eye-on-you",
wait_until="networkidle",
)
await context.storage_state(path=self.player.ctx_cookie_path)
cookies = self.player.ctx_cookies.reload(self.player.ctx_cookie_path)
logger.success("flush_token", path=self.player.ctx_cookie_path)
return cookies
async def claim_weekly_games(self, page: Page, promotions: List[Game]):
"""
:param page:
:param promotions: 未在库的 promotions
:return:
"""
# --> Add promotions to Cart
for promotion in promotions:
logger.info("claim_weekly_games", action="go to store", url=promotion.url)
await page.goto(promotion.url, wait_until="load")
# <-- Handle pre-page
with suppress(TimeoutError):
await page.click("//button//span[text()='Continue']", timeout=3000)
# --> Make sure promotion is not in the library before executing
cta_btn = page.locator("//aside//button[@data-testid='add-to-cart-cta-button']")
text = await cta_btn.text_content()
if text == "View In Cart":
continue
if text == "Add To Cart":
await cta_btn.click()
await expect(cta_btn).to_have_text("View In Cart")
# --> Goto cart page
await page.goto(URL_CART, wait_until="domcontentloaded")
await page.click("//button//span[text()='Check Out']")
# <-- Handle Any LICENSE
with suppress(TimeoutError):
await page.click("//label[@for='agree']", timeout=2000)
accept = page.locator("//button//span[text()='Accept']")
if await accept.is_enabled():
await accept.click()
# --> Move to webPurchaseContainer iframe
logger.info("claim_weekly_games", action="move to webPurchaseContainer iframe")
wpc = page.frame_locator("//iframe[@class='']")
payment_btn = wpc.locator("//div[@class='payment-order-confirm']")
with suppress(Exception):
await expect(payment_btn).to_be_attached()
await page.wait_for_timeout(2000)
await payment_btn.click()
logger.info("claim_weekly_games", action="click payment button")
# <-- Insert challenge
for _ in range(15):
# {{< if fall in challenge >}}
match await self._solver(window="free", recur_url=URL_CART_SUCCESS):
case self._solver.status.CHALLENGE_BACKCALL | self._solver.status.CHALLENGE_RETRY:
await wpc.locator("//a[@class='talon_close_button']").click()
await page.wait_for_timeout(1000)
await payment_btn.click(delay=200)
case self._solver.status.CHALLENGE_SUCCESS:
await page.wait_for_url(URL_CART_SUCCESS)
break
# --> Wait for success
await page.wait_for_url(URL_CART_SUCCESS)
logger.success("claim_weekly_games", action="success", url=page.url)
def get_promotions() -> List[Game]:
"""
获取周免游戏数据
<即将推出> promotion["promotions"]["upcomingPromotionalOffers"]
<本周免费> promotion["promotions"]["promotionalOffers"]
:return: {"pageLink1": "pageTitle1", "pageLink2": "pageTitle2", ...}
"""
_promotions: List[Game] = []
params = {"local": "zh-CN"}
resp = httpx.get(URL_PROMOTIONS, params=params)
try:
data = resp.json()
except JSONDecodeError:
pass
else:
elements = data["data"]["Catalog"]["searchStore"]["elements"]
promotions = [e for e in elements if e.get("promotions")]
# 获取商城促销数据&&获取<本周免费>的游戏对象
for promotion in promotions:
if offer := promotion["promotions"]["promotionalOffers"]:
# 去除打折了但只打一点点的商品
with suppress(KeyError, IndexError):
offer = offer[0]["promotionalOffers"][0]
if offer["discountSetting"]["discountPercentage"] != 0:
continue
try:
query = promotion["catalogNs"]["mappings"][0]["pageSlug"]
promotion["url"] = f"{URL_PRODUCT_PAGE}{query}"
except IndexError:
promotion["url"] = f"{URL_PRODUCT_PAGE}{promotion['productSlug']}"
promotion["thumbnail"] = promotion["keyImages"][-1]["url"]
_promotions.append(from_dict_to_model(Game, promotion))
return _promotions
def get_order_history(
cookies: Dict[str, str], page: str | None = None, last_create_at: str | None = None
) -> List[CompletedOrder]:
"""获取最近的订单纪录"""
def request_history() -> str | None:
headers = {
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)"
" Chrome/115.0.0.0 Safari/537.36 Edg/115.0.1901.203"
}
params = {"locale": "zh-CN", "page": page or "0", "latCreateAt": last_create_at or ""}
resp = httpx.get(URL_ORDER_HISTORY, headers=headers, cookies=cookies, params=params)
if not resp.is_success:
raise httpx.RequestError("Failed to get order history, cookie may have expired")
return resp.text
completed_orders: List[CompletedOrder] = []
try:
data = json.loads(request_history())
for order in data["orders"]:
if order["orderType"] != "PURCHASE":
continue
for item in order["items"]:
if len(item["namespace"]) != 32:
continue
completed_orders.append(from_dict_to_model(CompletedOrder, item))
except (httpx.RequestError, JSONDecodeError, KeyError) as err:
logger.warning(err)
return completed_orders