diff --git a/genshin-dev/setup.py b/genshin-dev/setup.py index 883d6b67..712ff131 100644 --- a/genshin-dev/setup.py +++ b/genshin-dev/setup.py @@ -1,4 +1,5 @@ """Mock package to install the dev requirements.""" + import pathlib import typing diff --git a/genshin/__main__.py b/genshin/__main__.py index 2904cf03..3076f6fe 100644 --- a/genshin/__main__.py +++ b/genshin/__main__.py @@ -219,7 +219,7 @@ async def starrail_notes(client: genshin.Client, uid: typing.Optional[int]) -> N f"{click.style('Echo of War:', bold=True)} {data.remaining_weekly_discounts}/{data.max_weekly_discounts}" ) - click.echo(f"\n{click.style('Assignments:', bold=True)} {data.accepted_epedition_num}/{data.total_expedition_num}") + click.echo(f"\n{click.style('Assignments:', bold=True)} {data.accepted_expedition_num}/{data.total_expedition_num}") for expedition in data.expeditions: if expedition.remaining_time > datetime.timedelta(0): remaining = f"{expedition.remaining_time} remaining" diff --git a/genshin/client/components/base.py b/genshin/client/components/base.py index d47bc319..5d43e286 100644 --- a/genshin/client/components/base.py +++ b/genshin/client/components/base.py @@ -43,6 +43,7 @@ class BaseClient(abc.ABC): "authkeys", "_hoyolab_id", "_accounts", + "custom_headers", ) USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36" # noqa: E501 @@ -71,6 +72,9 @@ def __init__( game: typing.Optional[types.Game] = None, uid: typing.Optional[int] = None, hoyolab_id: typing.Optional[int] = None, + device_id: typing.Optional[str] = None, + device_fp: typing.Optional[str] = None, + headers: typing.Optional[aiohttp.typedefs.LooseHeaders] = None, cache: typing.Optional[client_cache.Cache] = None, debug: bool = False, ) -> None: @@ -90,6 +94,10 @@ def __init__( self.uid = uid self.hoyolab_id = hoyolab_id + self.custom_headers = dict(headers or {}) + self.custom_headers.update({"x-rpc-device_id": device_id} if device_id else {}) + self.custom_headers.update({"x-rpc-device_fp": device_fp} if device_fp else {}) + def __repr__(self) -> str: kwargs = dict( lang=self.lang, @@ -338,6 +346,7 @@ async def request( headers = dict(headers or {}) headers["User-Agent"] = self.USER_AGENT + headers.update(self.custom_headers) if method is None: method = "POST" if data else "GET" @@ -384,6 +393,7 @@ async def request_webstatic( headers = dict(headers or {}) headers["User-Agent"] = self.USER_AGENT + headers.update(self.custom_headers) await self._request_hook("GET", url, headers=headers, **kwargs) @@ -593,7 +603,6 @@ def region_specific(region: types.Region) -> typing.Callable[[AsyncCallableT], A def decorator(func: AsyncCallableT) -> AsyncCallableT: @functools.wraps(func) async def wrapper(self: typing.Any, *args: typing.Any, **kwargs: typing.Any) -> typing.Any: - if not hasattr(self, "region"): raise TypeError("Cannot use @region_specific on a plain function.") if region != self.region: diff --git a/genshin/client/components/chronicle/starrail.py b/genshin/client/components/chronicle/starrail.py index 4abc7757..576ce05f 100644 --- a/genshin/client/components/chronicle/starrail.py +++ b/genshin/client/components/chronicle/starrail.py @@ -138,17 +138,16 @@ async def get_starrail_pure_fiction( """Get starrail pure fiction runs.""" payload = dict(schedule_type=2 if previous else 1, need_all="true") data = await self._request_starrail_record("challenge_story", uid, lang=lang, payload=payload) - - # In "groups", it contains time data from both the current season and previous season. - data = dict(data) - time = data["groups"][0] - if previous is True and len(data["groups"]) > 1: - time = data["groups"][1] - - # Extract the time data. - data["schedule_id"] = time["schedule_id"] - data["begin_time"] = time["begin_time"] - data["end_time"] = time["end_time"] - data["name_mi18n"] = time["name_mi18n"] - return models.StarRailPureFiction(**data) + + async def get_starrail_apc_shadow( + self, + uid: typing.Optional[int] = None, + *, + previous: bool = False, + lang: typing.Optional[str] = None, + ) -> models.StarRailAPCShadow: + """Get starrail apocalyptic shadow runs.""" + payload = dict(schedule_type=2 if previous else 1, need_all="true") + data = await self._request_starrail_record("challenge_boss", uid, lang=lang, payload=payload) + return models.StarRailAPCShadow(**data) diff --git a/genshin/client/components/daily.py b/genshin/client/components/daily.py index f9d51800..b8fc0399 100644 --- a/genshin/client/components/daily.py +++ b/genshin/client/components/daily.py @@ -17,8 +17,6 @@ __all__ = ["DailyRewardClient"] -CN_TIMEZONE = datetime.timezone(datetime.timedelta(hours=8)) - class DailyRewardClient(base.BaseClient): """Daily reward component.""" @@ -105,7 +103,7 @@ async def get_monthly_rewards( game=game, static_cache=cache.cache_key( "rewards", - month=datetime.datetime.now(CN_TIMEZONE).month, + month=datetime.datetime.now(constants.CN_TIMEZONE).month, region=self.region, game=typing.cast("types.Game", game or self.default_game), # (resolved later) lang=lang or self.lang, diff --git a/genshin/client/components/diary.py b/genshin/client/components/diary.py index 02b6d042..b3513e1e 100644 --- a/genshin/client/components/diary.py +++ b/genshin/client/components/diary.py @@ -8,13 +8,12 @@ from genshin.client import cache, routes from genshin.client.components import base from genshin.client.manager import managers +from genshin.constants import CN_TIMEZONE from genshin.models.genshin import diary as models from genshin.utility import deprecation __all__ = ["DiaryClient"] -CN_TIMEZONE = datetime.timezone(datetime.timedelta(hours=8)) - class DiaryCallback(typing.Protocol): """Callback which requires a diary page.""" diff --git a/genshin/client/manager/managers.py b/genshin/client/manager/managers.py index a0958a41..1bc84277 100644 --- a/genshin/client/manager/managers.py +++ b/genshin/client/manager/managers.py @@ -65,6 +65,7 @@ class BaseCookieManager(abc.ABC): """A cookie manager for making requests.""" _proxy: typing.Optional[yarl.URL] = None + _socks_proxy: typing.Optional[str] = None @classmethod def from_cookies(cls, cookies: typing.Optional[AnyCookieOrHeader] = None) -> BaseCookieManager: @@ -115,15 +116,28 @@ def proxy(self, proxy: typing.Optional[aiohttp.typedefs.StrOrURL]) -> None: return proxy = yarl.URL(proxy) - if str(proxy.scheme) not in ("https", "http", "ws", "wss"): + + if proxy.scheme in {"socks4", "socks5"}: + self._socks_proxy = str(proxy) + return + + if proxy.scheme not in {"https", "http", "ws", "wss"}: raise ValueError("Proxy URL must have a valid scheme.") self._proxy = proxy def create_session(self, **kwargs: typing.Any) -> aiohttp.ClientSession: """Create a client session.""" + if self._socks_proxy is not None: + import aiohttp_socks + + connector = aiohttp_socks.ProxyConnector.from_url(self._socks_proxy) + else: + connector = None + return aiohttp.ClientSession( cookie_jar=aiohttp.DummyCookieJar(), + connector=connector, **kwargs, ) diff --git a/genshin/constants.py b/genshin/constants.py index 340bc61f..1d6d89b6 100644 --- a/genshin/constants.py +++ b/genshin/constants.py @@ -1,5 +1,7 @@ """Constants hardcoded for optimizations.""" +import datetime + from . import types __all__ = ["APP_IDS", "APP_KEYS", "DS_SALT", "GEETEST_RETCODES", "LANGS"] @@ -83,3 +85,5 @@ types.Game.ZZZ: "nap_game_record", } """Keys used to submit geetest result.""" + +CN_TIMEZONE = datetime.timezone(datetime.timedelta(hours=8)) diff --git a/genshin/models/genshin/chronicle/abyss.py b/genshin/models/genshin/chronicle/abyss.py index 159c2f8f..42bf533a 100644 --- a/genshin/models/genshin/chronicle/abyss.py +++ b/genshin/models/genshin/chronicle/abyss.py @@ -9,6 +9,7 @@ except ImportError: import pydantic +from genshin.constants import CN_TIMEZONE from genshin.models.genshin import character from genshin.models.model import Aliased, APIModel @@ -113,6 +114,10 @@ def __nest_ranks(cls, values: typing.Dict[str, typing.Any]) -> typing.Dict[str, values.setdefault("ranks", {}).update(values) return values + @pydantic.validator("start_time", "end_time", pre=True) + def __parse_timezones(cls, value: str) -> datetime.datetime: + return datetime.datetime.fromtimestamp(int(value), tz=CN_TIMEZONE) + class SpiralAbyssPair(APIModel): """Pair of both current and previous spiral abyss. diff --git a/genshin/models/genshin/daily.py b/genshin/models/genshin/daily.py index 113eaee6..3cbd50aa 100644 --- a/genshin/models/genshin/daily.py +++ b/genshin/models/genshin/daily.py @@ -3,6 +3,7 @@ import datetime import typing +from genshin.constants import CN_TIMEZONE from genshin.models.model import Aliased, APIModel, Unique __all__ = ["ClaimedDailyReward", "DailyReward", "DailyRewardInfo"] @@ -16,8 +17,7 @@ class DailyRewardInfo(typing.NamedTuple): @property def missed_rewards(self) -> int: - cn_timezone = datetime.timezone(datetime.timedelta(hours=8)) - now = datetime.datetime.now(cn_timezone) + now = datetime.datetime.now(CN_TIMEZONE) return now.day - self.claimed_rewards diff --git a/genshin/models/starrail/chronicle/challenge.py b/genshin/models/starrail/chronicle/challenge.py index cf6a529e..cf4304b7 100644 --- a/genshin/models/starrail/chronicle/challenge.py +++ b/genshin/models/starrail/chronicle/challenge.py @@ -1,6 +1,6 @@ """Starrail chronicle challenge.""" -from typing import TYPE_CHECKING, List, Optional +from typing import TYPE_CHECKING, Any, Dict, List, Optional if TYPE_CHECKING: import pydantic.v1 as pydantic @@ -16,39 +16,61 @@ from .base import PartialTime __all__ = [ - "FictionBuff", + "APCShadowBoss", + "APCShadowFloor", + "APCShadowFloorNode", + "APCShadowSeason", + "ChallengeBuff", "FictionFloor", "FictionFloorNode", "FloorNode", + "StarRailAPCShadow", "StarRailChallenge", + "StarRailChallengeSeason", "StarRailFloor", "StarRailPureFiction", ] class FloorNode(APIModel): - """Node for a floor.""" + """Node for a memory of chaos floor.""" challenge_time: PartialTime avatars: List[FloorCharacter] -class StarRailFloor(APIModel): - """Floor in a challenge.""" +class StarRailChallengeFloor(APIModel): + """Base model for star rail challenge floors.""" id: int = Aliased("maze_id") name: str - round_num: int star_num: int + is_quick_clear: bool = Aliased("is_fast") + + +class StarRailFloor(StarRailChallengeFloor): + """Floor in a memory of chaos challenge.""" + + round_num: int + is_chaos: bool node_1: FloorNode node_2: FloorNode - is_chaos: bool - is_fast: bool + + +class StarRailChallengeSeason(APIModel): + """A season of a challenge.""" + + id: int = Aliased("schedule_id") + name: str = Aliased("name_mi18n") + status: str + begin_time: PartialTime + end_time: PartialTime class StarRailChallenge(APIModel): - """Challenge in a season.""" + """Memory of chaos challenge in a season.""" + name: str season: int = Aliased("schedule_id") begin_time: PartialTime end_time: PartialTime @@ -59,10 +81,20 @@ class StarRailChallenge(APIModel): has_data: bool floors: List[StarRailFloor] = Aliased("all_floor_detail") + seasons: List[StarRailChallengeSeason] = Aliased("groups") + @pydantic.root_validator(pre=True) + def __extract_name(cls, values: Dict[str, Any]) -> Dict[str, Any]: + if "seasons" in values and isinstance(values["seasons"], List): + seasons: List[Dict[str, Any]] = values["seasons"] + if len(seasons) > 0: + values["name"] = seasons[0]["name_mi18n"] -class FictionBuff(APIModel): - """Buff for a Pure Fiction floor.""" + return values + + +class ChallengeBuff(APIModel): + """Buff used in a pure fiction or apocalyptic shadow node.""" id: int name: str = Aliased("name_mi18n") @@ -73,20 +105,16 @@ class FictionBuff(APIModel): class FictionFloorNode(FloorNode): """Node for a Pure Fiction floor.""" - buff: Optional[FictionBuff] + buff: Optional[ChallengeBuff] score: int -class FictionFloor(APIModel): +class FictionFloor(StarRailChallengeFloor): """Floor in a Pure Fiction challenge.""" - id: int = Aliased("maze_id") - name: str round_num: int - star_num: int node_1: FictionFloorNode node_2: FictionFloorNode - is_fast: bool @property def score(self) -> int: @@ -97,10 +125,10 @@ def score(self) -> int: class StarRailPureFiction(APIModel): """Pure Fiction challenge in a season.""" - season_id: int = Aliased("schedule_id") - begin_time: PartialTime - end_time: PartialTime - name: str = Aliased("name_mi18n") + name: str = pydantic.Field(deprecated="Use `season_id` together with `seasons instead`.") + season_id: int = pydantic.Field(deprecated="Use `season_id` together with `seasons instead`.") + begin_time: PartialTime = pydantic.Field(deprecated="Use `season_id` together with `seasons instead`.") + end_time: PartialTime = pydantic.Field(deprecated="Use `season_id` together with `seasons instead`.") total_stars: int = Aliased("star_num") max_floor: str @@ -108,4 +136,72 @@ class StarRailPureFiction(APIModel): has_data: bool floors: List[FictionFloor] = Aliased("all_floor_detail") + seasons: List[StarRailChallengeSeason] = Aliased("groups") + max_floor_id: int + + @pydantic.root_validator(pre=True) + def __unnest_groups(cls, values: Dict[str, Any]) -> Dict[str, Any]: + if "seasons" in values and isinstance(values["seasons"], List): + seasons: List[Dict[str, Any]] = values["seasons"] + if len(seasons) > 0: + values["name"] = seasons[0]["name_mi18n"] + values["season_id"] = seasons[0]["schedule_id"] + values["begin_time"] = seasons[0]["begin_time"] + values["end_time"] = seasons[0]["end_time"] + + return values + + +class APCShadowFloorNode(FloorNode): + """Node for a apocalyptic shadow floor.""" + + challenge_time: Optional[PartialTime] + buff: Optional[ChallengeBuff] + score: int + boss_defeated: bool + + @property + def has_data(self) -> bool: + """Check if the node has data.""" + return bool(self.avatars) + + +class APCShadowFloor(StarRailChallengeFloor): + """Floor in an apocalyptic shadow challenge.""" + + node_1: APCShadowFloorNode + node_2: APCShadowFloorNode + last_update_time: PartialTime + + @property + def score(self) -> int: + """Total score of the floor.""" + return self.node_1.score + self.node_2.score + + +class APCShadowBoss(APIModel): + """Boss in an apocalyptic shadow challenge.""" + + id: int + name_mi18n: str + icon: str + + +class APCShadowSeason(StarRailChallengeSeason): + """Season of an apocalyptic shadow challenge.""" + + upper_boss: APCShadowBoss + lower_boss: APCShadowBoss + + +class StarRailAPCShadow(APIModel): + """Apocalyptic shadow challenge in a season.""" + + total_stars: int = Aliased("star_num") + max_floor: str + total_battles: int = Aliased("battle_num") + has_data: bool + + floors: List[APCShadowFloor] = Aliased("all_floor_detail") + seasons: List[APCShadowSeason] = Aliased("groups") max_floor_id: int diff --git a/genshin/models/starrail/chronicle/notes.py b/genshin/models/starrail/chronicle/notes.py index ca09e147..08ef7dfd 100644 --- a/genshin/models/starrail/chronicle/notes.py +++ b/genshin/models/starrail/chronicle/notes.py @@ -33,7 +33,7 @@ class StarRailNote(APIModel): current_stamina: int max_stamina: int stamina_recover_time: datetime.timedelta - accepted_epedition_num: int + accepted_expedition_num: int = Aliased("accepted_epedition_num") total_expedition_num: int expeditions: typing.Sequence[StarRailExpedition] @@ -47,6 +47,13 @@ class StarRailNote(APIModel): max_rogue_score: int """Max simulated universe weekly points""" + have_bonus_synchronicity_points: bool = Aliased("rogue_tourn_weekly_unlocked") + """Whether the Divergent Universe is unlocked""" + max_bonus_synchronicity_points: int = Aliased("rogue_tourn_weekly_max") + """The max number of this week's Bonus Synchronicity Points""" + current_bonus_synchronicity_points: int = Aliased("rogue_tourn_weekly_cur") + """The current number of this week's Bonus Synchronicity Points""" + remaining_weekly_discounts: int = Aliased("weekly_cocoon_cnt") """Remaining echo of war rewards""" max_weekly_discounts: int = Aliased("weekly_cocoon_limit") diff --git a/genshin/utility/auth.py b/genshin/utility/auth.py index 618a8292..cbd6c78d 100644 --- a/genshin/utility/auth.py +++ b/genshin/utility/auth.py @@ -105,7 +105,7 @@ "x-rpc-channel_id": "1", "x-rpc-game_biz": "hkrpg_global", "x-rpc-device_id": DEVICE_ID, - "x-rpc-language": "ru", + "x-rpc-language": "en", } GAME_LOGIN_HEADERS = { diff --git a/requirements.txt b/requirements.txt index fa1c601e..7dae057f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,4 +9,5 @@ browser-cookie3 rsa aioredis click -qrcode[pil] \ No newline at end of file +qrcode[pil] +aiohttp-socks diff --git a/setup.py b/setup.py index bb51b5b6..a6864b06 100644 --- a/setup.py +++ b/setup.py @@ -18,10 +18,11 @@ python_requires=">=3.8", install_requires=["aiohttp", "pydantic"], extras_require={ - "all": ["browser-cookie3", "rsa", "click", "qrcode[pil]"], + "all": ["browser-cookie3", "rsa", "click", "qrcode[pil]", "aiohttp-socks"], "cookies": ["browser-cookie3"], "auth": ["rsa", "qrcode[pil]"], "cli": ["click"], + "socks-proxy": ["aiohttp-socks"], }, include_package_data=True, package_data={"genshin": ["py.typed"]}, diff --git a/tests/client/components/test_daily.py b/tests/client/components/test_daily.py index 9160a4ab..c4fab65b 100644 --- a/tests/client/components/test_daily.py +++ b/tests/client/components/test_daily.py @@ -5,8 +5,6 @@ import genshin -CN_TIMEZONE = datetime.timezone(datetime.timedelta(hours=8)) - async def test_daily_reward(lclient: genshin.Client): signed_in, claimed_rewards = await lclient.get_reward_info() @@ -54,7 +52,7 @@ async def test_starrail_daily_reward(lclient: genshin.Client): async def test_monthly_rewards(lclient: genshin.Client): rewards = await lclient.get_monthly_rewards() - now = datetime.datetime.now(CN_TIMEZONE) + now = datetime.datetime.now(genshin.constants.CN_TIMEZONE) assert len(rewards) == calendar.monthrange(now.year, now.month)[1] diff --git a/tests/client/components/test_diary.py b/tests/client/components/test_diary.py index 25497e13..9ee37815 100644 --- a/tests/client/components/test_diary.py +++ b/tests/client/components/test_diary.py @@ -2,8 +2,6 @@ import genshin -CN_TIMEZONE = datetime.timezone(datetime.timedelta(hours=8)) - async def test_diary(lclient: genshin.Client, genshin_uid: int): diary = await lclient.get_diary()