From 13ab1edb490f0cb3faec12ba57f425f09eb11675 Mon Sep 17 00:00:00 2001 From: seria Date: Tue, 10 Sep 2024 21:40:59 +0800 Subject: [PATCH] Merge branch dev into master (#221) * Implement detailed Genshin character endpoint (#219) * Implement detailed Genshin character endpoint. * Run nox and fix formatting issues. * Fix type checker issues * Replace root_validator with validator * Improve __fill_prop_info * Rename some fields and models * Improve artifact set effect impl --------- Co-authored-by: seriaati * Fix error on get_genshin_user * Add detailed character test * Add ZZZPropertyType.ANOMALY_MASTERY * Remove the usage of galias * Remove custom validation dunder methods * Remove the use of lang in APIModel * Fix validation error on FullGenshinUserStats * Fix validation error on CalculatorConsumable * Fix TypeError on generic models * Fix KeyError on GenshinDetailCharacters * Fix ValidationError on FullHonkaiUserStats * Fix ValidationError on LineupPreview * Fix ValidationError on PartialLineupCharacter * Revert PartialLineupCharacter changes * Fix ValidationError on PartialLineupCharacter * Make stored_attendance_refresh_countdown optional * Allow returning raw data * Fix returning wrong icons * Fix _create_icon method * Fix problem with aliased fields in root validators * Im dumb * Fix validation error on DailyReward * Fix error in getting db char * Add TheaterDifficulty.IDK * Fix character icons with img theater * Remove partial/unknown character test * Add pyroculi * Remove messed up character test * Add missing events in HSR announcements * Add img field to Announcement * Rename TheaterDifficulty.IDK to TheaterDifficulty.VISIONARY * Fix qrcode login * Change otp code mmt model to v4 * Fix using tuple class to type hint * Add support for SQLiteCache * Add clear_cache method to SQLiteCache * Allow not passing in connection for SQLiteCache * Fix HSR gacha log route * Make wish record time timezone aware * Export StarRailBannerType * Fix tz offset causing KeyError in Genshin * Add Natlan tribe reputations * Fix field not being Aliased * Add ImgTheater battle stats * Fix HSR code redeem not working * Fix ValidationError in TheaterBattleStats * Disable coverage workflows temporarily --------- Co-authored-by: Furia <83609040+FuriaPaladins@users.noreply.github.com> --- .github/workflows/checks.yml | 76 +++---- .gitignore | 4 + genshin/client/cache.py | 123 ++++++++++- genshin/client/components/auth/client.py | 29 +-- .../client/components/auth/subclients/app.py | 35 +--- .../client/components/auth/subclients/web.py | 6 +- genshin/client/components/base.py | 2 +- .../client/components/chronicle/genshin.py | 42 +++- genshin/client/components/chronicle/honkai.py | 10 +- genshin/client/components/gacha.py | 28 ++- genshin/client/components/hoyolab.py | 6 +- genshin/client/components/transaction.py | 2 +- genshin/client/components/wiki.py | 2 +- genshin/client/routes.py | 10 +- genshin/models/auth/cookie.py | 11 +- genshin/models/auth/qrcode.py | 36 +--- genshin/models/genshin/calculator.py | 2 +- genshin/models/genshin/character.py | 38 +++- genshin/models/genshin/chronicle/abyss.py | 4 +- .../models/genshin/chronicle/activities.py | 2 +- .../models/genshin/chronicle/characters.py | 195 +++++++++++++++++- .../models/genshin/chronicle/img_theater.py | 32 ++- genshin/models/genshin/chronicle/notes.py | 4 +- genshin/models/genshin/chronicle/stats.py | 24 ++- genshin/models/genshin/daily.py | 2 +- genshin/models/genshin/gacha.py | 38 ++-- genshin/models/genshin/lineup.py | 1 - genshin/models/honkai/chronicle/modes.py | 24 +-- genshin/models/honkai/chronicle/stats.py | 32 +-- genshin/models/hoyolab/announcements.py | 2 + genshin/models/model.py | 113 +--------- .../models/starrail/chronicle/challenge.py | 8 +- genshin/models/zzz/character.py | 1 + genshin/utility/auth.py | 8 + requirements.txt | 1 + .../components/test_genshin_chronicle.py | 6 + tests/models/test_model.py | 41 +--- 37 files changed, 622 insertions(+), 378 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 037f1b9c..d0fe626b 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -49,44 +49,44 @@ jobs: python -m nox -s test --verbose -- --cov-append mv .coverage .coverage.${{ matrix.python-version }} - - name: Upload coverage - uses: actions/upload-artifact@v3 - with: - name: coverage - path: .coverage.${{ matrix.python-version }} - retention-days: 1 - if-no-files-found: error - - upload-coverage: - needs: [test] - runs-on: ubuntu-latest - - steps: - - name: Checkout repo - uses: actions/checkout@v4 - - - name: Setup python 3.10 - uses: actions/setup-python@v5 - with: - python-version: "3.10" - - - name: Download coverage - uses: actions/download-artifact@v3 - with: - name: coverage - - - name: Combine coverage - run: | - pip install -r ./genshin-dev/pytest-requirements.txt - coverage combine - coverage xml -i - - - name: Upload coverage to codeclimate - uses: paambaati/codeclimate-action@v3.0.0 - env: - CC_TEST_REPORTER_ID: cd8c7d84ae5f98d86882d666dce0946fe5aae1e63f442995bd9c6e17869e6513 - with: - coverageLocations: .coverage.xml:coverage.py + # - name: Upload coverage + # uses: actions/upload-artifact@v3 + # with: + # name: coverage + # path: .coverage.${{ matrix.python-version }} + # retention-days: 1 + # if-no-files-found: error + + # upload-coverage: + # needs: [test] + # runs-on: ubuntu-latest + + # steps: + # - name: Checkout repo + # uses: actions/checkout@v4 + + # - name: Setup python 3.10 + # uses: actions/setup-python@v5 + # with: + # python-version: "3.10" + + # - name: Download coverage + # uses: actions/download-artifact@v3 + # with: + # name: coverage + + # - name: Combine coverage + # run: | + # pip install -r ./genshin-dev/pytest-requirements.txt + # coverage combine + # coverage xml -i + + # - name: Upload coverage to codeclimate + # uses: paambaati/codeclimate-action@v3.0.0 + # env: + # CC_TEST_REPORTER_ID: cd8c7d84ae5f98d86882d666dce0946fe5aae1e63f442995bd9c6e17869e6513 + # with: + # coverageLocations: .coverage.xml:coverage.py type-check: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index de7df898..7f790319 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,7 @@ docs/pdoc .mypy_cache/ .dmypy.json dmypy.json + +# databases +*.sqlite3 +*.db \ No newline at end of file diff --git a/genshin/client/cache.py b/genshin/client/cache.py index 1138c1d8..2468d83c 100644 --- a/genshin/client/cache.py +++ b/genshin/client/cache.py @@ -12,8 +12,10 @@ if typing.TYPE_CHECKING: import aioredis + import aiosqlite -__all__ = ["BaseCache", "Cache", "RedisCache", "StaticCache"] + +__all__ = ["BaseCache", "Cache", "RedisCache", "StaticCache", "SQLiteCache"] MINUTE = 60 HOUR = MINUTE * 60 @@ -200,3 +202,122 @@ async def set_static(self, key: typing.Any, value: typing.Any) -> None: self.serialize_value(value), ex=self.static_ttl, ) + + +class SQLiteCache(BaseCache): + """SQLite implementation of the cache.""" + + conn: aiosqlite.Connection | None + ttl: int + static_ttl: int + + def __init__( + self, + conn: aiosqlite.Connection | None = None, + *, + ttl: int = HOUR, + static_ttl: int = DAY, + db_name: str = "genshin_py.db", + ) -> None: + self.conn = conn + self.ttl = ttl + self.static_ttl = static_ttl + self.db_name = db_name + + async def _clear_cache(self, conn: aiosqlite.Connection) -> None: + """Clear timed-out items.""" + now = time.time() + + await conn.execute("DELETE FROM cache WHERE expiration < ?", (now,)) + await conn.commit() + + async def initialize(self) -> None: + """Initialize the cache.""" + import aiosqlite + + if self.conn is None: + conn = await aiosqlite.connect(self.db_name) + else: + conn = self.conn + + await conn.execute("CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value TEXT, expiration INTEGER)") + await conn.commit() + + if self.conn is None: + await conn.close() + + def serialize_key(self, key: typing.Any) -> str: + """Serialize a key by turning it into a string.""" + return str(key) + + def serialize_value(self, value: typing.Any) -> str: + """Serialize a value by turning it into a string.""" + return json.dumps(value) + + def deserialize_value(self, value: str) -> typing.Any: + """Deserialize a value back into data.""" + return json.loads(value) + + async def get(self, key: typing.Any) -> typing.Optional[typing.Any]: + """Get an object with a key.""" + import aiosqlite + + if self.conn is None: + conn = await aiosqlite.connect(self.db_name) + else: + conn = self.conn + + async with conn.execute( + "SELECT value FROM cache WHERE key = ? AND expiration > ?", (self.serialize_key(key), int(time.time())) + ) as cursor: + value = await cursor.fetchone() + + if self.conn is None: + await conn.close() + + if value is None: + return None + + return self.deserialize_value(value[0]) + + async def set(self, key: typing.Any, value: typing.Any) -> None: + """Save an object with a key.""" + import aiosqlite + + if self.conn is None: + conn = await aiosqlite.connect(self.db_name) + else: + conn = self.conn + + await conn.execute( + "INSERT OR REPLACE INTO cache (key, value, expiration) VALUES (?, ?, ?)", + (self.serialize_key(key), self.serialize_value(value), int(time.time() + self.ttl)), + ) + await conn.commit() + await self._clear_cache(conn) + + if self.conn is None: + await conn.close() + + async def get_static(self, key: typing.Any) -> typing.Optional[typing.Any]: + """Get a static object with a key.""" + return await self.get(key) + + async def set_static(self, key: typing.Any, value: typing.Any) -> None: + """Save a static object with a key.""" + import aiosqlite + + if self.conn is None: + conn = await aiosqlite.connect(self.db_name) + else: + conn = self.conn + + await conn.execute( + "INSERT OR REPLACE INTO cache (key, value, expiration) VALUES (?, ?, ?)", + (self.serialize_key(key), self.serialize_value(value), int(time.time() + self.static_ttl)), + ) + await conn.commit() + await self._clear_cache(conn) + + if self.conn is None: + await conn.close() diff --git a/genshin/client/components/auth/client.py b/genshin/client/components/auth/client.py index 829f1931..3f480338 100644 --- a/genshin/client/components/auth/client.py +++ b/genshin/client/components/auth/client.py @@ -15,7 +15,6 @@ from genshin.client import routes from genshin.client.components import base from genshin.client.manager import managers -from genshin.client.manager.cookie import fetch_cookie_token_with_game_token, fetch_stoken_with_game_token from genshin.models.auth.cookie import ( AppLoginResult, CNWebLoginResult, @@ -247,35 +246,19 @@ async def login_with_qrcode(self) -> QRLoginResult: scanned = False while True: - check_result = await self._check_qrcode( - creation_result.app_id, creation_result.device_id, creation_result.ticket - ) - if check_result.status == QRCodeStatus.SCANNED and not scanned: + status, cookies = await self._check_qrcode(creation_result.ticket) + if status is QRCodeStatus.SCANNED and not scanned: LOGGER_.info("QR code scanned") scanned = True - elif check_result.status == QRCodeStatus.CONFIRMED: + elif status is QRCodeStatus.CONFIRMED: LOGGER_.info("QR code login confirmed") break - await asyncio.sleep(2) - - raw_data = check_result.payload.raw - assert raw_data is not None + await asyncio.sleep(1) - cookie_token = await fetch_cookie_token_with_game_token( - game_token=raw_data.game_token, account_id=raw_data.account_id - ) - stoken = await fetch_stoken_with_game_token(game_token=raw_data.game_token, account_id=int(raw_data.account_id)) - - cookies = { - "stoken_v2": stoken.token, - "ltuid": stoken.aid, - "account_id": stoken.aid, - "ltmid": stoken.mid, - "cookie_token": cookie_token, - } self.set_cookies(cookies) - return QRLoginResult(**cookies) + dict_cookies = {key: morsel.value for key, morsel in cookies.items()} + return QRLoginResult(**dict_cookies) @managers.no_multi async def create_mmt(self) -> MMT: diff --git a/genshin/client/components/auth/subclients/app.py b/genshin/client/components/auth/subclients/app.py index 61662c18..0af38edb 100644 --- a/genshin/client/components/auth/subclients/app.py +++ b/genshin/client/components/auth/subclients/app.py @@ -4,9 +4,8 @@ """ import json -import random import typing -from string import ascii_letters, digits +from http.cookies import SimpleCookie import aiohttp @@ -15,7 +14,7 @@ from genshin.client.components import base from genshin.models.auth.cookie import AppLoginResult from genshin.models.auth.geetest import SessionMMT, SessionMMTResult -from genshin.models.auth.qrcode import QRCodeCheckResult, QRCodeCreationResult +from genshin.models.auth.qrcode import QRCodeCreationResult, QRCodeStatus from genshin.models.auth.verification import ActionTicket from genshin.utility import auth as auth_utility from genshin.utility import ds as ds_utility @@ -180,46 +179,34 @@ async def _verify_email(self, code: str, ticket: ActionTicket) -> None: async def _create_qrcode(self) -> QRCodeCreationResult: """Create a QR code for login.""" - if self.default_game is None: - raise RuntimeError("No default game set.") - - app_id = constants.APP_IDS[self.default_game][self.region] - device_id = "".join(random.choices(ascii_letters + digits, k=64)) - async with aiohttp.ClientSession() as session: async with session.post( routes.CREATE_QRCODE_URL.get_url(), - json={"app_id": app_id, "device": device_id}, + headers=auth_utility.QRCODE_HEADERS, ) as r: data = await r.json() if not data["data"]: errors.raise_for_retcode(data) - url: str = data["data"]["url"] return QRCodeCreationResult( - app_id=app_id, - ticket=url.split("ticket=")[1], - device_id=device_id, - url=url, + ticket=data["data"]["ticket"], + url=data["data"]["url"], ) - async def _check_qrcode(self, app_id: str, device_id: str, ticket: str) -> QRCodeCheckResult: + async def _check_qrcode(self, ticket: str) -> typing.Tuple[QRCodeStatus, SimpleCookie]: """Check the status of a QR code login.""" - payload = { - "app_id": app_id, - "device": device_id, - "ticket": ticket, - } + payload = {"ticket": ticket} async with aiohttp.ClientSession() as session: async with session.post( routes.CHECK_QRCODE_URL.get_url(), json=payload, + headers=auth_utility.QRCODE_HEADERS, ) as r: data = await r.json() - if not data["data"]: - errors.raise_for_retcode(data) + if not data["data"]: + errors.raise_for_retcode(data) - return QRCodeCheckResult(**data["data"]) + return QRCodeStatus(data["data"]["status"]), r.cookies diff --git a/genshin/client/components/auth/subclients/web.py b/genshin/client/components/auth/subclients/web.py index caec8ccd..3a62d3ac 100644 --- a/genshin/client/components/auth/subclients/web.py +++ b/genshin/client/components/auth/subclients/web.py @@ -12,7 +12,7 @@ from genshin.client import routes from genshin.client.components import base from genshin.models.auth.cookie import CNWebLoginResult, MobileLoginResult, WebLoginResult -from genshin.models.auth.geetest import SessionMMT, SessionMMTResult +from genshin.models.auth.geetest import SessionMMT, SessionMMTResult, SessionMMTv4 from genshin.utility import auth as auth_utility from genshin.utility import ds as ds_utility @@ -164,7 +164,7 @@ async def _send_mobile_otp( *, encrypted: bool = False, mmt_result: typing.Optional[SessionMMTResult] = None, - ) -> typing.Union[None, SessionMMT]: + ) -> typing.Union[None, SessionMMTv4]: """Attempt to send OTP to the provided mobile number. May return aigis headers if captcha is triggered, None otherwise. @@ -192,7 +192,7 @@ async def _send_mobile_otp( if data["retcode"] == -3101: # Captcha triggered aigis = json.loads(r.headers["x-rpc-aigis"]) - return SessionMMT(**aigis) + return SessionMMTv4(**aigis) if not data["data"]: errors.raise_for_retcode(data) diff --git a/genshin/client/components/base.py b/genshin/client/components/base.py index c45beead..71902ab5 100644 --- a/genshin/client/components/base.py +++ b/genshin/client/components/base.py @@ -84,7 +84,7 @@ def __init__( 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, + cache: typing.Optional[client_cache.BaseCache] = None, debug: bool = False, ) -> None: self.cookie_manager = managers.BaseCookieManager.from_cookies(cookies) diff --git a/genshin/client/components/chronicle/genshin.py b/genshin/client/components/chronicle/genshin.py index d89d60eb..514052fb 100644 --- a/genshin/client/components/chronicle/genshin.py +++ b/genshin/client/components/chronicle/genshin.py @@ -79,6 +79,46 @@ async def get_genshin_characters( data = await self._request_genshin_record("character", uid, lang=lang, method="POST") return [models.Character(**i) for i in data["avatars"]] + @typing.overload + async def get_genshin_detailed_characters( + self, + uid: int, + *, + characters: typing.Optional[typing.Sequence[int]] = ..., + lang: typing.Optional[str] = ..., + return_raw_data: typing.Literal[False] = ..., + ) -> models.GenshinDetailCharacters: ... + @typing.overload + async def get_genshin_detailed_characters( + self, + uid: int, + *, + characters: typing.Optional[typing.Sequence[int]] = ..., + lang: typing.Optional[str] = ..., + return_raw_data: typing.Literal[True] = ..., + ) -> typing.Mapping[str, typing.Any]: ... + async def get_genshin_detailed_characters( + self, + uid: int, + *, + characters: typing.Optional[typing.Sequence[int]] = None, + lang: typing.Optional[str] = None, + return_raw_data: bool = False, + ) -> typing.Union[models.GenshinDetailCharacters, typing.Mapping[str, typing.Any]]: + """Return a list of genshin characters with full details.""" + if ( + characters is None + ): # If characters aren't provided, fetch the list of owned ID's first as they're required in the payload. + character_data = await self._request_genshin_record("character/list", uid, lang=lang, method="POST") + characters = [char["id"] for char in character_data["list"]] + + data = await self._request_genshin_record( + "character/detail", uid, lang=lang, method="POST", payload={"character_ids": (*characters,)} + ) + if return_raw_data: + return data + return models.GenshinDetailCharacters(**data) + async def get_genshin_user( self, uid: int, @@ -220,7 +260,7 @@ async def get_full_genshin_user( ) abyss = models.SpiralAbyssPair(current=abyss1, previous=abyss2) - return models.FullGenshinUserStats(**user.dict(), abyss=abyss, activities=activities) + return models.FullGenshinUserStats(**user.dict(by_alias=True), abyss=abyss, activities=activities) async def set_top_genshin_characters( self, diff --git a/genshin/client/components/chronicle/honkai.py b/genshin/client/components/chronicle/honkai.py index eaca480a..47c8c25d 100644 --- a/genshin/client/components/chronicle/honkai.py +++ b/genshin/client/components/chronicle/honkai.py @@ -52,7 +52,7 @@ async def get_honkai_user( ) -> models.HonkaiUserStats: """Get honkai user stats.""" data = await self._request_honkai_record("index", uid, lang=lang) - return models.HonkaiUserStats(**data, lang=lang or self.lang) + return models.HonkaiUserStats(**data) async def get_honkai_battlesuits( self, @@ -75,7 +75,7 @@ async def get_honkai_old_abyss( Only for level > 80. """ data = await self._request_honkai_record("latestOldAbyssReport", uid, lang=lang) - return [models.OldAbyss(**x, lang=lang or self.lang) for x in data["reports"]] + return [models.OldAbyss(**x) for x in data["reports"]] async def get_honkai_superstring_abyss( self, @@ -88,7 +88,7 @@ async def get_honkai_superstring_abyss( Only for level <= 80. """ data = await self._request_honkai_record("newAbyssReport", uid, lang=lang) - return [models.SuperstringAbyss(**x, lang=lang or self.lang) for x in data["reports"]] + return [models.SuperstringAbyss(**x) for x in data["reports"]] async def get_honkai_abyss( self, @@ -128,7 +128,7 @@ async def get_honkai_memorial_arena( ) -> typing.Sequence[models.MemorialArena]: """Get honkai memorial arena.""" data = await self._request_honkai_record("battleFieldReport", uid, lang=lang) - return [models.MemorialArena(**x, lang=lang or self.lang) for x in data["reports"]] + return [models.MemorialArena(**x) for x in data["reports"]] async def get_full_honkai_user( self, @@ -146,7 +146,7 @@ async def get_full_honkai_user( ) return models.FullHonkaiUserStats( - **user.dict(), + **user.dict(by_alias=True), battlesuits=battlesuits, abyss=abyss, memorial_arena=mr, diff --git a/genshin/client/components/gacha.py b/genshin/client/components/gacha.py index bd41b7bb..b45af6a4 100644 --- a/genshin/client/components/gacha.py +++ b/genshin/client/components/gacha.py @@ -57,7 +57,7 @@ async def _get_gacha_page( game: typing.Optional[types.Game] = None, lang: typing.Optional[str] = None, authkey: typing.Optional[str] = None, - ) -> typing.Sequence[typing.Any]: + ) -> typing.Tuple[typing.Sequence[typing.Any], int]: """Get a single page of wishes.""" data = await self.request_gacha_info( "getGachaLog", @@ -66,7 +66,18 @@ async def _get_gacha_page( authkey=authkey, params=dict(gacha_type=banner_type, real_gacha_type=banner_type, size=20, end_id=end_id), ) - return data["list"] + + if game is types.Game.GENSHIN: + # Genshin doesn't return timezone data + # America: UTC-5, Europe: UTC+1, others are UTC+8 + tz_offsets = {"os_usa": -13, "os_euro": -7} + tz_offset = tz_offsets.get(data["region"], 0) + else: + tz_offset = data["region_time_zone"] + if game is types.Game.STARRAIL: + tz_offset -= 8 # Star rail returns UTC+n for this value + + return data["list"], tz_offset async def _get_wish_page( self, @@ -77,15 +88,14 @@ async def _get_wish_page( authkey: typing.Optional[str] = None, ) -> typing.Sequence[models.Wish]: """Get a single page of wishes.""" - data = await self._get_gacha_page( + data, tz_offset = await self._get_gacha_page( end_id=end_id, banner_type=banner_type, lang=lang, authkey=authkey, game=types.Game.GENSHIN, ) - - return [models.Wish(**i, banner_type=banner_type) for i in data] + return [models.Wish(**i, banner_type=banner_type, tz_offset=tz_offset) for i in data] async def _get_warp_page( self, @@ -96,7 +106,7 @@ async def _get_warp_page( authkey: typing.Optional[str] = None, ) -> typing.Sequence[models.Warp]: """Get a single page of warps.""" - data = await self._get_gacha_page( + data, tz_offset = await self._get_gacha_page( end_id=end_id, banner_type=banner_type, lang=lang, @@ -104,7 +114,7 @@ async def _get_warp_page( game=types.Game.STARRAIL, ) - return [models.Warp(**i, banner_type=banner_type) for i in data] + return [models.Warp(**i, banner_type=banner_type, tz_offset=tz_offset) for i in data] async def _get_signal_page( self, @@ -115,7 +125,7 @@ async def _get_signal_page( authkey: typing.Optional[str] = None, ) -> typing.Sequence[models.SignalSearch]: """Get a single page of warps.""" - data = await self._get_gacha_page( + data, tz_offset = await self._get_gacha_page( end_id=end_id, banner_type=banner_type, lang=lang, @@ -123,7 +133,7 @@ async def _get_signal_page( game=types.Game.ZZZ, ) - return [models.SignalSearch(**i, banner_type=banner_type) for i in data] + return [models.SignalSearch(**i, banner_type=banner_type, tz_offset=tz_offset) for i in data] def wish_history( self, diff --git a/genshin/client/components/hoyolab.py b/genshin/client/components/hoyolab.py index e026aefb..9fd7623e 100644 --- a/genshin/client/components/hoyolab.py +++ b/genshin/client/components/hoyolab.py @@ -95,7 +95,10 @@ async def _request_announcements( ) announcements: typing.List[typing.Mapping[str, typing.Any]] = [] - for sublist in info["list"]: + extra_list: typing.List[typing.Mapping[str, typing.Any]] = ( + info["pic_list"][0]["type_list"] if "pic_list" in info and info["pic_list"] else [] + ) + for sublist in info["list"] + extra_list: for info in sublist["list"]: detail = next((i for i in details["list"] if i["ann_id"] == info["ann_id"]), None) announcements.append({**info, **(detail or {})}) @@ -224,6 +227,7 @@ async def redeem_code( game_biz=utility.get_prod_game_biz(self.region, game), lang=utility.create_short_lang_code(lang or self.lang), ), + method="POST" if game is types.Game.STARRAIL else "GET" ) @managers.no_multi diff --git a/genshin/client/components/transaction.py b/genshin/client/components/transaction.py index d9e37af7..d74099fe 100644 --- a/genshin/client/components/transaction.py +++ b/genshin/client/components/transaction.py @@ -65,7 +65,7 @@ async def _get_transaction_page( for trans in data["list"]: model = models.ItemTransaction if "name" in trans else models.Transaction model = typing.cast("typing.Type[models.BaseTransaction]", model) - transactions.append(model(**trans, kind=kind, lang=lang or self.lang)) + transactions.append(model(**trans, kind=kind)) return transactions diff --git a/genshin/client/components/wiki.py b/genshin/client/components/wiki.py index 5320fa46..71853612 100644 --- a/genshin/client/components/wiki.py +++ b/genshin/client/components/wiki.py @@ -96,7 +96,7 @@ async def get_wiki_page( data = await self.request_wiki("entry_page", lang=lang, params=params, static_cache=cache_key) data["page"].pop("lang", "") # always an empty string - return models.WikiPage(**data["page"], lang=lang or self.lang) + return models.WikiPage(**data["page"]) async def get_wiki_pages( self, diff --git a/genshin/client/routes.py b/genshin/client/routes.py index d3f7ace3..0e4096a2 100644 --- a/genshin/client/routes.py +++ b/genshin/client/routes.py @@ -220,7 +220,7 @@ def get_url(self, region: types.Region, game: types.Game) -> yarl.URL: CODE_URL = GameRoute( overseas=dict( genshin="https://sg-hk4e-api.hoyoverse.com/common/apicdkey/api/webExchangeCdkey", - hkrpg="https://sg-hkrpg-api.hoyoverse.com/common/apicdkey/api/webExchangeCdkey", + hkrpg="https://sg-hkrpg-api.hoyoverse.com/common/apicdkey/api/webExchangeCdkeyRisk", nap="https://public-operation-nap.hoyoverse.com/common/apicdkey/api/webExchangeCdkey", tot="https://sg-public-api.hoyoverse.com/common/apicdkey/api/webExchangeCdkey", ), @@ -230,12 +230,12 @@ def get_url(self, region: types.Region, game: types.Game) -> yarl.URL: GACHA_URL = GameRoute( overseas=dict( genshin="https://public-operation-hk4e-sg.hoyoverse.com/gacha_info/api/", - hkrpg="https://api-os-takumi.mihoyo.com/common/gacha_record/api/", + hkrpg="https://public-operation-hkrpg-sg.hoyoverse.com/common/gacha_record/api/", nap="https://public-operation-nap-sg.hoyoverse.com/common/gacha_record/api/", ), chinese=dict( genshin="https://public-operation-hk4e.mihoyo.com/gacha_info/api/", - hkrpg="https://api-takumi.mihoyo.com/common/gacha_record/api/", + hkrpg="https://public-operation-hkrpg.mihoyo.com/common/gacha_record/api/", nap="https://public-operation-nap.mihoyo.com/common/gacha_record/api/", ), ) @@ -269,8 +269,8 @@ def get_url(self, region: types.Region, game: types.Game) -> yarl.URL: MOBILE_OTP_URL = Route("https://passport-api.miyoushe.com/account/ma-cn-verifier/verifier/createLoginCaptcha") MOBILE_LOGIN_URL = Route("https://passport-api.miyoushe.com/account/ma-cn-passport/web/loginByMobileCaptcha") -CREATE_QRCODE_URL = Route("https://hk4e-sdk.mihoyo.com/hk4e_cn/combo/panda/qrcode/fetch") -CHECK_QRCODE_URL = Route("https://hk4e-sdk.mihoyo.com/hk4e_cn/combo/panda/qrcode/query") +CREATE_QRCODE_URL = Route("https://passport-api.miyoushe.com/account/ma-cn-passport/web/createQRLogin") +CHECK_QRCODE_URL = Route("https://passport-api.miyoushe.com/account/ma-cn-passport/web/queryQRLoginStatus") CREATE_MMT_URL = InternationalRoute( overseas="https://sg-public-api.hoyolab.com/event/toolcomsrv/risk/createGeetest?is_high=true", diff --git a/genshin/models/auth/cookie.py b/genshin/models/auth/cookie.py index 05381e58..229538dc 100644 --- a/genshin/models/auth/cookie.py +++ b/genshin/models/auth/cookie.py @@ -58,11 +58,12 @@ class QRLoginResult(CookieLoginResult): Returned by `client.login_with_qrcode`. """ - stoken_v2: str - account_id: str - ltuid: str - ltmid: str - cookie_token: str + cookie_token_v2: str + account_mid_v2: str + account_id_v2: str + ltoken_v2: str + ltmid_v2: str + ltuid_v2: str class AppLoginResult(CookieLoginResult): diff --git a/genshin/models/auth/qrcode.py b/genshin/models/auth/qrcode.py index 63d45d0e..d04c4801 100644 --- a/genshin/models/auth/qrcode.py +++ b/genshin/models/auth/qrcode.py @@ -1,7 +1,6 @@ """Miyoushe QR Code Models""" import enum -import json import typing if typing.TYPE_CHECKING: @@ -12,50 +11,19 @@ except ImportError: import pydantic -__all__ = ["QRCodeCheckResult", "QRCodeCreationResult", "QRCodePayload", "QRCodeRawData", "QRCodeStatus"] +__all__ = ["QRCodeCreationResult", "QRCodeStatus"] class QRCodeStatus(enum.Enum): """QR code check status.""" - INIT = "Init" + CREATED = "Created" SCANNED = "Scanned" CONFIRMED = "Confirmed" -class QRCodeRawData(pydantic.BaseModel): - """QR code raw data.""" - - account_id: str = pydantic.Field(alias="uid") - """Miyoushe account id.""" - game_token: str = pydantic.Field(alias="token") - - -class QRCodePayload(pydantic.BaseModel): - """QR code check result payload.""" - - proto: str - ext: str - raw: typing.Optional[QRCodeRawData] = None - - @pydantic.validator("raw", pre=True) - def _convert_raw_data(cls, value: typing.Optional[str] = None) -> typing.Union[QRCodeRawData, None]: - if value: - return QRCodeRawData(**json.loads(value)) - return None - - -class QRCodeCheckResult(pydantic.BaseModel): - """QR code check result.""" - - status: QRCodeStatus = pydantic.Field(alias="stat") - payload: QRCodePayload - - class QRCodeCreationResult(pydantic.BaseModel): """QR code creation result.""" - app_id: str ticket: str - device_id: str url: str diff --git a/genshin/models/genshin/calculator.py b/genshin/models/genshin/calculator.py index 4e871f13..d837da39 100644 --- a/genshin/models/genshin/calculator.py +++ b/genshin/models/genshin/calculator.py @@ -214,7 +214,7 @@ class CalculatorConsumable(APIModel, Unique): id: int name: str icon: str - amount: int = Aliased("num") + amount: int = Aliased("num", default=1) class CalculatorArtifactResult(APIModel): diff --git a/genshin/models/genshin/character.py b/genshin/models/genshin/character.py index 4720d009..3cbf2eaa 100644 --- a/genshin/models/genshin/character.py +++ b/genshin/models/genshin/character.py @@ -41,14 +41,32 @@ def _parse_icon(icon: typing.Union[str, int]) -> str: return icon +def _get_icon_name_from_id(character_id: int) -> str: + if "en-us" not in constants.CHARACTER_NAMES: + raise ValueError( + "Character names not loaded for en-us. Please run `await genshin.utility.update_characters_any()`." + ) + try: + return constants.CHARACTER_NAMES["en-us"][character_id].icon_name + except KeyError as e: + raise ValueError( + f"Can't find character with id {character_id} in character names. Run `await genshin.utility.update_characters_any()`" + ) from e + + def _create_icon(icon: str, specifier: str) -> str: - if "http" in icon and "genshin" not in icon: + if "http" in icon and "genshin" not in icon and "enka" not in icon: return icon # no point in trying to parse invalid urls icon_name = _parse_icon(icon) return ICON_BASE + f"{specifier.format(icon_name)}.png" +def _create_icon_from_id(character_id: int, specifier: str) -> str: + icon_name = _get_icon_name_from_id(character_id) + return ICON_BASE + f"{specifier.format(icon_name)}.png" + + def _get_db_char( id: typing.Optional[int] = None, name: typing.Optional[str] = None, @@ -121,9 +139,19 @@ class BaseCharacter(APIModel, Unique): @pydantic.root_validator(pre=True) def __autocomplete(cls, values: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: """Complete missing data.""" - id, name, icon, element, rarity = (values.get(x) for x in ("id", "name", "icon", "element", "rarity")) + all_fields = list(cls.__fields__.keys()) + all_aliases = {f: cls.__fields__[f].alias for f in all_fields if cls.__fields__[f].alias} + # If the field is aliased, it may have a different key name in 'values', + # so we need to get the correct key name from the alias + + id, name, icon, element, rarity = ( + values.get(all_aliases.get(x, x)) for x in ("id", "name", "icon", "element", "rarity") + ) + if id is None: + # Sometimes the model doesn't have id field, but the data may be present. + id = values.get("avatar_id") - char = _get_db_char(id, name, icon, element, rarity, lang=values["lang"]) + char = _get_db_char(id, name, icon, element, rarity, lang="en-us") icon = _create_icon(char.icon_name, "UI_AvatarIcon_{}") values["id"] = char.id @@ -154,11 +182,11 @@ def __autocomplete(cls, values: typing.Dict[str, typing.Any]) -> typing.Dict[str @property @deprecation.deprecated("gacha_art") def image(self) -> str: - return _create_icon(self.icon, "UI_Gacha_AvatarImg_{}") + return _create_icon_from_id(self.id, "UI_Gacha_AvatarImg_{}") @property def gacha_art(self) -> str: - return _create_icon(self.icon, "UI_Gacha_AvatarImg_{}") + return _create_icon_from_id(self.id, "UI_Gacha_AvatarImg_{}") @property def side_icon(self) -> str: diff --git a/genshin/models/genshin/chronicle/abyss.py b/genshin/models/genshin/chronicle/abyss.py index 42bf533a..205cdf07 100644 --- a/genshin/models/genshin/chronicle/abyss.py +++ b/genshin/models/genshin/chronicle/abyss.py @@ -53,10 +53,10 @@ class CharacterRanks(APIModel): most_skills_used: typing.Sequence[AbyssRankCharacter] = Aliased("normal_skill_rank", default=[], mi18n="bbs/element_skill_use_count") # noqa: E501 # fmt: on - def as_dict(self, lang: typing.Optional[str] = None) -> typing.Mapping[str, typing.Any]: + def as_dict(self, lang: str = "en-us") -> typing.Mapping[str, typing.Any]: """Turn fields into properly named ones.""" return { - self._get_mi18n(field, lang or self.lang): getattr(self, field.name) + self._get_mi18n(field, lang): getattr(self, field.name) for field in self.__fields__.values() if field.name != "lang" } diff --git a/genshin/models/genshin/chronicle/activities.py b/genshin/models/genshin/chronicle/activities.py index bb2b3931..9f7ecfa6 100644 --- a/genshin/models/genshin/chronicle/activities.py +++ b/genshin/models/genshin/chronicle/activities.py @@ -43,7 +43,7 @@ class OldActivity(APIModel, pydantic_generics.GenericModel, typing.Generic[Model records: typing.Sequence[ModelT] -class Activity(OldActivity[ModelT]): +class Activity(OldActivity[ModelT], typing.Generic[ModelT]): """Arbitrary activity for chinese events.""" start_time: typing.Optional[datetime.datetime] = None diff --git a/genshin/models/genshin/chronicle/characters.py b/genshin/models/genshin/chronicle/characters.py index 68fcd9f7..f4c427cf 100644 --- a/genshin/models/genshin/chronicle/characters.py +++ b/genshin/models/genshin/chronicle/characters.py @@ -1,6 +1,8 @@ """Genshin chronicle character.""" +import enum import typing +from collections import defaultdict if typing.TYPE_CHECKING: import pydantic.v1 as pydantic @@ -15,16 +17,36 @@ __all__ = [ "Artifact", + "ArtifactProperty", "ArtifactSet", "ArtifactSetEffect", "Character", + "CharacterSkill", "CharacterWeapon", "Constellation", + "DetailArtifact", + "DetailCharacterWeapon", + "GenshinDetailCharacter", + "GenshinDetailCharacters", + "GenshinWeaponType", "Outfit", "PartialCharacter", + "PropInfo", + "PropertyValue", + "SkillAffix", ] +class GenshinWeaponType(enum.IntEnum): + """Character weapon types.""" + + SWORD = 1 + CATALYST = 10 + CLAYMORE = 11 + BOW = 12 + POLEARM = 13 + + class PartialCharacter(character.BaseCharacter): """Character without any equipment.""" @@ -50,13 +72,9 @@ class CharacterWeapon(APIModel, Unique): class ArtifactSetEffect(APIModel): """Effect of an artifact set.""" - pieces: int = Aliased("activation_number") + required_piece_num: int = Aliased("activation_number") effect: str - enabled: bool = False - - class Config: - # this is for the "enabled" field, hopefully nobody abuses this - allow_mutation = True + active: bool = Aliased("enabled", default=False) class ArtifactSet(APIModel, Unique): @@ -113,14 +131,169 @@ class Character(PartialCharacter): outfits: typing.Sequence[Outfit] = Aliased("costumes") @pydantic.validator("artifacts") - def __add_artifact_effect_enabled(cls, artifacts: typing.Sequence[Artifact]) -> typing.Sequence[Artifact]: - sets: typing.Dict[int, typing.List[Artifact]] = {} + @classmethod + def __enable_artifact_set_effects(cls, artifacts: typing.Sequence[Artifact]) -> typing.Sequence[Artifact]: + set_nums: typing.DefaultDict[int, int] = defaultdict(int) for arti in artifacts: - sets.setdefault(arti.set.id, []).append(arti) + set_nums[arti.set.id] += 1 for artifact in artifacts: for effect in artifact.set.effects: - if effect.pieces <= len(sets[artifact.set.id]): - effect.enabled = True + if effect.required_piece_num <= set_nums[artifact.set.id]: + # To bypass model's immutability + effect = effect.copy(update={"active": True}) return artifacts + + +class PropInfo(APIModel): + """A property such as Crit Rate, HP, HP%.""" + + type: int = Aliased("property_type") + name: str + icon: typing.Optional[str] + filter_name: str + + @pydantic.validator("name", "filter_name") + @classmethod + def __fix_names(cls, value: str) -> str: + r"""Fix "\xa0" in Crit Damage + Crit Rate names.""" + return value.replace("\xa0", " ") + + +class PropertyValue(APIModel): + """A property with a value.""" + + base: str + add: str + final: str + info: PropInfo + + +class DetailCharacterWeapon(CharacterWeapon): + """Detailed Genshin Weapon with main/sub stats.""" + + main_stat: PropertyValue = Aliased("main_property") + sub_stat: typing.Optional[PropertyValue] = Aliased("sub_property") + + +class ArtifactProperty(APIModel): + """Artifact's Property value & roll count.""" + + value: str + times: int + info: PropInfo + + +class DetailArtifact(Artifact): + """Detailed artifact with main/sub stats.""" + + main_stat: ArtifactProperty = Aliased("main_property") + sub_stats: typing.Sequence[ArtifactProperty] = Aliased("sub_property_list") + + +class SkillAffix(APIModel): + """Skill affix texts.""" + + name: str + value: str + + +class CharacterSkill(APIModel): + """Character's skill.""" + + id: int = Aliased("skill_id") + skill_type: int + name: str + level: int + + description: str = Aliased("desc") + affixes: typing.Sequence[SkillAffix] = Aliased("skill_affix_list") + icon: str + is_unlocked: bool = Aliased("is_unlock") + + +class GenshinDetailCharacter(PartialCharacter): + """Full Detailed Genshin Character""" + + is_chosen: bool + + # display_image is a different image that is returned by the full character endpoint, but it is not the full gacha art. + display_image: str = Aliased("image") + + weapon_type: GenshinWeaponType + weapon: DetailCharacterWeapon + + costumes: typing.Sequence[Outfit] + + artifacts: typing.Sequence[DetailArtifact] = Aliased("relics") + constellations: typing.Sequence[Constellation] + + skills: typing.Sequence[CharacterSkill] + + selected_properties: typing.Sequence[PropertyValue] + base_properties: typing.Sequence[PropertyValue] + extra_properties: typing.Sequence[PropertyValue] + element_properties: typing.Sequence[PropertyValue] + + +class GenshinDetailCharacters(APIModel): + """Genshin character list.""" + + characters: typing.Sequence[GenshinDetailCharacter] = Aliased("list") + + property_map: typing.Mapping[str, PropInfo] + possible_artifact_stats: typing.Mapping[str, typing.Sequence[PropInfo]] = Aliased("relic_property_options") + + artifact_wiki: typing.Mapping[str, str] = Aliased("relic_wiki") + weapon_wiki: typing.Mapping[str, str] + avatar_wiki: typing.Mapping[str, str] + + @pydantic.root_validator(pre=True) + def __fill_prop_info(cls, values: typing.Dict[str, typing.Any]) -> typing.Mapping[str, typing.Any]: + """Fill property info from properety_map.""" + relic_property_options: typing.Dict[str, list[int]] = values.get("relic_property_options", {}) + prop_map: typing.Dict[str, typing.Dict[str, typing.Any]] = values.get("property_map", {}) + characters: list[typing.Dict[str, typing.Any]] = values.get("list", []) + + # Map properties to artifacts + new_relic_prop_options: typing.Dict[str, list[typing.Dict[str, typing.Any]]] = {} + for relic_type, properties in relic_property_options.items(): + formatted_properties: list[typing.Dict[str, typing.Any]] = [ + prop_map[str(prop)] for prop in properties if str(prop) in prop_map + ] + new_relic_prop_options[relic_type] = formatted_properties + + # Override relic_property_options + values["relic_property_options"] = new_relic_prop_options + + for char in characters: + # Extract character info from .base + for key, value in char["base"].items(): + if key == "weapon": # Ignore .weapon in base as it does not have full info. + continue + char[key] = value + + # Map properties to main/sub stat for weapon. + main_property = char["weapon"]["main_property"] + char["weapon"]["main_property"]["info"] = prop_map[str(main_property["property_type"])] + if sub_property := char["weapon"]["sub_property"]: + char["weapon"]["sub_property"]["info"] = prop_map[str(sub_property["property_type"])] + + # Map properties to artifacts + for artifact in char["relics"]: + main_property = artifact["main_property"] + artifact["main_property"]["info"] = prop_map[str(main_property["property_type"])] + for sub_property in artifact["sub_property_list"]: + sub_property["info"] = prop_map[str(sub_property["property_type"])] + + # Map character properties + for prop in ( + char["base_properties"] + + char["selected_properties"] + + char["extra_properties"] + + char["element_properties"] + ): + prop["info"] = prop_map[str(prop["property_type"])] + + return values diff --git a/genshin/models/genshin/chronicle/img_theater.py b/genshin/models/genshin/chronicle/img_theater.py index 88a6b422..f8edb30d 100644 --- a/genshin/models/genshin/chronicle/img_theater.py +++ b/genshin/models/genshin/chronicle/img_theater.py @@ -24,6 +24,8 @@ "TheaterDifficulty", "TheaterSchedule", "TheaterStats", + "TheaterBattleStats", + "BattleStatCharacter", ) @@ -42,6 +44,7 @@ class TheaterDifficulty(enum.IntEnum): EASY = 1 NORMAL = 2 HARD = 3 + VISIONARY = 4 class ActCharacter(character.BaseCharacter): @@ -128,21 +131,48 @@ def __parse_datetime(cls, value: typing.Mapping[str, typing.Any]) -> datetime.da ) +class BattleStatCharacter(APIModel): + """Imaginarium theater battle statistic character.""" + + id: int = Aliased("avatar_id") + icon: str = Aliased("avatar_icon") + value: int + rarity: int + + @pydantic.validator("value", pre=True) + def __intify_value(cls, value: str) -> int: + if not value: + return 0 + return int(value) + + +class TheaterBattleStats(APIModel): + """Imaginarium theater battle statistics.""" + + max_defeat_character: typing.Optional[BattleStatCharacter] = Aliased("max_defeat_avatar", default=None) + max_damage_character: typing.Optional[BattleStatCharacter] = Aliased("max_damage_avatar", default=None) + max_take_damage_character: typing.Optional[BattleStatCharacter] = Aliased("max_take_damage_avatar", default=None) + fastest_character_list: typing.Sequence[BattleStatCharacter] = Aliased("shortest_avatar_list") + total_cast_seconds: int = Aliased("total_use_time") + + class ImgTheaterData(APIModel): """Imaginarium theater data.""" acts: typing.Sequence[Act] = Aliased(alias="rounds_data") - backup_characters: typing.Sequence[ActCharacter] = Aliased(alias="backup_avatars") # Not sure what this is + backup_characters: typing.Sequence[ActCharacter] = Aliased("backup_avatars") # Not sure what this is stats: TheaterStats = Aliased(alias="stat") schedule: TheaterSchedule has_data: bool has_detail_data: bool + battle_stats: TheaterBattleStats = Aliased("fight_statisic", default=None) @pydantic.root_validator(pre=True) def __unnest_detail(cls, values: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: detail: typing.Optional[typing.Dict[str, typing.Any]] = values.get("detail") values["rounds_data"] = detail.get("rounds_data", []) if detail is not None else [] values["backup_avatars"] = detail.get("backup_avatars", []) if detail is not None else [] + values["fight_statisic"] = detail.get("fight_statisic", None) if detail is not None else None return values diff --git a/genshin/models/genshin/chronicle/notes.py b/genshin/models/genshin/chronicle/notes.py index 6bc33379..1ea0db7a 100644 --- a/genshin/models/genshin/chronicle/notes.py +++ b/genshin/models/genshin/chronicle/notes.py @@ -142,7 +142,9 @@ class DailyTasks(APIModel): attendance_visible: bool stored_attendance: float - stored_attendance_refresh_countdown: datetime.timedelta = Aliased("attendance_refresh_time") + stored_attendance_refresh_countdown: typing.Optional[datetime.timedelta] = Aliased( + "attendance_refresh_time", default=None + ) class ArchonQuestStatus(str, enum.Enum): diff --git a/genshin/models/genshin/chronicle/stats.py b/genshin/models/genshin/chronicle/stats.py index 9f45e277..82aef949 100644 --- a/genshin/models/genshin/chronicle/stats.py +++ b/genshin/models/genshin/chronicle/stats.py @@ -27,6 +27,8 @@ "Stats", "Teapot", "TeapotRealm", + "NatlanReputation", + "NatlanTribe", ] @@ -45,6 +47,7 @@ class Stats(APIModel): dendroculi: int = Aliased("dendroculus_number", mi18n="bbs/dendro_culus") electroculi: int = Aliased("electroculus_number", mi18n="bbs/electroculus_god") hydroculi: int = Aliased("hydroculus_number", mi18n="bbs/hydro_god") + pyroculi: int = Aliased("pyroculus_number", mi18n="bbs/pyro_gid") common_chests: int = Aliased("common_chest_number", mi18n="bbs/general_treasure_box_count") exquisite_chests: int = Aliased("exquisite_chest_number", mi18n="bbs/delicacy_treasure_box_count") precious_chests: int = Aliased("precious_chest_number", mi18n="bbs/rarity_treasure_box_count") @@ -54,10 +57,10 @@ class Stats(APIModel): unlocked_domains: int = Aliased("domain_number", mi18n="bbs/unlock_secret_area") # fmt: on - def as_dict(self, lang: typing.Optional[str] = None) -> typing.Mapping[str, typing.Any]: + def as_dict(self, lang: str = "en-us") -> typing.Mapping[str, typing.Any]: """Turn fields into properly named ones.""" return { - self._get_mi18n(field, lang or self.lang): getattr(self, field.name) + self._get_mi18n(field, lang): getattr(self, field.name) for field in self.__fields__.values() if field.name != "lang" } @@ -90,6 +93,22 @@ def explored(self) -> float: return self.raw_explored / 10 +class NatlanTribe(APIModel): + """Natlan tribe data.""" + + icon: str + image: str + name: str + id: int + level: int + + +class NatlanReputation(APIModel): + """Natlan reputation data.""" + + tribes: typing.Sequence[NatlanTribe] = Aliased("tribal_list") + + class Exploration(APIModel): """Exploration data.""" @@ -111,6 +130,7 @@ class Exploration(APIModel): offerings: typing.Sequence[Offering] boss_list: typing.Sequence[BossKill] area_exploration_list: typing.Sequence[AreaExploration] + natlan_reputation: typing.Optional[NatlanReputation] = Aliased("natan_reputation", default=None) @property def explored(self) -> float: diff --git a/genshin/models/genshin/daily.py b/genshin/models/genshin/daily.py index 3cbd50aa..3d08e881 100644 --- a/genshin/models/genshin/daily.py +++ b/genshin/models/genshin/daily.py @@ -25,7 +25,7 @@ class DailyReward(APIModel): """Claimable daily reward.""" name: str - amount: int = Aliased("cnt") + amount: int = Aliased("cnt", default=0) icon: str diff --git a/genshin/models/genshin/gacha.py b/genshin/models/genshin/gacha.py index 551b1cb7..1970d437 100644 --- a/genshin/models/genshin/gacha.py +++ b/genshin/models/genshin/gacha.py @@ -21,6 +21,7 @@ "BannerDetailsUpItem", "GachaItem", "GenshinBannerType", + "StarRailBannerType", "SignalSearch", "Warp", "Wish", @@ -85,17 +86,30 @@ class ZZZBannerType(enum.IntEnum): """Bangboo banner.""" -class Wish(APIModel, Unique): - """Wish made on any banner.""" +class BaseWish(APIModel, Unique): + """Base wish model.""" uid: int - id: int - type: str = Aliased("item_type") name: str rarity: int = Aliased("rank_type") + + tz_offset: int + """Number of hours from UTC+8.""" time: datetime.datetime + """Timezone-aware time of when the wish was made""" + + @pydantic.validator("time", pre=True) + def __parse_time(cls, v: str, values: typing.Dict[str, typing.Any]) -> datetime.datetime: + return datetime.datetime.fromisoformat(v).replace( + tzinfo=datetime.timezone(datetime.timedelta(hours=8 + values["tz_offset"])) + ) + +class Wish(BaseWish): + """Wish made on any banner.""" + + type: str = Aliased("item_type") banner_type: GenshinBannerType @pydantic.validator("banner_type", pre=True) @@ -103,17 +117,11 @@ def __cast_banner_type(cls, v: typing.Any) -> int: return int(v) -class Warp(APIModel, Unique): +class Warp(BaseWish): """Warp made on any banner.""" - uid: int - - id: int item_id: int type: str = Aliased("item_type") - name: str - rarity: int = Aliased("rank_type") - time: datetime.datetime banner_type: StarRailBannerType banner_id: int = Aliased("gacha_id") @@ -123,17 +131,11 @@ def __cast_banner_type(cls, v: typing.Any) -> int: return int(v) -class SignalSearch(APIModel, Unique): +class SignalSearch(BaseWish): """Signal Search made on any banner.""" - uid: int - - id: int item_id: int type: str = Aliased("item_type") - name: str - rarity: int = Aliased("rank_type") - time: datetime.datetime banner_type: ZZZBannerType diff --git a/genshin/models/genshin/lineup.py b/genshin/models/genshin/lineup.py index 76f3865e..08ea3d0d 100644 --- a/genshin/models/genshin/lineup.py +++ b/genshin/models/genshin/lineup.py @@ -262,7 +262,6 @@ class LineupCharacter(LineupCharacterPreview): """Lineup character.""" icon: str = Aliased("head_icon") - pc_icon: str = Aliased("standard_icon") weapon: PartialLineupWeapon artifacts: typing.Sequence[PartialLineupArtifactSet] = Aliased("set_list") diff --git a/genshin/models/honkai/chronicle/modes.py b/genshin/models/honkai/chronicle/modes.py index 50a85116..f3f0240b 100644 --- a/genshin/models/honkai/chronicle/modes.py +++ b/genshin/models/honkai/chronicle/modes.py @@ -148,10 +148,10 @@ def tier(self) -> str: """The user's Abyss tier as displayed in-game.""" return self.get_tier() - def get_tier(self, lang: typing.Optional[str] = None) -> str: + def get_tier(self, lang: str = "en-us") -> str: """Get the user's Abyss tier in a specific language.""" key = get_competitive_tier_mi18n(self.raw_tier) - return self._get_mi18n(key, lang or self.lang) + return self._get_mi18n(key, lang) class OldAbyss(BaseAbyss): @@ -181,20 +181,20 @@ def rank(self) -> str: """The user's Abyss rank as displayed in-game.""" return self.get_rank() - def get_rank(self, lang: typing.Optional[str] = None) -> str: + def get_rank(self, lang: str = "en-us") -> str: """Get the user's Abyss rank in a specific language.""" key = get_abyss_rank_mi18n(self.raw_rank, self.raw_tier) - return self._get_mi18n(key, lang or self.lang) + return self._get_mi18n(key, lang) @property def type(self) -> str: """The name of this cycle's abyss type.""" return self.get_type() - def get_type(self, lang: typing.Optional[str] = None) -> str: + def get_type(self, lang: str = "en-us") -> str: """Get the name of this cycle's abyss type in a specific language.""" key = "bbs/" + ("level_of_ow" if self.raw_type == "OW" else self.raw_type) - return self._get_mi18n(key, lang or self.lang) + return self._get_mi18n(key, lang) class SuperstringAbyss(BaseAbyss): @@ -215,20 +215,20 @@ def start_rank(self) -> str: """The rank the user started the abyss cycle with, as displayed in-game.""" return self.get_start_rank() - def get_start_rank(self, lang: typing.Optional[str] = None) -> str: + def get_start_rank(self, lang: str = "en-us") -> str: """Get the rank the user started the abyss cycle with in a specific language.""" key = get_abyss_rank_mi18n(self.raw_start_rank, self.raw_tier) - return self._get_mi18n(key, lang or self.lang) + return self._get_mi18n(key, lang) @property def end_rank(self) -> str: """The rank the user ended the abyss cycle with, as displayed in-game.""" return self.get_end_rank() - def get_end_rank(self, lang: typing.Optional[str] = None) -> str: + def get_end_rank(self, lang: str = "en-us") -> str: """Get the rank the user ended the abyss cycle with in a specific language.""" key = get_abyss_rank_mi18n(self.raw_end_rank, self.raw_tier) - return self._get_mi18n(key, lang or self.lang) + return self._get_mi18n(key, lang) @property def start_trophies(self) -> int: @@ -273,10 +273,10 @@ def tier(self) -> str: """The user's Memorial Arena tier as displayed in-game.""" return self.get_tier() - def get_tier(self, lang: typing.Optional[str] = None) -> str: + def get_tier(self, lang: str = "en-us") -> str: """Get the user's Memorial Arena tier in a specific language.""" key = get_competitive_tier_mi18n(self.raw_tier) - return self._get_mi18n(key, lang or self.lang) + return self._get_mi18n(key, lang) # ELYSIAN REALMS diff --git a/genshin/models/honkai/chronicle/stats.py b/genshin/models/honkai/chronicle/stats.py index 1bb6745d..1007bcc1 100644 --- a/genshin/models/honkai/chronicle/stats.py +++ b/genshin/models/honkai/chronicle/stats.py @@ -55,8 +55,8 @@ class MemorialArenaStats(APIModel): def __normalize_ranking(cls, value: typing.Union[str, float]) -> float: return float(value) if value else 0 - def as_dict(self, lang: typing.Optional[str] = None) -> typing.Mapping[str, typing.Any]: - return _model_to_dict(self, lang or self.lang) + def as_dict(self, lang: str = "en-us") -> typing.Mapping[str, typing.Any]: + return _model_to_dict(self, lang) @property def rank(self) -> str: @@ -68,10 +68,10 @@ def tier(self) -> str: """The user's Memorial Arena tier as displayed in-game.""" return self.get_tier() - def get_tier(self, lang: typing.Optional[str] = None) -> str: + def get_tier(self, lang: str = "en-us") -> str: """Get the user's Memorial Arena tier in a specific language.""" key = modes.get_competitive_tier_mi18n(self.raw_tier) - return self._get_mi18n(key, lang or self.lang) + return self._get_mi18n(key, lang) # flake8: noqa: E222 @@ -88,28 +88,28 @@ class SuperstringAbyssStats(APIModel): # for consistency between types; also allows us to forego the mi18n fuckery latest_type: typing.ClassVar[str] = "Superstring" - def as_dict(self, lang: typing.Optional[str] = None) -> typing.Mapping[str, typing.Any]: - return _model_to_dict(self, lang or self.lang) + def as_dict(self, lang: str = "en-us") -> typing.Mapping[str, typing.Any]: + return _model_to_dict(self, lang) @property def rank(self) -> str: """The user's Abyss rank as displayed in-game.""" return self.get_rank() - def get_rank(self, lang: typing.Optional[str] = None) -> str: + def get_rank(self, lang: str = "en-us") -> str: """Get the user's Abyss rank in a specific language.""" key = modes.get_abyss_rank_mi18n(self.raw_rank, self.raw_tier) - return self._get_mi18n(key, lang or self.lang) + return self._get_mi18n(key, lang) @property def tier(self) -> str: """The user's Abyss tier as displayed in-game.""" return self.get_tier() - def get_tier(self, lang: typing.Optional[str] = None) -> str: + def get_tier(self, lang: str = "en-us") -> str: """Get the user's Abyss tier in a specific language.""" key = modes.get_competitive_tier_mi18n(self.raw_tier) - return self._get_mi18n(key, lang or self.lang) + return self._get_mi18n(key, lang) # flake8: noqa: E222 @@ -162,26 +162,26 @@ def latest_rank(self) -> typing.Optional[str]: return self.get_rank(self.raw_latest_rank) - def get_rank(self, rank: int, lang: typing.Optional[str] = None) -> str: + def get_rank(self, rank: int, lang: str = "en-us") -> str: """Get the user's Abyss rank in a specific language. Must be supplied with one of the raw ranks stored on this class. """ key = modes.get_abyss_rank_mi18n(rank, self.raw_tier) - return self._get_mi18n(key, lang or self.lang) + return self._get_mi18n(key, lang) @property def tier(self) -> str: """The user's Abyss tier as displayed in-game.""" return modes.get_competitive_tier_mi18n(self.raw_tier) - def get_tier(self, lang: typing.Optional[str] = None) -> str: + def get_tier(self, lang: str = "en-us") -> str: """Get the user's Abyss tier in a specific language.""" key = modes.get_competitive_tier_mi18n(self.raw_tier) - return self._get_mi18n(key, lang or self.lang) + return self._get_mi18n(key, lang) - def as_dict(self, lang: typing.Optional[str] = None) -> typing.Mapping[str, typing.Any]: - return _model_to_dict(self, lang or self.lang) + def as_dict(self, lang: str = "en-us") -> typing.Mapping[str, typing.Any]: + return _model_to_dict(self, lang) class Config: # this is for the "stat_lang" field, hopefully nobody abuses this diff --git a/genshin/models/hoyolab/announcements.py b/genshin/models/hoyolab/announcements.py index 920a3080..c6e1f484 100644 --- a/genshin/models/hoyolab/announcements.py +++ b/genshin/models/hoyolab/announcements.py @@ -1,6 +1,7 @@ """Public genshin announcement models.""" import datetime +import typing from genshin.models.model import Aliased, APIModel, Unique @@ -15,6 +16,7 @@ class Announcement(APIModel, Unique): subtitle: str banner: str content: str + img: typing.Optional[str] = None type_label: str type: int diff --git a/genshin/models/model.py b/genshin/models/model.py index 335e4336..a33b755d 100644 --- a/genshin/models/model.py +++ b/genshin/models/model.py @@ -4,8 +4,6 @@ import abc import datetime -import sys -import types import typing if typing.TYPE_CHECKING: @@ -17,119 +15,16 @@ except ImportError: import pydantic -import genshin.constants as genshin_constants __all__ = ["APIModel", "Aliased", "Unique"] _SENTINEL = object() -def _get_init_fields(cls: typing.Type[APIModel]) -> typing.Tuple[typing.Set[str], typing.Set[str]]: - api_init_fields: typing.Set[str] = set() - model_init_fields: typing.Set[str] = set() - - for name, field in cls.__fields__.items(): - alias = field.field_info.extra.get("galias") - if alias: - api_init_fields.add(alias) - model_init_fields.add(name) - - for name in dir(cls): - obj = getattr(cls, name, None) - if isinstance(obj, property): - model_init_fields.add(name) - - return api_init_fields, model_init_fields - - class APIModel(pydantic.BaseModel, abc.ABC): """Modified pydantic model.""" - __api_init_fields__: typing.ClassVar[typing.Set[str]] - __model_init_fields__: typing.ClassVar[typing.Set[str]] - - # nasty pydantic bug fixed only on the master branch - waiting for pypi release - if typing.TYPE_CHECKING: - _mi18n: typing.ClassVar[typing.Dict[str, typing.Dict[str, str]]] - else: - _mi18n = {} - - lang: str = "UNKNOWN" - - def __init__(self, _frame: int = 1, **data: typing.Any) -> None: - """""" - from genshin.client.components import base as client_base - - lang = data.pop("lang", None) - - if lang is None: - frames = [sys._getframe(_frame)] - while _frame <= 100: # ensure we give up in a reasonable amount of time - _frame += 1 - try: - frame = sys._getframe(_frame) - except ValueError: - break - - if frame.f_code.co_name == "__init__" and frame.f_code.co_filename == __file__: - frames.append(frame) - break - - for frame in frames: - if frame.f_code.co_name == "": - frame = typing.cast("types.FrameType", frame.f_back) - assert frame - - if isinstance(frame.f_locals.get("lang"), str): - lang = frame.f_locals["lang"] - - for name, value in frame.f_locals.items(): - if isinstance(value, (APIModel, client_base.BaseClient)): - lang = value.lang - - if lang: - break - - # validator, it's a skipper - if isinstance(frame.f_locals.get("cls"), type) and issubclass(frame.f_locals["cls"], APIModel): - continue - - else: - raise Exception("lang not found") - - object.__setattr__(self, "lang", lang) - super().__init__(**data, lang=lang) - - for name in self.__fields__.keys(): - value = getattr(self, name) - if isinstance(value, APIModel): - object.__setattr__(value, "lang", self.lang) - - if self.lang not in genshin_constants.LANGS: - raise Exception(f"Invalid model lang: {self.lang}") - - def __init_subclass__(cls) -> None: - cls.__api_init_fields__, cls.__model_init_fields__ = _get_init_fields(cls) - - @pydantic.root_validator(pre=True) - def __parse_galias(cls, values: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: - """Due to alias being reserved for actual aliases we use a custom alias.""" - if cls.__model_init_fields__: - # has all model init fields - if cls.__model_init_fields__.issubset(set(values)): - return values - - # has some model init fields but no api init fields - if set(values) & cls.__model_init_fields__ and not set(values) & cls.__api_init_fields__: - return values - - aliases: typing.Dict[str, str] = {} - for name, field in cls.__fields__.items(): - alias = field.field_info.extra.get("galias") - if alias is not None: - aliases[alias] = name - - return {aliases.get(name, name): value for name, value in values.items()} + _mi18n: typing.ClassVar[typing.Dict[str, typing.Dict[str, str]]] = {} @pydantic.root_validator() def __parse_timezones(cls, values: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: @@ -206,7 +101,7 @@ def __hash__(self) -> int: def Aliased( - galias: typing.Optional[str] = None, + alias: typing.Optional[str] = None, default: typing.Any = pydantic.main.Undefined, # type: ignore *, timezone: typing.Optional[typing.Union[int, datetime.datetime]] = None, @@ -214,11 +109,9 @@ def Aliased( **kwargs: typing.Any, ) -> typing.Any: """Create an aliased field.""" - if galias is not None: - kwargs.update(galias=galias) if timezone is not None: kwargs.update(timezone=timezone) if mi18n is not None: kwargs.update(mi18n=mi18n) - return pydantic.Field(default, **kwargs) + return pydantic.Field(default, alias=alias, **kwargs) diff --git a/genshin/models/starrail/chronicle/challenge.py b/genshin/models/starrail/chronicle/challenge.py index d1b274fc..91f68b66 100644 --- a/genshin/models/starrail/chronicle/challenge.py +++ b/genshin/models/starrail/chronicle/challenge.py @@ -85,8 +85,8 @@ class StarRailChallenge(APIModel): @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 "groups" in values and isinstance(values["groups"], List): + seasons: List[Dict[str, Any]] = values["groups"] if len(seasons) > 0: values["name"] = seasons[0]["name_mi18n"] @@ -141,8 +141,8 @@ class StarRailPureFiction(APIModel): @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 "groups" in values and isinstance(values["groups"], List): + seasons: List[Dict[str, Any]] = values["groups"] if len(seasons) > 0: values["name"] = seasons[0]["name_mi18n"] values["season_id"] = seasons[0]["schedule_id"] diff --git a/genshin/models/zzz/character.py b/genshin/models/zzz/character.py index 82fb9202..897c0a1d 100644 --- a/genshin/models/zzz/character.py +++ b/genshin/models/zzz/character.py @@ -125,6 +125,7 @@ class ZZZPropertyType(enum.IntEnum): CRIT_RATE = 20103 CRIT_DMG = 21103 ANOMALY_PROFICIENCY = 31203 + ANOMALY_MASTERY = 31402 PEN_RATIO = 23103 IMPACT = 12202 diff --git a/genshin/utility/auth.py b/genshin/utility/auth.py index 00148254..c0de46e4 100644 --- a/genshin/utility/auth.py +++ b/genshin/utility/auth.py @@ -69,6 +69,14 @@ "x-rpc-client_type": "2", } +QRCODE_HEADERS = { + "x-rpc-app_id": "bll8iq97cem8", + "x-rpc-client_type": "4", + "x-rpc-game_biz": "bbs_cn", + "x-rpc-device_fp": "38d7fa104e5d7", + "x-rpc-device_id": "586f1440-856a-4243-8076-2b0a12314197", +} + CREATE_MMT_HEADERS = { types.Region.OVERSEAS: { "x-rpc-challenge_path": "https://bbs-api-os.hoyolab.com/game_record/app/hkrpg/api/challenge", diff --git a/requirements.txt b/requirements.txt index 20657441..d8b7e16f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,7 @@ yarl browser-cookie3 rsa aioredis +aiosqlite click qrcode[pil] aiohttp-socks \ No newline at end of file diff --git a/tests/client/components/test_genshin_chronicle.py b/tests/client/components/test_genshin_chronicle.py index 81c667ee..5fccb10f 100644 --- a/tests/client/components/test_genshin_chronicle.py +++ b/tests/client/components/test_genshin_chronicle.py @@ -17,6 +17,12 @@ async def test_genshin_user(client: genshin.Client, genshin_uid: int): assert data +async def test_genshin_detailed_characters(client: genshin.Client, genshin_uid: int): + data = await client.get_genshin_detailed_characters(genshin_uid) + + assert data + + async def test_partial_genshin_user(client: genshin.Client, genshin_uid: int): data = await client.get_partial_genshin_user(genshin_uid) diff --git a/tests/models/test_model.py b/tests/models/test_model.py index 074bb523..757f16d1 100644 --- a/tests/models/test_model.py +++ b/tests/models/test_model.py @@ -31,7 +31,6 @@ class LiteralCharacter(genshin.models.BaseCharacter): ... element="Cryo", rarity=5, icon="https://enka.network/ui/UI_AvatarIcon_Ayaka.png", - lang="en-us", ), ), # partial should provide the proper name @@ -46,23 +45,6 @@ class LiteralCharacter(genshin.models.BaseCharacter): ... element="Anemo", rarity=5, icon="https://enka.network/ui/UI_AvatarIcon_Qin.png", - lang="en-us", - ), - ), - # partial for an unknown character should return the icon name - ( - { - "id": 10000001, - "icon": "https://upload-os-bbs.mihoyo.com/game_record/genshin/character_icon/UI_AvatarIcon_Signora.png", - "rarity": 6, - }, - LiteralCharacter( - id=10000001, - name="Signora", - element="Anemo", # Anemo is the arbitrary fallback - rarity=6, # 5 is the arbitrary fallback - icon="https://enka.network/ui/UI_AvatarIcon_Signora.png", - lang="en-us", ), ), # traveler element should be kept @@ -79,7 +61,6 @@ class LiteralCharacter(genshin.models.BaseCharacter): ... element="Light", rarity=5, icon="https://enka.network/ui/UI_AvatarIcon_PlayerBoy.png", - lang="en-us", ), ), # messed up icon should be replaced @@ -97,25 +78,6 @@ class LiteralCharacter(genshin.models.BaseCharacter): ... element="Hydro", rarity=5, icon="https://enka.network/ui/UI_AvatarIcon_Mona.png", - lang="en-us", - ), - ), - # messed up icon for unknown character should be kept - ( - { - "id": 10000000, - "name": "Katherine", - "element": "Geo", - "rarity": 6, - "icon": "https://uploadstatic-sea.hoyoverse.com/hk4e/e20200928calculate/item_icon_ud09dc/1dbbc3a2852c11033e1754314d9b292d.png", - }, - LiteralCharacter( - id=10000000, - name="Katherine", - element="Geo", - rarity=6, - icon="https://uploadstatic-sea.hoyoverse.com/hk4e/e20200928calculate/item_icon_ud09dc/1dbbc3a2852c11033e1754314d9b292d.png", - lang="en-us", ), ), # foreign languages should be kept @@ -133,13 +95,12 @@ class LiteralCharacter(genshin.models.BaseCharacter): ... element="Pyro", rarity=5, icon="https://enka.network/ui/UI_AvatarIcon_Hutao.png", - lang="en-us", ), ), ), ) def test_genshin_base_character_model(data: typing.Dict[str, typing.Any], expected: genshin.models.BaseCharacter): - assert genshin.models.BaseCharacter(**data, lang="en-us") == expected + assert genshin.models.BaseCharacter(**data) == expected # reserialization stuff