diff --git a/simnet/client/base.py b/simnet/client/base.py index 71a2fd1..722473f 100644 --- a/simnet/client/base.py +++ b/simnet/client/base.py @@ -8,7 +8,7 @@ from httpx import AsyncClient, TimeoutException, Response, HTTPError, Timeout from simnet.client.cookies import Cookies from simnet.client.headers import Headers from simnet.errors import TimedOut, NetworkError, BadRequest, raise_for_ret_code -from simnet.utils.ds import generate_dynamic_secret +from simnet.utils.ds import generate_dynamic_secret, DSType from simnet.utils.enum_ import Region, Game from simnet.utils.types import ( RT, @@ -187,7 +187,7 @@ class BaseClient(AsyncContextManager["BaseClient"]): header (HeaderTypes): The header to use. lang (Optional[str], optional): The language to use for overseas regions. Defaults to None. ds (str, optional): The DS string to use. Defaults to None. - ds_type (str, optional): The DS type to use. Defaults to None. + ds_type (Optional[DSType], optional): The DS type to use. Defaults to None. new_ds (bool, optional): Whether to generate a new DS. Defaults to False. data (Any, optional): The data to use. Defaults to None. params (Optional[QueryParamTypes], optional): The query parameters to use. Defaults to None. @@ -309,7 +309,7 @@ class BaseClient(AsyncContextManager["BaseClient"]): headers: Optional[HeaderTypes] = None, lang: Optional[str] = None, new_ds: bool = False, - ds_type: str = None, + ds_type: Optional[DSType] = None, ): """Make a request to the lab API and return the data. @@ -325,7 +325,7 @@ class BaseClient(AsyncContextManager["BaseClient"]): headers (Optional[HeaderTypes]): The headers to include in the request. lang (Optional[str]): The language of the request (e.g., "en", "zh"). new_ds (bool): Whether to use a new dataset for the request. - ds_type (str): The type of dataset to use for the request (e.g., "news", "qa"). + ds_type (Optional[DSType]): The type of dataset to use for the request (e.g., "news", "qa"). Returns: Any: The data returned by the lab API. diff --git a/simnet/client/components/chronicle/genshin.py b/simnet/client/components/chronicle/genshin.py index d63d977..036cfac 100644 --- a/simnet/client/components/chronicle/genshin.py +++ b/simnet/client/components/chronicle/genshin.py @@ -218,15 +218,19 @@ class GenshinBattleChronicleClient(BaseChronicleClient): Returns: Character: The requested genshin user with all their possible data. """ - user, abyss1, abyss2, activities = await asyncio.gather( - self.get_genshin_user(player_id, lang=lang), + index, character, abyss1, abyss2, activities = await asyncio.gather( + self._request_genshin_record("index", player_id, lang=lang), + self._request_genshin_record( + "character", player_id, lang=lang, method="POST" + ), self.get_genshin_spiral_abyss(player_id, lang=lang, previous=False), self.get_genshin_spiral_abyss(player_id, lang=lang, previous=True), self.get_genshin_activities(player_id, lang=lang), ) + user = {**index, **character} abyss = SpiralAbyssPair(current=abyss1, previous=abyss2) - return FullGenshinUserStats(**user.dict(), abyss=abyss, activities=activities) + return FullGenshinUserStats(**user, abyss=abyss, activities=activities) async def get_genshin_activities( self, player_id: Optional[int] = None, *, lang: Optional[str] = None diff --git a/simnet/client/components/daily.py b/simnet/client/components/daily.py index 812c6c9..5ccf1bc 100644 --- a/simnet/client/components/daily.py +++ b/simnet/client/components/daily.py @@ -2,11 +2,14 @@ import asyncio from typing import Optional, Dict, Any, List +from httpx import QueryParams + from simnet.client.base import BaseClient -from simnet.utils.ds import hex_digest from simnet.client.routes import REWARD_URL from simnet.models.lab.daily import DailyRewardInfo, DailyReward, ClaimedDailyReward +from simnet.utils.ds import hex_digest from simnet.utils.enum_ import Game, Region +from simnet.utils.player import recognize_genshin_server, recognize_starrail_server __all__ = ("DailyRewardClient",) @@ -40,19 +43,18 @@ class DailyRewardClient(BaseClient): Returns: A dictionary containing the response data. """ - new_ds: bool = False headers: Optional[Dict[str, str]] = None + params = QueryParams(params) + + base_url = REWARD_URL.get_url(self.region, self.game or game) + params = params.merge(base_url.params) + if self.region == Region.CHINESE: headers = {} if challenge is not None and validate is not None: headers["x-rpc-challenge"] = challenge headers["x-rpc-validate"] = validate headers["x-rpc-seccode"] = f"{validate}|jordan" - headers["Referer"] = ( - "https://webstatic.mihoyo.com/bbs/event/signin-ys/index.html?" - "bbs_auth_required=true&act_id=e202009291139501&utm_source=bbs&utm_medium=mys&utm_campaign=icon" - ) - headers["x-rpc-device_name"] = "Chrome 20 2023" headers["x-rpc-channel"] = "chrome" headers["x-rpc-device_model"] = "Chrome 2023" @@ -61,13 +63,31 @@ class DailyRewardClient(BaseClient): device_id = self.device_id hash_value = hex_digest(device_id) headers["x-rpc-device_fp"] = hash_value[:13] - new_ds = endpoint == "sign" + if self.game == Game.GENSHIN: + headers["referer"] = ( + "https://webstatic.mihoyo.com/bbs/event/signin-ys/index.html?" + "bbs_auth_required=true&act_id=e202009291139501&utm_source=bbs&utm_medium=mys&utm_campaign=icon" + ) + params = params.set("uid", self.player_id) + params = params.set("region", recognize_genshin_server(self.player_id)) + if self.game == Game.STARRAIL: + headers["referer"] = ( + "https://webstatic.mihoyo.com/bbs/event/signin/hkrpg/index.html?" + "bbs_auth_required=true&act_id=e202304121516551&" + "bbs_auth_required=true&bbs_presentation_style=fullscreen&" + "utm_source=bbs&utm_medium=mys&utm_campaign=icon" + ) + params = params.set("uid", self.player_id) + params = params.set("region", recognize_starrail_server(self.player_id)) - base_url = REWARD_URL.get_url(self.region, self.game or game) - url = (base_url / endpoint).update_query(**base_url.query) + url = base_url / endpoint return await self.request_lab( - method, url, params=params, headers=headers, lang=lang, new_ds=new_ds + url, + method, + params=params, + headers=headers, + lang=lang, ) async def get_reward_info( @@ -105,7 +125,7 @@ class DailyRewardClient(BaseClient): """ data = await self.request_daily_reward( "home", - game=game, + game=game or self.game, lang=lang, ) return [DailyReward(**i) for i in data["awards"]] @@ -129,7 +149,7 @@ class DailyRewardClient(BaseClient): page. """ data = await self.request_daily_reward( - "award", params=dict(current_page=page), game=game, lang=lang + "award", params=dict(current_page=page), game=game or self.game, lang=lang ) return [ClaimedDailyReward(**i) for i in data["list"]] @@ -159,7 +179,7 @@ class DailyRewardClient(BaseClient): break fetched_items = await self._get_claimed_rewards_page( - page, game=game, lang=lang + page, game=game or self.game, lang=lang ) if not fetched_items: break @@ -206,7 +226,7 @@ class DailyRewardClient(BaseClient): await self.request_daily_reward( "sign", method="POST", - game=game, + game=game or self.game, lang=lang, challenge=challenge, validate=validate, @@ -216,7 +236,7 @@ class DailyRewardClient(BaseClient): return None info, rewards = await asyncio.gather( - self.get_reward_info(game=game, lang=lang), - self.get_monthly_rewards(game=game, lang=lang), + self.get_reward_info(game=game or self.game, lang=lang), + self.get_monthly_rewards(game=game or self.game, lang=lang), ) return rewards[info.claimed_rewards - 1] diff --git a/simnet/client/components/wish/base.py b/simnet/client/components/wish/base.py index b607239..08aae48 100644 --- a/simnet/client/components/wish/base.py +++ b/simnet/client/components/wish/base.py @@ -102,7 +102,9 @@ class BaseWishClient(BaseClient): game=game, lang=lang, authkey=authkey, - params=dict(gacha_type=banner_type, size=size, end_id=end_id), + params=dict( + gacha_type=banner_type, size=size, end_id=end_id, game_biz=Game.value + ), ) async def get_banner_names( diff --git a/simnet/client/components/wish/genshin.py b/simnet/client/components/wish/genshin.py index c226bb7..0921d4c 100644 --- a/simnet/client/components/wish/genshin.py +++ b/simnet/client/components/wish/genshin.py @@ -6,7 +6,6 @@ from simnet.models.genshin.wish import Wish from simnet.utils.enum_ import Game from simnet.utils.paginator import WishPaginator - __all__ = ("GenshinWishClient",) @@ -15,17 +14,16 @@ class GenshinWishClient(BaseWishClient): async def wish_history( self, - banner_type: int, + banner_types: Optional[List[int]] = None, limit: Optional[int] = None, lang: Optional[str] = None, authkey: Optional[str] = None, end_id: int = 0, ) -> List[Wish]: - """ - Get the wish history for a list of banner types. + """Get the wish history for a list of banner types. Args: - banner_type (int, optional): The banner types to get the wish history for. + banner_types (Optional[List[int]], optional): The banner types to get the wish history for. limit (Optional[int] , optional): The maximum number of wishes to retrieve. If not provided, all available wishes will be returned. lang (Optional[str], optional): The language code to use for the request. @@ -36,19 +34,22 @@ class GenshinWishClient(BaseWishClient): Returns: List[Wish]: A list of GenshinWish objects representing the retrieved wishes. """ + banner_types = banner_types or [100, 200, 301, 302] banner_names = await self.get_banner_names( game=Game.GENSHIN, lang=lang, authkey=authkey ) - paginator = WishPaginator( - end_id, - partial( - self.get_wish_page, - banner_type=banner_type, - game=Game.GENSHIN, - authkey=authkey, - ), - ) - items = await paginator.get(limit) - banner_name = banner_names[banner_type] - wish = [Wish(**i, banner_name=banner_name) for i in items] - return wish + wishes = [] + for banner_type in banner_types: + paginator = WishPaginator( + end_id, + partial( + self.get_wish_page, + banner_type=banner_type, + game=Game.GENSHIN, + authkey=authkey, + ), + ) + items = await paginator.get(limit) + banner_name = banner_names[banner_type] + wishes.extend([Wish(**i, banner_name=banner_name) for i in items]) + return sorted(wishes, key=lambda wish: wish.time.timestamp()) diff --git a/simnet/client/components/wish/starrail.py b/simnet/client/components/wish/starrail.py index 541a67b..e980b09 100644 --- a/simnet/client/components/wish/starrail.py +++ b/simnet/client/components/wish/starrail.py @@ -1,5 +1,6 @@ from functools import partial from typing import Optional, List + from simnet.client.components.wish.base import BaseWishClient from simnet.models.starrail.wish import StarRailWish from simnet.utils.enum_ import Game @@ -13,7 +14,7 @@ class StarRailWishClient(BaseWishClient): async def wish_history( self, - banner_type: int, + banner_types: Optional[List[int]] = None, limit: Optional[int] = None, lang: Optional[str] = None, authkey: Optional[str] = None, @@ -23,7 +24,7 @@ class StarRailWishClient(BaseWishClient): Get the wish history for a list of banner types. Args: - banner_type (int, optional): The banner types to get the wish history for. + banner_types (Optional[List[int]], optional): The banner types to get the wish history for. limit (Optional[int] , optional): The maximum number of wishes to retrieve. If not provided, all available wishes will be returned. lang (Optional[str], optional): The language code to use for the request. @@ -34,15 +35,18 @@ class StarRailWishClient(BaseWishClient): Returns: List[StarRailWish]: A list of StarRailWish objects representing the retrieved wishes. """ - paginator = WishPaginator( - end_id, - partial( - self.get_wish_page, - banner_type=banner_type, - game=Game.STARRAIL, - authkey=authkey, - ), - ) - items = await paginator.get(limit) - wish = [StarRailWish(**i) for i in items] - return wish + banner_types = banner_types or [1, 2, 11, 12] + wishes = [] + for banner_type in banner_types: + paginator = WishPaginator( + end_id, + partial( + self.get_wish_page, + banner_type=banner_type, + game=Game.STARRAIL, + authkey=authkey, + ), + ) + items = await paginator.get(limit) + wishes.extend([StarRailWish(**i) for i in items]) + return sorted(wishes, key=lambda wish: wish.time.timestamp()) diff --git a/simnet/client/genshin.py b/simnet/client/genshin.py index 719433d..804fadd 100644 --- a/simnet/client/genshin.py +++ b/simnet/client/genshin.py @@ -2,13 +2,16 @@ from typing import Optional from simnet.client.components.auth import AuthClient from simnet.client.components.chronicle.genshin import GenshinBattleChronicleClient +from simnet.client.components.daily import DailyRewardClient from simnet.client.components.wish.genshin import GenshinWishClient from simnet.utils.enum_ import Game __all__ = ("GenshinClient",) -class GenshinClient(GenshinBattleChronicleClient, GenshinWishClient, AuthClient): +class GenshinClient( + GenshinBattleChronicleClient, GenshinWishClient, AuthClient, DailyRewardClient +): """A simple http client for StarRail endpoints.""" game: Optional[Game] = Game.GENSHIN diff --git a/simnet/client/genshin.pyi b/simnet/client/genshin.pyi index b8fc2a5..fdaa978 100644 --- a/simnet/client/genshin.pyi +++ b/simnet/client/genshin.pyi @@ -2,11 +2,13 @@ from typing import Optional from simnet.client.components.auth import AuthClient from simnet.client.components.chronicle.genshin import GenshinBattleChronicleClient +from simnet.client.components.daily import DailyRewardClient from simnet.client.components.wish.genshin import GenshinWishClient from simnet.utils.enum_ import Region from simnet.utils.types import CookieTypes, HeaderTypes, TimeoutTypes -class GenshinClient(GenshinBattleChronicleClient, GenshinWishClient, AuthClient): + +class GenshinClient(GenshinBattleChronicleClient, GenshinWishClient, AuthClient, DailyRewardClient): def __init__( self, cookies: Optional[CookieTypes] = None, diff --git a/simnet/models/base.py b/simnet/models/base.py index 9f2c99f..a773ac0 100644 --- a/simnet/models/base.py +++ b/simnet/models/base.py @@ -1,3 +1,5 @@ +from typing import Any + from pydantic import BaseModel try: @@ -9,6 +11,13 @@ except ImportError: class APIModel(BaseModel): """A Pydantic BaseModel class used for modeling JSON data returned by an API.""" + def __init__(self, **data: Any) -> None: + for field_name, field in self.__fields__.items(): + aliases = field.field_info.extra.get("aliases") + if aliases and aliases in data: + data[field_name] = data.pop(aliases) + super().__init__(**data) + class Config: """A nested class defining configuration options for the APIModel.""" diff --git a/simnet/models/genshin/character.py b/simnet/models/genshin/character.py index 04e3739..b66ac75 100644 --- a/simnet/models/genshin/character.py +++ b/simnet/models/genshin/character.py @@ -1,3 +1,5 @@ +from typing import Optional + from simnet.models.base import APIModel __all__ = ("BaseCharacter",) @@ -20,10 +22,10 @@ class BaseCharacter(APIModel): """ id: int - name: str - element: str - rarity: int - icon: str + name: Optional[str] = None + element: Optional[str] = None + rarity: Optional[str] = None + icon: Optional[str] = None collab: bool = False diff --git a/simnet/models/genshin/chronicle/characters.py b/simnet/models/genshin/chronicle/characters.py index 9668f4c..e4e8e74 100644 --- a/simnet/models/genshin/chronicle/characters.py +++ b/simnet/models/genshin/chronicle/characters.py @@ -27,6 +27,7 @@ class PartialCharacter(BaseCharacter): constellation (int): The number of constellations that are currently active for the character. """ + icon: str = Field(alias="image") level: int friendship: int = Field(alias="fetter") constellation: int = Field(alias="actived_constellation_num") diff --git a/simnet/models/genshin/chronicle/stats.py b/simnet/models/genshin/chronicle/stats.py index 6c09b45..db691d3 100644 --- a/simnet/models/genshin/chronicle/stats.py +++ b/simnet/models/genshin/chronicle/stats.py @@ -30,21 +30,21 @@ class Stats(APIModel): unlocked_domains (int): Number of domains unlocked by the user. """ - achievements: int = Field(alias="achievement_number") - days_active: int = Field(alias="active_day_number") - characters: int = Field(alias="avatar_number") - spiral_abyss: str = Field(alias="spiral_abyss") - anemoculi: int = Field(alias="anemoculus_number") - geoculi: int = Field(alias="geoculus_number") - dendroculi: int = Field(alias="dendroculus_number") - electroculi: int = Field(alias="electroculus_number") - common_chests: int = Field(alias="common_chest_number") - exquisite_chests: int = Field(alias="exquisite_chest_number") - precious_chests: int = Field(alias="precious_chest_number") - luxurious_chests: int = Field(alias="luxurious_chest_number") - remarkable_chests: int = Field(alias="magic_chest_number") - unlocked_waypoints: int = Field(alias="way_point_number") - unlocked_domains: int = Field(alias="domain_number") + achievements: int = Field(aliases="achievement_number") + days_active: int = Field(aliases="active_day_number") + characters: int = Field(aliases="avatar_number") + spiral_abyss: str = Field(aliases="spiral_abyss") + anemoculi: int = Field(aliases="anemoculus_number") + geoculi: int = Field(aliases="geoculus_number") + dendroculi: int = Field(aliases="dendroculus_number") + electroculi: int = Field(aliases="electroculus_number") + common_chests: int = Field(aliases="common_chest_number") + exquisite_chests: int = Field(aliases="exquisite_chest_number") + precious_chests: int = Field(aliases="precious_chest_number") + luxurious_chests: int = Field(aliases="luxurious_chest_number") + remarkable_chests: int = Field(aliases="magic_chest_number") + unlocked_waypoints: int = Field(aliases="way_point_number") + unlocked_domains: int = Field(aliases="domain_number") class Offering(APIModel): @@ -183,9 +183,9 @@ class PartialGenshinUserStats(APIModel): info: UserInfo = Field("role") stats: Stats - characters: List[PartialCharacter] = Field(alias="avatars") - explorations: List[Exploration] = Field(alias="world_explorations") - teapot: Optional[Teapot] = Field(alias="homes") + characters: List[PartialCharacter] = Field(aliases="avatars") + explorations: List[Exploration] = Field(aliases="world_explorations") + teapot: Optional[Teapot] = Field(aliases="homes") @validator("teapot", pre=True) def format_teapot(cls, v: Any) -> Optional[Dict[str, Any]]: diff --git a/simnet/models/lab/daily.py b/simnet/models/lab/daily.py index 02aa7bd..26b4688 100644 --- a/simnet/models/lab/daily.py +++ b/simnet/models/lab/daily.py @@ -1,4 +1,3 @@ -import calendar from datetime import timezone, timedelta, datetime from typing import NamedTuple @@ -25,8 +24,7 @@ class DailyRewardInfo(NamedTuple): """The number of rewards that the user has missed since the start of the month.""" cn_timezone = timezone(timedelta(hours=8)) now = datetime.now(cn_timezone) - month_days = calendar.monthrange(now.year, now.month)[1] - return month_days - self.claimed_rewards + return now.day - self.claimed_rewards class DailyReward(APIModel): diff --git a/simnet/utils/ds.py b/simnet/utils/ds.py index ff86a9c..92173d1 100644 --- a/simnet/utils/ds.py +++ b/simnet/utils/ds.py @@ -15,14 +15,12 @@ class DSType(Enum): Enumeration of dynamic secret types. Attributes: + WEB (str): Android dynamic secret type. ANDROID (str): Android dynamic secret type. - ANDROID_NEW (str): New Android dynamic secret type. - SIGN (str): Sign dynamic secret type. """ + WEB = "web" ANDROID = "android" - ANDROID_NEW = "android_new" - SIGN = "sign" def hex_digest(text): @@ -87,16 +85,22 @@ def generate_dynamic_secret( salt = "6s25p5ox5y14umn1p61aqyyvbvvl3lrt" app_version = "1.5.0" elif region == Region.CHINESE: - if ds_type is None: - salt = "xV8v4Qu54lUKrEYFZkJhB8cuOh9Asafs" - elif ds_type == DSType.ANDROID: - salt = "KZazpG4cO2QECFDBUCxdhS8cYCsQHfzn" - client_type = "2" - elif ds_type == DSType.ANDROID_NEW: - client_type = "2" - salt = "t0qEgfub6cvueAPgR5m9aQWWVciEer7v" + if new_ds: + if ds_type is None: + salt = "xV8v4Qu54lUKrEYFZkJhB8cuOh9Asafs" + elif ds_type == DSType.ANDROID: + client_type = "2" + salt = "KZazpG4cO2QECFDBUCxdhS8cYCsQHfzn" + else: + raise ValueError(f"Unknown ds_type: {ds_type}") else: - raise ValueError(f"Unknown ds_type: {ds_type}") + if ds_type is None: + salt = "X7UOLLnTuNS3kgTJ1BUHOvKpiqp3kmym" + elif ds_type == DSType.ANDROID: + salt = "t0qEgfub6cvueAPgR5m9aQWWVciEer7v" + client_type = "2" + else: + raise ValueError(f"Unknown ds_type: {ds_type}") else: raise ValueError(f"Unknown region: {region}") if new_ds: diff --git a/simnet/utils/paginator.py b/simnet/utils/paginator.py index 8e84b88..d740a6e 100644 --- a/simnet/utils/paginator.py +++ b/simnet/utils/paginator.py @@ -1,3 +1,4 @@ +import asyncio from typing import List, Dict, Callable, Any, Awaitable @@ -45,6 +46,7 @@ class WishPaginator: break all_items.extend(filtered_items) + await asyncio.sleep(0.5) # Return up to the specified limit. - return all_items[: min(len(all_items), limit)] + return all_items[: min(len(all_items), limit)] if limit else all_items