From 202dc9ea2580623aa493eb6a051c7b2ec560a95b Mon Sep 17 00:00:00 2001 From: KT Date: Sat, 3 Feb 2024 07:38:32 +0800 Subject: [PATCH 01/42] Add HSR pure fiction (#157) --- .flake8 | 4 +- genshin/__init__.py | 1 + genshin/__main__.py | 1 + genshin/client/__init__.py | 1 + genshin/client/cache.py | 1 + genshin/client/clients.py | 1 + genshin/client/compatibility.py | 1 + genshin/client/components/base.py | 1 + .../client/components/calculator/__init__.py | 1 + .../components/calculator/calculator.py | 1 + .../client/components/calculator/client.py | 1 + .../client/components/chronicle/__init__.py | 1 + genshin/client/components/chronicle/client.py | 1 + .../client/components/chronicle/starrail.py | 13 +++ genshin/client/components/daily.py | 7 +- genshin/client/components/diary.py | 1 + genshin/client/components/gacha.py | 1 + genshin/client/components/geetest/__init__.py | 1 + genshin/client/components/geetest/client.py | 1 + genshin/client/components/geetest/server.py | 1 + genshin/client/components/hoyolab.py | 1 + genshin/client/components/lineup.py | 1 + genshin/client/components/teapot.py | 1 + genshin/client/components/transaction.py | 1 + genshin/client/components/wiki.py | 16 ++-- genshin/client/manager/__init__.py | 1 + genshin/client/manager/cookie.py | 1 + genshin/client/manager/managers.py | 1 + genshin/client/ratelimit.py | 1 + genshin/client/routes.py | 1 + genshin/constants.py | 1 + genshin/errors.py | 1 + genshin/models/__init__.py | 1 + genshin/models/genshin/__init__.py | 1 + genshin/models/genshin/calculator.py | 1 + genshin/models/genshin/chronicle/__init__.py | 1 + .../models/genshin/chronicle/activities.py | 1 + genshin/models/genshin/chronicle/notes.py | 1 + genshin/models/genshin/chronicle/tcg.py | 1 + genshin/models/genshin/constants.py | 1 + genshin/models/genshin/daily.py | 1 + genshin/models/genshin/diary.py | 1 + genshin/models/genshin/gacha.py | 1 + genshin/models/genshin/lineup.py | 1 + genshin/models/genshin/teapot.py | 1 + genshin/models/genshin/wiki.py | 1 + genshin/models/honkai/__init__.py | 1 + genshin/models/honkai/battlesuit.py | 1 + genshin/models/honkai/chronicle/__init__.py | 1 + .../models/honkai/chronicle/battlesuits.py | 1 + genshin/models/honkai/chronicle/modes.py | 1 + genshin/models/honkai/chronicle/stats.py | 1 + genshin/models/honkai/constants.py | 1 + genshin/models/hoyolab/__init__.py | 1 + genshin/models/hoyolab/record.py | 1 + genshin/models/model.py | 1 + genshin/models/starrail/__init__.py | 1 + genshin/models/starrail/character.py | 1 + genshin/models/starrail/chronicle/base.py | 1 + .../models/starrail/chronicle/challenge.py | 83 ++++++++++++++++++- .../models/starrail/chronicle/characters.py | 1 + genshin/models/starrail/chronicle/notes.py | 1 + genshin/models/starrail/chronicle/rogue.py | 1 + genshin/models/starrail/chronicle/stats.py | 1 + genshin/paginators/__init__.py | 1 + genshin/paginators/api.py | 1 + genshin/paginators/base.py | 3 +- genshin/types.py | 1 + genshin/utility/__init__.py | 1 + genshin/utility/concurrency.py | 1 + genshin/utility/deprecation.py | 1 + genshin/utility/ds.py | 1 + genshin/utility/fs.py | 1 + genshin/utility/geetest.py | 1 + genshin/utility/logfile.py | 1 + genshin/utility/uid.py | 1 + noxfile.py | 1 + tests/models/test_model.py | 3 +- 78 files changed, 179 insertions(+), 21 deletions(-) diff --git a/.flake8 b/.flake8 index c39efc79..eb1a7f74 100644 --- a/.flake8 +++ b/.flake8 @@ -6,6 +6,7 @@ exclude = tests, test.py # D105: Missing docstring in magic method # D106: Missing docstring Model.Config # D419: Docstring is empty +# E704: Multiple statements on one line (def) # S101: Use of assert for type checking # S303: Use of md5 # S311: Use of pseudo-random generators @@ -14,7 +15,8 @@ exclude = tests, test.py ignore = A001, A002, A003, C408, - D105, D106, D419 + D105, D106, D419, + E704, S101, S303, S311, S324, W503, diff --git a/genshin/__init__.py b/genshin/__init__.py index 66b92d77..a5a9ffb9 100644 --- a/genshin/__init__.py +++ b/genshin/__init__.py @@ -6,6 +6,7 @@ Source Code: https://github.com/thesadru/genshin.py """ + from . import models, utility from .client import * from .constants import * diff --git a/genshin/__main__.py b/genshin/__main__.py index 629ed987..2f7e8f3a 100644 --- a/genshin/__main__.py +++ b/genshin/__main__.py @@ -1,4 +1,5 @@ """CLI tools.""" + import asyncio import datetime import functools diff --git a/genshin/client/__init__.py b/genshin/client/__init__.py index b955bcc9..9faaf5c5 100644 --- a/genshin/client/__init__.py +++ b/genshin/client/__init__.py @@ -1,4 +1,5 @@ """Default client implementation.""" + from . import components from .cache import * from .clients import * diff --git a/genshin/client/cache.py b/genshin/client/cache.py index fbd42b29..1138c1d8 100644 --- a/genshin/client/cache.py +++ b/genshin/client/cache.py @@ -1,4 +1,5 @@ """Cache for client.""" + from __future__ import annotations import abc diff --git a/genshin/client/clients.py b/genshin/client/clients.py index a4b426e6..87e8aa9d 100644 --- a/genshin/client/clients.py +++ b/genshin/client/clients.py @@ -1,4 +1,5 @@ """A simple HTTP client for API endpoints.""" + from .components import ( calculator, chronicle, diff --git a/genshin/client/compatibility.py b/genshin/client/compatibility.py index 4ed9e1be..ce4d8fc2 100644 --- a/genshin/client/compatibility.py +++ b/genshin/client/compatibility.py @@ -1,4 +1,5 @@ """Reverse-compatibility layer for previous versions.""" + from __future__ import annotations import typing diff --git a/genshin/client/components/base.py b/genshin/client/components/base.py index a8d24137..49673cf9 100644 --- a/genshin/client/components/base.py +++ b/genshin/client/components/base.py @@ -1,4 +1,5 @@ """Base ABC Client.""" + import abc import asyncio import base64 diff --git a/genshin/client/components/calculator/__init__.py b/genshin/client/components/calculator/__init__.py index 8c47acf1..b8770c14 100644 --- a/genshin/client/components/calculator/__init__.py +++ b/genshin/client/components/calculator/__init__.py @@ -1,2 +1,3 @@ """Calculator client.""" + from .client import * diff --git a/genshin/client/components/calculator/calculator.py b/genshin/client/components/calculator/calculator.py index 6ac2ba7f..435bd755 100644 --- a/genshin/client/components/calculator/calculator.py +++ b/genshin/client/components/calculator/calculator.py @@ -2,6 +2,7 @@ Over-engineered for the sake of extendability and maintainability. """ + from __future__ import annotations import abc diff --git a/genshin/client/components/calculator/client.py b/genshin/client/components/calculator/client.py index 5140f9c0..a629ab84 100644 --- a/genshin/client/components/calculator/client.py +++ b/genshin/client/components/calculator/client.py @@ -1,4 +1,5 @@ """Calculator client.""" + from __future__ import annotations import asyncio diff --git a/genshin/client/components/chronicle/__init__.py b/genshin/client/components/chronicle/__init__.py index 30fb3fc8..076cfcbe 100644 --- a/genshin/client/components/chronicle/__init__.py +++ b/genshin/client/components/chronicle/__init__.py @@ -1,2 +1,3 @@ """Battle chronicle client components.""" + from .client import * diff --git a/genshin/client/components/chronicle/client.py b/genshin/client/components/chronicle/client.py index a8a2eca3..94f31407 100644 --- a/genshin/client/components/chronicle/client.py +++ b/genshin/client/components/chronicle/client.py @@ -1,4 +1,5 @@ """Battle chronicle component.""" + from . import genshin, honkai, starrail __all__ = ["BattleChronicleClient"] diff --git a/genshin/client/components/chronicle/starrail.py b/genshin/client/components/chronicle/starrail.py index 57cbedb6..b1935341 100644 --- a/genshin/client/components/chronicle/starrail.py +++ b/genshin/client/components/chronicle/starrail.py @@ -1,4 +1,5 @@ """StarRail battle chronicle component.""" + import asyncio import typing @@ -126,3 +127,15 @@ async def get_starrail_rogue( payload = dict(schedule_type=schedule_type, need_detail="true") data = await self._request_starrail_record("rogue", uid, lang=lang, payload=payload) return models.StarRailRogue(**data) + + async def get_starrail_pure_fiction( + self, + uid: typing.Optional[int] = None, + *, + previous: bool = False, + lang: typing.Optional[str] = None, + ) -> models.StarRailPureFiction: + """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) + return models.StarRailPureFiction(**data) diff --git a/genshin/client/components/daily.py b/genshin/client/components/daily.py index b9ca2964..53981d29 100644 --- a/genshin/client/components/daily.py +++ b/genshin/client/components/daily.py @@ -1,4 +1,5 @@ """Daily reward component.""" + import asyncio import datetime import functools @@ -150,8 +151,7 @@ async def claim_daily_reward( # noqa: D102 missing docstring in overload? lang: typing.Optional[str] = None, reward: typing.Literal[True] = ..., challenge: typing.Optional[typing.Mapping[str, str]] = None, - ) -> models.DailyReward: - ... + ) -> models.DailyReward: ... @typing.overload async def claim_daily_reward( # noqa: D102 missing docstring in overload? @@ -161,8 +161,7 @@ async def claim_daily_reward( # noqa: D102 missing docstring in overload? lang: typing.Optional[str] = None, reward: typing.Literal[False], challenge: typing.Optional[typing.Mapping[str, str]] = None, - ) -> None: - ... + ) -> None: ... async def claim_daily_reward( self, diff --git a/genshin/client/components/diary.py b/genshin/client/components/diary.py index f175d1bd..02b6d042 100644 --- a/genshin/client/components/diary.py +++ b/genshin/client/components/diary.py @@ -1,4 +1,5 @@ """Diary component.""" + import datetime import functools import typing diff --git a/genshin/client/components/gacha.py b/genshin/client/components/gacha.py index 71e27dfc..fe6cad62 100644 --- a/genshin/client/components/gacha.py +++ b/genshin/client/components/gacha.py @@ -1,4 +1,5 @@ """Wish component.""" + import asyncio import functools import typing diff --git a/genshin/client/components/geetest/__init__.py b/genshin/client/components/geetest/__init__.py index 97d622bd..3871455b 100644 --- a/genshin/client/components/geetest/__init__.py +++ b/genshin/client/components/geetest/__init__.py @@ -2,4 +2,5 @@ Credits to M-307 - https://github.com/mrwan200 """ + from .client import * diff --git a/genshin/client/components/geetest/client.py b/genshin/client/components/geetest/client.py index 898a4d23..036fa4b4 100644 --- a/genshin/client/components/geetest/client.py +++ b/genshin/client/components/geetest/client.py @@ -1,4 +1,5 @@ """Geetest client component.""" + import json import typing diff --git a/genshin/client/components/geetest/server.py b/genshin/client/components/geetest/server.py index 639c3fc9..a8bb641b 100644 --- a/genshin/client/components/geetest/server.py +++ b/genshin/client/components/geetest/server.py @@ -1,4 +1,5 @@ """Aiohttp webserver used for captcha solving and email verification.""" + from __future__ import annotations import asyncio diff --git a/genshin/client/components/hoyolab.py b/genshin/client/components/hoyolab.py index eef52a91..7e1804dc 100644 --- a/genshin/client/components/hoyolab.py +++ b/genshin/client/components/hoyolab.py @@ -1,4 +1,5 @@ """Hoyolab component.""" + import asyncio import typing diff --git a/genshin/client/components/lineup.py b/genshin/client/components/lineup.py index bc71ae7f..69326e3d 100644 --- a/genshin/client/components/lineup.py +++ b/genshin/client/components/lineup.py @@ -1,4 +1,5 @@ """Lineup component.""" + import functools import typing diff --git a/genshin/client/components/teapot.py b/genshin/client/components/teapot.py index e58e9c25..b3a75252 100644 --- a/genshin/client/components/teapot.py +++ b/genshin/client/components/teapot.py @@ -1,4 +1,5 @@ """Teapot component.""" + import functools import typing diff --git a/genshin/client/components/transaction.py b/genshin/client/components/transaction.py index 010e9bee..73dcd9cb 100644 --- a/genshin/client/components/transaction.py +++ b/genshin/client/components/transaction.py @@ -1,4 +1,5 @@ """Transaction client.""" + import functools import typing import urllib.parse diff --git a/genshin/client/components/wiki.py b/genshin/client/components/wiki.py index fbe1bb9c..5320fa46 100644 --- a/genshin/client/components/wiki.py +++ b/genshin/client/components/wiki.py @@ -1,4 +1,5 @@ """Wiki component.""" + import typing from genshin import types @@ -34,8 +35,7 @@ async def get_wiki_previews( # noqa: D102 missing docstring in overload? menu: typing.Literal[models.WikiPageType.CHARACTER], *, lang: typing.Optional[str] = None, - ) -> typing.Sequence[models.CharacterPreview]: - ... + ) -> typing.Sequence[models.CharacterPreview]: ... @typing.overload async def get_wiki_previews( # noqa: D102 missing docstring in overload? @@ -43,8 +43,7 @@ async def get_wiki_previews( # noqa: D102 missing docstring in overload? menu: typing.Literal[models.WikiPageType.WEAPON], *, lang: typing.Optional[str] = None, - ) -> typing.Sequence[models.WeaponPreview]: - ... + ) -> typing.Sequence[models.WeaponPreview]: ... @typing.overload async def get_wiki_previews( # noqa: D102 missing docstring in overload? @@ -52,8 +51,7 @@ async def get_wiki_previews( # noqa: D102 missing docstring in overload? menu: typing.Literal[models.WikiPageType.ARTIFACT], *, lang: typing.Optional[str] = None, - ) -> typing.Sequence[models.ArtifactPreview]: - ... + ) -> typing.Sequence[models.ArtifactPreview]: ... @typing.overload async def get_wiki_previews( # noqa: D102 missing docstring in overload? @@ -61,8 +59,7 @@ async def get_wiki_previews( # noqa: D102 missing docstring in overload? menu: typing.Literal[models.WikiPageType.ENEMY], *, lang: typing.Optional[str] = None, - ) -> typing.Sequence[models.EnemyPreview]: - ... + ) -> typing.Sequence[models.EnemyPreview]: ... @typing.overload async def get_wiki_previews( # noqa: D102 missing docstring in overload? @@ -70,8 +67,7 @@ async def get_wiki_previews( # noqa: D102 missing docstring in overload? menu: int, *, lang: typing.Optional[str] = None, - ) -> typing.Sequence[models.BaseWikiPreview]: - ... + ) -> typing.Sequence[models.BaseWikiPreview]: ... async def get_wiki_previews( self, diff --git a/genshin/client/manager/__init__.py b/genshin/client/manager/__init__.py index 5520a866..070264a0 100644 --- a/genshin/client/manager/__init__.py +++ b/genshin/client/manager/__init__.py @@ -1,3 +1,4 @@ """Cookie managers.""" + from .cookie import * from .managers import * diff --git a/genshin/client/manager/cookie.py b/genshin/client/manager/cookie.py index 1780de6e..a2b59d0c 100644 --- a/genshin/client/manager/cookie.py +++ b/genshin/client/manager/cookie.py @@ -15,6 +15,7 @@ - stoken (v2) + mid -> ltoken_v2 (token_type=2) - stoken (v2) + mid -> cookie_token_v2 (token_type=4) """ + from __future__ import annotations import typing diff --git a/genshin/client/manager/managers.py b/genshin/client/manager/managers.py index fceb4ca5..0e33324b 100644 --- a/genshin/client/manager/managers.py +++ b/genshin/client/manager/managers.py @@ -1,4 +1,5 @@ """Cookie managers for making authenticated requests.""" + from __future__ import annotations import abc diff --git a/genshin/client/ratelimit.py b/genshin/client/ratelimit.py index c5385400..61ef328d 100644 --- a/genshin/client/ratelimit.py +++ b/genshin/client/ratelimit.py @@ -1,4 +1,5 @@ """Ratelimit handlers.""" + import asyncio import functools import typing diff --git a/genshin/client/routes.py b/genshin/client/routes.py index e4804618..becf0e37 100644 --- a/genshin/client/routes.py +++ b/genshin/client/routes.py @@ -1,4 +1,5 @@ """API routes.""" + import abc import typing diff --git a/genshin/constants.py b/genshin/constants.py index 800d6012..2d86a6ff 100644 --- a/genshin/constants.py +++ b/genshin/constants.py @@ -1,4 +1,5 @@ """Constants hardcoded for optimizations.""" + from . import types __all__ = ["LANGS"] diff --git a/genshin/errors.py b/genshin/errors.py index de22d3b4..1ea5e1e2 100644 --- a/genshin/errors.py +++ b/genshin/errors.py @@ -1,4 +1,5 @@ """Errors received from the API.""" + import typing __all__ = [ diff --git a/genshin/models/__init__.py b/genshin/models/__init__.py index 5fb96b34..35edc5e7 100644 --- a/genshin/models/__init__.py +++ b/genshin/models/__init__.py @@ -1,4 +1,5 @@ """API models.""" + from .genshin import * from .honkai import * from .hoyolab import * diff --git a/genshin/models/genshin/__init__.py b/genshin/models/genshin/__init__.py index e841f5a4..248c29cc 100644 --- a/genshin/models/genshin/__init__.py +++ b/genshin/models/genshin/__init__.py @@ -1,4 +1,5 @@ """Genshin models.""" + from .calculator import * from .character import * from .chronicle import * diff --git a/genshin/models/genshin/calculator.py b/genshin/models/genshin/calculator.py index d7c258fb..4e871f13 100644 --- a/genshin/models/genshin/calculator.py +++ b/genshin/models/genshin/calculator.py @@ -1,4 +1,5 @@ """Genshin calculator models.""" + from __future__ import annotations import collections diff --git a/genshin/models/genshin/chronicle/__init__.py b/genshin/models/genshin/chronicle/__init__.py index 5fc8848c..094b8f71 100644 --- a/genshin/models/genshin/chronicle/__init__.py +++ b/genshin/models/genshin/chronicle/__init__.py @@ -1,4 +1,5 @@ """Battle chronicle models.""" + from .abyss import * from .activities import * from .characters import * diff --git a/genshin/models/genshin/chronicle/activities.py b/genshin/models/genshin/chronicle/activities.py index 2f9535d5..bb2b3931 100644 --- a/genshin/models/genshin/chronicle/activities.py +++ b/genshin/models/genshin/chronicle/activities.py @@ -1,4 +1,5 @@ """Chronicle activities models.""" + import datetime import re import typing diff --git a/genshin/models/genshin/chronicle/notes.py b/genshin/models/genshin/chronicle/notes.py index ea73337e..c9c52ac8 100644 --- a/genshin/models/genshin/chronicle/notes.py +++ b/genshin/models/genshin/chronicle/notes.py @@ -1,4 +1,5 @@ """Genshin chronicle notes.""" + import datetime import enum import typing diff --git a/genshin/models/genshin/chronicle/tcg.py b/genshin/models/genshin/chronicle/tcg.py index d2d5b934..ea1ec1dc 100644 --- a/genshin/models/genshin/chronicle/tcg.py +++ b/genshin/models/genshin/chronicle/tcg.py @@ -1,4 +1,5 @@ """Genshin serenitea pot replica display models.""" + from __future__ import annotations import enum diff --git a/genshin/models/genshin/constants.py b/genshin/models/genshin/constants.py index 8b8dd8ef..3b5cc134 100644 --- a/genshin/models/genshin/constants.py +++ b/genshin/models/genshin/constants.py @@ -1,4 +1,5 @@ """Genshin model constants.""" + import typing __all__ = ["CHARACTER_NAMES", "DBChar"] diff --git a/genshin/models/genshin/daily.py b/genshin/models/genshin/daily.py index c21854b0..1b2e2443 100644 --- a/genshin/models/genshin/daily.py +++ b/genshin/models/genshin/daily.py @@ -1,4 +1,5 @@ """Daily reward models.""" + import calendar import datetime import typing diff --git a/genshin/models/genshin/diary.py b/genshin/models/genshin/diary.py index d34617f5..afe27314 100644 --- a/genshin/models/genshin/diary.py +++ b/genshin/models/genshin/diary.py @@ -1,4 +1,5 @@ """Genshin diary models.""" + import datetime import enum import typing diff --git a/genshin/models/genshin/gacha.py b/genshin/models/genshin/gacha.py index 01bd0fdd..777b32a3 100644 --- a/genshin/models/genshin/gacha.py +++ b/genshin/models/genshin/gacha.py @@ -1,4 +1,5 @@ """Genshin wish models.""" + import datetime import enum import re diff --git a/genshin/models/genshin/lineup.py b/genshin/models/genshin/lineup.py index 5090601b..6ae71946 100644 --- a/genshin/models/genshin/lineup.py +++ b/genshin/models/genshin/lineup.py @@ -1,4 +1,5 @@ """Genshin lineup models.""" + from __future__ import annotations import datetime diff --git a/genshin/models/genshin/teapot.py b/genshin/models/genshin/teapot.py index 543c0216..c12e3c90 100644 --- a/genshin/models/genshin/teapot.py +++ b/genshin/models/genshin/teapot.py @@ -1,4 +1,5 @@ """Genshin serenitea pot replica display models.""" + from __future__ import annotations import datetime diff --git a/genshin/models/genshin/wiki.py b/genshin/models/genshin/wiki.py index a37d92fb..d1898455 100644 --- a/genshin/models/genshin/wiki.py +++ b/genshin/models/genshin/wiki.py @@ -1,4 +1,5 @@ """Genshin wish models.""" + import enum import json import typing diff --git a/genshin/models/honkai/__init__.py b/genshin/models/honkai/__init__.py index 36ec2f19..3d8f5c8f 100644 --- a/genshin/models/honkai/__init__.py +++ b/genshin/models/honkai/__init__.py @@ -1,4 +1,5 @@ """Honkai models.""" + from .battlesuit import * from .chronicle import * from .constants import * diff --git a/genshin/models/honkai/battlesuit.py b/genshin/models/honkai/battlesuit.py index cbc8ef6b..e77fbe34 100644 --- a/genshin/models/honkai/battlesuit.py +++ b/genshin/models/honkai/battlesuit.py @@ -1,4 +1,5 @@ """Honkai battlesuit model.""" + import logging import re import typing diff --git a/genshin/models/honkai/chronicle/__init__.py b/genshin/models/honkai/chronicle/__init__.py index fcf46466..d5698270 100644 --- a/genshin/models/honkai/chronicle/__init__.py +++ b/genshin/models/honkai/chronicle/__init__.py @@ -1,4 +1,5 @@ """Battle chronicle models.""" + from .battlesuits import * from .modes import * from .stats import * diff --git a/genshin/models/honkai/chronicle/battlesuits.py b/genshin/models/honkai/chronicle/battlesuits.py index eb08661e..6fec9c9d 100644 --- a/genshin/models/honkai/chronicle/battlesuits.py +++ b/genshin/models/honkai/chronicle/battlesuits.py @@ -1,4 +1,5 @@ """Honkai chronicle battlesuits.""" + import re import typing diff --git a/genshin/models/honkai/chronicle/modes.py b/genshin/models/honkai/chronicle/modes.py index 3ba35625..92067ebd 100644 --- a/genshin/models/honkai/chronicle/modes.py +++ b/genshin/models/honkai/chronicle/modes.py @@ -1,4 +1,5 @@ """Honkai battle chronicle models.""" + from __future__ import annotations import datetime diff --git a/genshin/models/honkai/chronicle/stats.py b/genshin/models/honkai/chronicle/stats.py index 43d9d513..b7f57490 100644 --- a/genshin/models/honkai/chronicle/stats.py +++ b/genshin/models/honkai/chronicle/stats.py @@ -1,4 +1,5 @@ """Honkai stats models.""" + import typing if typing.TYPE_CHECKING: diff --git a/genshin/models/honkai/constants.py b/genshin/models/honkai/constants.py index f793e5a8..3f644fa4 100644 --- a/genshin/models/honkai/constants.py +++ b/genshin/models/honkai/constants.py @@ -1,4 +1,5 @@ """Honkai model constants.""" + import typing __all__ = ["BATTLESUIT_IDENTIFIERS"] diff --git a/genshin/models/hoyolab/__init__.py b/genshin/models/hoyolab/__init__.py index b11f0436..9a911330 100644 --- a/genshin/models/hoyolab/__init__.py +++ b/genshin/models/hoyolab/__init__.py @@ -1,4 +1,5 @@ """Hoyolab models.""" + from .announcements import * from .private import * from .record import * diff --git a/genshin/models/hoyolab/record.py b/genshin/models/hoyolab/record.py index c6a22648..e47225d4 100644 --- a/genshin/models/hoyolab/record.py +++ b/genshin/models/hoyolab/record.py @@ -1,4 +1,5 @@ """Base hoyolab APIModels.""" + from __future__ import annotations import enum diff --git a/genshin/models/model.py b/genshin/models/model.py index c930eba5..9f80e7f3 100644 --- a/genshin/models/model.py +++ b/genshin/models/model.py @@ -1,4 +1,5 @@ """Modified pydantic model.""" + from __future__ import annotations import abc diff --git a/genshin/models/starrail/__init__.py b/genshin/models/starrail/__init__.py index 5f44a33b..2ecf1bf2 100644 --- a/genshin/models/starrail/__init__.py +++ b/genshin/models/starrail/__init__.py @@ -1,3 +1,4 @@ """Starrail models.""" + from .character import * from .chronicle import * diff --git a/genshin/models/starrail/character.py b/genshin/models/starrail/character.py index dc943f6e..1f311bd8 100644 --- a/genshin/models/starrail/character.py +++ b/genshin/models/starrail/character.py @@ -1,4 +1,5 @@ """Starrail base character model.""" + from genshin.models.model import APIModel, Unique diff --git a/genshin/models/starrail/chronicle/base.py b/genshin/models/starrail/chronicle/base.py index 97645018..0bd44af5 100644 --- a/genshin/models/starrail/chronicle/base.py +++ b/genshin/models/starrail/chronicle/base.py @@ -1,4 +1,5 @@ """Starrail Chronicle Base Model.""" + import datetime from genshin.models.model import APIModel diff --git a/genshin/models/starrail/chronicle/challenge.py b/genshin/models/starrail/chronicle/challenge.py index 18510312..b985ecf8 100644 --- a/genshin/models/starrail/chronicle/challenge.py +++ b/genshin/models/starrail/chronicle/challenge.py @@ -1,12 +1,29 @@ """Starrail chronicle challenge.""" -from typing import List + +from typing import TYPE_CHECKING, Any, Dict, List + +if TYPE_CHECKING: + import pydantic.v1 as pydantic +else: + try: + import pydantic.v1 as pydantic + except ImportError: + import pydantic from genshin.models.model import Aliased, APIModel from genshin.models.starrail.character import FloorCharacter from .base import PartialTime -__all__ = ["FloorNode", "StarRailChallenge", "StarRailFloor"] +__all__ = [ + "FictionBuff", + "FictionFloor", + "FictionFloorNode", + "FloorNode", + "StarRailChallenge", + "StarRailFloor", + "StarRailPureFiction", +] class FloorNode(APIModel): @@ -40,3 +57,65 @@ class StarRailChallenge(APIModel): has_data: bool floors: List[StarRailFloor] = Aliased("all_floor_detail") + + +class FictionBuff(APIModel): + """Buff for a Pure Fiction floor.""" + + id: int + name: str = Aliased("name_mi18n") + description: str = Aliased("desc_mi18n") + icon: str + + +class FictionFloorNode(FloorNode): + """Node for a Pure Fiction floor.""" + + buff: FictionBuff + score: int + + +class FictionFloor(APIModel): + """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: + """Total score of the floor.""" + return self.node_1.score + self.node_2.score + + +class StarRailPureFiction(APIModel): + """Pure Fiction challenge in a season.""" + + name: str + season_id: int + begin_time: PartialTime + end_time: PartialTime + + total_stars: int = Aliased("star_num") + max_floor: str + total_battles: int = Aliased("battle_num") + has_data: bool + + floors: List[FictionFloor] = Aliased("all_floor_detail") + max_floor_id: int + + @pydantic.root_validator(pre=True) + def __unnest_groups(cls, values: Dict[str, Any]) -> Dict[str, Any]: + if "groups" in values and isinstance(values["groups"], List): + groups: List[Dict[str, Any]] = values["groups"] + if len(groups) > 0: + values["name"] = groups[0]["name_mi18n"] + values["season_id"] = groups[0]["schedule_id"] + values["begin_time"] = groups[0]["begin_time"] + values["end_time"] = groups[0]["end_time"] + + return values diff --git a/genshin/models/starrail/chronicle/characters.py b/genshin/models/starrail/chronicle/characters.py index bf8d4c55..f6d3bde3 100644 --- a/genshin/models/starrail/chronicle/characters.py +++ b/genshin/models/starrail/chronicle/characters.py @@ -1,4 +1,5 @@ """Starrail chronicle character.""" + from typing import List, Optional from genshin.models.model import APIModel diff --git a/genshin/models/starrail/chronicle/notes.py b/genshin/models/starrail/chronicle/notes.py index 59e03c21..d3fa8f8e 100644 --- a/genshin/models/starrail/chronicle/notes.py +++ b/genshin/models/starrail/chronicle/notes.py @@ -1,4 +1,5 @@ """Starrail chronicle notes.""" + import datetime import typing diff --git a/genshin/models/starrail/chronicle/rogue.py b/genshin/models/starrail/chronicle/rogue.py index 9a034e95..ea2b5329 100644 --- a/genshin/models/starrail/chronicle/rogue.py +++ b/genshin/models/starrail/chronicle/rogue.py @@ -1,4 +1,5 @@ """Starrail Rogue models.""" + from typing import List from genshin.models.model import APIModel diff --git a/genshin/models/starrail/chronicle/stats.py b/genshin/models/starrail/chronicle/stats.py index 89aa17c8..c25a17ba 100644 --- a/genshin/models/starrail/chronicle/stats.py +++ b/genshin/models/starrail/chronicle/stats.py @@ -1,4 +1,5 @@ """Starrail chronicle stats.""" + import typing from genshin.models.model import Aliased, APIModel diff --git a/genshin/paginators/__init__.py b/genshin/paginators/__init__.py index 8afc4e8a..468a0250 100644 --- a/genshin/paginators/__init__.py +++ b/genshin/paginators/__init__.py @@ -1,3 +1,4 @@ """Fancy paginators with a large amount of useful methods.""" + from .api import * from .base import * diff --git a/genshin/paginators/api.py b/genshin/paginators/api.py index 217665a9..8201a84f 100644 --- a/genshin/paginators/api.py +++ b/genshin/paginators/api.py @@ -1,4 +1,5 @@ """Base paginators made specifically for interaction with the api.""" + from __future__ import annotations import abc diff --git a/genshin/paginators/base.py b/genshin/paginators/base.py index 4eff9bae..cf5be77e 100644 --- a/genshin/paginators/base.py +++ b/genshin/paginators/base.py @@ -91,8 +91,7 @@ def __await__(self) -> typing.Generator[None, None, typing.Sequence[T]]: return self.flatten().__await__() @abc.abstractmethod - async def __anext__(self) -> T: - ... + async def __anext__(self) -> T: ... class BasicPaginator(typing.Generic[T], Paginator[T], abc.ABC): diff --git a/genshin/types.py b/genshin/types.py index c1ced452..9b87fd9c 100644 --- a/genshin/types.py +++ b/genshin/types.py @@ -1,4 +1,5 @@ """Types used in the library.""" + import enum import typing diff --git a/genshin/utility/__init__.py b/genshin/utility/__init__.py index b2254923..cd1e07c3 100644 --- a/genshin/utility/__init__.py +++ b/genshin/utility/__init__.py @@ -1,4 +1,5 @@ """Utilities for genshin.py.""" + from . import geetest from .concurrency import * from .ds import * diff --git a/genshin/utility/concurrency.py b/genshin/utility/concurrency.py index 9e6d0967..681a5cf5 100644 --- a/genshin/utility/concurrency.py +++ b/genshin/utility/concurrency.py @@ -1,4 +1,5 @@ """Utilities for concurrency optimizations.""" + from __future__ import annotations import asyncio diff --git a/genshin/utility/deprecation.py b/genshin/utility/deprecation.py index 0d06fd1d..c99d9bb1 100644 --- a/genshin/utility/deprecation.py +++ b/genshin/utility/deprecation.py @@ -1,4 +1,5 @@ """Deprecation decorator.""" + import functools import inspect import typing diff --git a/genshin/utility/ds.py b/genshin/utility/ds.py index 8f9466d5..1cd169f5 100644 --- a/genshin/utility/ds.py +++ b/genshin/utility/ds.py @@ -1,4 +1,5 @@ """Dynamic secret generation.""" + import hashlib import json import random diff --git a/genshin/utility/fs.py b/genshin/utility/fs.py index feac290a..51f33186 100644 --- a/genshin/utility/fs.py +++ b/genshin/utility/fs.py @@ -1,4 +1,5 @@ """File system related utilities.""" + import functools import pathlib import tempfile diff --git a/genshin/utility/geetest.py b/genshin/utility/geetest.py index 4d535d7d..300ba2ae 100644 --- a/genshin/utility/geetest.py +++ b/genshin/utility/geetest.py @@ -1,4 +1,5 @@ """Geetest utilities.""" + import base64 import json import typing diff --git a/genshin/utility/logfile.py b/genshin/utility/logfile.py index f516b5e9..f0b6719b 100644 --- a/genshin/utility/logfile.py +++ b/genshin/utility/logfile.py @@ -1,4 +1,5 @@ """Search logfile for authkeys.""" + import pathlib import re import typing diff --git a/genshin/utility/uid.py b/genshin/utility/uid.py index 132aece9..e0fabcae 100644 --- a/genshin/utility/uid.py +++ b/genshin/utility/uid.py @@ -1,4 +1,5 @@ """Utility functions related to genshin.""" + import typing from genshin import types diff --git a/noxfile.py b/noxfile.py index 9f5c0ea1..550a0401 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,4 +1,5 @@ """Nox file.""" + from __future__ import annotations import logging diff --git a/tests/models/test_model.py b/tests/models/test_model.py index 50897a6e..c9978389 100644 --- a/tests/models/test_model.py +++ b/tests/models/test_model.py @@ -6,8 +6,7 @@ import genshin -class LiteralCharacter(genshin.models.BaseCharacter): - ... +class LiteralCharacter(genshin.models.BaseCharacter): ... LiteralCharacter.__pre_root_validators__ = LiteralCharacter.__pre_root_validators__[:-1] From a424187d1a085980cf9a4f0142c3a0d099be4c94 Mon Sep 17 00:00:00 2001 From: Zhi Heng Date: Tue, 6 Feb 2024 22:25:39 +0800 Subject: [PATCH 02/42] Make HSR Pure Fiction floor buff optional (#158) --- genshin/models/starrail/chronicle/challenge.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/genshin/models/starrail/chronicle/challenge.py b/genshin/models/starrail/chronicle/challenge.py index b985ecf8..d71f2ee2 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, Any, Dict, List +from typing import TYPE_CHECKING, Any, Dict, List, Optional if TYPE_CHECKING: import pydantic.v1 as pydantic @@ -71,7 +71,7 @@ class FictionBuff(APIModel): class FictionFloorNode(FloorNode): """Node for a Pure Fiction floor.""" - buff: FictionBuff + buff: Optional[FictionBuff] score: int From 9b3182771f1bf1cb03d5f35fc5fcb850ff638c19 Mon Sep 17 00:00:00 2001 From: JokelBaf <60827680+JokelBaf@users.noreply.github.com> Date: Mon, 4 Mar 2024 17:08:28 +0200 Subject: [PATCH 03/42] Fixes and improvements in geetest module --- genshin/client/components/geetest/client.py | 31 +++++++++++++-------- genshin/client/components/geetest/server.py | 5 ++-- genshin/utility/geetest.py | 16 +++++------ 3 files changed, 30 insertions(+), 22 deletions(-) diff --git a/genshin/client/components/geetest/client.py b/genshin/client/components/geetest/client.py index 036fa4b4..d5d794bd 100644 --- a/genshin/client/components/geetest/client.py +++ b/genshin/client/components/geetest/client.py @@ -20,7 +20,7 @@ class GeetestClient(base.BaseClient): """Geetest client component.""" - async def web_login( + async def _web_login( self, account: str, password: str, @@ -69,12 +69,13 @@ async def web_login( return cookies - async def app_login( + async def _app_login( self, account: str, password: str, *, geetest: typing.Optional[typing.Dict[str, typing.Any]] = None, + ticket: typing.Optional[typing.Dict[str, typing.Any]] = None, ) -> typing.Dict[str, typing.Any]: """Login with a password using HoYoLab app endpoint. @@ -89,6 +90,10 @@ async def app_login( session_id = geetest["session_id"] headers["x-rpc-aigis"] = geetest_utility.get_aigis_header(session_id, mmt_data) + if ticket: + ticket["verify_str"] = json.dumps(ticket["verify_str"]) + headers["x-rpc-verify"] = json.dumps(ticket) + payload = { "account": geetest_utility.encrypt_geetest_credentials(account), "password": geetest_utility.encrypt_geetest_credentials(password), @@ -110,7 +115,9 @@ async def app_login( if data["retcode"] == -3239: # Email verification required - return json.loads(r.headers["x-rpc-verify"]) + verify = json.loads(r.headers["x-rpc-verify"]) + verify["verify_str"] = json.loads(verify["verify_str"]) + return verify if not data["data"]: errors.raise_for_retcode(data) @@ -126,7 +133,7 @@ async def app_login( return cookies - async def send_verification_email( + async def _send_verification_email( self, ticket: typing.Dict[str, typing.Any], *, @@ -164,7 +171,7 @@ async def send_verification_email( return None - async def verify_email(self, code: str, ticket: typing.Dict[str, typing.Any]) -> None: + async def _verify_email(self, code: str, ticket: typing.Dict[str, typing.Any]) -> None: """Verify email.""" async with aiohttp.ClientSession() as session: async with session.post( @@ -200,7 +207,7 @@ async def login_with_password( Note that this will start a webserver if captcha is triggered and `geetest_solver` is not passed. """ - result = await self.web_login(account, password, tokenType=tokenType) + result = await self._web_login(account, password, tokenType=tokenType) if "session_id" not in result: # Captcha not triggered @@ -211,7 +218,7 @@ async def login_with_password( else: geetest = await server.solve_geetest(result, port=port) - return await self.web_login(account, password, tokenType=tokenType, geetest=geetest) + return await self._web_login(account, password, tokenType=tokenType, geetest=geetest) async def login_with_app_password( self, @@ -232,7 +239,7 @@ async def login_with_app_password( 2. Email verification is triggered (can happen if you first login with a new device). """ - result = await self.app_login(account, password) + result = await self._app_login(account, password) if "session_id" in result: # Captcha triggered @@ -241,18 +248,20 @@ async def login_with_app_password( else: geetest = await server.solve_geetest(result, port=port) - result = await self.app_login(account, password, geetest=geetest) + result = await self._app_login(account, password, geetest=geetest) if "risk_ticket" in result: # Email verification required - mmt = await self.send_verification_email(result) + mmt = await self._send_verification_email(result) if mmt: if geetest_solver: geetest = await geetest_solver(mmt) else: geetest = await server.solve_geetest(mmt, port=port) + await self._send_verification_email(result, geetest=geetest) + await server.verify_email(self, result, port=port) - result = await self.app_login(account, password) + result = await self._app_login(account, password, ticket=result) return result diff --git a/genshin/client/components/geetest/server.py b/genshin/client/components/geetest/server.py index a8bb641b..6f69ae8e 100644 --- a/genshin/client/components/geetest/server.py +++ b/genshin/client/components/geetest/server.py @@ -65,7 +65,7 @@ def get_page(page: typing.Literal["captcha", "verify-email"]) -> str: - """ - if page == "captcha" - else """ + """, + "verify-email": """ @@ -76,15 +72,35 @@ def get_page(page: typing.Literal["captcha", "verify-email"]) -> str: }; - """ - ) + """, + "enter-otp": """ + + + + + + + + + """, +} GT_URL = "https://raw.githubusercontent.com/GeeTeam/gt3-node-sdk/master/demo/static/libs/gt.js" async def launch_webapp( - page: typing.Literal["captcha", "verify-email"], + page: typing.Literal["captcha", "verify-email", "enter-otp"], *, port: int = 5000, mmt: typing.Optional[typing.Dict[str, typing.Any]] = None, @@ -95,11 +111,15 @@ async def launch_webapp( @routes.get("/captcha") async def captcha(request: web.Request) -> web.StreamResponse: - return web.Response(body=get_page("captcha"), content_type="text/html") + return web.Response(body=PAGES["captcha"], content_type="text/html") @routes.get("/verify-email") async def verify_email(request: web.Request) -> web.StreamResponse: - return web.Response(body=get_page("verify-email"), content_type="text/html") + return web.Response(body=PAGES["verify-email"], content_type="text/html") + + @routes.get("/enter-otp") + async def enter_otp(request: web.Request) -> web.StreamResponse: + return web.Response(body=PAGES["enter-otp"], content_type="text/html") @routes.get("/gt.js") async def gt(request: web.Request) -> web.StreamResponse: @@ -162,3 +182,11 @@ async def verify_email( code = data["code"] return await client._verify_email(code, ticket) + + +async def enter_otp(port: int = 5000) -> str: + """Lets user enter the OTP.""" + # The enter-otp page is the same as verify-email page. + data = await launch_webapp("enter-otp", port=port) + code = data["code"] + return code diff --git a/genshin/client/routes.py b/genshin/client/routes.py index f61dea89..aa153ee3 100644 --- a/genshin/client/routes.py +++ b/genshin/client/routes.py @@ -13,6 +13,7 @@ "BBS_REFERER_URL", "BBS_URL", "CALCULATOR_URL", + "CN_WEB_LOGIN_URL", "COMMUNITY_URL", "COOKIE_V2_REFRESH_URL", "DETAIL_LEDGER_URL", @@ -217,8 +218,13 @@ def get_url(self, region: types.Region, game: types.Game) -> yarl.URL: WEB_LOGIN_URL = Route("https://sg-public-api.hoyolab.com/account/ma-passport/api/webLoginByPassword") APP_LOGIN_URL = Route("https://sg-public-api.hoyoverse.com/account/ma-passport/api/appLoginByPassword") +CN_WEB_LOGIN_URL = Route("https://passport-api.miyoushe.com/account/ma-cn-passport/web/loginByPassword") SEND_VERIFICATION_CODE_URL = Route( "https://sg-public-api.hoyoverse.com/account/ma-verifier/api/createEmailCaptchaByActionTicket" ) VERIFY_EMAIL_URL = Route("https://sg-public-api.hoyoverse.com/account/ma-verifier/api/verifyActionTicketPartly") + +CHECK_MOBILE_VALIDITY_URL = Route("https://webapi.account.mihoyo.com/Api/is_mobile_registrable") +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") diff --git a/genshin/errors.py b/genshin/errors.py index 1ea5e1e2..f62ade2f 100644 --- a/genshin/errors.py +++ b/genshin/errors.py @@ -31,7 +31,11 @@ class GenshinException(Exception): original: str = "" msg: str = "" - def __init__(self, response: typing.Mapping[str, typing.Any] = {}, msg: typing.Optional[str] = None) -> None: + def __init__( + self, + response: typing.Mapping[str, typing.Any] = {}, + msg: typing.Optional[str] = None, + ) -> None: self.retcode = response.get("retcode", self.retcode) self.original = response.get("message", "") self.msg = msg or self.msg or self.original @@ -176,6 +180,12 @@ class AccountHasLocked(GenshinException): msg = "Account has been locked because exceeded password limit. Please wait 20 minute and try again" +class WrongOTP(GenshinException): + """Wrong OTP code.""" + + msg = "The provided OTP code is wrong." + + _TGE = typing.Type[GenshinException] _errors: typing.Dict[int, typing.Union[_TGE, str, typing.Tuple[_TGE, typing.Optional[str]]]] = { # misc hoyolab @@ -189,7 +199,10 @@ class AccountHasLocked(GenshinException): # database game record 10101: TooManyRequests, 10102: DataNotPublic, - 10103: (InvalidCookies, "Cookies are valid but do not have a hoyolab account bound to them."), + 10103: ( + InvalidCookies, + "Cookies are valid but do not have a hoyolab account bound to them.", + ), 10104: "Cannot view real-time notes of other users.", # calculator -500001: "Invalid fields in calculation.", @@ -210,7 +223,10 @@ class AccountHasLocked(GenshinException): -2016: RedemptionCooldown, -2017: RedemptionClaimed, -2018: RedemptionClaimed, - -2021: (RedemptionException, "Cannot claim codes for accounts with adventure rank lower than 10."), + -2021: ( + RedemptionException, + "Cannot claim codes for accounts with adventure rank lower than 10.", + ), # rewards -5003: AlreadyClaimed, # chinese @@ -219,6 +235,7 @@ class AccountHasLocked(GenshinException): # account -3208: AccountLoginFail, -3202: AccountHasLocked, + -3205: WrongOTP, } ERRORS: typing.Dict[int, typing.Tuple[_TGE, typing.Optional[str]]] = { diff --git a/genshin/utility/geetest.py b/genshin/utility/geetest.py index ad8ed8bd..fe388afb 100644 --- a/genshin/utility/geetest.py +++ b/genshin/utility/geetest.py @@ -4,11 +4,13 @@ import json import typing +from ..types import Region + __all__ = ["encrypt_geetest_credentials"] # RSA key is the same for app and web login -LOGIN_KEY_CERT = b""" +OS_LOGIN_KEY_CERT = b""" -----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4PMS2JVMwBsOIrYWRluY wEiFZL7Aphtm9z5Eu/anzJ09nB00uhW+ScrDWFECPwpQto/GlOJYCUwVM/raQpAj @@ -20,6 +22,15 @@ -----END PUBLIC KEY----- """ +CN_LOGIN_KEY_CERT = b""" +-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDDvekdPMHN3AYhm/vktJT+YJr7 +cI5DcsNKqdsx5DZX0gDuWFuIjzdwButrIYPNmRJ1G8ybDIF7oDW2eEpm5sMbL9zs +9ExXCdvqrn51qELbqj0XxtMTIpaCHFSI50PfPpTFV9Xt/hmyVwokoOXFlAEgCn+Q +CgGs52bFoYMtyi+xEQIDAQAB +-----END PUBLIC KEY----- +""" + WEB_LOGIN_HEADERS = { "x-rpc-app_id": "c9oqaq3s3gu8", "x-rpc-client_type": "4", @@ -38,6 +49,16 @@ # "x-rpc-device_id": "1c33337bd45c1bfs", } +CN_LOGIN_HEADERS = { + "x-rpc-app_id": "bll8iq97cem8", + "x-rpc-client_type": "4", + "Origin": "https://user.miyoushe.com", + "Referer": "https://user.miyoushe.com/", + "x-rpc-source": "v2.webLogin", + "x-rpc-mi_referrer": "https://user.miyoushe.com/login-platform/index.html?app_id=bll8iq97cem8&theme=&token_type=4&game_biz=bbs_cn&message_origin=https%253A%252F%252Fwww.miyoushe.com&succ_back_type=message%253Alogin-platform%253Alogin-success&fail_back_type=message%253Alogin-platform%253Alogin-fail&ux_mode=popup&iframe_level=1#/login/password", # noqa: E501 + "x-rpc-device_id": "586f2440-856a-4243-8076-2b0a12314197", +} + EMAIL_SEND_HEADERS = { "x-rpc-app_id": "c9oqaq3s3gu8", "x-rpc-client_type": "2", @@ -49,11 +70,13 @@ } -def encrypt_geetest_credentials(text: str) -> str: +def encrypt_geetest_credentials(text: str, region: Region = Region.OVERSEAS) -> str: """Encrypt text for geetest.""" import rsa - public_key = rsa.PublicKey.load_pkcs1_openssl_pem(LOGIN_KEY_CERT) + public_key = rsa.PublicKey.load_pkcs1_openssl_pem( + OS_LOGIN_KEY_CERT if region is Region.OVERSEAS else CN_LOGIN_KEY_CERT + ) crypto = rsa.encrypt(text.encode("utf-8"), public_key) return base64.b64encode(crypto).decode("utf-8") From f6f4c11bd22abe5b31c741db5a29598480c8bf27 Mon Sep 17 00:00:00 2001 From: ashlen Date: Wed, 27 Mar 2024 12:02:03 +0100 Subject: [PATCH 14/42] Add star-rail notes to cli (#170) resolves #169 --- genshin/__main__.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/genshin/__main__.py b/genshin/__main__.py index 2f7e8f3a..fd047c9a 100644 --- a/genshin/__main__.py +++ b/genshin/__main__.py @@ -68,8 +68,10 @@ async def accounts(client: genshin.Client) -> None: genshin_group: click.Group = click.Group("genshin", help="Genshin-related commands.") honkai_group: click.Group = click.Group("honkai", help="Honkai-related commands.") +starrail_group: click.Group = click.Group("starrail", help="StarRail-related commands.") cli.add_command(genshin_group) cli.add_command(honkai_group) +cli.add_command(starrail_group) @honkai_group.command("stats") @@ -199,6 +201,33 @@ async def genshin_notes(client: genshin.Client, uid: typing.Optional[int]) -> No click.echo(f" - {expedition.status}") +@starrail_group.command("notes") +@click.argument("uid", type=int, default=None, required=False) +@client_command +async def starrail_notes(client: genshin.Client, uid: typing.Optional[int]) -> None: + """Show real-Time starrail notes.""" + click.echo("Real-Time notes.") + + data = await client.get_starrail_notes(uid) + + click.echo(f"{click.style('TB power:', bold=True)} {data.current_stamina}/{data.max_stamina}", nl=False) + click.echo(f" (Full in {data.stamina_recover_time})" if data.stamina_recover_time > datetime.timedelta(0) else "") + click.echo(f"{click.style('Reserved TB power:', bold=True)} {data.current_reserve_stamina}/2400") + click.echo(f"{click.style('Daily training:', bold=True)} {data.current_train_score}/{data.max_train_score}") + click.echo(f"{click.style('Simulated Universe:', bold=True)} {data.current_rogue_score}/{data.max_rogue_score}") + click.echo( + 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}") + for expedition in data.expeditions: + if expedition.remaining_time > datetime.timedelta(0): + remaining = f"{expedition.remaining_time} remaining" + click.echo(f" - {expedition.name} | {remaining}") + else: + click.echo(f" - {expedition.name} | Finished") + + @cli.command() @click.option("--scenario", help="Scenario ID or name to use (eg '12-3').", type=str, default=None) @client_command From 803b94d0444fb973e820b6cdc8bbac704c863e60 Mon Sep 17 00:00:00 2001 From: seria Date: Thu, 28 Mar 2024 23:34:44 +0900 Subject: [PATCH 15/42] Migrate to ruff (#168) --- .flake8 | 43 -------------- genshin-dev/lint-requirements.txt | 14 +---- genshin-dev/reformat-requirements.txt | 4 +- genshin/models/genshin/chronicle/abyss.py | 8 +-- genshin/models/genshin/chronicle/stats.py | 2 +- genshin/models/genshin/lineup.py | 2 +- genshin/models/honkai/chronicle/stats.py | 5 +- genshin/utility/extdb.py | 5 +- noxfile.py | 9 ++- pyproject.toml | 68 ++++++++++++++++++++++- 10 files changed, 85 insertions(+), 75 deletions(-) delete mode 100644 .flake8 diff --git a/.flake8 b/.flake8 deleted file mode 100644 index eb1a7f74..00000000 --- a/.flake8 +++ /dev/null @@ -1,43 +0,0 @@ -[flake8] -exclude = tests, test.py - -# A001, A002, A003: `id` variable/parameter/attribute -# C408: dict() with keyword arguments -# D105: Missing docstring in magic method -# D106: Missing docstring Model.Config -# D419: Docstring is empty -# E704: Multiple statements on one line (def) -# S101: Use of assert for type checking -# S303: Use of md5 -# S311: Use of pseudo-random generators -# S324: Use of md5 without usedforsecurity=False (3.9+) -# W503: line break before binary operator -ignore = - A001, A002, A003, - C408, - D105, D106, D419, - E704, - S101, S303, S311, S324, - W503, - -# F401: unused import. -# F403: cannot detect unused vars if we use starred import -# D10*: docstrings -# S10*: hardcoded passwords -# F841: unused variable -# I900: nox is a dev dependency -per-file-ignores = - **/__init__.py: F401, F403 - tests/**: D10, S10, F841 - noxfile.py: I900 - -max-complexity = 16 -max-function-length = 100 -max-line-length = 130 - -max_annotations_complexity = 5 - -accept-encodings = utf-8 -docstring-convention = numpy -ignore-decorators = property -requirements_file = requirements.txt \ No newline at end of file diff --git a/genshin-dev/lint-requirements.txt b/genshin-dev/lint-requirements.txt index 4073601f..ede3eb4e 100644 --- a/genshin-dev/lint-requirements.txt +++ b/genshin-dev/lint-requirements.txt @@ -1,13 +1 @@ -flake8 - -flake8-annotations-complexity # complex annotation -flake8-black # runs black -flake8-builtins # builtin shadowing -flake8-docstrings # proper formatting and grammar in docstrings -flake8-isort # runs isort -flake8-mutable # mutable default argument detection -flake8-pep3101 # new-style format strings only -flake8-print # complain about print statements in code -flake8-pytest-style # pytest checks -flake8-raise # exception raising -flake8-requirements # requirements.txt check +ruff \ No newline at end of file diff --git a/genshin-dev/reformat-requirements.txt b/genshin-dev/reformat-requirements.txt index bee49858..1cf5c13f 100644 --- a/genshin-dev/reformat-requirements.txt +++ b/genshin-dev/reformat-requirements.txt @@ -1,3 +1,3 @@ black -isort -sort-all +ruff +sort-all \ No newline at end of file diff --git a/genshin/models/genshin/chronicle/abyss.py b/genshin/models/genshin/chronicle/abyss.py index 829fcd7d..159c2f8f 100644 --- a/genshin/models/genshin/chronicle/abyss.py +++ b/genshin/models/genshin/chronicle/abyss.py @@ -47,13 +47,13 @@ class CharacterRanks(APIModel): most_played: typing.Sequence[AbyssRankCharacter] = Aliased("reveal_rank", default=[], mi18n="bbs/go_fight_count") most_kills: typing.Sequence[AbyssRankCharacter] = Aliased("defeat_rank", default=[], mi18n="bbs/max_rout_count") strongest_strike: typing.Sequence[AbyssRankCharacter] = Aliased("damage_rank", default=[], mi18n="bbs/powerful_attack") - most_damage_taken: typing.Sequence[AbyssRankCharacter] = Aliased("take_damage_rank", default=[], mi18n="bbs/receive_max_damage") - most_bursts_used: typing.Sequence[AbyssRankCharacter] = Aliased("energy_skill_rank", default=[], mi18n="bbs/element_break_count") - most_skills_used: typing.Sequence[AbyssRankCharacter] = Aliased("normal_skill_rank", default=[], mi18n="bbs/element_skill_use_count") + most_damage_taken: typing.Sequence[AbyssRankCharacter] = Aliased("take_damage_rank", default=[], mi18n="bbs/receive_max_damage") # noqa: E501 + most_bursts_used: typing.Sequence[AbyssRankCharacter] = Aliased("energy_skill_rank", default=[], mi18n="bbs/element_break_count") # noqa: E501 + 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]: - """Helper function which turns fields into properly named ones""" + """Turn fields into properly named ones.""" return { self._get_mi18n(field, lang or self.lang): getattr(self, field.name) for field in self.__fields__.values() diff --git a/genshin/models/genshin/chronicle/stats.py b/genshin/models/genshin/chronicle/stats.py index fb4d9e75..0734a886 100644 --- a/genshin/models/genshin/chronicle/stats.py +++ b/genshin/models/genshin/chronicle/stats.py @@ -53,7 +53,7 @@ class Stats(APIModel): # fmt: on def as_dict(self, lang: typing.Optional[str] = None) -> typing.Mapping[str, typing.Any]: - """Helper function which turns fields into properly named ones""" + """Turn fields into properly named ones.""" return { self._get_mi18n(field, lang or self.lang): getattr(self, field.name) for field in self.__fields__.values() diff --git a/genshin/models/genshin/lineup.py b/genshin/models/genshin/lineup.py index 6ae71946..76f3865e 100644 --- a/genshin/models/genshin/lineup.py +++ b/genshin/models/genshin/lineup.py @@ -296,7 +296,7 @@ def __parse_characters(cls, value: typing.Any) -> typing.Any: if isinstance(value[0], typing.Sequence): return value - return [[character for character in group["group"]] for group in value] + return [list(group["group"]) for group in value] class Lineup(LineupPreview): diff --git a/genshin/models/honkai/chronicle/stats.py b/genshin/models/honkai/chronicle/stats.py index b7f57490..1bb6745d 100644 --- a/genshin/models/honkai/chronicle/stats.py +++ b/genshin/models/honkai/chronicle/stats.py @@ -24,7 +24,7 @@ def _model_to_dict(model: APIModel, lang: str = "en-us") -> typing.Mapping[str, typing.Any]: - """Helper function which turns fields into properly named ones""" + """Turn fields into properly named ones.""" ret: typing.Dict[str, typing.Any] = {} for field in model.__fields__.values(): if not field.field_info.extra.get("mi18n"): @@ -123,7 +123,7 @@ class OldAbyssStats(APIModel): raw_tier: int = Aliased("latest_area", mi18n="bbs/settled_level") raw_latest_rank: typing.Optional[int] = Aliased("latest_level", mi18n="bbs/rank") # TODO: Add proper key - latest_type: str = Aliased( mi18n="bbs/latest_type") + latest_type: str = Aliased( mi18n="bbs/latest_type") # fmt: on @pydantic.validator("raw_q_singularis_rank", "raw_dirac_sea_rank", "raw_latest_rank", pre=True) @@ -240,6 +240,7 @@ def __pack_gamemode_stats(cls, values: typing.Dict[str, typing.Any]) -> typing.D return values def as_dict(self, lang: str = "en-us") -> typing.Mapping[str, typing.Any]: + """Turn fields into properly named ones.""" return _model_to_dict(self, lang) diff --git a/genshin/utility/extdb.py b/genshin/utility/extdb.py index 52fb5ac6..8225d504 100644 --- a/genshin/utility/extdb.py +++ b/genshin/utility/extdb.py @@ -2,6 +2,7 @@ import asyncio import json +import logging import time import typing import warnings @@ -20,6 +21,8 @@ "update_characters_genshindata", ) +LOGGER_ = logging.getLogger(__name__) + CACHE_FILE = fs.get_tempdir() / "characters.json" if CACHE_FILE.exists() and time.time() - CACHE_FILE.stat().st_mtime < 7 * 24 * 60 * 60: @@ -235,7 +238,7 @@ async def update_characters_any( try: await updator(langs) except Exception: - continue + LOGGER_.exception("Failed to update characters with %s", updator.__name__) else: return diff --git a/noxfile.py b/noxfile.py index 550a0401..3da55b37 100644 --- a/noxfile.py +++ b/noxfile.py @@ -31,7 +31,7 @@ def install_requirements(session: nox.Session, *requirements: str, literal: bool """Install requirements.""" if not literal and all(requirement.isalpha() for requirement in requirements): files = ["requirements.txt"] + [f"./genshin-dev/{requirement}-requirements.txt" for requirement in requirements] - requirements = ("pip",) + tuple(arg for file in files for arg in ("-r", file)) + requirements = ("pip", *tuple(arg for file in files for arg in ("-r", file))) session.install("--upgrade", *requirements, silent=not isverbose()) @@ -52,16 +52,15 @@ def docs(session: nox.Session) -> None: def lint(session: nox.Session) -> None: """Run this project's modules against the pre-defined flake8 linters.""" install_requirements(session, "lint") - session.run("flake8", "--version") - session.run("flake8", *GENERAL_TARGETS, *verbose_args()) + session.run("ruff", "check", *GENERAL_TARGETS, *verbose_args()) @nox.session() def reformat(session: nox.Session) -> None: """Reformat this project's modules to fit the standard style.""" install_requirements(session, "reformat") - session.run("black", *GENERAL_TARGETS, *verbose_args()) - session.run("isort", *GENERAL_TARGETS, *verbose_args()) + session.run("python", "-m", "black", *GENERAL_TARGETS, *verbose_args()) + session.run("python", "-m", "ruff", "check", "--fix-only", "--fixable", "ALL", *GENERAL_TARGETS, *verbose_args()) session.log("sort-all") LOGGER.disabled = True diff --git a/pyproject.toml b/pyproject.toml index 804aafe2..96634f51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,10 +4,72 @@ build-backend = "setuptools.build_meta" [tool.black] line-length = 120 -target-version = ["py39"] -[tool.isort] -profile = "black" +[tool.ruff] +line-length = 120 +target-version = "py38" + +[tool.ruff.lint] +select = [ + "A", + "C4", + "C9", + "D", + "E", + "F", + "S", + "W", + "T20", + "PT", + "RSE" +] +exclude = ["tests", "test.py"] + +# A001, A002, A003: `id` variable/parameter/attribute +# C408: dict() with keyword arguments +# D101: Missing docstring in public module +# D105: Missing docstring in magic method +# D106: Missing docstring Model.Config +# D400: First line should end with a period +# D419: Docstring is empty +# PT007: Wrong values type in `@pytest.mark.parametrize` expected `list` of `tuple` +# PT018: Assertion should be broken down into multiple parts +# S101: Use of assert for type checking +# S303: Use of md5 +# S311: Use of pseudo-random generators +# S324: Use of md5 without usedforsecurity=False (3.9+) +ignore = [ + "A001", "A002", "A003", + "C408", + "D100", "D105", "D106", "D400", "D419", + "PT007", "PT018", + "S101", "S303", "S311", "S324", +] + +# auto-fixing too intrusive +# F401: Unused import +# F841: Unused variable +# B007: Unused loop variable +unfixable = ["F401", "F841", "B007"] + +[tool.ruff.lint.per-file-ignores] +# F401: unused import. +# F403: cannot detect unused vars if we use starred import +# D10*: docstrings +# S10*: hardcoded passwords +# F841: unused variable +"**/__init__.py" = ["F401", "F403"] +"tests/**" = ["D10", "S10", "F841"] + +[tool.ruff.lint.mccabe] +max-complexity = 16 + +[tool.ruff.lint.pycodestyle] +max-line-length = 130 + +[tool.ruff.lint.pydocstyle] +convention = "numpy" +ignore-decorators = ["property"] [tool.pytest.ini_options] asyncio_mode = "auto" From 16d7a7c258a1aefefbaa07bbbe0922f0f7239f78 Mon Sep 17 00:00:00 2001 From: seria Date: Mon, 1 Apr 2024 07:27:20 +0900 Subject: [PATCH 16/42] Add QR code login method for Miyoushe (#173) * chore(deps): Add dependencies * feat: Add cookie and qrcode models * feat: Add new routes * feat: Add new ds salt * feat: Add new qrcode-related methods to GeetestClient * feat: Add new game token related functions * feat: Add new ds utilities * Export miyoushe modules * refactor: Refactor passport ds utility func * chore: Run nox * chore(deps): Merge qrcode and pillow * fix: Change account_id type to int * feat: Add public method to client * feat: Set cookies to client after obtaining cookies * chore(deps): Add qrcode[pil] to geetest extras * docs: Add docstring for private methods --- genshin/client/components/geetest/client.py | 98 +++++++++++++++++++++ genshin/client/manager/cookie.py | 62 +++++++++++++ genshin/client/routes.py | 9 ++ genshin/constants.py | 1 + genshin/models/__init__.py | 1 + genshin/models/miyoushe/__init__.py | 4 + genshin/models/miyoushe/cookie.py | 23 +++++ genshin/models/miyoushe/qrcode.py | 54 ++++++++++++ genshin/utility/ds.py | 13 ++- requirements.txt | 1 + setup.py | 5 +- 11 files changed, 268 insertions(+), 3 deletions(-) create mode 100644 genshin/models/miyoushe/__init__.py create mode 100644 genshin/models/miyoushe/cookie.py create mode 100644 genshin/models/miyoushe/qrcode.py diff --git a/genshin/client/components/geetest/client.py b/genshin/client/components/geetest/client.py index 5398ab29..dbc9245c 100644 --- a/genshin/client/components/geetest/client.py +++ b/genshin/client/components/geetest/client.py @@ -1,14 +1,23 @@ """Geetest client component.""" +import asyncio import json +import logging +import random import typing +from string import ascii_letters, digits import aiohttp import aiohttp.web +import qrcode +import qrcode.image.pil +from qrcode.constants import ERROR_CORRECT_L from genshin import constants, errors from genshin.client import routes from genshin.client.components import base +from genshin.client.manager.cookie import fetch_cookie_token_by_game_token, fetch_stoken_by_game_token +from genshin.models.miyoushe.qrcode import QRCodeCheckResult, QRCodeCreationResult, QRCodeStatus from genshin.utility import ds as ds_utility from genshin.utility import geetest as geetest_utility @@ -16,6 +25,8 @@ __all__ = ["GeetestClient"] +LOGGER_ = logging.getLogger(__name__) + class GeetestClient(base.BaseClient): """Geetest client component.""" @@ -314,6 +325,53 @@ async def _login_with_mobile_otp(self, mobile: str, otp: str) -> typing.Dict[str return cookies + async def _create_qrcode(self) -> QRCodeCreationResult: + """Create a QR code for login.""" + device_id = "".join(random.choices(ascii_letters + digits, k=64)) + app_id = "8" + payload = { + "app_id": app_id, + "device": device_id, + } + + async with aiohttp.ClientSession() as session: + async with session.post( + routes.CREATE_QRCODE_URL.get_url(), + json=payload, + ) 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, + ) + + async def _check_qrcode(self, app_id: str, device_id: str, ticket: str) -> QRCodeCheckResult: + """Check the status of a QR code login.""" + payload = { + "app_id": app_id, + "device": device_id, + "ticket": ticket, + } + + async with aiohttp.ClientSession() as session: + async with session.post( + routes.CHECK_QRCODE_URL.get_url(), + json=payload, + ) as r: + data = await r.json() + + if not data["data"]: + errors.raise_for_retcode(data) + + return QRCodeCheckResult(**data["data"]) + async def login_with_password( self, account: str, @@ -463,3 +521,43 @@ async def login_with_app_password( result = await self._app_login(account, password, ticket=result) return result + + async def login_with_qrcode(self) -> typing.Dict[str, str]: + """Login with QR code, only available for Miyoushe users. + + Returns cookies. + """ + creation_result = await self._create_qrcode() + qrcode_: qrcode.image.pil.PilImage = qrcode.make(creation_result.url, error_correction=ERROR_CORRECT_L) # type: ignore + qrcode_.show() + + 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: + LOGGER_.info("QR code scanned") + scanned = True + elif check_result.status == 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 + + cookie_token = await fetch_cookie_token_by_game_token( + game_token=raw_data.game_token, account_id=raw_data.account_id + ) + stoken = await fetch_stoken_by_game_token(game_token=raw_data.game_token, account_id=int(raw_data.account_id)) + + cookies = { + "stoken_v2": stoken.token, + "stuid": stoken.aid, + "mid": stoken.mid, + "cookie_token": cookie_token, + } + self.set_cookies(cookies) + return cookies diff --git a/genshin/client/manager/cookie.py b/genshin/client/manager/cookie.py index a2b59d0c..76779f4d 100644 --- a/genshin/client/manager/cookie.py +++ b/genshin/client/manager/cookie.py @@ -18,7 +18,10 @@ from __future__ import annotations +import random import typing +import uuid +from string import ascii_letters, digits import aiohttp import aiohttp.typedefs @@ -26,16 +29,33 @@ from genshin import constants, errors, types from genshin.client import routes from genshin.client.manager import managers +from genshin.models.miyoushe.cookie import StokenResult from genshin.utility import ds as ds_utility __all__ = [ "complete_cookies", + "fetch_cookie_token_by_game_token", "fetch_cookie_token_info", "fetch_cookie_with_cookie", "fetch_cookie_with_stoken_v2", + "fetch_stoken_by_game_token", "refresh_cookie_token", ] +STOKEN_BY_GAME_TOKEN_HEADERS = { + "x-rpc-app_version": "2.41.0", + "x-rpc-aigis": "", + "Content-Type": "application/json", + "Accept": "application/json", + "x-rpc-game_biz": "bbs_cn", + "x-rpc-sys_version": "11", + "x-rpc-device_name": "GenshinUid_login_device_lulu", + "x-rpc-device_model": "GenshinUid_login_device_lulu", + "x-rpc-app_id": "bll8iq97cem8", + "x-rpc-client_type": "2", + "User-Agent": "okhttp/4.8.0", +} + async def fetch_cookie_with_cookie( cookies: managers.CookieOrHeader, @@ -166,3 +186,45 @@ async def complete_cookies( cookies = await refresh_cookie_token(cookies, region=region) # type: ignore[assignment] return cookies + + +async def fetch_cookie_token_by_game_token(*, game_token: str, account_id: str) -> str: + """Fetch cookie token by game token, which is obtained by scanning a QR code.""" + url = routes.GET_COOKIE_TOKEN_BY_GAME_TOKEN_URL.get_url() + params = { + "game_token": game_token, + "account_id": account_id, + } + + async with aiohttp.ClientSession() as session: + async with session.get(url, params=params) as r: + data = await r.json() + + if not data["data"]: + errors.raise_for_retcode(data) + + return data["data"]["cookie_token"] + + +async def fetch_stoken_by_game_token(*, game_token: str, account_id: int) -> StokenResult: + """Fetch cookie token by game token, which is obtained by scanning a QR code.""" + url = routes.GET_STOKEN_BY_GAME_TOKEN_URL.get_url() + payload = { + "account_id": account_id, + "game_token": game_token, + } + headers = { + "DS": ds_utility.generate_passport_ds(body=payload), + "x-rpc-device_id": uuid.uuid4().hex, + "x-rpc-device_fp": "".join(random.choices(ascii_letters + digits, k=13)), + **STOKEN_BY_GAME_TOKEN_HEADERS, + } + + async with aiohttp.ClientSession() as session: + async with session.post(url, json=payload, headers=headers) as r: + data = await r.json() + + if not data["data"]: + errors.raise_for_retcode(data) + + return StokenResult(**data["data"]) diff --git a/genshin/client/routes.py b/genshin/client/routes.py index aa153ee3..a398deb8 100644 --- a/genshin/client/routes.py +++ b/genshin/client/routes.py @@ -13,11 +13,15 @@ "BBS_REFERER_URL", "BBS_URL", "CALCULATOR_URL", + "CHECK_QRCODE_URL", "CN_WEB_LOGIN_URL", "COMMUNITY_URL", "COOKIE_V2_REFRESH_URL", + "CREATE_QRCODE_URL", "DETAIL_LEDGER_URL", "GACHA_URL", + "GET_COOKIE_TOKEN_BY_GAME_TOKEN_URL", + "GET_STOKEN_BY_GAME_TOKEN_URL", "HK4E_URL", "INFO_LEDGER_URL", "LINEUP_URL", @@ -215,6 +219,8 @@ def get_url(self, region: types.Region, game: types.Game) -> yarl.URL: ) COOKIE_V2_REFRESH_URL = Route("https://sg-public-api.hoyoverse.com/account/ma-passport/token/getBySToken") +GET_COOKIE_TOKEN_BY_GAME_TOKEN_URL = Route("https://api-takumi.mihoyo.com/auth/api/getCookieAccountInfoByGameToken") +GET_STOKEN_BY_GAME_TOKEN_URL = Route("https://passport-api.mihoyo.com/account/ma-cn-session/app/getTokenByGameToken") WEB_LOGIN_URL = Route("https://sg-public-api.hoyolab.com/account/ma-passport/api/webLoginByPassword") APP_LOGIN_URL = Route("https://sg-public-api.hoyoverse.com/account/ma-passport/api/appLoginByPassword") @@ -228,3 +234,6 @@ def get_url(self, region: types.Region, game: types.Game) -> yarl.URL: CHECK_MOBILE_VALIDITY_URL = Route("https://webapi.account.mihoyo.com/Api/is_mobile_registrable") 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") diff --git a/genshin/constants.py b/genshin/constants.py index 2d86a6ff..64e42e05 100644 --- a/genshin/constants.py +++ b/genshin/constants.py @@ -29,5 +29,6 @@ types.Region.CHINESE: "xV8v4Qu54lUKrEYFZkJhB8cuOh9Asafs", "app_login": "IZPgfb0dRPtBeLuFkdDznSZ6f4wWt6y2", "cn_signin": "9nQiU3AV0rJSIBWgdynfoGMGKaklfbM7", + "cn_passport": "JwYDpKvLj6MrMqqYU6jTKF17KNO2PXoS", } """Dynamic Secret Salts.""" diff --git a/genshin/models/__init__.py b/genshin/models/__init__.py index 35edc5e7..38da6131 100644 --- a/genshin/models/__init__.py +++ b/genshin/models/__init__.py @@ -3,5 +3,6 @@ from .genshin import * from .honkai import * from .hoyolab import * +from .miyoushe import * from .model import * from .starrail import * diff --git a/genshin/models/miyoushe/__init__.py b/genshin/models/miyoushe/__init__.py new file mode 100644 index 00000000..f34841ca --- /dev/null +++ b/genshin/models/miyoushe/__init__.py @@ -0,0 +1,4 @@ +"""Miyoushe models.""" + +from .cookie import * +from .qrcode import * diff --git a/genshin/models/miyoushe/cookie.py b/genshin/models/miyoushe/cookie.py new file mode 100644 index 00000000..4cca01cf --- /dev/null +++ b/genshin/models/miyoushe/cookie.py @@ -0,0 +1,23 @@ +"""Miyoushe Cookie Models""" + +import typing + +from pydantic import BaseModel, model_validator + +__all__ = ("StokenResult",) + + +class StokenResult(BaseModel): + """Stoken result.""" + + aid: str + mid: str + token: str + + @model_validator(mode="before") + def _transform_result(cls, values: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + return { + "aid": values["user_info"]["aid"], + "mid": values["user_info"]["mid"], + "token": values["token"]["token"], + } diff --git a/genshin/models/miyoushe/qrcode.py b/genshin/models/miyoushe/qrcode.py new file mode 100644 index 00000000..5ce6d0a6 --- /dev/null +++ b/genshin/models/miyoushe/qrcode.py @@ -0,0 +1,54 @@ +"""Miyoushe QR Code Models""" + +import json +from enum import Enum + +from pydantic import BaseModel, Field, field_validator + +__all__ = ("QRCodeCheckResult", "QRCodeCreationResult", "QRCodePayload", "QRCodeRawData", "QRCodeStatus") + + +class QRCodeStatus(Enum): + """QR code check status.""" + + INIT = "Init" + SCANNED = "Scanned" + CONFIRMED = "Confirmed" + + +class QRCodeRawData(BaseModel): + """QR code raw data.""" + + account_id: str = Field(alias="uid") + """Miyoushe account id.""" + game_token: str = Field(alias="token") + + +class QRCodePayload(BaseModel): + """QR code check result payload.""" + + proto: str + raw: QRCodeRawData | None + ext: str + + @field_validator("raw", mode="before") + def _convert_raw_data(cls, value: str | None) -> QRCodeRawData | None: + if value: + return QRCodeRawData(**json.loads(value)) + return None + + +class QRCodeCheckResult(BaseModel): + """QR code check result.""" + + status: QRCodeStatus = Field(alias="stat") + payload: QRCodePayload + + +class QRCodeCreationResult(BaseModel): + """QR code creation result.""" + + app_id: str + ticket: str + device_id: str + url: str diff --git a/genshin/utility/ds.py b/genshin/utility/ds.py index 1cd169f5..adae34c3 100644 --- a/genshin/utility/ds.py +++ b/genshin/utility/ds.py @@ -9,7 +9,7 @@ from genshin import constants, types -__all__ = ["generate_cn_dynamic_secret", "generate_dynamic_secret", "get_ds_headers"] +__all__ = ["generate_cn_dynamic_secret", "generate_dynamic_secret", "generate_passport_ds", "get_ds_headers"] def generate_dynamic_secret(salt: str = constants.DS_SALT[types.Region.OVERSEAS]) -> str: @@ -59,3 +59,14 @@ def get_ds_headers( else: raise TypeError(f"{region!r} is not a valid region.") return ds_headers + + +def generate_passport_ds(body: typing.Mapping[str, typing.Any]) -> str: + """Create a dynamic secret for Miyoushe passport API.""" + salt = constants.DS_SALT["cn_passport"] + t = int(time.time()) + r = "".join(random.sample(string.ascii_letters, 6)) + b = json.dumps(body) + h = hashlib.md5(f"salt={salt}&t={t}&r={r}&b={b}&q=".encode()).hexdigest() + result = f"{t},{r},{h}" + return result diff --git a/requirements.txt b/requirements.txt index 95cbe366..fa1c601e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ browser-cookie3 rsa aioredis click +qrcode[pil] \ No newline at end of file diff --git a/setup.py b/setup.py index 96e87d66..6ac94305 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,5 @@ """Run setuptools.""" + from setuptools import find_packages, setup setup( @@ -17,9 +18,9 @@ python_requires=">=3.8", install_requires=["aiohttp", "pydantic"], extras_require={ - "all": ["browser-cookie3", "rsa", "click"], + "all": ["browser-cookie3", "rsa", "click", "qrcode[pil]"], "cookies": ["browser-cookie3"], - "geetest": ["rsa"], + "geetest": ["rsa", "qrcode[pil]"], "cli": ["click"], }, include_package_data=True, From 45532009be345af2792d6c9c1876d4239ee88ec3 Mon Sep 17 00:00:00 2001 From: seria Date: Mon, 1 Apr 2024 07:49:32 +0900 Subject: [PATCH 17/42] Fix UID utilities to work with new Genshin UID standard (#171) * fix: Fix uid utilities * fix: Fix logic * refactor: Use string slicing approach * refactor: Use in-member check --- genshin/utility/uid.py | 72 ++++++++++++++++++++---------------------- 1 file changed, 35 insertions(+), 37 deletions(-) diff --git a/genshin/utility/uid.py b/genshin/utility/uid.py index e0fabcae..64e4b668 100644 --- a/genshin/utility/uid.py +++ b/genshin/utility/uid.py @@ -15,22 +15,42 @@ "recognize_starrail_server", ] -UID_RANGE: typing.Mapping[types.Game, typing.Mapping[types.Region, typing.Sequence[int]]] = { +UID_RANGE: typing.Mapping[types.Game, typing.Mapping[types.Region, typing.Sequence[str]]] = { types.Game.GENSHIN: { - types.Region.OVERSEAS: (6, 7, 8, 9), - types.Region.CHINESE: (1, 2, 5), + types.Region.OVERSEAS: ("6", "7", "8", "18", "9"), + types.Region.CHINESE: ("1", "2", "3", "5"), }, types.Game.STARRAIL: { - types.Region.OVERSEAS: (6, 7, 8, 9), - types.Region.CHINESE: (1, 2, 5), + types.Region.OVERSEAS: ("6", "7", "8", "9"), + types.Region.CHINESE: ("1", "2", "5"), }, types.Game.HONKAI: { - types.Region.OVERSEAS: (1, 2), - types.Region.CHINESE: (3, 4), + types.Region.OVERSEAS: ("1", "2"), + types.Region.CHINESE: ("3", "4"), }, } """Mapping of games and regions to their respective UID ranges.""" +GENSHIN_SERVER_RANGE: typing.Mapping[str, typing.Sequence[str]] = { + "cn_gf01": ("1", "2", "3"), + "cn_qd01": ("5",), + "os_usa": ("6",), + "os_euro": ("7",), + "os_asia": ("8", "18"), + "os_cht": ("9",), +} +"""Mapping of Genshin servers to their respective UID ranges.""" + +STARRAIL_SERVER_RANGE: typing.Mapping[str, typing.Sequence[str]] = { + "prod_gf_cn": ("1", "2"), + "prod_qd_cn": ("5",), + "prod_official_usa": ("6",), + "prod_official_eur": ("7",), + "prod_official_asia": ("8",), + "prod_official_cht": ("9",), +} +"""Mapping of Star Rail servers to their respective UID ranges.""" + def create_short_lang_code(lang: str) -> str: """Create an alternative short lang code.""" @@ -39,18 +59,9 @@ def create_short_lang_code(lang: str) -> str: def recognize_genshin_server(uid: int) -> str: """Recognize which server a Genshin UID is from.""" - server = { - "1": "cn_gf01", - "2": "cn_gf01", - "5": "cn_qd01", - "6": "os_usa", - "7": "os_euro", - "8": "os_asia", - "9": "os_cht", - }.get(str(uid)[0]) - - if server: - return server + for server_name, digits in GENSHIN_SERVER_RANGE.items(): + if str(uid)[:-8] in digits: + return server_name raise ValueError(f"UID {uid} isn't associated with any server") @@ -91,18 +102,9 @@ def recognize_honkai_server(uid: int) -> str: def recognize_starrail_server(uid: int) -> str: """Recognize which server a Star Rail UID is from.""" - server = { - "1": "prod_gf_cn", - "2": "prod_gf_cn", - "5": "prod_qd_cn", - "6": "prod_official_usa", - "7": "prod_official_eur", - "8": "prod_official_asia", - "9": "prod_official_cht", - }.get(str(uid)[0]) - - if server: - return server + for server, digits in STARRAIL_SERVER_RANGE.items(): + if str(uid)[:-8] in digits: + return server raise ValueError(f"UID {uid} isn't associated with any server") @@ -124,10 +126,8 @@ def recognize_game(uid: int, region: types.Region) -> typing.Optional[types.Game if len(str(uid)) == 8: return types.Game.HONKAI - first = int(str(uid)[0]) - for game, digits in UID_RANGE.items(): - if first in digits[region]: + if str(uid)[:-8] in digits[region]: return game return None @@ -135,10 +135,8 @@ def recognize_game(uid: int, region: types.Region) -> typing.Optional[types.Game def recognize_region(uid: int, game: types.Game) -> typing.Optional[types.Region]: """Recognize the region of a uid.""" - first = int(str(uid)[0]) - for region, digits in UID_RANGE[game].items(): - if first in digits: + if str(uid)[:-8] in digits: return region return None From 6b30d30e211b655bee1fcf5aaa342ed4ba0f9564 Mon Sep 17 00:00:00 2001 From: seria Date: Mon, 1 Apr 2024 08:36:08 +0900 Subject: [PATCH 18/42] Fix get_banner_details (#174) * fix: Fix routes * fix: Fix routes and add temp fix --- genshin/client/components/gacha.py | 13 ++++++++++--- genshin/client/routes.py | 4 ++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/genshin/client/components/gacha.py b/genshin/client/components/gacha.py index fe6cad62..19fc90b0 100644 --- a/genshin/client/components/gacha.py +++ b/genshin/client/components/gacha.py @@ -222,7 +222,7 @@ async def _get_banner_details( server = "prod_official_asia" if game == types.Game.STARRAIL else "os_asia" data = await self.request_webstatic( - f"/{region}/gacha_info/{server}/{banner_id}/{lang}.json", + f"/gacha_info/{region}/{server}/{banner_id}/{lang}.json", cache=client_cache.cache_key("banner", endpoint="details", banner=banner_id, lang=lang), ) return models.BannerDetails(**data, banner_id=banner_id) @@ -240,12 +240,19 @@ async def get_genshin_banner_ids(self) -> typing.Sequence[str]: Uses the current cn banners. """ + + def process_gacha(data: typing.Mapping[str, typing.Any]) -> str: + # Temporary fix for 4.5 chronicled wish + if data["gacha_type"] == 500: + return "8b10b48c52dd6870f92d72e9963b44bb8968ed2f" + return data["gacha_id"] + data = await self.request_webstatic( - "hk4e/gacha_info/cn_gf01/gacha/list.json", + "gacha_info/hk4e/cn_gf01/gacha/list.json", region=types.Region.CHINESE, cache=client_cache.cache_key("banner", endpoint="ids"), ) - return [i["gacha_id"] for i in data["data"]["list"]] + return list(map(process_gacha, data["data"]["list"])) async def get_banner_details( self, diff --git a/genshin/client/routes.py b/genshin/client/routes.py index a398deb8..a24d048c 100644 --- a/genshin/client/routes.py +++ b/genshin/client/routes.py @@ -102,8 +102,8 @@ def get_url(self, region: types.Region, game: types.Game) -> yarl.URL: WEBSTATIC_URL = InternationalRoute( - "https://webstatic-sea.hoyoverse.com/", - "https://webstatic.mihoyo.com/", + "https://operation-webstatic.hoyoverse.com/", + "https://operation-webstatic.mihoyo.com/", ) WEBAPI_URL = InternationalRoute( From b83eeb6f62392429d6a9aa10215d8ad5211c5d83 Mon Sep 17 00:00:00 2001 From: seria Date: Wed, 3 Apr 2024 09:45:25 +0900 Subject: [PATCH 19/42] Fix update_charactes_enka function (#176) --- genshin/utility/extdb.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/genshin/utility/extdb.py b/genshin/utility/extdb.py index 8225d504..b3058cd3 100644 --- a/genshin/utility/extdb.py +++ b/genshin/utility/extdb.py @@ -173,8 +173,10 @@ async def update_characters_enka(langs: typing.Sequence[str] = ()) -> None: continue # traveler element for short_lang, loc in locs.items(): + if (lang := ENKA_LANG_MAP.get(short_lang)) is None: + continue update_character_name( - lang=ENKA_LANG_MAP[short_lang], + lang=lang, id=int(strid), icon_name=char["SideIconName"][len("UI_AvatarIcon_Side_") :], # noqa: E203 name=loc[str(char["NameTextMapHash"])], From 524b51a0ce372820831f39bb4f78b053149aadeb Mon Sep 17 00:00:00 2001 From: ashlen Date: Wed, 10 Apr 2024 17:49:12 +0000 Subject: [PATCH 20/42] Bump pypi version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 6ac94305..c8cb0974 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ setup( name="genshin", - version="1.6.2", + version="1.6.3", author="thesadru", author_email="thesadru@gmail.com", description="An API wrapper for Genshin Impact.", From 47aa014493631e59982a7ec06134062a5dadab2a Mon Sep 17 00:00:00 2001 From: seria Date: Sat, 13 Apr 2024 12:27:59 +0900 Subject: [PATCH 21/42] Improve reformat session in nox (#178) * chore(deps): Remove sort-all from requirements * chore(nox): Update reformat session * style: Format `__all__` --- genshin-dev/reformat-requirements.txt | 3 +-- genshin/client/routes.py | 2 +- genshin/errors.py | 2 +- genshin/models/honkai/chronicle/modes.py | 2 +- noxfile.py | 21 +++++++++++++++------ 5 files changed, 19 insertions(+), 11 deletions(-) diff --git a/genshin-dev/reformat-requirements.txt b/genshin-dev/reformat-requirements.txt index 1cf5c13f..6b7ba109 100644 --- a/genshin-dev/reformat-requirements.txt +++ b/genshin-dev/reformat-requirements.txt @@ -1,3 +1,2 @@ black -ruff -sort-all \ No newline at end of file +ruff \ No newline at end of file diff --git a/genshin/client/routes.py b/genshin/client/routes.py index a24d048c..908143c2 100644 --- a/genshin/client/routes.py +++ b/genshin/client/routes.py @@ -28,7 +28,6 @@ "MI18N", "RECORD_URL", "REWARD_URL", - "Route", "TAKUMI_URL", "TEAPOT_URL", "VERIFY_EMAIL_URL", @@ -36,6 +35,7 @@ "WEBSTATIC_URL", "WEB_LOGIN_URL", "YSULOG_URL", + "Route", ] diff --git a/genshin/errors.py b/genshin/errors.py index f62ade2f..b5225000 100644 --- a/genshin/errors.py +++ b/genshin/errors.py @@ -3,13 +3,13 @@ import typing __all__ = [ + "ERRORS", "AccountNotFound", "AlreadyClaimed", "AuthkeyException", "AuthkeyTimeout", "CookieException", "DataNotPublic", - "ERRORS", "GeetestTriggered", "GenshinException", "InvalidAuthkey", diff --git a/genshin/models/honkai/chronicle/modes.py b/genshin/models/honkai/chronicle/modes.py index 92067ebd..50a85116 100644 --- a/genshin/models/honkai/chronicle/modes.py +++ b/genshin/models/honkai/chronicle/modes.py @@ -18,8 +18,8 @@ from genshin.models.model import Aliased, APIModel, Unique __all__ = [ - "Boss", "ELF", + "Boss", "ElysianRealm", "MemorialArena", "MemorialBattle", diff --git a/noxfile.py b/noxfile.py index 3da55b37..93c6c5a4 100644 --- a/noxfile.py +++ b/noxfile.py @@ -60,12 +60,21 @@ def reformat(session: nox.Session) -> None: """Reformat this project's modules to fit the standard style.""" install_requirements(session, "reformat") session.run("python", "-m", "black", *GENERAL_TARGETS, *verbose_args()) - session.run("python", "-m", "ruff", "check", "--fix-only", "--fixable", "ALL", *GENERAL_TARGETS, *verbose_args()) - - session.log("sort-all") - LOGGER.disabled = True - session.run("sort-all", *map(str, pathlib.Path(PACKAGE).glob("**/*.py")), success_codes=[0, 1]) - LOGGER.disabled = False + # sort __all__ and format imports + session.run( + "python", + "-m", + "ruff", + "check", + "--preview", + "--select", + "RUF022,I", + "--fix", + *GENERAL_TARGETS, + *verbose_args(), + ) + # fix all fixable linting errors + session.run("ruff", "check", "--fix", *GENERAL_TARGETS, *verbose_args()) @nox.session(name="test") From a28a675858c57e648c3ddd6cacf473c6d4951d65 Mon Sep 17 00:00:00 2001 From: seria Date: Sat, 13 Apr 2024 12:32:02 +0900 Subject: [PATCH 22/42] fix: Move qrcode lib import statement into method (#179) --- genshin/client/components/geetest/client.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/genshin/client/components/geetest/client.py b/genshin/client/components/geetest/client.py index dbc9245c..0b1d8373 100644 --- a/genshin/client/components/geetest/client.py +++ b/genshin/client/components/geetest/client.py @@ -9,8 +9,6 @@ import aiohttp import aiohttp.web -import qrcode -import qrcode.image.pil from qrcode.constants import ERROR_CORRECT_L from genshin import constants, errors @@ -527,6 +525,9 @@ async def login_with_qrcode(self) -> typing.Dict[str, str]: Returns cookies. """ + import qrcode + import qrcode.image.pil + creation_result = await self._create_qrcode() qrcode_: qrcode.image.pil.PilImage = qrcode.make(creation_result.url, error_correction=ERROR_CORRECT_L) # type: ignore qrcode_.show() From 28d3c2e7a1c4ce9eaac6ff27d0f610091bc934c4 Mon Sep 17 00:00:00 2001 From: seria Date: Sat, 13 Apr 2024 12:54:00 +0900 Subject: [PATCH 23/42] Move import statement of qrcode constants into method --- genshin/client/components/geetest/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/genshin/client/components/geetest/client.py b/genshin/client/components/geetest/client.py index 0b1d8373..c594ecc1 100644 --- a/genshin/client/components/geetest/client.py +++ b/genshin/client/components/geetest/client.py @@ -9,7 +9,6 @@ import aiohttp import aiohttp.web -from qrcode.constants import ERROR_CORRECT_L from genshin import constants, errors from genshin.client import routes @@ -527,6 +526,7 @@ async def login_with_qrcode(self) -> typing.Dict[str, str]: """ import qrcode import qrcode.image.pil + from qrcode.constants import ERROR_CORRECT_L creation_result = await self._create_qrcode() qrcode_: qrcode.image.pil.PilImage = qrcode.make(creation_result.url, error_correction=ERROR_CORRECT_L) # type: ignore From c32ed4b81d23fa734da768753b566466c23a8d7a Mon Sep 17 00:00:00 2001 From: seria Date: Sun, 14 Apr 2024 09:02:51 +0900 Subject: [PATCH 24/42] Revert qrcode dependency fix (#180) --- genshin/client/components/geetest/client.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/genshin/client/components/geetest/client.py b/genshin/client/components/geetest/client.py index c594ecc1..dbc9245c 100644 --- a/genshin/client/components/geetest/client.py +++ b/genshin/client/components/geetest/client.py @@ -9,6 +9,9 @@ import aiohttp import aiohttp.web +import qrcode +import qrcode.image.pil +from qrcode.constants import ERROR_CORRECT_L from genshin import constants, errors from genshin.client import routes @@ -524,10 +527,6 @@ async def login_with_qrcode(self) -> typing.Dict[str, str]: Returns cookies. """ - import qrcode - import qrcode.image.pil - from qrcode.constants import ERROR_CORRECT_L - creation_result = await self._create_qrcode() qrcode_: qrcode.image.pil.PilImage = qrcode.make(creation_result.url, error_correction=ERROR_CORRECT_L) # type: ignore qrcode_.show() From 1a1c03bada6f215142a5b370f778caea8abfdc0e Mon Sep 17 00:00:00 2001 From: seria Date: Thu, 23 May 2024 19:43:43 +0900 Subject: [PATCH 25/42] Merge dev Branch Into master (#187) * Add geetest v4 support, refactor geetest server code * Don't use pipe for older python versions compatibility * feat: Add create geetest functionality * Use client cookies in `create_geetest` method * Refactor and modelify auth component * Don't use pipe for older python versions compatibility (2) * feat: Add CNWebLoginResult and MobileLoginResult * refactor: Rename methods for better clarity * Add geetest proxying to bypass referrer check * Add hmac hashing function * Implement game login * Add regions decorators * Add error handling to `create_mmt` method * Add HI3 support * Refactor qr code models * Use game-specific `app_id` * refactor: Remove unnecessary cn login headers * refactor: Remove unnecessary headers * style: Format imports * feat: Use client's game when requesting qr code * Don't use explicit defaults in overloads * Use proper RSA key for cn routes * Merge captcha-related HTML * Reformat and fix type errors * Fix QR code imports * Add ZZZ support in game login * fix: Fix mypy errors Fixes error: "type" expects no type arguments, but 1 given [type-arg] Fixes error: "dict" is not subscriptable, use "typing.Dict" instead [misc] * Fix HI3 new battle suit type * Fix icon assertion in tests * Fix QR code login invalid cookies error * Fix test failing because icon URL changed * Fix Signora test icon URL causing test to fail * Remove AccountNotFound exception test * Add `archon_quest_progress` to genshin notes * Add missing models to __all__ * Add value to ArchonQuestStatus enum * Fix daily check-in for Miyoushe Genshin not working * Bypass referer check without using proxy * Allow passing encrypted credentials to all auth funcs * Fix cn daily check-in and update salt * Auto-detect account server during daily check-in * Rename HSR character path field and use enum * Add merged `login_with_password` method * Add missing `encrypted` arg * Rename `encrypt_geetest_credentials` into `encrypt_credentials` * Remove usage of pydantic V2 stuff * Ensure Pydantic V1 compatibility * Add exceptions for auth component * Prevent type check from failing --------- Co-authored-by: JokelBaf <60827680+JokelBaf@users.noreply.github.com> Co-authored-by: Lalatina <111925939+save-lalatina@users.noreply.github.com> --- genshin/__main__.py | 6 +- genshin/client/clients.py | 4 +- genshin/client/components/auth/__init__.py | 10 + genshin/client/components/auth/client.py | 343 +++++++++++ genshin/client/components/auth/server.py | 266 +++++++++ .../components/auth/subclients/__init__.py | 5 + .../client/components/auth/subclients/app.py | 225 +++++++ .../client/components/auth/subclients/game.py | 197 ++++++ .../client/components/auth/subclients/web.py | 232 ++++++++ genshin/client/components/base.py | 72 ++- genshin/client/components/daily.py | 19 +- genshin/client/components/geetest/__init__.py | 6 - genshin/client/components/geetest/client.py | 563 ------------------ genshin/client/components/geetest/server.py | 192 ------ genshin/client/components/transaction.py | 2 +- genshin/client/manager/cookie.py | 36 +- genshin/client/manager/managers.py | 5 + genshin/client/routes.py | 54 +- genshin/constants.py | 45 +- genshin/errors.py | 61 ++ genshin/models/__init__.py | 2 +- genshin/models/auth/__init__.py | 6 + genshin/models/auth/cookie.py | 144 +++++ genshin/models/auth/geetest.py | 173 ++++++ genshin/models/auth/qrcode.py | 61 ++ genshin/models/auth/responses.py | 53 ++ genshin/models/auth/verification.py | 46 ++ genshin/models/genshin/chronicle/notes.py | 30 + genshin/models/genshin/teapot.py | 2 +- genshin/models/honkai/battlesuit.py | 1 + genshin/models/hoyolab/announcements.py | 2 +- genshin/models/miyoushe/__init__.py | 4 - genshin/models/miyoushe/cookie.py | 23 - genshin/models/miyoushe/qrcode.py | 54 -- .../models/starrail/chronicle/characters.py | 18 +- genshin/paginators/base.py | 2 +- genshin/types.py | 3 + genshin/utility/__init__.py | 2 +- genshin/utility/auth.py | 159 +++++ genshin/utility/ds.py | 17 +- genshin/utility/geetest.py | 86 --- setup.py | 2 +- tests/client/components/test_calculator.py | 2 +- .../components/test_genshin_chronicle.py | 3 - tests/models/test_model.py | 16 +- 45 files changed, 2262 insertions(+), 992 deletions(-) create mode 100644 genshin/client/components/auth/__init__.py create mode 100644 genshin/client/components/auth/client.py create mode 100644 genshin/client/components/auth/server.py create mode 100644 genshin/client/components/auth/subclients/__init__.py create mode 100644 genshin/client/components/auth/subclients/app.py create mode 100644 genshin/client/components/auth/subclients/game.py create mode 100644 genshin/client/components/auth/subclients/web.py delete mode 100644 genshin/client/components/geetest/__init__.py delete mode 100644 genshin/client/components/geetest/client.py delete mode 100644 genshin/client/components/geetest/server.py create mode 100644 genshin/models/auth/__init__.py create mode 100644 genshin/models/auth/cookie.py create mode 100644 genshin/models/auth/geetest.py create mode 100644 genshin/models/auth/qrcode.py create mode 100644 genshin/models/auth/responses.py create mode 100644 genshin/models/auth/verification.py delete mode 100644 genshin/models/miyoushe/__init__.py delete mode 100644 genshin/models/miyoushe/cookie.py delete mode 100644 genshin/models/miyoushe/qrcode.py create mode 100644 genshin/utility/auth.py delete mode 100644 genshin/utility/geetest.py diff --git a/genshin/__main__.py b/genshin/__main__.py index fd047c9a..2904cf03 100644 --- a/genshin/__main__.py +++ b/genshin/__main__.py @@ -88,7 +88,7 @@ async def honkai_stats(client: genshin.Client, uid: int) -> None: for k, v in data.stats.as_dict(lang=client.lang).items(): if isinstance(v, dict): click.echo(f"{k}:") - for nested_k, nested_v in typing.cast("dict[str, object]", v).items(): + for nested_k, nested_v in typing.cast("typing.Dict[str, object]", v).items(): click.echo(f" {nested_k}: {click.style(str(nested_v), bold=True)}") else: click.echo(f"{k}: {click.style(str(v), bold=True)}") @@ -338,8 +338,8 @@ def authkey() -> None: async def login(account: str, password: str, port: int) -> None: """Login with a password.""" client = genshin.Client() - cookies = await client.login_with_password(account, password, port=port) - cookies = await genshin.complete_cookies(cookies) + result = await client.os_login_with_password(account, password, port=port) + cookies = await genshin.complete_cookies(result.dict()) base: http.cookies.BaseCookie[str] = http.cookies.BaseCookie(cookies) click.echo(f"Your cookies are: {click.style(base.output(header='', sep=';'), bold=True)}") diff --git a/genshin/client/clients.py b/genshin/client/clients.py index 87e8aa9d..99baf8ab 100644 --- a/genshin/client/clients.py +++ b/genshin/client/clients.py @@ -1,12 +1,12 @@ """A simple HTTP client for API endpoints.""" from .components import ( + auth, calculator, chronicle, daily, diary, gacha, - geetest, hoyolab, lineup, teapot, @@ -28,6 +28,6 @@ class Client( wiki.WikiClient, gacha.WishClient, transaction.TransactionClient, - geetest.GeetestClient, + auth.AuthClient, ): """A simple HTTP client for API endpoints.""" diff --git a/genshin/client/components/auth/__init__.py b/genshin/client/components/auth/__init__.py new file mode 100644 index 00000000..7e9ded02 --- /dev/null +++ b/genshin/client/components/auth/__init__.py @@ -0,0 +1,10 @@ +"""Auth-related utility. + +Credits to: +- JokelBaf - https://github.com/jokelbaf +- Seria - https://github.com/seriaati +- M-307 - https://github.com/mrwan200 +- gsuid_core - https://github.com/Genshin-bots/gsuid_core +""" + +from .client import * diff --git a/genshin/client/components/auth/client.py b/genshin/client/components/auth/client.py new file mode 100644 index 00000000..73223aa1 --- /dev/null +++ b/genshin/client/components/auth/client.py @@ -0,0 +1,343 @@ +"""Main auth client.""" + +import asyncio +import logging +import typing + +import aiohttp + +from genshin import errors, types +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, + GameLoginResult, + MobileLoginResult, + QRLoginResult, + WebLoginResult, +) +from genshin.models.auth.geetest import MMT, RiskyCheckMMT, RiskyCheckMMTResult, SessionMMT, SessionMMTResult +from genshin.models.auth.qrcode import QRCodeStatus +from genshin.models.auth.verification import ActionTicket +from genshin.types import Game +from genshin.utility import auth as auth_utility +from genshin.utility import ds as ds_utility + +from . import server, subclients + +__all__ = ["AuthClient"] + +LOGGER_ = logging.getLogger(__name__) + + +class AuthClient(subclients.AppAuthClient, subclients.WebAuthClient, subclients.GameAuthClient): + """Auth client component.""" + + async def login_with_password( + self, + account: str, + password: str, + *, + port: int = 5000, + encrypted: bool = False, + geetest_solver: typing.Optional[typing.Callable[[SessionMMT], typing.Awaitable[SessionMMTResult]]] = None, + ) -> typing.Union[WebLoginResult, CNWebLoginResult]: + """Login with a password via web endpoint. + + Endpoint is chosen based on client region. + + Note that this will start a webserver if captcha is + triggered and `geetest_solver` is not passed. + + Raises + ------ + - AccountLoginFail: Invalid password provided. + - AccountDoesNotExist: Invalid email/username. + """ + if self.region is types.Region.CHINESE: + return await self.cn_login_with_password( + account, password, encrypted=encrypted, port=port, geetest_solver=geetest_solver + ) + + return await self.os_login_with_password( + account, password, port=port, encrypted=encrypted, geetest_solver=geetest_solver + ) + + @base.region_specific(types.Region.OVERSEAS) + async def os_login_with_password( + self, + account: str, + password: str, + *, + port: int = 5000, + encrypted: bool = False, + token_type: typing.Optional[int] = 6, + geetest_solver: typing.Optional[typing.Callable[[SessionMMT], typing.Awaitable[SessionMMTResult]]] = None, + ) -> WebLoginResult: + """Login with a password via web endpoint. + + Note that this will start a webserver if captcha is + triggered and `geetest_solver` is not passed. + + Raises + ------ + - AccountLoginFail: Invalid password provided. + - AccountDoesNotExist: Invalid email/username. + """ + result = await self._os_web_login(account, password, encrypted=encrypted, token_type=token_type) + + if not isinstance(result, SessionMMT): + # Captcha not triggered + return result + + if geetest_solver: + mmt_result = await geetest_solver(result) + else: + mmt_result = await server.solve_geetest(result, port=port) + + return await self._os_web_login( + account, password, encrypted=encrypted, token_type=token_type, mmt_result=mmt_result + ) + + @base.region_specific(types.Region.CHINESE) + async def cn_login_with_password( + self, + account: str, + password: str, + *, + encrypted: bool = False, + port: int = 5000, + geetest_solver: typing.Optional[typing.Callable[[SessionMMT], typing.Awaitable[SessionMMTResult]]] = None, + ) -> CNWebLoginResult: + """Login with a password via Miyoushe loginByPassword endpoint. + + Note that this will start a webserver if captcha is + triggered and `geetest_solver` is not passed. + """ + result = await self._cn_web_login(account, password, encrypted=encrypted) + + if not isinstance(result, SessionMMT): + # Captcha not triggered + return result + + if geetest_solver: + mmt_result = await geetest_solver(result) + else: + mmt_result = await server.solve_geetest(result, port=port) + + return await self._cn_web_login(account, password, encrypted=encrypted, mmt_result=mmt_result) + + @base.region_specific(types.Region.OVERSEAS) + async def check_mobile_number_validity(self, mobile: str) -> bool: + """Check if a mobile number is valid (it's registered on Miyoushe). + + Returns True if the mobile number is valid, False otherwise. + """ + async with aiohttp.ClientSession() as session: + async with session.get( + routes.CHECK_MOBILE_VALIDITY_URL.get_url(), + params={"mobile": mobile}, + ) as r: + data = await r.json() + + return data["data"]["status"] != data["data"]["is_registable"] + + @base.region_specific(types.Region.CHINESE) + async def login_with_mobile_number( + self, + mobile: str, + *, + encrypted: bool = False, + port: int = 5000, + ) -> MobileLoginResult: + """Login with mobile number, returns cookies. + + Only works for Chinese region (Miyoushe) users, do not include + area code (+86) in the mobile number. + + Steps: + 1. Sends OTP to the provided mobile number. + 2. If captcha is triggered, prompts the user to solve it. + 3. Lets user enter the OTP. + 4. Logs in with the OTP. + 5. Returns cookies. + """ + result = await self._send_mobile_otp(mobile, encrypted=encrypted) + + if isinstance(result, SessionMMT): + # Captcha triggered + mmt_result = await server.solve_geetest(result, port=port) + await self._send_mobile_otp(mobile, encrypted=encrypted, mmt_result=mmt_result) + + otp = await server.enter_code(port=port) + return await self._login_with_mobile_otp(mobile, otp, encrypted=encrypted) + + @base.region_specific(types.Region.OVERSEAS) + async def login_with_app_password( + self, + account: str, + password: str, + *, + encrypted: bool = False, + port: int = 5000, + geetest_solver: typing.Optional[typing.Callable[[SessionMMT], typing.Awaitable[SessionMMTResult]]] = None, + ) -> AppLoginResult: + """Login with a password via HoYoLab app endpoint. + + Note that this will start a webserver if either of the + following happens: + + 1. Captcha is triggered and `geetest_solver` is not passed. + 2. Email verification is triggered (can happen if you + first login with a new device). + + Raises + ------ + - AccountLoginFail: Invalid password provided. + - AccountDoesNotExist: Invalid email/username. + - VerificationCodeRateLimited: Too many verification code requests. + """ + result = await self._app_login(account, password, encrypted=encrypted) + + if isinstance(result, SessionMMT): + # Captcha triggered + if geetest_solver: + mmt_result = await geetest_solver(result) + else: + mmt_result = await server.solve_geetest(result, port=port) + + result = await self._app_login(account, password, encrypted=encrypted, mmt_result=mmt_result) + + if isinstance(result, ActionTicket): + # Email verification required + mmt = await self._send_verification_email(result) + if mmt: + if geetest_solver: + mmt_result = await geetest_solver(mmt) + else: + mmt_result = await server.solve_geetest(mmt, port=port) + + await asyncio.sleep(2) # Add delay to prevent [-3206] + await self._send_verification_email(result, mmt_result=mmt_result) + + code = await server.enter_code(port=port) + await self._verify_email(code, result) + + result = await self._app_login(account, password, encrypted=encrypted, ticket=result) + + return result + + @base.region_specific(types.Region.CHINESE) + async def login_with_qrcode(self) -> QRLoginResult: + """Login with QR code, only available for Miyoushe users.""" + import qrcode + import qrcode.image.pil + from qrcode.constants import ERROR_CORRECT_L + + creation_result = await self._create_qrcode() + qrcode_: qrcode.image.pil.PilImage = qrcode.make(creation_result.url, error_correction=ERROR_CORRECT_L) # type: ignore + qrcode_.show() + + 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: + LOGGER_.info("QR code scanned") + scanned = True + elif check_result.status == 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 + + 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) + + @base.region_specific(types.Region.CHINESE) + @managers.no_multi + async def create_mmt(self) -> MMT: + """Create a geetest challenge.""" + is_genshin = self.game is Game.GENSHIN + headers = { + "DS": ds_utility.generate_create_geetest_ds(), + "x-rpc-challenge_game": "2" if is_genshin else "6", + "x-rpc-page": "v4.1.5-ys_#ys" if is_genshin else "v1.4.1-rpg_#/rpg", + "x-rpc-tool-verison": "v4.1.5-ys" if is_genshin else "v1.4.1-rpg", + **auth_utility.CREATE_MMT_HEADERS, + } + + assert isinstance(self.cookie_manager, managers.CookieManager) + async with self.cookie_manager.create_session() as session: + async with session.get( + routes.CREATE_MMT_URL.get_url(), headers=headers, cookies=self.cookie_manager.cookies + ) as r: + data = await r.json() + + if not data["data"]: + errors.raise_for_retcode(data) + + return MMT(**data["data"]) + + @base.region_specific(types.Region.OVERSEAS) + async def os_game_login( + self, + account: str, + password: str, + *, + encrypted: bool = False, + port: int = 5000, + geetest_solver: typing.Optional[typing.Callable[[RiskyCheckMMT], typing.Awaitable[RiskyCheckMMTResult]]] = None, + ) -> GameLoginResult: + """Perform a login to the game. + + Raises + ------ + - IncorrectGameAccount: Invalid account provided. + - IncorrectGamePassword: Invalid password provided. + """ + result = await self._shield_login(account, password, encrypted=encrypted) + + if isinstance(result, RiskyCheckMMT): + if geetest_solver: + mmt_result = await geetest_solver(result) + else: + mmt_result = await server.solve_geetest(result, port=port) + + result = await self._shield_login(account, password, encrypted=encrypted, mmt_result=mmt_result) + + if not result.device_grant_required: + return await self._os_game_login(result.account.uid, result.account.token) + + mmt = await self._send_game_verification_email(result.account.device_grant_ticket) + if mmt: + if geetest_solver: + mmt_result = await geetest_solver(mmt) + else: + mmt_result = await server.solve_geetest(mmt, port=port) + + await self._send_game_verification_email(result.account.device_grant_ticket, mmt_result=mmt_result) + + code = await server.enter_code() + verification_result = await self._verify_game_email(code, result.account.device_grant_ticket) + + return await self._os_game_login(result.account.uid, verification_result.game_token) diff --git a/genshin/client/components/auth/server.py b/genshin/client/components/auth/server.py new file mode 100644 index 00000000..eb7789d1 --- /dev/null +++ b/genshin/client/components/auth/server.py @@ -0,0 +1,266 @@ +"""Aiohttp webserver used for captcha solving and verification.""" + +from __future__ import annotations + +import asyncio +import typing +import webbrowser + +import aiohttp +from aiohttp import web + +from genshin.models.auth.geetest import ( + MMT, + MMTResult, + MMTv4, + MMTv4Result, + RiskyCheckMMT, + RiskyCheckMMTResult, + SessionMMT, + SessionMMTResult, + SessionMMTv4, + SessionMMTv4Result, +) +from genshin.utility import auth as auth_utility + +__all__ = ["PAGES", "enter_code", "launch_webapp", "solve_geetest"] + +PAGES: typing.Final[typing.Dict[typing.Literal["captcha", "captcha-v4", "enter-code"], str]] = { + "captcha": """ + + + + + + + + + + """, + "enter-code": """ + + + + + + + + + """, +} + + +GT_V3_URL = "https://static.geetest.com/static/js/gt.0.5.0.js" +GT_V4_URL = "https://static.geetest.com/v4/gt4.js" + + +@typing.overload +async def launch_webapp( + page: typing.Literal["captcha"], + *, + mmt: typing.Union[MMT, MMTv4, SessionMMT, SessionMMTv4, RiskyCheckMMT], + lang: str = ..., + api_server: str = ..., + port: int = ..., +) -> typing.Union[MMTResult, MMTv4Result, SessionMMTResult, SessionMMTv4Result, RiskyCheckMMTResult]: ... +@typing.overload +async def launch_webapp( + page: typing.Literal["enter-code"], + *, + mmt: None = ..., + lang: None = ..., + api_server: None = ..., + port: int = ..., +) -> str: ... +async def launch_webapp( + page: typing.Literal["captcha", "enter-code"], + *, + mmt: typing.Optional[typing.Union[MMT, MMTv4, SessionMMT, SessionMMTv4, RiskyCheckMMT]] = None, + lang: typing.Optional[str] = None, + api_server: typing.Optional[str] = None, + port: int = 5000, +) -> typing.Union[MMTResult, MMTv4Result, SessionMMTResult, SessionMMTv4Result, RiskyCheckMMTResult, str]: + """Create and run a webapp to solve captcha or enter a verification code.""" + routes = web.RouteTableDef() + future: asyncio.Future[typing.Any] = asyncio.Future() + + @routes.get("/") + async def index(request: web.Request) -> web.StreamResponse: + body = PAGES[page] + body = body.replace("{gt_version}", "4" if isinstance(mmt, MMTv4) else "3") + body = body.replace("{api_server}", api_server or "api-na.geetest.com") + body = body.replace("{lang}", lang or "en") + return web.Response(body=body, content_type="text/html") + + @routes.get("/gt/{version}.js") + async def gt(request: web.Request) -> web.StreamResponse: + version = request.match_info.get("version", "v3") + gt_url = GT_V4_URL if version == "v4" else GT_V3_URL + + async with aiohttp.ClientSession() as session: + r = await session.get(gt_url) + content = await r.read() + + return web.Response(body=content, content_type="text/javascript") + + @routes.get("/mmt") + async def mmt_endpoint(request: web.Request) -> web.Response: + return web.json_response(mmt.dict() if mmt else {}) + + @routes.post("/send-data") + async def send_data_endpoint(request: web.Request) -> web.Response: + result = await request.json() + if "code" in result: + result = result["code"] + else: + if isinstance(mmt, RiskyCheckMMT): + result = RiskyCheckMMTResult(**result) + elif isinstance(mmt, SessionMMT): + result = SessionMMTResult(**result) + elif isinstance(mmt, SessionMMTv4): + result = SessionMMTv4Result(**result) + elif isinstance(mmt, MMT): + result = MMTResult(**result) + elif isinstance(mmt, MMTv4): + result = MMTv4Result(**result) + + future.set_result(result) + return web.Response(status=204) + + app = web.Application() + app.add_routes(routes) + + runner = web.AppRunner(app) + await runner.setup() + + site = web.TCPSite(runner, host="localhost", port=port) + print(f"Opening http://localhost:{port} in browser...") # noqa + webbrowser.open_new_tab(f"http://localhost:{port}") + + await site.start() + + try: + data = await future + finally: + await asyncio.sleep(0.3) + await runner.shutdown() + await runner.cleanup() + + return data + + +@typing.overload +async def solve_geetest( + mmt: RiskyCheckMMT, + *, + lang: str = ..., + api_server: str = ..., + port: int = ..., +) -> RiskyCheckMMTResult: ... +@typing.overload +async def solve_geetest( + mmt: SessionMMT, + *, + lang: str = ..., + api_server: str = ..., + port: int = ..., +) -> SessionMMTResult: ... +@typing.overload +async def solve_geetest( + mmt: MMT, + *, + lang: str = ..., + api_server: str = ..., + port: int = ..., +) -> MMTResult: ... +@typing.overload +async def solve_geetest( + mmt: SessionMMTv4, + *, + lang: str = ..., + api_server: str = ..., + port: int = ..., +) -> SessionMMTv4Result: ... +@typing.overload +async def solve_geetest( + mmt: MMTv4, + *, + lang: str = ..., + api_server: str = ..., + port: int = ..., +) -> MMTv4Result: ... +async def solve_geetest( + mmt: typing.Union[MMT, MMTv4, SessionMMT, SessionMMTv4, RiskyCheckMMT], + *, + lang: str = "en-us", + api_server: str = "api-na.geetest.com", + port: int = 5000, +) -> typing.Union[MMTResult, MMTv4Result, SessionMMTResult, SessionMMTv4Result, RiskyCheckMMTResult]: + """Start a web server and manually solve geetest captcha.""" + lang = auth_utility.lang_to_geetest_lang(lang) + return await launch_webapp( + "captcha", + mmt=mmt, + lang=lang, + api_server=api_server, + port=port, + ) + + +async def enter_code(*, port: int = 5000) -> str: + """Get email or phone number verification code from user.""" + return await launch_webapp("enter-code", port=port) diff --git a/genshin/client/components/auth/subclients/__init__.py b/genshin/client/components/auth/subclients/__init__.py new file mode 100644 index 00000000..566790b5 --- /dev/null +++ b/genshin/client/components/auth/subclients/__init__.py @@ -0,0 +1,5 @@ +"""Sub clients for AuthClient. Separated by functionality.""" + +from .app import * +from .game import * +from .web import * diff --git a/genshin/client/components/auth/subclients/app.py b/genshin/client/components/auth/subclients/app.py new file mode 100644 index 00000000..61662c18 --- /dev/null +++ b/genshin/client/components/auth/subclients/app.py @@ -0,0 +1,225 @@ +"""App sub client for AuthClient. + +Covers HoYoLAB and Miyoushe app auth endpoints. +""" + +import json +import random +import typing +from string import ascii_letters, digits + +import aiohttp + +from genshin import constants, errors +from genshin.client import routes +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.verification import ActionTicket +from genshin.utility import auth as auth_utility +from genshin.utility import ds as ds_utility + +__all__ = ["AppAuthClient"] + + +class AppAuthClient(base.BaseClient): + """App sub client for AuthClient.""" + + @typing.overload + async def _app_login( # noqa: D102 missing docstring in overload? + self, + account: str, + password: str, + *, + encrypted: bool = ..., + mmt_result: SessionMMTResult, + ticket: None = ..., + ) -> typing.Union[AppLoginResult, ActionTicket]: ... + + @typing.overload + async def _app_login( # noqa: D102 missing docstring in overload? + self, + account: str, + password: str, + *, + encrypted: bool = ..., + mmt_result: None = ..., + ticket: ActionTicket, + ) -> AppLoginResult: ... + + @typing.overload + async def _app_login( # noqa: D102 missing docstring in overload? + self, + account: str, + password: str, + *, + encrypted: bool = ..., + mmt_result: None = ..., + ticket: None = ..., + ) -> typing.Union[AppLoginResult, SessionMMT, ActionTicket]: ... + + async def _app_login( + self, + account: str, + password: str, + *, + encrypted: bool = False, + mmt_result: typing.Optional[SessionMMTResult] = None, + ticket: typing.Optional[ActionTicket] = None, + ) -> typing.Union[AppLoginResult, SessionMMT, ActionTicket]: + """Login with a password using HoYoLab app endpoint. + + Returns + ------- + - Cookies if login is successful. + - SessionMMT if captcha is triggered. + - ActionTicket if email verification is required. + """ + headers = { + **auth_utility.APP_LOGIN_HEADERS, + "ds": ds_utility.generate_dynamic_secret(constants.DS_SALT["app_login"]), + } + if mmt_result: + headers["x-rpc-aigis"] = mmt_result.to_aigis_header() + + if ticket: + headers["x-rpc-verify"] = ticket.to_rpc_verify_header() + + payload = { + "account": account if encrypted else auth_utility.encrypt_credentials(account, 1), + "password": password if encrypted else auth_utility.encrypt_credentials(password, 1), + } + + async with aiohttp.ClientSession() as session: + async with session.post( + routes.APP_LOGIN_URL.get_url(), + json=payload, + headers=headers, + ) as r: + data = await r.json() + + if data["retcode"] == -3101: + # Captcha triggered + aigis = json.loads(r.headers["x-rpc-aigis"]) + return SessionMMT(**aigis) + + if data["retcode"] == -3239: + # Email verification required + action_ticket = json.loads(r.headers["x-rpc-verify"]) + return ActionTicket(**action_ticket) + + if not data["data"]: + errors.raise_for_retcode(data) + + cookies = { + "stoken": data["data"]["token"]["token"], + "ltuid_v2": data["data"]["user_info"]["aid"], + "ltmid_v2": data["data"]["user_info"]["mid"], + "account_id_v2": data["data"]["user_info"]["aid"], + "account_mid_v2": data["data"]["user_info"]["mid"], + } + self.set_cookies(cookies) + + return AppLoginResult(**cookies) + + async def _send_verification_email( + self, + ticket: ActionTicket, + *, + mmt_result: typing.Optional[SessionMMTResult] = None, + ) -> typing.Union[None, SessionMMT]: + """Send verification email. + + Returns None if success, SessionMMT data if geetest triggered. + """ + headers = {**auth_utility.EMAIL_SEND_HEADERS} + if mmt_result: + headers["x-rpc-aigis"] = mmt_result.to_aigis_header() + + async with aiohttp.ClientSession() as session: + async with session.post( + routes.SEND_VERIFICATION_CODE_URL.get_url(), + json={ + "action_type": "verify_for_component", + "action_ticket": ticket.verify_str.ticket, + }, + headers=headers, + ) as r: + data = await r.json() + + if data["retcode"] == -3101: + # Captcha triggered + aigis = json.loads(r.headers["x-rpc-aigis"]) + return SessionMMT(**aigis) + + if data["retcode"] != 0: + errors.raise_for_retcode(data) + + return None + + async def _verify_email(self, code: str, ticket: ActionTicket) -> None: + """Verify email.""" + async with aiohttp.ClientSession() as session: + async with session.post( + routes.VERIFY_EMAIL_URL.get_url(), + json={ + "action_type": "verify_for_component", + "action_ticket": ticket.verify_str.ticket, + "email_captcha": code, + "verify_method": 2, + }, + headers=auth_utility.EMAIL_VERIFY_HEADERS, + ) as r: + data = await r.json() + + if data["retcode"] != 0: + errors.raise_for_retcode(data) + + return 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}, + ) 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, + ) + + async def _check_qrcode(self, app_id: str, device_id: str, ticket: str) -> QRCodeCheckResult: + """Check the status of a QR code login.""" + payload = { + "app_id": app_id, + "device": device_id, + "ticket": ticket, + } + + async with aiohttp.ClientSession() as session: + async with session.post( + routes.CHECK_QRCODE_URL.get_url(), + json=payload, + ) as r: + data = await r.json() + + if not data["data"]: + errors.raise_for_retcode(data) + + return QRCodeCheckResult(**data["data"]) diff --git a/genshin/client/components/auth/subclients/game.py b/genshin/client/components/auth/subclients/game.py new file mode 100644 index 00000000..417df2ef --- /dev/null +++ b/genshin/client/components/auth/subclients/game.py @@ -0,0 +1,197 @@ +"""Game sub client for AuthClient. + +Covers OS and CN game auth endpoints. +""" + +import json +import typing + +import aiohttp + +from genshin import constants, errors, types +from genshin.client import routes +from genshin.client.components import base +from genshin.models.auth.cookie import DeviceGrantResult, GameLoginResult +from genshin.models.auth.geetest import RiskyCheckMMT, RiskyCheckMMTResult, RiskyCheckResult +from genshin.models.auth.responses import ShieldLoginResponse +from genshin.utility import auth as auth_utility + +__all__ = ["GameAuthClient"] + + +class GameAuthClient(base.BaseClient): + """Game sub client for AuthClient.""" + + async def _risky_check( + self, action_type: str, api_name: str, *, username: typing.Optional[str] = None + ) -> RiskyCheckResult: + """Check if the given action (endpoint) is risky (whether captcha verification is required).""" + payload = {"action_type": action_type, "api_name": api_name} + if username: + payload["username"] = username + + async with aiohttp.ClientSession() as session: + async with session.post( + routes.GAME_RISKY_CHECK_URL.get_url(self.region), json=payload, headers=auth_utility.RISKY_CHECK_HEADERS + ) as r: + data = await r.json() + + if not data["data"]: + errors.raise_for_retcode(data) + + return RiskyCheckResult(**data["data"]) + + @typing.overload + async def _shield_login( # noqa: D102 missing docstring in overload? + self, + account: str, + password: str, + *, + encrypted: bool = ..., + mmt_result: RiskyCheckMMTResult, + ) -> ShieldLoginResponse: ... + + @typing.overload + async def _shield_login( # noqa: D102 missing docstring in overload? + self, + account: str, + password: str, + *, + encrypted: bool = ..., + mmt_result: None = ..., + ) -> typing.Union[ShieldLoginResponse, RiskyCheckMMT]: ... + + async def _shield_login( + self, + account: str, + password: str, + *, + encrypted: bool = False, + mmt_result: typing.Optional[RiskyCheckMMTResult] = None, + ) -> typing.Union[ShieldLoginResponse, RiskyCheckMMT]: + """Log in with the given account and password. + + Returns MMT if geetest verification is required. + """ + if self.default_game is None: + raise ValueError("No default game set.") + + headers = auth_utility.SHIELD_LOGIN_HEADERS.copy() + if mmt_result: + headers["x-rpc-risky"] = mmt_result.to_rpc_risky() + else: + # Check if geetest is required + check_result = await self._risky_check("login", "/shield/api/login", username=account) + if check_result.mmt: + return check_result.to_mmt() + else: + headers["x-rpc-risky"] = auth_utility.generate_risky_header(check_result.id) + + payload = { + "account": account, + "password": password if encrypted else auth_utility.encrypt_credentials(password, 2), + "is_crypto": True, + } + async with aiohttp.ClientSession() as session: + async with session.post( + routes.SHIELD_LOGIN_URL.get_url(self.region, self.default_game), json=payload, headers=headers + ) as r: + data = await r.json() + + if not data["data"]: + errors.raise_for_retcode(data) + + return ShieldLoginResponse(**data["data"]) + + @typing.overload + async def _send_game_verification_email( # noqa: D102 missing docstring in overload? + self, + action_ticket: str, + *, + mmt_result: RiskyCheckMMTResult, + ) -> None: ... + + @typing.overload + async def _send_game_verification_email( # noqa: D102 missing docstring in overload? + self, + action_ticket: str, + *, + mmt_result: None = ..., + ) -> typing.Union[None, RiskyCheckMMT]: ... + + async def _send_game_verification_email( + self, action_ticket: str, *, mmt_result: typing.Optional[RiskyCheckMMTResult] = None + ) -> typing.Union[None, RiskyCheckMMT]: + """Send email verification code. + + Returns `None` if success, `RiskyCheckMMT` if geetest verification is required. + """ + headers = auth_utility.GRANT_TICKET_HEADERS.copy() + if mmt_result: + headers["x-rpc-risky"] = mmt_result.to_rpc_risky() + else: + # Check if geetest is required + check_result = await self._risky_check("device_grant", "/device/api/preGrantByTicket") + if check_result.mmt: + return check_result.to_mmt() + else: + headers["x-rpc-risky"] = auth_utility.generate_risky_header(check_result.id) + + payload = { + "way": "Way_Email", + "action_ticket": action_ticket, + "device": { + "device_model": "iPhone15,4", + "device_id": auth_utility.DEVICE_ID, + "client": 1, + "device_name": "iPhone", + }, + } + async with aiohttp.ClientSession() as session: + async with session.post( + routes.PRE_GRANT_TICKET_URL.get_url(self.region), json=payload, headers=headers + ) as r: + data = await r.json() + + if data["retcode"] != 0: + errors.raise_for_retcode(data) + + return None + + async def _verify_game_email(self, code: str, action_ticket: str) -> DeviceGrantResult: + """Verify the email code.""" + payload = {"code": code, "ticket": action_ticket} + async with aiohttp.ClientSession() as session: + async with session.post( + routes.DEVICE_GRANT_URL.get_url(self.region), json=payload, headers=auth_utility.GRANT_TICKET_HEADERS + ) as r: + data = await r.json() + + return DeviceGrantResult(**data["data"]) + + @base.region_specific(types.Region.OVERSEAS) + async def _os_game_login(self, uid: str, game_token: str) -> GameLoginResult: + """Log in to the game.""" + if self.default_game is None: + raise ValueError("No default game set.") + + payload = { + "channel_id": 1, + "device": auth_utility.DEVICE_ID, + "app_id": constants.APP_IDS[self.default_game][self.region], + } + payload["data"] = json.dumps({"uid": uid, "token": game_token, "guest": False}) + payload["sign"] = auth_utility.generate_sign(payload, constants.APP_KEYS[self.default_game][self.region]) + + async with aiohttp.ClientSession() as session: + async with session.post( + routes.GAME_LOGIN_URL.get_url(self.region, self.default_game), + json=payload, + headers=auth_utility.GAME_LOGIN_HEADERS, + ) as r: + data = await r.json() + + if not data["data"]: + errors.raise_for_retcode(data) + + return GameLoginResult(**data["data"]) diff --git a/genshin/client/components/auth/subclients/web.py b/genshin/client/components/auth/subclients/web.py new file mode 100644 index 00000000..caec8ccd --- /dev/null +++ b/genshin/client/components/auth/subclients/web.py @@ -0,0 +1,232 @@ +"""Web sub client for AuthClient. + +Covers HoYoLAB and Miyoushe web auth endpoints. +""" + +import json +import typing + +import aiohttp + +from genshin import constants, errors, types +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.utility import auth as auth_utility +from genshin.utility import ds as ds_utility + +__all__ = ["WebAuthClient"] + + +class WebAuthClient(base.BaseClient): + """Web sub client for AuthClient.""" + + @typing.overload + async def _os_web_login( # noqa: D102 missing docstring in overload? + self, + account: str, + password: str, + *, + encrypted: bool = ..., + token_type: typing.Optional[int] = ..., + mmt_result: SessionMMTResult, + ) -> WebLoginResult: ... + + @typing.overload + async def _os_web_login( # noqa: D102 missing docstring in overload? + self, + account: str, + password: str, + *, + encrypted: bool = ..., + token_type: typing.Optional[int] = ..., + mmt_result: None = ..., + ) -> typing.Union[SessionMMT, WebLoginResult]: ... + + @base.region_specific(types.Region.OVERSEAS) + async def _os_web_login( + self, + account: str, + password: str, + *, + encrypted: bool = False, + token_type: typing.Optional[int] = 6, + mmt_result: typing.Optional[SessionMMTResult] = None, + ) -> typing.Union[SessionMMT, WebLoginResult]: + """Login with a password using web endpoint. + + Returns either data from aigis header or cookies. + """ + headers = {**auth_utility.WEB_LOGIN_HEADERS} + if mmt_result: + headers["x-rpc-aigis"] = mmt_result.to_aigis_header() + + payload = { + "account": account if encrypted else auth_utility.encrypt_credentials(account, 1), + "password": password if encrypted else auth_utility.encrypt_credentials(password, 1), + "token_type": token_type, + } + + async with aiohttp.ClientSession() as session: + async with session.post( + routes.WEB_LOGIN_URL.get_url(), + json=payload, + headers=headers, + ) as r: + data = await r.json() + cookies = {cookie.key: cookie.value for cookie in r.cookies.values()} + + if data["retcode"] == -3101: + # Captcha triggered + aigis = json.loads(r.headers["x-rpc-aigis"]) + return SessionMMT(**aigis) + + if not data["data"]: + errors.raise_for_retcode(data) + + if data["data"].get("stoken"): + cookies["stoken"] = data["data"]["stoken"] + + self.set_cookies(cookies) + return WebLoginResult(**cookies) + + @typing.overload + async def _cn_web_login( # noqa: D102 missing docstring in overload? + self, + account: str, + password: str, + *, + encrypted: bool = ..., + mmt_result: SessionMMTResult, + ) -> CNWebLoginResult: ... + + @typing.overload + async def _cn_web_login( # noqa: D102 missing docstring in overload? + self, + account: str, + password: str, + *, + encrypted: bool = ..., + mmt_result: None = ..., + ) -> typing.Union[SessionMMT, CNWebLoginResult]: ... + + @base.region_specific(types.Region.CHINESE) + async def _cn_web_login( + self, + account: str, + password: str, + *, + encrypted: bool = False, + mmt_result: typing.Optional[SessionMMTResult] = None, + ) -> typing.Union[SessionMMT, CNWebLoginResult]: + """ + Login with account and password using Miyoushe loginByPassword endpoint. + + Returns data from aigis header or cookies. + """ + headers = { + **auth_utility.CN_LOGIN_HEADERS, + "ds": ds_utility.generate_dynamic_secret(constants.DS_SALT["cn_signin"]), + } + if mmt_result: + headers["x-rpc-aigis"] = mmt_result.to_aigis_header() + + payload = { + "account": account if encrypted else auth_utility.encrypt_credentials(account, 2), + "password": password if encrypted else auth_utility.encrypt_credentials(password, 2), + } + + async with aiohttp.ClientSession() as session: + async with session.post( + routes.CN_WEB_LOGIN_URL.get_url(), + json=payload, + headers=headers, + ) as r: + data = await r.json() + + if data["retcode"] == -3102: + # Captcha triggered + aigis = json.loads(r.headers["x-rpc-aigis"]) + return SessionMMT(**aigis) + + if not data["data"]: + errors.raise_for_retcode(data) + + cookies = {cookie.key: cookie.value for cookie in r.cookies.values()} + self.set_cookies(cookies) + + return CNWebLoginResult(**cookies) + + async def _send_mobile_otp( + self, + mobile: str, + *, + encrypted: bool = False, + mmt_result: typing.Optional[SessionMMTResult] = None, + ) -> typing.Union[None, SessionMMT]: + """Attempt to send OTP to the provided mobile number. + + May return aigis headers if captcha is triggered, None otherwise. + """ + headers = { + **auth_utility.CN_LOGIN_HEADERS, + "ds": ds_utility.generate_dynamic_secret(constants.DS_SALT["cn_signin"]), + } + if mmt_result: + headers["x-rpc-aigis"] = mmt_result.to_aigis_header() + + payload = { + "mobile": mobile if encrypted else auth_utility.encrypt_credentials(mobile, 2), + "area_code": auth_utility.encrypt_credentials("+86", 2), + } + + async with aiohttp.ClientSession() as session: + async with session.post( + routes.MOBILE_OTP_URL.get_url(), + json=payload, + headers=headers, + ) as r: + data = await r.json() + + if data["retcode"] == -3101: + # Captcha triggered + aigis = json.loads(r.headers["x-rpc-aigis"]) + return SessionMMT(**aigis) + + if not data["data"]: + errors.raise_for_retcode(data) + + return None + + async def _login_with_mobile_otp(self, mobile: str, otp: str, *, encrypted: bool = False) -> MobileLoginResult: + """Login with OTP and mobile number. + + Returns cookies if OTP matches the one sent, raises an error otherwise. + """ + headers = { + **auth_utility.CN_LOGIN_HEADERS, + "ds": ds_utility.generate_dynamic_secret(constants.DS_SALT["cn_signin"]), + } + + payload = { + "mobile": mobile if encrypted else auth_utility.encrypt_credentials(mobile, 2), + "area_code": auth_utility.encrypt_credentials("+86", 2), + "captcha": otp, + } + + async with aiohttp.ClientSession() as session: + async with session.post( + routes.MOBILE_LOGIN_URL.get_url(), + json=payload, + headers=headers, + ) as r: + data = await r.json() + + if not data["data"]: + errors.raise_for_retcode(data) + + cookies = {cookie.key: cookie.value for cookie in r.cookies.values()} + self.set_cookies(cookies) + + return MobileLoginResult(**cookies) diff --git a/genshin/client/components/base.py b/genshin/client/components/base.py index 49673cf9..d47bc319 100644 --- a/genshin/client/components/base.py +++ b/genshin/client/components/base.py @@ -3,6 +3,7 @@ import abc import asyncio import base64 +import functools import json import logging import os @@ -24,10 +25,25 @@ __all__ = ["BaseClient"] +T = typing.TypeVar("T") +CallableT = typing.TypeVar("CallableT", bound="typing.Callable[..., object]") +AsyncCallableT = typing.TypeVar("AsyncCallableT", bound="typing.Callable[..., typing.Awaitable[object]]") + + class BaseClient(abc.ABC): """Base ABC Client.""" - __slots__ = ("cookie_manager", "cache", "_lang", "_region", "_default_game", "uids", "authkeys", "_hoyolab_id") + __slots__ = ( + "cookie_manager", + "cache", + "_lang", + "_region", + "_default_game", + "uids", + "authkeys", + "_hoyolab_id", + "_accounts", + ) 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 @@ -42,6 +58,7 @@ class BaseClient(abc.ABC): uids: typing.Dict[types.Game, int] authkeys: typing.Dict[types.Game, str] _hoyolab_id: typing.Optional[int] + _accounts: typing.Dict[types.Game, hoyolab_models.GenshinAccount] def __init__( self, @@ -62,6 +79,7 @@ def __init__( self.uids = {} self.authkeys = {} + self._accounts = {} self.default_game = game self.lang = lang @@ -494,6 +512,39 @@ async def _get_uid(self, game: types.Game) -> int: raise errors.AccountNotFound(msg="No UID provided and account has no game account bound to it.") + async def _update_cached_accounts(self) -> None: + """Update cached fallback accounts.""" + mixed_accounts = await self.get_game_accounts() + + game_accounts: typing.Dict[types.Game, typing.List[hoyolab_models.GenshinAccount]] = {} + for account in mixed_accounts: + if not isinstance(account.game, types.Game): # pyright: ignore[reportUnnecessaryIsInstance] + continue + + game_accounts.setdefault(account.game, []).append(account) + + self._accounts = {} + for game, accounts in game_accounts.items(): + self._accounts[game] = next( + (acc for acc in accounts if acc.uid == self.uids.get(game)), max(accounts, key=lambda a: a.level) + ) + + @concurrency.prevent_concurrency + async def _get_account(self, game: types.Game) -> hoyolab_models.GenshinAccount: + """Get a cached fallback account.""" + if (account := self._accounts.get(game)) and (uid := self.uids.get(game)) and account.uid == uid: + return account + + await self._update_cached_accounts() + + if account := self._accounts.get(game): + if (uid := self.uids.get(game)) and account.uid != uid: + raise errors.AccountNotFound(msg="There is no game account with such UID.") + + return account + + raise errors.AccountNotFound(msg="Account has no game account bound to it.") + def _get_hoyolab_id(self) -> int: """Get a cached fallback hoyolab ID.""" if self.hoyolab_id is not None: @@ -534,3 +585,22 @@ async def update_mi18n(self, langs: typing.Iterable[str] = constants.LANGS, *, f coros.append(self._fetch_mi18n(key, lang, force=force)) await asyncio.gather(*coros) + + +def region_specific(region: types.Region) -> typing.Callable[[AsyncCallableT], AsyncCallableT]: + """Prevent function to be ran with unsupported regions.""" + + 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: + raise RuntimeError("The method can only be used with client region set to " + region) + + return await func(self, *args, **kwargs) + + return typing.cast("AsyncCallableT", wrapper) + + return decorator diff --git a/genshin/client/components/daily.py b/genshin/client/components/daily.py index 53981d29..f9d51800 100644 --- a/genshin/client/components/daily.py +++ b/genshin/client/components/daily.py @@ -8,7 +8,7 @@ import aiohttp.typedefs -from genshin import constants, paginators, types, utility +from genshin import constants, paginators, types from genshin.client import cache, routes from genshin.client.components import base from genshin.client.manager import managers @@ -54,23 +54,22 @@ async def request_daily_reward( headers["referer"] = "https://act.hoyolab.com/" elif self.region == types.Region.CHINESE: - uid = await self._get_uid(game) + account = await self._get_account(game) - params["uid"] = uid - params["region"] = utility.recognize_server(uid, game) + params["uid"] = account.uid + params["region"] = account.server - # most of the extra headers are likely just placebo - headers["x-rpc-app_version"] = "2.34.1" + # These headers are optional but left here because they might affect geetest trigger rate + headers["x-rpc-app_version"] = "2.70.1" headers["x-rpc-client_type"] = "5" headers["x-rpc-device_id"] = str(uuid.uuid4()) headers["x-rpc-sys_version"] = "12" headers["x-rpc-platform"] = "android" headers["x-rpc-channel"] = "miyousheluodi" headers["x-rpc-device_model"] = str(self.hoyolab_id) or "" - 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" - ) + + if game == types.Game.GENSHIN: + headers["x-rpc-signgame"] = "hk4e" headers["ds"] = ds_utility.generate_dynamic_secret(constants.DS_SALT["cn_signin"]) diff --git a/genshin/client/components/geetest/__init__.py b/genshin/client/components/geetest/__init__.py deleted file mode 100644 index 3871455b..00000000 --- a/genshin/client/components/geetest/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Geetest captcha handler. - -Credits to M-307 - https://github.com/mrwan200 -""" - -from .client import * diff --git a/genshin/client/components/geetest/client.py b/genshin/client/components/geetest/client.py deleted file mode 100644 index dbc9245c..00000000 --- a/genshin/client/components/geetest/client.py +++ /dev/null @@ -1,563 +0,0 @@ -"""Geetest client component.""" - -import asyncio -import json -import logging -import random -import typing -from string import ascii_letters, digits - -import aiohttp -import aiohttp.web -import qrcode -import qrcode.image.pil -from qrcode.constants import ERROR_CORRECT_L - -from genshin import constants, errors -from genshin.client import routes -from genshin.client.components import base -from genshin.client.manager.cookie import fetch_cookie_token_by_game_token, fetch_stoken_by_game_token -from genshin.models.miyoushe.qrcode import QRCodeCheckResult, QRCodeCreationResult, QRCodeStatus -from genshin.utility import ds as ds_utility -from genshin.utility import geetest as geetest_utility - -from . import server - -__all__ = ["GeetestClient"] - -LOGGER_ = logging.getLogger(__name__) - - -class GeetestClient(base.BaseClient): - """Geetest client component.""" - - async def _web_login( - self, - account: str, - password: str, - *, - tokenType: typing.Optional[int] = 6, - geetest: typing.Optional[typing.Dict[str, typing.Any]] = None, - ) -> typing.Dict[str, typing.Any]: - """Login with a password using web endpoint. - - Returns either data from aigis header or cookies. - """ - headers = {**geetest_utility.WEB_LOGIN_HEADERS} - if geetest: - mmt_data = geetest["data"] - session_id = geetest["session_id"] - headers["x-rpc-aigis"] = geetest_utility.get_aigis_header(session_id, mmt_data) - - payload = { - "account": geetest_utility.encrypt_geetest_credentials(account, self._region), - "password": geetest_utility.encrypt_geetest_credentials(password, self._region), - "token_type": tokenType, - } - - async with aiohttp.ClientSession() as session: - async with session.post( - routes.WEB_LOGIN_URL.get_url(), - json=payload, - headers=headers, - ) as r: - data = await r.json() - cookies = {cookie.key: cookie.value for cookie in r.cookies.values()} - - if data["retcode"] == -3101: - # Captcha triggered - aigis = json.loads(r.headers["x-rpc-aigis"]) - aigis["data"] = json.loads(aigis["data"]) - return aigis - - if not data["data"]: - errors.raise_for_retcode(data) - - if data["data"].get("stoken"): - cookies["stoken"] = data["data"]["stoken"] - - self.set_cookies(cookies) - - return cookies - - async def _cn_login_by_password( - self, - account: str, - password: str, - *, - geetest: typing.Optional[typing.Dict[str, typing.Any]] = None, - ) -> typing.Dict[str, typing.Any]: - """ - Login with account and password using Miyoushe loginByPassword endpoint. - - Returns data from aigis header or cookies. - """ - headers = { - **geetest_utility.CN_LOGIN_HEADERS, - "ds": ds_utility.generate_dynamic_secret(constants.DS_SALT["cn_signin"]), - } - if geetest: - mmt_data = geetest["data"] - session_id = geetest["session_id"] - headers["x-rpc-aigis"] = geetest_utility.get_aigis_header(session_id, mmt_data) - - payload = { - "account": geetest_utility.encrypt_geetest_credentials(account, self._region), - "password": geetest_utility.encrypt_geetest_credentials(password, self._region), - } - - async with aiohttp.ClientSession() as session: - async with session.post( - routes.CN_WEB_LOGIN_URL.get_url(), - json=payload, - headers=headers, - ) as r: - data = await r.json() - - if data["retcode"] == -3102: - # Captcha triggered - aigis = json.loads(r.headers["x-rpc-aigis"]) - aigis["data"] = json.loads(aigis["data"]) - return aigis - - if not data["data"]: - errors.raise_for_retcode(data) - - cookies = {cookie.key: cookie.value for cookie in r.cookies.values()} - - self.set_cookies(cookies) - return cookies - - async def _app_login( - self, - account: str, - password: str, - *, - geetest: typing.Optional[typing.Dict[str, typing.Any]] = None, - ticket: typing.Optional[typing.Dict[str, typing.Any]] = None, - ) -> typing.Dict[str, typing.Any]: - """Login with a password using HoYoLab app endpoint. - - Returns data from aigis header or action_ticket or cookies. - """ - headers = { - **geetest_utility.APP_LOGIN_HEADERS, - "ds": ds_utility.generate_dynamic_secret(constants.DS_SALT["app_login"]), - } - if geetest: - mmt_data = geetest["data"] - session_id = geetest["session_id"] - headers["x-rpc-aigis"] = geetest_utility.get_aigis_header(session_id, mmt_data) - - if ticket: - ticket["verify_str"] = json.dumps(ticket["verify_str"]) - headers["x-rpc-verify"] = json.dumps(ticket) - - payload = { - "account": geetest_utility.encrypt_geetest_credentials(account, self._region), - "password": geetest_utility.encrypt_geetest_credentials(password, self._region), - } - - async with aiohttp.ClientSession() as session: - async with session.post( - routes.APP_LOGIN_URL.get_url(), - json=payload, - headers=headers, - ) as r: - data = await r.json() - - if data["retcode"] == -3101: - # Captcha triggered - aigis = json.loads(r.headers["x-rpc-aigis"]) - aigis["data"] = json.loads(aigis["data"]) - return aigis - - if data["retcode"] == -3239: - # Email verification required - verify = json.loads(r.headers["x-rpc-verify"]) - verify["verify_str"] = json.loads(verify["verify_str"]) - return verify - - if not data["data"]: - errors.raise_for_retcode(data) - - cookies = { - "stoken": data["data"]["token"]["token"], - "ltuid_v2": data["data"]["user_info"]["aid"], - "ltmid_v2": data["data"]["user_info"]["mid"], - "account_id_v2": data["data"]["user_info"]["aid"], - "account_mid_v2": data["data"]["user_info"]["mid"], - } - self.set_cookies(cookies) - - return cookies - - async def _send_verification_email( - self, - ticket: typing.Dict[str, typing.Any], - *, - geetest: typing.Optional[typing.Dict[str, typing.Any]] = None, - ) -> typing.Union[None, typing.Dict[str, typing.Any]]: - """Send verification email. - - Returns None if success, aigis headers (mmt/aigis) otherwise. - """ - headers = {**geetest_utility.EMAIL_SEND_HEADERS} - if geetest: - mmt_data = geetest["data"] - session_id = geetest["session_id"] - headers["x-rpc-aigis"] = geetest_utility.get_aigis_header(session_id, mmt_data) - - async with aiohttp.ClientSession() as session: - async with session.post( - routes.SEND_VERIFICATION_CODE_URL.get_url(), - json={ - "action_type": "verify_for_component", - "action_ticket": ticket["verify_str"]["ticket"], - }, - headers=headers, - ) as r: - data = await r.json() - - if data["retcode"] == -3101: - # Captcha triggered - aigis = json.loads(r.headers["x-rpc-aigis"]) - aigis["data"] = json.loads(aigis["data"]) - return aigis - - if data["retcode"] != 0: - errors.raise_for_retcode(data) - - return None - - async def _verify_email(self, code: str, ticket: typing.Dict[str, typing.Any]) -> None: - """Verify email.""" - async with aiohttp.ClientSession() as session: - async with session.post( - routes.VERIFY_EMAIL_URL.get_url(), - json={ - "action_type": "verify_for_component", - "action_ticket": ticket["verify_str"]["ticket"], - "email_captcha": code, - "verify_method": 2, - }, - headers=geetest_utility.EMAIL_VERIFY_HEADERS, - ) as r: - data = await r.json() - - if data["retcode"] != 0: - errors.raise_for_retcode(data) - - return None - - async def _send_mobile_otp( - self, - mobile: str, - *, - geetest: typing.Optional[typing.Dict[str, typing.Any]] = None, - ) -> typing.Dict[str, typing.Any] | None: - """Attempt to send OTP to the provided mobile number. - - May return aigis headers if captcha is triggered, None otherwise. - """ - headers = { - **geetest_utility.CN_LOGIN_HEADERS, - "ds": ds_utility.generate_dynamic_secret(constants.DS_SALT["cn_signin"]), - } - if geetest: - mmt_data = geetest["data"] - session_id = geetest["session_id"] - headers["x-rpc-aigis"] = geetest_utility.get_aigis_header(session_id, mmt_data) - - payload = { - "mobile": geetest_utility.encrypt_geetest_credentials(mobile, self._region), - "area_code": geetest_utility.encrypt_geetest_credentials("+86", self._region), - } - - async with aiohttp.ClientSession() as session: - async with session.post( - routes.MOBILE_OTP_URL.get_url(), - json=payload, - headers=headers, - ) as r: - data = await r.json() - - if data["retcode"] == -3101: - # Captcha triggered - aigis = json.loads(r.headers["x-rpc-aigis"]) - aigis["data"] = json.loads(aigis["data"]) - return aigis - - if not data["data"]: - errors.raise_for_retcode(data) - - return None - - async def _login_with_mobile_otp(self, mobile: str, otp: str) -> typing.Dict[str, typing.Any]: - """Login with OTP and mobile number. - - Returns cookies if OTP matches the one sent, raises an error otherwise. - """ - headers = { - **geetest_utility.CN_LOGIN_HEADERS, - "ds": ds_utility.generate_dynamic_secret(constants.DS_SALT["cn_signin"]), - } - - payload = { - "mobile": geetest_utility.encrypt_geetest_credentials(mobile, self._region), - "area_code": geetest_utility.encrypt_geetest_credentials("+86", self._region), - "captcha": otp, - } - - async with aiohttp.ClientSession() as session: - async with session.post( - routes.MOBILE_LOGIN_URL.get_url(), - json=payload, - headers=headers, - ) as r: - data = await r.json() - - if not data["data"]: - errors.raise_for_retcode(data) - - cookies = {cookie.key: cookie.value for cookie in r.cookies.values()} - self.set_cookies(cookies) - - return cookies - - async def _create_qrcode(self) -> QRCodeCreationResult: - """Create a QR code for login.""" - device_id = "".join(random.choices(ascii_letters + digits, k=64)) - app_id = "8" - payload = { - "app_id": app_id, - "device": device_id, - } - - async with aiohttp.ClientSession() as session: - async with session.post( - routes.CREATE_QRCODE_URL.get_url(), - json=payload, - ) 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, - ) - - async def _check_qrcode(self, app_id: str, device_id: str, ticket: str) -> QRCodeCheckResult: - """Check the status of a QR code login.""" - payload = { - "app_id": app_id, - "device": device_id, - "ticket": ticket, - } - - async with aiohttp.ClientSession() as session: - async with session.post( - routes.CHECK_QRCODE_URL.get_url(), - json=payload, - ) as r: - data = await r.json() - - if not data["data"]: - errors.raise_for_retcode(data) - - return QRCodeCheckResult(**data["data"]) - - async def login_with_password( - self, - account: str, - password: str, - *, - port: int = 5000, - tokenType: typing.Optional[int] = 6, - geetest_solver: typing.Optional[ - typing.Callable[ - [typing.Dict[str, typing.Any]], - typing.Awaitable[typing.Dict[str, typing.Any]], - ] - ] = None, - ) -> typing.Dict[str, str]: - """Login with a password via web endpoint. - - Note that this will start a webserver if captcha is - triggered and `geetest_solver` is not passed. - """ - result = await self._web_login(account, password, tokenType=tokenType) - - if "session_id" not in result: - # Captcha not triggered - return result - - if geetest_solver: - geetest = await geetest_solver(result) - else: - geetest = await server.solve_geetest(result, port=port) - - return await self._web_login(account, password, tokenType=tokenType, geetest=geetest) - - async def cn_login_by_password( - self, - account: str, - password: str, - *, - port: int = 5000, - geetest_solver: typing.Optional[ - typing.Callable[ - [typing.Dict[str, typing.Any]], - typing.Awaitable[typing.Dict[str, typing.Any]], - ] - ] = None, - ) -> typing.Dict[str, str]: - """Login with a password via Miyoushe loginByPassword endpoint. - - Note that this will start a webserver if captcha is triggered and `geetest_solver` is not passed. - """ - result = await self._cn_login_by_password(account, password) - - if "session_id" not in result: - # Captcha not triggered - return result - - if geetest_solver: - geetest = await geetest_solver(result) - else: - geetest = await server.solve_geetest(result, port=port) - - return await self._cn_login_by_password(account, password, geetest=geetest) - - async def check_mobile_number_validity(self, mobile: str) -> bool: - """Check if a mobile number is valid (it's registered on Miyoushe). - - Returns True if the mobile number is valid, False otherwise. - """ - async with aiohttp.ClientSession() as session: - async with session.get( - routes.CHECK_MOBILE_VALIDITY_URL.get_url(), - params={"mobile": mobile}, - ) as r: - data = await r.json() - - return data["data"]["status"] != data["data"]["is_registable"] - - async def login_with_mobile_number( - self, - mobile: str, - ) -> typing.Dict[str, str]: - """Login with mobile number, returns cookies. - - Only works for Chinese region (Miyoushe) users, do not include area code (+86) in the mobile number. - Steps: - 1. Sends OTP to the provided mobile number. - 1-1. If captcha is triggered, prompts the user to solve it. - 2. Lets user enter the OTP. - 3. Logs in with the OTP. - 4. Returns cookies. - """ - result = await self._send_mobile_otp(mobile) - - if result is not None and "session_id" in result: - # Captcha triggered - geetest = await server.solve_geetest(result) - await self._send_mobile_otp(mobile, geetest=geetest) - - otp = await server.enter_otp() - cookies = await self._login_with_mobile_otp(mobile, otp) - return cookies - - async def login_with_app_password( - self, - account: str, - password: str, - *, - port: int = 5000, - geetest_solver: typing.Optional[ - typing.Callable[ - [typing.Dict[str, typing.Any]], - typing.Awaitable[typing.Dict[str, typing.Any]], - ] - ] = None, - ) -> typing.Dict[str, str]: - """Login with a password via HoYoLab app endpoint. - - Note that this will start a webserver if either of the - following happens: - - 1. Captcha is triggered and `geetest_solver` is not passed. - 2. Email verification is triggered (can happen if you - first login with a new device). - """ - result = await self._app_login(account, password) - - if "session_id" in result: - # Captcha triggered - if geetest_solver: - geetest = await geetest_solver(result) - else: - geetest = await server.solve_geetest(result, port=port) - - result = await self._app_login(account, password, geetest=geetest) - - if "risk_ticket" in result: - # Email verification required - mmt = await self._send_verification_email(result) - if mmt: - if geetest_solver: - geetest = await geetest_solver(mmt) - else: - geetest = await server.solve_geetest(mmt, port=port) - - await self._send_verification_email(result, geetest=geetest) - - await server.verify_email(self, result, port=port) - result = await self._app_login(account, password, ticket=result) - - return result - - async def login_with_qrcode(self) -> typing.Dict[str, str]: - """Login with QR code, only available for Miyoushe users. - - Returns cookies. - """ - creation_result = await self._create_qrcode() - qrcode_: qrcode.image.pil.PilImage = qrcode.make(creation_result.url, error_correction=ERROR_CORRECT_L) # type: ignore - qrcode_.show() - - 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: - LOGGER_.info("QR code scanned") - scanned = True - elif check_result.status == 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 - - cookie_token = await fetch_cookie_token_by_game_token( - game_token=raw_data.game_token, account_id=raw_data.account_id - ) - stoken = await fetch_stoken_by_game_token(game_token=raw_data.game_token, account_id=int(raw_data.account_id)) - - cookies = { - "stoken_v2": stoken.token, - "stuid": stoken.aid, - "mid": stoken.mid, - "cookie_token": cookie_token, - } - self.set_cookies(cookies) - return cookies diff --git a/genshin/client/components/geetest/server.py b/genshin/client/components/geetest/server.py deleted file mode 100644 index 0aa0081d..00000000 --- a/genshin/client/components/geetest/server.py +++ /dev/null @@ -1,192 +0,0 @@ -"""Aiohttp webserver used for captcha solving and email verification.""" - -from __future__ import annotations - -import asyncio -import typing -import webbrowser - -import aiohttp -from aiohttp import web - -from . import client - -__all__ = ["PAGES", "launch_webapp", "solve_geetest", "verify_email"] - -PAGES: typing.Final[typing.Dict[typing.Literal["captcha", "verify-email", "enter-otp"], str]] = { - "captcha": """ - - - - - - - """, - "verify-email": """ - - - - - - - - - """, - "enter-otp": """ - - - - - - - - - """, -} - - -GT_URL = "https://raw.githubusercontent.com/GeeTeam/gt3-node-sdk/master/demo/static/libs/gt.js" - - -async def launch_webapp( - page: typing.Literal["captcha", "verify-email", "enter-otp"], - *, - port: int = 5000, - mmt: typing.Optional[typing.Dict[str, typing.Any]] = None, -) -> typing.Any: - """Create and run a webapp to solve captcha or send verification code.""" - routes = web.RouteTableDef() - future: asyncio.Future[typing.Any] = asyncio.Future() - - @routes.get("/captcha") - async def captcha(request: web.Request) -> web.StreamResponse: - return web.Response(body=PAGES["captcha"], content_type="text/html") - - @routes.get("/verify-email") - async def verify_email(request: web.Request) -> web.StreamResponse: - return web.Response(body=PAGES["verify-email"], content_type="text/html") - - @routes.get("/enter-otp") - async def enter_otp(request: web.Request) -> web.StreamResponse: - return web.Response(body=PAGES["enter-otp"], content_type="text/html") - - @routes.get("/gt.js") - async def gt(request: web.Request) -> web.StreamResponse: - async with aiohttp.ClientSession() as session: - r = await session.get(GT_URL) - content = await r.read() - - return web.Response(body=content, content_type="text/javascript") - - @routes.get("/mmt") - async def mmt_endpoint(request: web.Request) -> web.Response: - return web.json_response(mmt) - - @routes.post("/send-data") - async def send_data_endpoint(request: web.Request) -> web.Response: - body = await request.json() - future.set_result(body) - - return web.Response(status=204) - - app = web.Application() - app.add_routes(routes) - - runner = web.AppRunner(app) - await runner.setup() - - site = web.TCPSite(runner, host="localhost", port=port) - print(f"Opening http://localhost:{port}/{page} in browser...") # noqa - webbrowser.open_new_tab(f"http://localhost:{port}/{page}") - - await site.start() - - try: - data = await future - finally: - await asyncio.sleep(0.3) - await runner.shutdown() - await runner.cleanup() - - return data - - -async def solve_geetest( - mmt: typing.Dict[str, typing.Any], - *, - port: int = 5000, -) -> typing.Dict[str, typing.Any]: - """Solve a geetest captcha manually.""" - return await launch_webapp("captcha", port=port, mmt=mmt) - - -async def verify_email( - client: client.GeetestClient, - ticket: typing.Dict[str, typing.Any], - *, - port: int = 5000, -) -> None: - """Verify email to login via HoYoLab app endpoint.""" - data = await launch_webapp("verify-email", port=port) - code = data["code"] - - return await client._verify_email(code, ticket) - - -async def enter_otp(port: int = 5000) -> str: - """Lets user enter the OTP.""" - # The enter-otp page is the same as verify-email page. - data = await launch_webapp("enter-otp", port=port) - code = data["code"] - return code diff --git a/genshin/client/components/transaction.py b/genshin/client/components/transaction.py index 73dcd9cb..d9e37af7 100644 --- a/genshin/client/components/transaction.py +++ b/genshin/client/components/transaction.py @@ -64,7 +64,7 @@ async def _get_transaction_page( transactions: typing.List[models.BaseTransaction] = [] for trans in data["list"]: model = models.ItemTransaction if "name" in trans else models.Transaction - model = typing.cast("type[models.BaseTransaction]", model) + model = typing.cast("typing.Type[models.BaseTransaction]", model) transactions.append(model(**trans, kind=kind, lang=lang or self.lang)) return transactions diff --git a/genshin/client/manager/cookie.py b/genshin/client/manager/cookie.py index 76779f4d..ef3af82e 100644 --- a/genshin/client/manager/cookie.py +++ b/genshin/client/manager/cookie.py @@ -1,6 +1,6 @@ """Cookie completion. -Available convertions: +Available conversions: - fetch_cookie_with_cookie - cookie_token -> cookie_token @@ -14,6 +14,10 @@ - fetch_cookie_with_stoken_v2 - stoken (v2) + mid -> ltoken_v2 (token_type=2) - stoken (v2) + mid -> cookie_token_v2 (token_type=4) +- fetch_cookie_token_with_game_token + - game_token -> cookie_token +- fetch_stoken_with_game_token + - game_token -> stoken """ from __future__ import annotations @@ -29,33 +33,19 @@ from genshin import constants, errors, types from genshin.client import routes from genshin.client.manager import managers -from genshin.models.miyoushe.cookie import StokenResult +from genshin.models.auth.cookie import StokenResult from genshin.utility import ds as ds_utility __all__ = [ "complete_cookies", - "fetch_cookie_token_by_game_token", "fetch_cookie_token_info", + "fetch_cookie_token_with_game_token", "fetch_cookie_with_cookie", "fetch_cookie_with_stoken_v2", - "fetch_stoken_by_game_token", + "fetch_stoken_with_game_token", "refresh_cookie_token", ] -STOKEN_BY_GAME_TOKEN_HEADERS = { - "x-rpc-app_version": "2.41.0", - "x-rpc-aigis": "", - "Content-Type": "application/json", - "Accept": "application/json", - "x-rpc-game_biz": "bbs_cn", - "x-rpc-sys_version": "11", - "x-rpc-device_name": "GenshinUid_login_device_lulu", - "x-rpc-device_model": "GenshinUid_login_device_lulu", - "x-rpc-app_id": "bll8iq97cem8", - "x-rpc-client_type": "2", - "User-Agent": "okhttp/4.8.0", -} - async def fetch_cookie_with_cookie( cookies: managers.CookieOrHeader, @@ -188,8 +178,8 @@ async def complete_cookies( return cookies -async def fetch_cookie_token_by_game_token(*, game_token: str, account_id: str) -> str: - """Fetch cookie token by game token, which is obtained by scanning a QR code.""" +async def fetch_cookie_token_with_game_token(*, game_token: str, account_id: str) -> str: + """Fetch cookie token with game token, which can be obtained by scanning a QR code.""" url = routes.GET_COOKIE_TOKEN_BY_GAME_TOKEN_URL.get_url() params = { "game_token": game_token, @@ -206,8 +196,8 @@ async def fetch_cookie_token_by_game_token(*, game_token: str, account_id: str) return data["data"]["cookie_token"] -async def fetch_stoken_by_game_token(*, game_token: str, account_id: int) -> StokenResult: - """Fetch cookie token by game token, which is obtained by scanning a QR code.""" +async def fetch_stoken_with_game_token(*, game_token: str, account_id: int) -> StokenResult: + """Fetch cookie token with game token, which can be obtained by scanning a QR code.""" url = routes.GET_STOKEN_BY_GAME_TOKEN_URL.get_url() payload = { "account_id": account_id, @@ -217,7 +207,7 @@ async def fetch_stoken_by_game_token(*, game_token: str, account_id: int) -> Sto "DS": ds_utility.generate_passport_ds(body=payload), "x-rpc-device_id": uuid.uuid4().hex, "x-rpc-device_fp": "".join(random.choices(ascii_letters + digits, k=13)), - **STOKEN_BY_GAME_TOKEN_HEADERS, + "x-rpc-app_id": "bll8iq97cem8", } async with aiohttp.ClientSession() as session: diff --git a/genshin/client/manager/managers.py b/genshin/client/manager/managers.py index 0e33324b..7b3b2704 100644 --- a/genshin/client/manager/managers.py +++ b/genshin/client/manager/managers.py @@ -17,6 +17,8 @@ from genshin.client import ratelimit from genshin.utility import fs as fs_utility +from ...constants import MIYOUSHE_GEETEST_RETCODES + _LOGGER = logging.getLogger(__name__) __all__ = [ @@ -153,6 +155,9 @@ async def _request( errors.check_for_geetest(data) + if data["retcode"] in MIYOUSHE_GEETEST_RETCODES: + raise errors.MiyousheGeetestError(data, {k: morsel.value for k, morsel in response.cookies.items()}) + if data["retcode"] == 0: return data["data"] diff --git a/genshin/client/routes.py b/genshin/client/routes.py index 908143c2..8b8c2282 100644 --- a/genshin/client/routes.py +++ b/genshin/client/routes.py @@ -17,6 +17,7 @@ "CN_WEB_LOGIN_URL", "COMMUNITY_URL", "COOKIE_V2_REFRESH_URL", + "CREATE_MMT_URL", "CREATE_QRCODE_URL", "DETAIL_LEDGER_URL", "GACHA_URL", @@ -184,8 +185,8 @@ def get_url(self, region: types.Region, game: types.Game) -> yarl.URL: hkrpg="https://sg-public-api.hoyolab.com/event/luna/os?act_id=e202303301540311", ), chinese=dict( - genshin="https://api-takumi.mihoyo.com/event/bbs_sign_reward/?act_id=e202009291139501", - honkai3rd="https://api-takumi.mihoyo.com/event/luna/?act_id=e202207181446311", + genshin="https://api-takumi.mihoyo.com/event/luna/?act_id=e202311201442471", + honkai3rd="https://api-takumi.mihoyo.com/event/luna/?act_id=e202306201626331", hkrpg="https://api-takumi.mihoyo.com/event/luna/?act_id=e202304121516551", ), ) @@ -237,3 +238,52 @@ def get_url(self, region: types.Region, game: types.Game) -> yarl.URL: 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_MMT_URL = Route( + "https://api-takumi-record.mihoyo.com/game_record/app/card/wapi/createVerification?is_high=false" +) + +GAME_RISKY_CHECK_URL = InternationalRoute( + overseas="https://api-account-os.hoyoverse.com/account/risky/api/check", + chinese="https://gameapi-account.mihoyo.com/account/risky/api/check", +) + +SHIELD_LOGIN_URL = GameRoute( + overseas=dict( + genshin="https://hk4e-sdk-os.hoyoverse.com/hk4e_global/mdk/shield/api/login", + honkai3rd="https://bh3-sdk-os.hoyoverse.com/bh3_os/mdk/shield/api/login", + hkrpg="https://hkrpg-sdk-os.hoyoverse.com/hkrpg_global/mdk/shield/api/login", + nap="https://nap-sdk-os.hoyoverse.com/nap_global/mdk/shield/api/login", + ), + chinese=dict( + genshin="https://hk4e-sdk.mihoyo.com/hk4e_cn/mdk/shield/api/login", + honkai3rd="https://api-sdk.mihoyo.com/bh3_cn/mdk/shield/api/login", + hkrpg="https://hkrpg-sdk.mihoyo.com/hkrpg_cn/mdk/shield/api/login", + nap="https://nap-sdk.mihoyo.com/nap_cn/mdk/shield/api/login", + ), +) + +PRE_GRANT_TICKET_URL = InternationalRoute( + overseas="https://api-account-os.hoyoverse.com/account/device/api/preGrantByTicket", + chinese="https://gameapi-account.mihoyo.com/account/device/api/preGrantByTicket", +) + +DEVICE_GRANT_URL = InternationalRoute( + overseas="https://api-account-os.hoyoverse.com/account/device/api/grant", + chinese="https://gameapi-account.mihoyo.com/account/device/api/grant", +) + +GAME_LOGIN_URL = GameRoute( + overseas=dict( + genshin="https://hk4e-sdk-os.hoyoverse.com/hk4e_global/combo/granter/login/v2/login", + honkai3rd="https://bh3-sdk-os.hoyoverse.com/bh3_os/combo/granter/login/v2/login", + hkrpg="https://hkrpg-sdk-os.hoyoverse.com/hkrpg_global/combo/granter/login/v2/login", + nap="https://nap-sdk-os.hoyoverse.com/nap_global/combo/granter/login/v2/login", + ), + chinese=dict( + genshin="https://hk4e-sdk.mihoyo.com/hk4e_cn/combo/granter/login/v2/login", + honkai3rd="https://api-sdk.mihoyo.com/bh3_cn/combo/granter/login/v2/login", + hkrpg="https://hkrpg-sdk.mihoyo.com/hkrpg_cn/combo/granter/login/v2/login", + nap="https://nap-sdk.mihoyo.com/nap_cn/combo/granter/login/v2/login", + ), +) diff --git a/genshin/constants.py b/genshin/constants.py index 64e42e05..02d94d1b 100644 --- a/genshin/constants.py +++ b/genshin/constants.py @@ -28,7 +28,50 @@ types.Region.OVERSEAS: "6s25p5ox5y14umn1p61aqyyvbvvl3lrt", types.Region.CHINESE: "xV8v4Qu54lUKrEYFZkJhB8cuOh9Asafs", "app_login": "IZPgfb0dRPtBeLuFkdDznSZ6f4wWt6y2", - "cn_signin": "9nQiU3AV0rJSIBWgdynfoGMGKaklfbM7", + "cn_signin": "LyD1rXqMv2GJhnwdvCBjFOKGiKuLY3aO", "cn_passport": "JwYDpKvLj6MrMqqYU6jTKF17KNO2PXoS", } """Dynamic Secret Salts.""" + +MIYOUSHE_GEETEST_RETCODES = {10035, 5003, 10041, 1034} +"""API error codes that indicate a Geetest was triggered during this Miyoushe API request.""" + +APP_KEYS = { + types.Game.GENSHIN: { + types.Region.OVERSEAS: "6a4c78fe0356ba4673b8071127b28123", + types.Region.CHINESE: "d0d3a7342df2026a70f650b907800111", + }, + types.Game.STARRAIL: { + types.Region.OVERSEAS: "d74818dabd4182d4fbac7f8df1622648", + types.Region.CHINESE: "4650f3a396d34d576c3d65df26415394", + }, + types.Game.HONKAI: { + types.Region.OVERSEAS: "243187699ab762b682a2a2e50ba02285", + types.Region.CHINESE: "0ebc517adb1b62c6b408df153331f9aa", + }, + types.Game.ZZZ: { + types.Region.OVERSEAS: "ff0f2776bf515d79d1f8ff1fb98b2a06", + types.Region.CHINESE: "4650f3a396d34d576c3d65df26415394", + }, +} +"""App keys used for game login.""" + +APP_IDS = { + types.Game.GENSHIN: { + types.Region.OVERSEAS: "4", + types.Region.CHINESE: "4", + }, + types.Game.STARRAIL: { + types.Region.OVERSEAS: "11", + types.Region.CHINESE: "8", + }, + types.Game.HONKAI: { + types.Region.OVERSEAS: "8", + types.Region.CHINESE: "1", + }, + types.Game.ZZZ: { + types.Region.OVERSEAS: "15", + types.Region.CHINESE: "12", + }, +} +"""App IDs used for game login.""" diff --git a/genshin/errors.py b/genshin/errors.py index b5225000..611085e8 100644 --- a/genshin/errors.py +++ b/genshin/errors.py @@ -14,6 +14,7 @@ "GenshinException", "InvalidAuthkey", "InvalidCookies", + "MiyousheGeetestError", "RedemptionClaimed", "RedemptionCooldown", "RedemptionException", @@ -186,6 +187,58 @@ class WrongOTP(GenshinException): msg = "The provided OTP code is wrong." +class MiyousheGeetestError(GenshinException): + """Geetest triggered during Miyoushe API request.""" + + def __init__( + self, + response: typing.Dict[str, typing.Any], + cookies: typing.Mapping[str, str], + ) -> None: + self.cookies = cookies + super().__init__(response) + + msg = "Geetest triggered during Miyoushe API request." + + +class OTPRateLimited(GenshinException): + """Too many OTP messages sent for the number. + + The limit is 40 messages/day/number. + """ + + retcode = -119 + msg = "Too many OTP messages sent for the number." + + +class IncorrectGameAccount(GenshinException): + """Game account is incorrect.""" + + retcode = -216 + msg = "Game account is incorrect." + + +class IncorrectGamePassword(GenshinException): + """Game password is incorrect.""" + + retcode = -202 + msg = "Game password is incorrect." + + +class AccountDoesNotExist(GenshinException): + """Account does not exist.""" + + retcode = -3203 + msg = "Account does not exist." + + +class VerificationCodeRateLimited(GenshinException): + """Too many verification code requests for the account.""" + + retcode = -3206 + msg = "Too many verification code requests for the account." + + _TGE = typing.Type[GenshinException] _errors: typing.Dict[int, typing.Union[_TGE, str, typing.Tuple[_TGE, typing.Optional[str]]]] = { # misc hoyolab @@ -235,7 +288,15 @@ class WrongOTP(GenshinException): # account -3208: AccountLoginFail, -3202: AccountHasLocked, + -3203: AccountDoesNotExist, -3205: WrongOTP, + -3206: VerificationCodeRateLimited, + # Miyoushe + -119: OTPRateLimited, + -3006: "Request too frequent.", # OTP endpoint + # Game login + -216: IncorrectGameAccount, + -202: IncorrectGamePassword, } ERRORS: typing.Dict[int, typing.Tuple[_TGE, typing.Optional[str]]] = { diff --git a/genshin/models/__init__.py b/genshin/models/__init__.py index 38da6131..b515d70e 100644 --- a/genshin/models/__init__.py +++ b/genshin/models/__init__.py @@ -1,8 +1,8 @@ """API models.""" +from .auth import * from .genshin import * from .honkai import * from .hoyolab import * -from .miyoushe import * from .model import * from .starrail import * diff --git a/genshin/models/auth/__init__.py b/genshin/models/auth/__init__.py new file mode 100644 index 00000000..53f1a208 --- /dev/null +++ b/genshin/models/auth/__init__.py @@ -0,0 +1,6 @@ +"""Auth-related models.""" + +from .cookie import * +from .geetest import * +from .qrcode import * +from .verification import * diff --git a/genshin/models/auth/cookie.py b/genshin/models/auth/cookie.py new file mode 100644 index 00000000..05381e58 --- /dev/null +++ b/genshin/models/auth/cookie.py @@ -0,0 +1,144 @@ +"""Cookie-related models""" + +import typing + +if typing.TYPE_CHECKING: + import pydantic.v1 as pydantic +else: + try: + import pydantic.v1 as pydantic + except ImportError: + import pydantic + +__all__ = [ + "AppLoginResult", + "CNWebLoginResult", + "CookieLoginResult", + "DeviceGrantResult", + "DeviceGrantResult", + "GameLoginResult", + "MobileLoginResult", + "QRLoginResult", + "StokenResult", + "WebLoginResult", +] + + +class StokenResult(pydantic.BaseModel): + """Result of fetching `stoken` with `fetch_stoken_by_game_token`.""" + + aid: str + mid: str + token: str + + @pydantic.root_validator(pre=True) + def _transform_result(cls, values: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + return { + "aid": values["user_info"]["aid"], + "mid": values["user_info"]["mid"], + "token": values["token"]["token"], + } + + +class CookieLoginResult(pydantic.BaseModel): + """Base model for cookie login result.""" + + def to_str(self) -> str: + """Convert the login cookies to a string.""" + return "; ".join(f"{key}={value}" for key, value in self.dict().items()) + + def to_dict(self) -> typing.Dict[str, str]: + """Convert the login cookies to a dictionary.""" + return self.dict() + + +class QRLoginResult(CookieLoginResult): + """QR code login cookies. + + Returned by `client.login_with_qrcode`. + """ + + stoken_v2: str + account_id: str + ltuid: str + ltmid: str + cookie_token: str + + +class AppLoginResult(CookieLoginResult): + """App login cookies. + + Returned by `client.login_with_app_password`. + """ + + stoken: str + ltuid_v2: str + ltmid_v2: str + account_id_v2: str + account_mid_v2: str + + +class WebLoginResult(CookieLoginResult): + """Web login cookies. + + Returned by `client.login_with_password`. + """ + + cookie_token_v2: str + account_mid_v2: str + account_id_v2: str + ltoken_v2: str + ltmid_v2: str + ltuid_v2: str + + +class CNWebLoginResult(CookieLoginResult): + """Web login cookies. + + Returned by `client.cn_login_with_password`. + """ + + cookie_token_v2: str + account_mid_v2: str + account_id_v2: str + ltoken_v2: str + ltmid_v2: str + ltuid_v2: str + + +class MobileLoginResult(CookieLoginResult): + """Mobile number login cookies. + + Returned by `client.login_with_mobile_number`. + """ + + cookie_token_v2: str + account_mid_v2: str + account_id_v2: str + ltoken_v2: str + ltmid_v2: str + + +class DeviceGrantResult(pydantic.BaseModel): + """Cookies returned by the device grant endpoint.""" + + game_token: str + login_ticket: typing.Optional[str] = None + + @pydantic.root_validator(pre=True) + def _str_to_none(cls, data: typing.Dict[str, typing.Union[str, None]]) -> typing.Dict[str, typing.Union[str, None]]: + """Convert empty strings to `None`.""" + for key in data: + if data[key] == "" or data[key] == "None": + data[key] = None + return data + + +class GameLoginResult(pydantic.BaseModel): + """Game login result.""" + + combo_id: str + open_id: str + combo_token: str + heartbeat: bool + account_type: int diff --git a/genshin/models/auth/geetest.py b/genshin/models/auth/geetest.py new file mode 100644 index 00000000..bfa7464c --- /dev/null +++ b/genshin/models/auth/geetest.py @@ -0,0 +1,173 @@ +"""Geetest-related models""" + +import enum +import json +import typing + +if typing.TYPE_CHECKING: + import pydantic.v1 as pydantic +else: + try: + import pydantic.v1 as pydantic + except ImportError: + import pydantic + +from genshin.utility import auth as auth_utility + +__all__ = [ + "MMT", + "BaseMMT", + "BaseMMTResult", + "BaseSessionMMTResult", + "MMTResult", + "MMTv4", + "MMTv4Result", + "RiskyCheckMMT", + "RiskyCheckMMTResult", + "SessionMMT", + "SessionMMTResult", + "SessionMMTv4", + "SessionMMTv4Result", +] + + +class BaseMMT(pydantic.BaseModel): + """Base Geetest verification data model.""" + + new_captcha: int + success: int + + @pydantic.root_validator(pre=True) + def __parse_data(cls, data: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + """Parse the data if it was provided in a raw format.""" + if "data" in data: + # Assume the data is aigis header and parse it + session_id = data["session_id"] + data = data["data"] + if isinstance(data, str): + data = json.loads(data) + + data["session_id"] = session_id + + return data + + +class MMT(BaseMMT): + """Geetest verification data.""" + + challenge: str + gt: str + + +class SessionMMT(MMT): + """Session-based geetest verification data.""" + + session_id: str + + def get_mmt(self) -> MMT: + """Get the base MMT data.""" + return MMT(**self.dict(exclude={"session_id"})) + + +class MMTv4(BaseMMT): + """Geetest verification data (V4).""" + + captcha_id: str = pydantic.Field(alias="gt") + risk_type: str + + +class SessionMMTv4(MMTv4): + """Session-based geetest verification data (V4).""" + + session_id: str + + def get_mmt(self) -> MMTv4: + """Get the base MMTv4 data.""" + return MMTv4(**self.dict(exclude={"session_id"})) + + +class RiskyCheckMMT(MMT): + """MMT returned by the risky check endpoint.""" + + check_id: str + + +class BaseMMTResult(pydantic.BaseModel): + """Base Geetest verification result model.""" + + def get_data(self) -> typing.Dict[str, typing.Any]: + """Get the base MMT result data. + + This method acts as `dict` but excludes the `session_id` field. + """ + return self.dict(exclude={"session_id"}) + + +class BaseSessionMMTResult(BaseMMTResult): + """Base session-based Geetest verification result model.""" + + session_id: str + + def to_aigis_header(self) -> str: + """Convert the result to `x-rpc-aigis` header.""" + return auth_utility.get_aigis_header(self.session_id, self.get_data()) + + +class MMTResult(BaseMMTResult): + """Geetest verification result.""" + + geetest_challenge: str + geetest_validate: str + geetest_seccode: str + + +class SessionMMTResult(MMTResult, BaseSessionMMTResult): + """Session-based geetest verification result.""" + + +class MMTv4Result(BaseMMTResult): + """Geetest verification result (V4).""" + + captcha_id: str + lot_number: str + pass_token: str + gen_time: str + captcha_output: str + + +class SessionMMTv4Result(MMTv4Result, BaseSessionMMTResult): + """Session-based geetest verification result (V4).""" + + +class RiskyCheckMMTResult(MMTResult): + """Risky check MMT result.""" + + check_id: str + + def to_rpc_risky(self) -> str: + """Convert the MMT result to a RPC risky header.""" + return auth_utility.generate_risky_header(self.check_id, self.geetest_challenge, self.geetest_validate) + + +class RiskyCheckAction(str, enum.Enum): + """Risky check action returned by the API.""" + + ACTION_NONE = "ACTION_NONE" + """No action required.""" + ACTION_GEETEST = "ACTION_GEETEST" + """Geetest verification required.""" + + +class RiskyCheckResult(pydantic.BaseModel): + """Model for the risky check result.""" + + id: str + action: RiskyCheckAction + mmt: typing.Optional[MMT] = pydantic.Field(alias="geetest") + + def to_mmt(self) -> RiskyCheckMMT: + """Convert the check result to a `RiskyCheckMMT` object.""" + if self.mmt is None: + raise ValueError("The check result does not contain a MMT object.") + + return RiskyCheckMMT(**self.mmt.dict(), check_id=self.id) diff --git a/genshin/models/auth/qrcode.py b/genshin/models/auth/qrcode.py new file mode 100644 index 00000000..63d45d0e --- /dev/null +++ b/genshin/models/auth/qrcode.py @@ -0,0 +1,61 @@ +"""Miyoushe QR Code Models""" + +import enum +import json +import typing + +if typing.TYPE_CHECKING: + import pydantic.v1 as pydantic +else: + try: + import pydantic.v1 as pydantic + except ImportError: + import pydantic + +__all__ = ["QRCodeCheckResult", "QRCodeCreationResult", "QRCodePayload", "QRCodeRawData", "QRCodeStatus"] + + +class QRCodeStatus(enum.Enum): + """QR code check status.""" + + INIT = "Init" + 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/auth/responses.py b/genshin/models/auth/responses.py new file mode 100644 index 00000000..dd347e0a --- /dev/null +++ b/genshin/models/auth/responses.py @@ -0,0 +1,53 @@ +"""Auth endpoints responses models.""" + +import typing + +if typing.TYPE_CHECKING: + import pydantic.v1 as pydantic +else: + try: + import pydantic.v1 as pydantic + except ImportError: + import pydantic + +__all__ = ["Account", "ShieldLoginResponse"] + + +class Account(pydantic.BaseModel): + """Account data returned by the shield login endpoint.""" + + uid: str + name: str + email: str + mobile: str + is_email_verify: str + realname: str + identity_card: str + token: str + safe_mobile: str + facebook_name: str + google_name: str + twitter_name: str + game_center_name: str + apple_name: str + sony_name: str + tap_name: str + country: str + reactivate_ticket: str + area_code: str + device_grant_ticket: str + steam_name: str + unmasked_email: str + unmasked_email_type: int + cx_name: typing.Optional[str] = None + + +class ShieldLoginResponse(pydantic.BaseModel): + """Response model for the shield login endpoint.""" + + account: Account + device_grant_required: bool + safe_moblie_required: bool + realperson_required: bool + reactivate_required: bool + realname_operation: str diff --git a/genshin/models/auth/verification.py b/genshin/models/auth/verification.py new file mode 100644 index 00000000..0f207410 --- /dev/null +++ b/genshin/models/auth/verification.py @@ -0,0 +1,46 @@ +"""Email verification -related models""" + +import json +import typing + +if typing.TYPE_CHECKING: + import pydantic.v1 as pydantic +else: + try: + import pydantic.v1 as pydantic + except ImportError: + import pydantic + +__all__ = [ + "ActionTicket", + "VerifyStrategy", +] + + +class VerifyStrategy(pydantic.BaseModel): + """Verification strategy.""" + + ticket: str + verify_type: str + + +class ActionTicket(pydantic.BaseModel): + """Action ticket. Can be used to verify email addresses.""" + + risk_ticket: str + verify_str: VerifyStrategy + + @pydantic.root_validator(pre=True) + def __parse_data(cls, data: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + """Parse the data if it was provided in a raw format.""" + verify_str = data["verify_str"] + if isinstance(verify_str, str): + data["verify_str"] = json.loads(verify_str) + + return data + + def to_rpc_verify_header(self) -> str: + """Convert the action ticket to `x-rpc-verify` header.""" + ticket = self.dict() + ticket["verify_str"] = json.dumps(ticket["verify_str"]) + return json.dumps(ticket) diff --git a/genshin/models/genshin/chronicle/notes.py b/genshin/models/genshin/chronicle/notes.py index c9c52ac8..aba31f80 100644 --- a/genshin/models/genshin/chronicle/notes.py +++ b/genshin/models/genshin/chronicle/notes.py @@ -15,6 +15,9 @@ from genshin.models.model import Aliased, APIModel __all__ = [ + "ArchonQuest", + "ArchonQuestProgress", + "ArchonQuestStatus", "AttendanceReward", "AttendanceRewardStatus", "DailyTasks", @@ -125,6 +128,31 @@ class DailyTasks(APIModel): attendance_visible: bool +class ArchonQuestStatus(str, enum.Enum): + """Archon quest status.""" + + ONGOING = "StatusOngoing" + NOT_OPEN = "StatusNotOpen" + + +class ArchonQuest(APIModel): + """Archon Quest.""" + + id: int + status: ArchonQuestStatus + chapter_num: str + chapter_title: str + + +class ArchonQuestProgress(APIModel): + """Archon Quest Progress.""" + + list: typing.Sequence[ArchonQuest] + mainlines_finished: bool = Aliased("is_finish_all_mainline") + archon_quest_unlocked: bool = Aliased("is_open_archon_quest") + interchapters_finished: bool = Aliased("is_finish_all_interchapter") + + class Notes(APIModel): """Real-Time notes.""" @@ -148,6 +176,8 @@ class Notes(APIModel): expeditions: typing.Sequence[Expedition] max_expeditions: int = Aliased("max_expedition_num") + archon_quest_progress: ArchonQuestProgress + @property def resin_recovery_time(self) -> datetime.datetime: """The time when resin will be recovered.""" diff --git a/genshin/models/genshin/teapot.py b/genshin/models/genshin/teapot.py index c12e3c90..9fb7eefc 100644 --- a/genshin/models/genshin/teapot.py +++ b/genshin/models/genshin/teapot.py @@ -61,7 +61,7 @@ class TeapotReplica(APIModel, Unique): images: typing.List[str] = Aliased("imgs") created_at: datetime.datetime stats: TeapotReplicaStats - lang: str + lang: str # type: ignore author: TeapotReplicaAuthor diff --git a/genshin/models/honkai/battlesuit.py b/genshin/models/honkai/battlesuit.py index e77fbe34..b7ba35eb 100644 --- a/genshin/models/honkai/battlesuit.py +++ b/genshin/models/honkai/battlesuit.py @@ -26,6 +26,7 @@ "YiNeng": "PSY", "LiangZi": "QUA", "XuShu": "IMG", + "Xingchen": "SD", } ICON_BASE = "https://upload-os-bbs.mihoyo.com/game_record/honkai3rd/global/SpriteOutput/" diff --git a/genshin/models/hoyolab/announcements.py b/genshin/models/hoyolab/announcements.py index fb667ec5..920a3080 100644 --- a/genshin/models/hoyolab/announcements.py +++ b/genshin/models/hoyolab/announcements.py @@ -31,5 +31,5 @@ class Announcement(APIModel, Unique): tag_start_time: datetime.datetime tag_end_time: datetime.datetime - lang: str + lang: str # type: ignore has_content: bool diff --git a/genshin/models/miyoushe/__init__.py b/genshin/models/miyoushe/__init__.py deleted file mode 100644 index f34841ca..00000000 --- a/genshin/models/miyoushe/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Miyoushe models.""" - -from .cookie import * -from .qrcode import * diff --git a/genshin/models/miyoushe/cookie.py b/genshin/models/miyoushe/cookie.py deleted file mode 100644 index 4cca01cf..00000000 --- a/genshin/models/miyoushe/cookie.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Miyoushe Cookie Models""" - -import typing - -from pydantic import BaseModel, model_validator - -__all__ = ("StokenResult",) - - -class StokenResult(BaseModel): - """Stoken result.""" - - aid: str - mid: str - token: str - - @model_validator(mode="before") - def _transform_result(cls, values: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: - return { - "aid": values["user_info"]["aid"], - "mid": values["user_info"]["mid"], - "token": values["token"]["token"], - } diff --git a/genshin/models/miyoushe/qrcode.py b/genshin/models/miyoushe/qrcode.py deleted file mode 100644 index 5ce6d0a6..00000000 --- a/genshin/models/miyoushe/qrcode.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Miyoushe QR Code Models""" - -import json -from enum import Enum - -from pydantic import BaseModel, Field, field_validator - -__all__ = ("QRCodeCheckResult", "QRCodeCreationResult", "QRCodePayload", "QRCodeRawData", "QRCodeStatus") - - -class QRCodeStatus(Enum): - """QR code check status.""" - - INIT = "Init" - SCANNED = "Scanned" - CONFIRMED = "Confirmed" - - -class QRCodeRawData(BaseModel): - """QR code raw data.""" - - account_id: str = Field(alias="uid") - """Miyoushe account id.""" - game_token: str = Field(alias="token") - - -class QRCodePayload(BaseModel): - """QR code check result payload.""" - - proto: str - raw: QRCodeRawData | None - ext: str - - @field_validator("raw", mode="before") - def _convert_raw_data(cls, value: str | None) -> QRCodeRawData | None: - if value: - return QRCodeRawData(**json.loads(value)) - return None - - -class QRCodeCheckResult(BaseModel): - """QR code check result.""" - - status: QRCodeStatus = Field(alias="stat") - payload: QRCodePayload - - -class QRCodeCreationResult(BaseModel): - """QR code creation result.""" - - app_id: str - ticket: str - device_id: str - url: str diff --git a/genshin/models/starrail/chronicle/characters.py b/genshin/models/starrail/chronicle/characters.py index cda87544..21d886e4 100644 --- a/genshin/models/starrail/chronicle/characters.py +++ b/genshin/models/starrail/chronicle/characters.py @@ -1,5 +1,6 @@ """Starrail chronicle character.""" +import enum import typing from typing import Any, Mapping, Optional, Sequence @@ -11,7 +12,7 @@ except ImportError: import pydantic -from genshin.models.model import APIModel +from genshin.models.model import Aliased, APIModel from .. import character @@ -28,9 +29,22 @@ "StarRailDetailCharacter", "StarRailDetailCharacters", "StarRailEquipment", + "StarRailPath", ] +class StarRailPath(enum.IntEnum): + """StarRail character path.""" + + DESTRUCTION = 1 + THE_HUNT = 2 + ERUDITION = 3 + HARMONY = 4 + NIHILITY = 5 + PRESERVATION = 6 + ABUNDANCE = 7 + + class StarRailEquipment(APIModel): """Character equipment.""" @@ -147,7 +161,7 @@ class StarRailDetailCharacter(character.StarRailPartialCharacter): ornaments: Sequence[Relic] ranks: Sequence[Rank] properties: Sequence[CharacterProperty] - base_type: int + path: StarRailPath = Aliased("base_type") figure_path: str skills: Sequence[Skill] diff --git a/genshin/paginators/base.py b/genshin/paginators/base.py index cf5be77e..a466c59a 100644 --- a/genshin/paginators/base.py +++ b/genshin/paginators/base.py @@ -16,7 +16,7 @@ async def flatten(iterable: typing.AsyncIterable[T]) -> typing.Sequence[T]: """Flatten an async iterable.""" if isinstance(iterable, Paginator): - return await iterable.flatten() + return await iterable.flatten() # type: ignore return [x async for x in iterable] diff --git a/genshin/types.py b/genshin/types.py index 9b87fd9c..93122b79 100644 --- a/genshin/types.py +++ b/genshin/types.py @@ -33,6 +33,9 @@ class Game(str, enum.Enum): STARRAIL = "hkrpg" """Honkai Star Rail""" + ZZZ = "nap" + """Zenless Zone Zero""" + IDOr = typing.Union[int, UniqueT] """Allows partial objects.""" diff --git a/genshin/utility/__init__.py b/genshin/utility/__init__.py index cd1e07c3..fd4b76bc 100644 --- a/genshin/utility/__init__.py +++ b/genshin/utility/__init__.py @@ -1,6 +1,6 @@ """Utilities for genshin.py.""" -from . import geetest +from .auth import * from .concurrency import * from .ds import * from .extdb import * diff --git a/genshin/utility/auth.py b/genshin/utility/auth.py new file mode 100644 index 00000000..8a7f1a0e --- /dev/null +++ b/genshin/utility/auth.py @@ -0,0 +1,159 @@ +"""Auth utilities.""" + +import base64 +import hmac +import json +import typing +from hashlib import sha256 + +from genshin import constants + +__all__ = ["encrypt_credentials", "generate_sign"] + + +# RSA key used for OS app/web login +LOGIN_KEY_TYPE_1 = b""" +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4PMS2JVMwBsOIrYWRluY +wEiFZL7Aphtm9z5Eu/anzJ09nB00uhW+ScrDWFECPwpQto/GlOJYCUwVM/raQpAj +/xvcjK5tNVzzK94mhk+j9RiQ+aWHaTXmOgurhxSp3YbwlRDvOgcq5yPiTz0+kSeK +ZJcGeJ95bvJ+hJ/UMP0Zx2qB5PElZmiKvfiNqVUk8A8oxLJdBB5eCpqWV6CUqDKQ +KSQP4sM0mZvQ1Sr4UcACVcYgYnCbTZMWhJTWkrNXqI8TMomekgny3y+d6NX/cFa6 +6jozFIF4HCX5aW8bp8C8vq2tFvFbleQ/Q3CU56EWWKMrOcpmFtRmC18s9biZBVR/ +8QIDAQAB +-----END PUBLIC KEY----- +""" + +# RSA key used for CN app/game and game login +LOGIN_KEY_TYPE_2 = b""" +-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDDvekdPMHN3AYhm/vktJT+YJr7 +cI5DcsNKqdsx5DZX0gDuWFuIjzdwButrIYPNmRJ1G8ybDIF7oDW2eEpm5sMbL9zs +9ExXCdvqrn51qELbqj0XxtMTIpaCHFSI50PfPpTFV9Xt/hmyVwokoOXFlAEgCn+Q +CgGs52bFoYMtyi+xEQIDAQAB +-----END PUBLIC KEY----- +""" + +WEB_LOGIN_HEADERS = { + "x-rpc-app_id": "c9oqaq3s3gu8", + "x-rpc-client_type": "4", + # If not equal account.hoyolab.com It's will return retcode 1200 [Unauthorized] + "Origin": "https://account.hoyolab.com", + "Referer": "https://account.hoyolab.com/", +} + +APP_LOGIN_HEADERS = { + "x-rpc-app_id": "c9oqaq3s3gu8", + "x-rpc-client_type": "2", + # Passing "x-rpc-device_id" header will trigger email verification + # (unless the device_id is already verified). + # + # For some reason, without this header, email verification is not triggered. + # "x-rpc-device_id": "1c33337bd45c1bfs", +} + +CN_LOGIN_HEADERS = { + "x-rpc-app_id": "bll8iq97cem8", + "x-rpc-client_type": "4", + "x-rpc-source": "v2.webLogin", + "x-rpc-device_id": "586f2440-856a-4243-8076-2b0a12314197", +} + +EMAIL_SEND_HEADERS = { + "x-rpc-app_id": "c9oqaq3s3gu8", + "x-rpc-client_type": "2", +} + +EMAIL_VERIFY_HEADERS = { + "x-rpc-app_id": "c9oqaq3s3gu8", + "x-rpc-client_type": "2", +} + +CREATE_MMT_HEADERS = { + "x-rpc-app_version": "2.60.1", + "x-rpc-client_type": "5", +} + +DEVICE_ID = "D6AF5103-D297-4A01-B86A-87F87DS5723E" + +RISKY_CHECK_HEADERS = { + "x-rpc-client_type": "1", + "x-rpc-channel_id": "1", + "x-rpc-game_biz": "hkrpg_global", +} + +SHIELD_LOGIN_HEADERS = { + "x-rpc-client_type": "1", + "x-rpc-channel_id": "1", + "x-rpc-game_biz": "hkrpg_global", + "x-rpc-device_id": DEVICE_ID, +} + +GRANT_TICKET_HEADERS = { + "x-rpc-client_type": "1", + "x-rpc-channel_id": "1", + "x-rpc-game_biz": "hkrpg_global", + "x-rpc-device_id": DEVICE_ID, + "x-rpc-language": "ru", +} + +GAME_LOGIN_HEADERS = { + "x-rpc-client_type": "1", + "x-rpc-channel_id": "1", + "x-rpc-game_biz": "hkrpg_global", + "x-rpc-device_id": DEVICE_ID, +} + +GEETEST_LANGS = { + "简体中文": "zh-cn", + "繁體中文": "zh-tw", + "Deutsch": "de", + "English": "en", + "Español": "es", + "Français": "fr", + "Indonesia": "id", + "Italiano": "it", + "日本語": "ja", + "한국어": "ko", + "Português": "pt-pt", + "Pусский": "ru", + "ภาษาไทย": "th", + "Tiếng Việt": "vi", + "Türkçe": "tr", +} + + +def lang_to_geetest_lang(lang: str) -> str: + """Convert `client.lang` to geetest lang.""" + return GEETEST_LANGS.get(constants.LANGS.get(lang, "en-us"), "en") + + +def encrypt_credentials(text: str, key_type: typing.Literal[1, 2]) -> str: + """Encrypt text for geetest.""" + import rsa + + public_key = rsa.PublicKey.load_pkcs1_openssl_pem(LOGIN_KEY_TYPE_1 if key_type == 1 else LOGIN_KEY_TYPE_2) + crypto = rsa.encrypt(text.encode("utf-8"), public_key) + return base64.b64encode(crypto).decode("utf-8") + + +def get_aigis_header(session_id: str, mmt_data: typing.Dict[str, typing.Any]) -> str: + """Get aigis header.""" + return f"{session_id};{base64.b64encode(json.dumps(mmt_data).encode()).decode()}" + + +def generate_sign(data: typing.Dict[str, typing.Any], key: str) -> str: + """Generate a sign for the given `data` and `app_key`.""" + string = "" + for k in sorted(data.keys()): + string += k + "=" + str(data[k]) + "&" + return hmac.new(key.encode(), string[:-1].encode(), sha256).hexdigest() + + +def generate_risky_header( + check_id: str, + challenge: str = "", + validate: str = "", +) -> str: + """Generate risky header for geetest verification.""" + return f"id={check_id};c={challenge};s={validate}|jordan;v={validate}" diff --git a/genshin/utility/ds.py b/genshin/utility/ds.py index adae34c3..18bd619c 100644 --- a/genshin/utility/ds.py +++ b/genshin/utility/ds.py @@ -9,7 +9,13 @@ from genshin import constants, types -__all__ = ["generate_cn_dynamic_secret", "generate_dynamic_secret", "generate_passport_ds", "get_ds_headers"] +__all__ = [ + "generate_cn_dynamic_secret", + "generate_create_geetest_ds", + "generate_dynamic_secret", + "generate_passport_ds", + "get_ds_headers", +] def generate_dynamic_secret(salt: str = constants.DS_SALT[types.Region.OVERSEAS]) -> str: @@ -70,3 +76,12 @@ def generate_passport_ds(body: typing.Mapping[str, typing.Any]) -> str: h = hashlib.md5(f"salt={salt}&t={t}&r={r}&b={b}&q=".encode()).hexdigest() result = f"{t},{r},{h}" return result + + +def generate_create_geetest_ds() -> str: + """Create a dynamic secret for Miyoushe createVerification API endpoint.""" + salt = constants.DS_SALT[types.Region.CHINESE] + t = int(time.time()) + r = random.randint(100000, 200000) + h = hashlib.md5(f"salt={salt}&t={t}&r={r}&b=&q=is_high=false".encode()).hexdigest() + return f"{t},{r},{h}" diff --git a/genshin/utility/geetest.py b/genshin/utility/geetest.py deleted file mode 100644 index fe388afb..00000000 --- a/genshin/utility/geetest.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Geetest utilities.""" - -import base64 -import json -import typing - -from ..types import Region - -__all__ = ["encrypt_geetest_credentials"] - - -# RSA key is the same for app and web login -OS_LOGIN_KEY_CERT = b""" ------BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4PMS2JVMwBsOIrYWRluY -wEiFZL7Aphtm9z5Eu/anzJ09nB00uhW+ScrDWFECPwpQto/GlOJYCUwVM/raQpAj -/xvcjK5tNVzzK94mhk+j9RiQ+aWHaTXmOgurhxSp3YbwlRDvOgcq5yPiTz0+kSeK -ZJcGeJ95bvJ+hJ/UMP0Zx2qB5PElZmiKvfiNqVUk8A8oxLJdBB5eCpqWV6CUqDKQ -KSQP4sM0mZvQ1Sr4UcACVcYgYnCbTZMWhJTWkrNXqI8TMomekgny3y+d6NX/cFa6 -6jozFIF4HCX5aW8bp8C8vq2tFvFbleQ/Q3CU56EWWKMrOcpmFtRmC18s9biZBVR/ -8QIDAQAB ------END PUBLIC KEY----- -""" - -CN_LOGIN_KEY_CERT = b""" ------BEGIN PUBLIC KEY----- -MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDDvekdPMHN3AYhm/vktJT+YJr7 -cI5DcsNKqdsx5DZX0gDuWFuIjzdwButrIYPNmRJ1G8ybDIF7oDW2eEpm5sMbL9zs -9ExXCdvqrn51qELbqj0XxtMTIpaCHFSI50PfPpTFV9Xt/hmyVwokoOXFlAEgCn+Q -CgGs52bFoYMtyi+xEQIDAQAB ------END PUBLIC KEY----- -""" - -WEB_LOGIN_HEADERS = { - "x-rpc-app_id": "c9oqaq3s3gu8", - "x-rpc-client_type": "4", - # If not equal account.hoyolab.com It's will return retcode 1200 [Unauthorized] - "Origin": "https://account.hoyolab.com", - "Referer": "https://account.hoyolab.com/", -} - -APP_LOGIN_HEADERS = { - "x-rpc-app_id": "c9oqaq3s3gu8", - "x-rpc-client_type": "2", - # Passing "x-rpc-device_id" header will trigger email verification - # (unless the device_id is already verified). - # - # For some reason, without this header, email verification is not triggered. - # "x-rpc-device_id": "1c33337bd45c1bfs", -} - -CN_LOGIN_HEADERS = { - "x-rpc-app_id": "bll8iq97cem8", - "x-rpc-client_type": "4", - "Origin": "https://user.miyoushe.com", - "Referer": "https://user.miyoushe.com/", - "x-rpc-source": "v2.webLogin", - "x-rpc-mi_referrer": "https://user.miyoushe.com/login-platform/index.html?app_id=bll8iq97cem8&theme=&token_type=4&game_biz=bbs_cn&message_origin=https%253A%252F%252Fwww.miyoushe.com&succ_back_type=message%253Alogin-platform%253Alogin-success&fail_back_type=message%253Alogin-platform%253Alogin-fail&ux_mode=popup&iframe_level=1#/login/password", # noqa: E501 - "x-rpc-device_id": "586f2440-856a-4243-8076-2b0a12314197", -} - -EMAIL_SEND_HEADERS = { - "x-rpc-app_id": "c9oqaq3s3gu8", - "x-rpc-client_type": "2", -} - -EMAIL_VERIFY_HEADERS = { - "x-rpc-app_id": "c9oqaq3s3gu8", - "x-rpc-client_type": "2", -} - - -def encrypt_geetest_credentials(text: str, region: Region = Region.OVERSEAS) -> str: - """Encrypt text for geetest.""" - import rsa - - public_key = rsa.PublicKey.load_pkcs1_openssl_pem( - OS_LOGIN_KEY_CERT if region is Region.OVERSEAS else CN_LOGIN_KEY_CERT - ) - crypto = rsa.encrypt(text.encode("utf-8"), public_key) - return base64.b64encode(crypto).decode("utf-8") - - -def get_aigis_header(session_id: str, mmt_data: typing.Dict[str, typing.Any]) -> str: - """Get aigis header.""" - return f"{session_id};{base64.b64encode(json.dumps(mmt_data).encode()).decode()}" diff --git a/setup.py b/setup.py index c8cb0974..ab7b352d 100644 --- a/setup.py +++ b/setup.py @@ -20,7 +20,7 @@ extras_require={ "all": ["browser-cookie3", "rsa", "click", "qrcode[pil]"], "cookies": ["browser-cookie3"], - "geetest": ["rsa", "qrcode[pil]"], + "auth": ["rsa", "qrcode[pil]"], "cli": ["click"], }, include_package_data=True, diff --git a/tests/client/components/test_calculator.py b/tests/client/components/test_calculator.py index d48ea499..1334166f 100644 --- a/tests/client/components/test_calculator.py +++ b/tests/client/components/test_calculator.py @@ -7,7 +7,7 @@ async def test_calculator_characters(client: genshin.Client): character = min(characters, key=lambda character: character.id) assert character.name == "Kamisato Ayaka" - assert "genshin" in character.icon + assert "enka.network" in character.icon assert character.max_level == 90 assert character.level == 0 assert not character.collab diff --git a/tests/client/components/test_genshin_chronicle.py b/tests/client/components/test_genshin_chronicle.py index 41ed193e..ce4daff3 100644 --- a/tests/client/components/test_genshin_chronicle.py +++ b/tests/client/components/test_genshin_chronicle.py @@ -59,6 +59,3 @@ async def test_full_genshin_user(client: genshin.Client, genshin_uid: int): async def test_exceptions(client: genshin.Client): with pytest.raises(genshin.DataNotPublic): await client.get_record_cards(10000000) - - with pytest.raises(genshin.AccountNotFound): - await client.get_spiral_abyss(70000001) diff --git a/tests/models/test_model.py b/tests/models/test_model.py index c9978389..57c5891c 100644 --- a/tests/models/test_model.py +++ b/tests/models/test_model.py @@ -24,14 +24,14 @@ class LiteralCharacter(genshin.models.BaseCharacter): ... "name": "Kamisato Ayaka", "element": "Cryo", "rarity": 5, - "icon": "https://upload-os-bbs.mihoyo.com/game_record/genshin/character_icon/UI_AvatarIcon_Ayaka.png", + "icon": "https://enka.network/ui/UI_AvatarIcon_Ayaka.png", }, LiteralCharacter( id=10000002, name="Kamisato Ayaka", element="Cryo", rarity=5, - icon="https://upload-os-bbs.mihoyo.com/game_record/genshin/character_icon/UI_AvatarIcon_Ayaka.png", + icon="https://enka.network/ui/UI_AvatarIcon_Ayaka.png", lang="en-us", ), ), @@ -46,7 +46,7 @@ class LiteralCharacter(genshin.models.BaseCharacter): ... name="Jean", element="Anemo", rarity=5, - icon="https://upload-os-bbs.mihoyo.com/game_record/genshin/character_icon/UI_AvatarIcon_Qin.png", + icon="https://enka.network/ui/UI_AvatarIcon_Qin.png", lang="en-us", ), ), @@ -62,7 +62,7 @@ class LiteralCharacter(genshin.models.BaseCharacter): ... name="Signora", element="Anemo", # Anemo is the arbitrary fallback rarity=6, # 5 is the arbitrary fallback - icon="https://upload-os-bbs.mihoyo.com/game_record/genshin/character_icon/UI_AvatarIcon_Signora.png", + icon="https://enka.network/ui/UI_AvatarIcon_Signora.png", lang="en-us", ), ), @@ -79,7 +79,7 @@ class LiteralCharacter(genshin.models.BaseCharacter): ... name="Traveler", element="Light", rarity=5, - icon="https://upload-os-bbs.mihoyo.com/game_record/genshin/character_icon/UI_AvatarIcon_PlayerBoy.png", + icon="https://enka.network/ui/UI_AvatarIcon_PlayerBoy.png", lang="en-us", ), ), @@ -97,7 +97,7 @@ class LiteralCharacter(genshin.models.BaseCharacter): ... name="Mona", element="Hydro", rarity=5, - icon="https://upload-os-bbs.mihoyo.com/game_record/genshin/character_icon/UI_AvatarIcon_Mona.png", + icon="https://enka.network/ui/UI_AvatarIcon_Mona.png", lang="en-us", ), ), @@ -126,14 +126,14 @@ class LiteralCharacter(genshin.models.BaseCharacter): ... "name": "胡桃", "element": "Pyro", "rarity": 5, - "icon": "https://upload-os-bbs.mihoyo.com/game_record/genshin/character_icon/UI_AvatarIcon_Hutao.png", + "icon": "https://enka.network/ui/UI_AvatarIcon_Hutao.png", }, LiteralCharacter( id=10000046, name="胡桃", element="Pyro", rarity=5, - icon="https://upload-os-bbs.mihoyo.com/game_record/genshin/character_icon/UI_AvatarIcon_Hutao.png", + icon="https://enka.network/ui/UI_AvatarIcon_Hutao.png", lang="en-us", ), ), From 69f82a07511be3aa8a6f23befbb4ab12ff0f947c Mon Sep 17 00:00:00 2001 From: ashlen Date: Thu, 30 May 2024 13:05:04 +0000 Subject: [PATCH 26/42] Bump pypi version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ab7b352d..4a1a649f 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ setup( name="genshin", - version="1.6.3", + version="1.7.0", author="thesadru", author_email="thesadru@gmail.com", description="An API wrapper for Genshin Impact.", From 76afc281e881aba7c2a5b9c5c795660b2314c0aa Mon Sep 17 00:00:00 2001 From: JokelBaf <60827680+jokelbaf@users.noreply.github.com> Date: Sat, 1 Jun 2024 01:52:27 +0300 Subject: [PATCH 27/42] Add HoYoLab geetest handling (#188) --- genshin/client/components/auth/client.py | 50 ++++++++++++++++++------ genshin/client/components/auth/server.py | 12 +++--- genshin/client/manager/managers.py | 7 +--- genshin/client/routes.py | 8 +++- genshin/constants.py | 14 +++++-- genshin/errors.py | 29 ++++++++------ genshin/utility/auth.py | 17 ++++++-- genshin/utility/ds.py | 9 ++--- tests/client/components/test_daily.py | 4 +- 9 files changed, 98 insertions(+), 52 deletions(-) diff --git a/genshin/client/components/auth/client.py b/genshin/client/components/auth/client.py index 73223aa1..0bbe36b9 100644 --- a/genshin/client/components/auth/client.py +++ b/genshin/client/components/auth/client.py @@ -6,7 +6,7 @@ import aiohttp -from genshin import errors, types +from genshin import constants, errors, types from genshin.client import routes from genshin.client.components import base from genshin.client.manager import managers @@ -19,10 +19,9 @@ QRLoginResult, WebLoginResult, ) -from genshin.models.auth.geetest import MMT, RiskyCheckMMT, RiskyCheckMMTResult, SessionMMT, SessionMMTResult +from genshin.models.auth.geetest import MMT, MMTResult, RiskyCheckMMT, RiskyCheckMMTResult, SessionMMT, SessionMMTResult from genshin.models.auth.qrcode import QRCodeStatus from genshin.models.auth.verification import ActionTicket -from genshin.types import Game from genshin.utility import auth as auth_utility from genshin.utility import ds as ds_utility @@ -273,24 +272,24 @@ async def login_with_qrcode(self) -> QRLoginResult: self.set_cookies(cookies) return QRLoginResult(**cookies) - @base.region_specific(types.Region.CHINESE) @managers.no_multi async def create_mmt(self) -> MMT: """Create a geetest challenge.""" - is_genshin = self.game is Game.GENSHIN + if self.default_game is None: + raise ValueError("No default game set.") + headers = { - "DS": ds_utility.generate_create_geetest_ds(), - "x-rpc-challenge_game": "2" if is_genshin else "6", - "x-rpc-page": "v4.1.5-ys_#ys" if is_genshin else "v1.4.1-rpg_#/rpg", - "x-rpc-tool-verison": "v4.1.5-ys" if is_genshin else "v1.4.1-rpg", - **auth_utility.CREATE_MMT_HEADERS, + "DS": ds_utility.generate_geetest_ds(self.region), + **auth_utility.CREATE_MMT_HEADERS[self.region], } + url = routes.CREATE_MMT_URL.get_url(self.region) + if self.region is types.Region.OVERSEAS: + url = url.update_query(app_key=constants.GEETEST_RECORD_KEYS[self.default_game]) + assert isinstance(self.cookie_manager, managers.CookieManager) async with self.cookie_manager.create_session() as session: - async with session.get( - routes.CREATE_MMT_URL.get_url(), headers=headers, cookies=self.cookie_manager.cookies - ) as r: + async with session.get(url, headers=headers, cookies=self.cookie_manager.cookies) as r: data = await r.json() if not data["data"]: @@ -298,6 +297,31 @@ async def create_mmt(self) -> MMT: return MMT(**data["data"]) + @base.region_specific(types.Region.OVERSEAS) + @managers.no_multi + async def verify_mmt(self, mmt_result: MMTResult) -> None: + """Verify a geetest challenge.""" + if self.default_game is None: + raise ValueError("No default game set.") + + headers = { + "DS": ds_utility.generate_geetest_ds(self.region), + **auth_utility.CREATE_MMT_HEADERS[self.region], + } + + body = mmt_result.dict() + body["app_key"] = constants.GEETEST_RECORD_KEYS[self.default_game] + + assert isinstance(self.cookie_manager, managers.CookieManager) + async with self.cookie_manager.create_session() as session: + async with session.post( + routes.VERIFY_MMT_URL.get_url(), json=body, headers=headers, cookies=self.cookie_manager.cookies + ) as r: + data = await r.json() + + if not data["data"]: + errors.raise_for_retcode(data) + @base.region_specific(types.Region.OVERSEAS) async def os_game_login( self, diff --git a/genshin/client/components/auth/server.py b/genshin/client/components/auth/server.py index eb7789d1..d6ef8248 100644 --- a/genshin/client/components/auth/server.py +++ b/genshin/client/components/auth/server.py @@ -25,7 +25,7 @@ __all__ = ["PAGES", "enter_code", "launch_webapp", "solve_geetest"] -PAGES: typing.Final[typing.Dict[typing.Literal["captcha", "captcha-v4", "enter-code"], str]] = { +PAGES: typing.Final[typing.Dict[typing.Literal["captcha", "enter-code"], str]] = { "captcha": """ @@ -44,25 +44,25 @@ gt: mmt.gt, challenge: mmt.challenge, new_captcha: mmt.new_captcha, - api_server: '{api_server}', + api_server: "{api_server}", https: /^https/i.test(window.location.protocol), product: "bind", - lang: '{lang}', + lang: "{lang}", } : { captchaId: mmt.gt, riskType: mmt.risk_type, userInfo: mmt.session_id ? JSON.stringify({ mmt_key: mmt.session_id }) : undefined, - api_server: '{api_server}', + api_server: "{api_server}", product: "bind", - language: '{lang}', + language: "{lang}", }; initGeetest( initParams, (captcha) => { captcha.onReady(() => { - captcha.verify(); + geetestVersion == 3 ? captcha.verify() : captcha.showCaptcha(); }); captcha.onSuccess(() => { fetch("/send-data", { diff --git a/genshin/client/manager/managers.py b/genshin/client/manager/managers.py index 7b3b2704..7f484918 100644 --- a/genshin/client/manager/managers.py +++ b/genshin/client/manager/managers.py @@ -17,8 +17,6 @@ from genshin.client import ratelimit from genshin.utility import fs as fs_utility -from ...constants import MIYOUSHE_GEETEST_RETCODES - _LOGGER = logging.getLogger(__name__) __all__ = [ @@ -153,10 +151,7 @@ async def _request( cookies.update(new_cookies) _LOGGER.debug("Updating cookies for %s: %s", get_cookie_identifier(cookies), new_keys) - errors.check_for_geetest(data) - - if data["retcode"] in MIYOUSHE_GEETEST_RETCODES: - raise errors.MiyousheGeetestError(data, {k: morsel.value for k, morsel in response.cookies.items()}) + errors.check_for_geetest(data, {k: morsel.value for k, morsel in response.cookies.items()}) if data["retcode"] == 0: return data["data"] diff --git a/genshin/client/routes.py b/genshin/client/routes.py index 8b8c2282..9f9e26c1 100644 --- a/genshin/client/routes.py +++ b/genshin/client/routes.py @@ -32,6 +32,7 @@ "TAKUMI_URL", "TEAPOT_URL", "VERIFY_EMAIL_URL", + "VERIFY_MMT_URL", "WEBAPI_URL", "WEBSTATIC_URL", "WEB_LOGIN_URL", @@ -239,10 +240,13 @@ def get_url(self, region: types.Region, game: types.Game) -> yarl.URL: 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_MMT_URL = Route( - "https://api-takumi-record.mihoyo.com/game_record/app/card/wapi/createVerification?is_high=false" +CREATE_MMT_URL = InternationalRoute( + overseas="https://sg-public-api.hoyolab.com/event/toolcomsrv/risk/createGeetest?is_high=true", + chinese="https://api-takumi-record.mihoyo.com/game_record/app/card/wapi/createVerification?is_high=false", ) +VERIFY_MMT_URL = Route("https://sg-public-api.hoyolab.com/event/toolcomsrv/risk/verifyGeetest") + GAME_RISKY_CHECK_URL = InternationalRoute( overseas="https://api-account-os.hoyoverse.com/account/risky/api/check", chinese="https://gameapi-account.mihoyo.com/account/risky/api/check", diff --git a/genshin/constants.py b/genshin/constants.py index 02d94d1b..340bc61f 100644 --- a/genshin/constants.py +++ b/genshin/constants.py @@ -2,7 +2,7 @@ from . import types -__all__ = ["LANGS"] +__all__ = ["APP_IDS", "APP_KEYS", "DS_SALT", "GEETEST_RETCODES", "LANGS"] LANGS = { @@ -33,8 +33,8 @@ } """Dynamic Secret Salts.""" -MIYOUSHE_GEETEST_RETCODES = {10035, 5003, 10041, 1034} -"""API error codes that indicate a Geetest was triggered during this Miyoushe API request.""" +GEETEST_RETCODES = {10035, 5003, 10041, 1034} +"""API error codes that indicate a Geetest was triggered during the API request.""" APP_KEYS = { types.Game.GENSHIN: { @@ -75,3 +75,11 @@ }, } """App IDs used for game login.""" + +GEETEST_RECORD_KEYS = { + types.Game.GENSHIN: "hk4e_game_record", + types.Game.STARRAIL: "hkrpg_game_record", + types.Game.HONKAI: "bh3_game_record", + types.Game.ZZZ: "nap_game_record", +} +"""Keys used to submit geetest result.""" diff --git a/genshin/errors.py b/genshin/errors.py index 611085e8..7cb796dc 100644 --- a/genshin/errors.py +++ b/genshin/errors.py @@ -2,6 +2,8 @@ import typing +from genshin.constants import GEETEST_RETCODES + __all__ = [ "ERRORS", "AccountNotFound", @@ -9,12 +11,12 @@ "AuthkeyException", "AuthkeyTimeout", "CookieException", + "DailyGeetestTriggered", "DataNotPublic", - "GeetestTriggered", + "GeetestError", "GenshinException", "InvalidAuthkey", "InvalidCookies", - "MiyousheGeetestError", "RedemptionClaimed", "RedemptionCooldown", "RedemptionException", @@ -114,8 +116,8 @@ class AlreadyClaimed(GenshinException): msg = "Already claimed the daily reward today." -class GeetestTriggered(GenshinException): - """Geetest triggered.""" +class DailyGeetestTriggered(GenshinException): + """Geetest triggered during daily reward claim.""" msg = "Geetest triggered during daily reward claim." @@ -187,8 +189,8 @@ class WrongOTP(GenshinException): msg = "The provided OTP code is wrong." -class MiyousheGeetestError(GenshinException): - """Geetest triggered during Miyoushe API request.""" +class GeetestError(GenshinException): + """Geetest triggered during the battle chronicle API request.""" def __init__( self, @@ -198,7 +200,7 @@ def __init__( self.cookies = cookies super().__init__(response) - msg = "Geetest triggered during Miyoushe API request." + msg = "Geetest triggered during the battle chronicle API request." class OTPRateLimited(GenshinException): @@ -340,12 +342,15 @@ def raise_for_retcode(data: typing.Dict[str, typing.Any]) -> typing.NoReturn: raise GenshinException(data) -def check_for_geetest(response: typing.Dict[str, typing.Any]) -> None: - """Check if geetest was triggered and raise an error.""" - if not response.get("data"): # if is an error +def check_for_geetest(data: typing.Dict[str, typing.Any], cookies: typing.Mapping[str, typing.Any]) -> None: + """Check if geetest was triggered during the request and raise an error if so.""" + if data["retcode"] in GEETEST_RETCODES: + raise GeetestError(data, cookies) + + if not data.get("data"): # if is an error return - gt_result = response["data"].get("gt_result", response["data"]) + gt_result = data["data"].get("gt_result", data["data"]) if ( gt_result.get("risk_code") != 0 @@ -353,4 +358,4 @@ def check_for_geetest(response: typing.Dict[str, typing.Any]) -> None: and gt_result.get("challenge") and gt_result.get("success") != 0 ): - raise GeetestTriggered(response, gt=gt_result.get("gt"), challenge=gt_result.get("challenge")) + raise DailyGeetestTriggered(data, gt=gt_result.get("gt"), challenge=gt_result.get("challenge")) diff --git a/genshin/utility/auth.py b/genshin/utility/auth.py index 8a7f1a0e..618a8292 100644 --- a/genshin/utility/auth.py +++ b/genshin/utility/auth.py @@ -6,7 +6,7 @@ import typing from hashlib import sha256 -from genshin import constants +from genshin import constants, types __all__ = ["encrypt_credentials", "generate_sign"] @@ -70,8 +70,19 @@ } CREATE_MMT_HEADERS = { - "x-rpc-app_version": "2.60.1", - "x-rpc-client_type": "5", + types.Region.OVERSEAS: { + "x-rpc-challenge_path": "https://bbs-api-os.hoyolab.com/game_record/app/hkrpg/api/challenge", + "x-rpc-app_version": "2.55.0", + "x-rpc-challenge_game": "6", + "x-rpc-client_type": "5", + }, + types.Region.CHINESE: { + "x-rpc-app_version": "2.60.1", + "x-rpc-client_type": "5", + "x-rpc-challenge_game": "6", + "x-rpc-page": "v1.4.1-rpg_#/rpg", + "x-rpc-tool-version": "v1.4.1-rpg", + }, } DEVICE_ID = "D6AF5103-D297-4A01-B86A-87F87DS5723E" diff --git a/genshin/utility/ds.py b/genshin/utility/ds.py index 18bd619c..2a9e3164 100644 --- a/genshin/utility/ds.py +++ b/genshin/utility/ds.py @@ -11,8 +11,8 @@ __all__ = [ "generate_cn_dynamic_secret", - "generate_create_geetest_ds", "generate_dynamic_secret", + "generate_geetest_ds", "generate_passport_ds", "get_ds_headers", ] @@ -78,10 +78,9 @@ def generate_passport_ds(body: typing.Mapping[str, typing.Any]) -> str: return result -def generate_create_geetest_ds() -> str: - """Create a dynamic secret for Miyoushe createVerification API endpoint.""" - salt = constants.DS_SALT[types.Region.CHINESE] +def generate_geetest_ds(region: types.Region) -> str: + """Create a dynamic secret for geetest API endpoint.""" t = int(time.time()) r = random.randint(100000, 200000) - h = hashlib.md5(f"salt={salt}&t={t}&r={r}&b=&q=is_high=false".encode()).hexdigest() + h = hashlib.md5(f"salt={constants.DS_SALT[region]}&t={t}&r={r}&b=&q=is_high=false".encode()).hexdigest() return f"{t},{r},{h}" diff --git a/tests/client/components/test_daily.py b/tests/client/components/test_daily.py index 95dfe5cb..9160a4ab 100644 --- a/tests/client/components/test_daily.py +++ b/tests/client/components/test_daily.py @@ -13,7 +13,7 @@ async def test_daily_reward(lclient: genshin.Client): try: reward = await lclient.claim_daily_reward() - except genshin.GeetestTriggered: + except genshin.DailyGeetestTriggered: pytest.skip("Geetest triggered on daily reward.") except genshin.AlreadyClaimed: assert signed_in @@ -35,7 +35,7 @@ async def test_starrail_daily_reward(lclient: genshin.Client): try: reward = await lclient.claim_daily_reward(game=genshin.types.Game.STARRAIL) - except genshin.GeetestTriggered: + except genshin.DailyGeetestTriggered: pytest.skip("Geetest triggered on daily reward.") except genshin.AlreadyClaimed: assert signed_in From a2b2755ebd889b1d01ab4253f7472539d2a57222 Mon Sep 17 00:00:00 2001 From: JokelBaf <60827680+jokelbaf@users.noreply.github.com> Date: Sat, 1 Jun 2024 02:03:12 +0300 Subject: [PATCH 28/42] fix: Update genshin gacha url --- genshin/client/routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/genshin/client/routes.py b/genshin/client/routes.py index 9f9e26c1..1288c067 100644 --- a/genshin/client/routes.py +++ b/genshin/client/routes.py @@ -202,7 +202,7 @@ def get_url(self, region: types.Region, game: types.Game) -> yarl.URL: GACHA_URL = GameRoute( overseas=dict( - genshin="https://hk4e-api-os.hoyoverse.com/event/gacha_info/api/", + genshin="https://hk4e-api-os.hoyoverse.com/gacha_info/api/", hkrpg="https://api-os-takumi.mihoyo.com/common/gacha_record/api/", ), chinese=dict( From 462d6e7f453fe9a5d1972cd8d48236c9c3071fcb Mon Sep 17 00:00:00 2001 From: seria Date: Sat, 1 Jun 2024 13:25:30 +0900 Subject: [PATCH 29/42] Remove the use of cookies in GeetestError --- genshin/client/manager/managers.py | 2 +- genshin/errors.py | 11 +++-------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/genshin/client/manager/managers.py b/genshin/client/manager/managers.py index 7f484918..0e33324b 100644 --- a/genshin/client/manager/managers.py +++ b/genshin/client/manager/managers.py @@ -151,7 +151,7 @@ async def _request( cookies.update(new_cookies) _LOGGER.debug("Updating cookies for %s: %s", get_cookie_identifier(cookies), new_keys) - errors.check_for_geetest(data, {k: morsel.value for k, morsel in response.cookies.items()}) + errors.check_for_geetest(data) if data["retcode"] == 0: return data["data"] diff --git a/genshin/errors.py b/genshin/errors.py index 7cb796dc..acda773f 100644 --- a/genshin/errors.py +++ b/genshin/errors.py @@ -192,12 +192,7 @@ class WrongOTP(GenshinException): class GeetestError(GenshinException): """Geetest triggered during the battle chronicle API request.""" - def __init__( - self, - response: typing.Dict[str, typing.Any], - cookies: typing.Mapping[str, str], - ) -> None: - self.cookies = cookies + def __init__(self, response: typing.Dict[str, typing.Any]) -> None: super().__init__(response) msg = "Geetest triggered during the battle chronicle API request." @@ -342,10 +337,10 @@ def raise_for_retcode(data: typing.Dict[str, typing.Any]) -> typing.NoReturn: raise GenshinException(data) -def check_for_geetest(data: typing.Dict[str, typing.Any], cookies: typing.Mapping[str, typing.Any]) -> None: +def check_for_geetest(data: typing.Dict[str, typing.Any]) -> None: """Check if geetest was triggered during the request and raise an error if so.""" if data["retcode"] in GEETEST_RETCODES: - raise GeetestError(data, cookies) + raise GeetestError(data) if not data.get("data"): # if is an error return From 9d2009318e1a65bdee3e409b9489dc609a43e207 Mon Sep 17 00:00:00 2001 From: JokelBaf <60827680+jokelbaf@users.noreply.github.com> Date: Sat, 1 Jun 2024 15:56:59 +0300 Subject: [PATCH 30/42] chore: Bump PyPi version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 4a1a649f..bb51b5b6 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ setup( name="genshin", - version="1.7.0", + version="1.7.1", author="thesadru", author_email="thesadru@gmail.com", description="An API wrapper for Genshin Impact.", From ff53348da2cdc8bcf52bc3d3f70e10fd48d3c950 Mon Sep 17 00:00:00 2001 From: seria Date: Fri, 7 Jun 2024 21:37:35 +0900 Subject: [PATCH 31/42] Allow passing in device_id and device_fp (#190) * Allow passing in device_id and device_fp * Refactor headers update code * Change according to Ashlen's review * Add missing headers update in request_webstatic * Add device_id and device_fp to init args --- genshin/client/components/base.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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: From 06add94e29d28c022275f7891b5a444dd92d798e Mon Sep 17 00:00:00 2001 From: seria Date: Sat, 8 Jun 2024 21:24:02 +0900 Subject: [PATCH 32/42] Add name Field to StarRailChallenge Model (#191) * Add MOC challenge seasons * Add challenge season to dunder all * Use extract name approach --- genshin/models/starrail/chronicle/challenge.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/genshin/models/starrail/chronicle/challenge.py b/genshin/models/starrail/chronicle/challenge.py index d71f2ee2..fc6e05a7 100644 --- a/genshin/models/starrail/chronicle/challenge.py +++ b/genshin/models/starrail/chronicle/challenge.py @@ -47,6 +47,7 @@ class StarRailFloor(APIModel): class StarRailChallenge(APIModel): """Challenge in a season.""" + name: str season: int = Aliased("schedule_id") begin_time: PartialTime end_time: PartialTime @@ -58,6 +59,15 @@ class StarRailChallenge(APIModel): floors: List[StarRailFloor] = Aliased("all_floor_detail") + @pydantic.root_validator(pre=True) + def __extract_name(cls, values: Dict[str, Any]) -> Dict[str, Any]: + if "groups" in values and isinstance(values["groups"], List): + groups: List[Dict[str, Any]] = values["groups"] + if len(groups) > 0: + values["name"] = groups[0]["name_mi18n"] + + return values + class FictionBuff(APIModel): """Buff for a Pure Fiction floor.""" From c92407c19d39d64a92dd16d2366e484f4184c1b4 Mon Sep 17 00:00:00 2001 From: JokelBaf <60827680+jokelbaf@users.noreply.github.com> Date: Wed, 12 Jun 2024 01:05:05 +0300 Subject: [PATCH 33/42] style: Fix typo --- genshin/models/starrail/chronicle/notes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/genshin/models/starrail/chronicle/notes.py b/genshin/models/starrail/chronicle/notes.py index ca09e147..9cbfbd8e 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 total_expedition_num: int expeditions: typing.Sequence[StarRailExpedition] From dc44045ba371e89e92f2d250f4bad66a6a7f4a6b Mon Sep 17 00:00:00 2001 From: jokelbaf <60827680+JokelBaf@users.noreply.github.com> Date: Wed, 12 Jun 2024 01:15:56 +0300 Subject: [PATCH 34/42] fix: Fix my fix --- genshin/models/starrail/chronicle/notes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/genshin/models/starrail/chronicle/notes.py b/genshin/models/starrail/chronicle/notes.py index 9cbfbd8e..a0990277 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_expedition_num: int + accepted_expedition_num: int = Aliased("accepted_epedition_num") total_expedition_num: int expeditions: typing.Sequence[StarRailExpedition] From 01200ad3a0930eb7ff3e9ceda50d047d6c500bc1 Mon Sep 17 00:00:00 2001 From: seria Date: Wed, 12 Jun 2024 08:43:01 +0900 Subject: [PATCH 35/42] Modify HSR Pure Fiction and MOC Models (#192) * Add MOC challenge seasons * Add challenge season to dunder all * Use extract name approach * Add seasons field to MOC and pure fiction models * Export model to dunder all Export StarRailChallengeSeason * Remove name field from StarRailChallenge * Remove name, season_id, begin_time, end_time fields * Mark fields as deprecated --- .../models/starrail/chronicle/challenge.py | 43 ++++++++++++------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/genshin/models/starrail/chronicle/challenge.py b/genshin/models/starrail/chronicle/challenge.py index fc6e05a7..b6886f49 100644 --- a/genshin/models/starrail/chronicle/challenge.py +++ b/genshin/models/starrail/chronicle/challenge.py @@ -21,6 +21,7 @@ "FictionFloorNode", "FloorNode", "StarRailChallenge", + "StarRailChallengeSeason", "StarRailFloor", "StarRailPureFiction", ] @@ -44,6 +45,16 @@ class StarRailFloor(APIModel): is_chaos: 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.""" @@ -58,13 +69,14 @@ 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 "groups" in values and isinstance(values["groups"], List): - groups: List[Dict[str, Any]] = values["groups"] - if len(groups) > 0: - values["name"] = groups[0]["name_mi18n"] + 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"] return values @@ -105,10 +117,10 @@ def score(self) -> int: class StarRailPureFiction(APIModel): """Pure Fiction challenge in a season.""" - name: str - season_id: int - begin_time: PartialTime - end_time: PartialTime + 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 @@ -116,16 +128,17 @@ 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 "groups" in values and isinstance(values["groups"], List): - groups: List[Dict[str, Any]] = values["groups"] - if len(groups) > 0: - values["name"] = groups[0]["name_mi18n"] - values["season_id"] = groups[0]["schedule_id"] - values["begin_time"] = groups[0]["begin_time"] - values["end_time"] = groups[0]["end_time"] + 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 From 944fd6a32aad1b2c8fec958541a2769aa49ad8b2 Mon Sep 17 00:00:00 2001 From: seria Date: Sun, 16 Jun 2024 18:35:08 +0900 Subject: [PATCH 36/42] Refactor CN Time Zones, Fix Spiral Abyss Start and end Time (#193) * Add MOC challenge seasons * Add challenge season to dunder all * Use extract name approach * Add seasons field to MOC and pure fiction models * Export model to dunder all Export StarRailChallengeSeason * Remove name field from StarRailChallenge * Remove name, season_id, begin_time, end_time fields * Mark fields as deprecated * reafctor: Replace CN timezones with one constant in constant.py * Fix spiral abyss model having wrong start and end time --- genshin-dev/setup.py | 1 + genshin/client/components/daily.py | 4 +--- genshin/client/components/diary.py | 3 +-- genshin/constants.py | 4 ++++ genshin/models/genshin/chronicle/abyss.py | 5 +++++ genshin/models/genshin/daily.py | 4 ++-- tests/client/components/test_daily.py | 4 +--- tests/client/components/test_diary.py | 2 -- 8 files changed, 15 insertions(+), 12 deletions(-) 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/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/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/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() From 164e65925510fba0dd16ddd19f78868f5a2f92a4 Mon Sep 17 00:00:00 2001 From: seria Date: Fri, 21 Jun 2024 14:07:52 +0900 Subject: [PATCH 37/42] Add support for Apocalyptic Shadow (#195) * Add MOC challenge seasons * Add challenge season to dunder all * Use extract name approach * Add seasons field to MOC and pure fiction models * Export model to dunder all Export StarRailChallengeSeason * Remove name field from StarRailChallenge * Remove name, season_id, begin_time, end_time fields * Mark fields as deprecated * reafctor: Replace CN timezones with one constant in constant.py * Fix spiral abyss model having wrong start and end time * Add support for apocalyptic shadow * Add APCShadowBoss, APCShadowSeason, and last_update_time field to APCShadowFloor * Add is_quick_clear field to StarRailFloor * Change seasons type in StarRailAPCShadow to List[APCShadowSeason] --- .../client/components/chronicle/starrail.py | 12 +++ .../models/starrail/chronicle/challenge.py | 87 +++++++++++++++---- 2 files changed, 84 insertions(+), 15 deletions(-) diff --git a/genshin/client/components/chronicle/starrail.py b/genshin/client/components/chronicle/starrail.py index b1935341..576ce05f 100644 --- a/genshin/client/components/chronicle/starrail.py +++ b/genshin/client/components/chronicle/starrail.py @@ -139,3 +139,15 @@ async def get_starrail_pure_fiction( 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) 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/models/starrail/chronicle/challenge.py b/genshin/models/starrail/chronicle/challenge.py index b6886f49..a765cee2 100644 --- a/genshin/models/starrail/chronicle/challenge.py +++ b/genshin/models/starrail/chronicle/challenge.py @@ -16,10 +16,15 @@ from .base import PartialTime __all__ = [ - "FictionBuff", + "APCShadowBoss", + "APCShadowFloor", + "APCShadowFloorNode", + "APCShadowSeason", + "ChallengeBuff", "FictionFloor", "FictionFloorNode", "FloorNode", + "StarRailAPCShadow", "StarRailChallenge", "StarRailChallengeSeason", "StarRailFloor", @@ -28,21 +33,28 @@ 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 class StarRailChallengeSeason(APIModel): @@ -56,7 +68,7 @@ class StarRailChallengeSeason(APIModel): class StarRailChallenge(APIModel): - """Challenge in a season.""" + """Memory of chaos challenge in a season.""" name: str season: int = Aliased("schedule_id") @@ -81,8 +93,8 @@ def __extract_name(cls, values: Dict[str, Any]) -> Dict[str, Any]: return values -class FictionBuff(APIModel): - """Buff for a Pure Fiction floor.""" +class ChallengeBuff(APIModel): + """Buff used in a pure fiction or apocalyptic shadow node.""" id: int name: str = Aliased("name_mi18n") @@ -93,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: @@ -142,3 +150,52 @@ def __unnest_groups(cls, values: Dict[str, Any]) -> Dict[str, Any]: values["end_time"] = seasons[0]["end_time"] return values + + +class APCShadowFloorNode(FloorNode): + """Node for a apocalyptic shadow floor.""" + + buff: Optional[ChallengeBuff] + score: int + boss_defeated: bool + + +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 From cff0c492d41be9e8c34644a1b41352ebc6203f3d Mon Sep 17 00:00:00 2001 From: seria Date: Sun, 23 Jun 2024 20:59:06 +0900 Subject: [PATCH 38/42] Support Socks Proxy (#196) * Add aiohttp-socks to optional dependencies * Add support for socks proxy * Use a different approach * Add aiohttp-socks to setup.py --- genshin/client/manager/managers.py | 16 +++++++++++++++- requirements.txt | 3 ++- setup.py | 3 ++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/genshin/client/manager/managers.py b/genshin/client/manager/managers.py index 0e33324b..2b3b2b93 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/requirements.txt b/requirements.txt index fa1c601e..20657441 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 \ No newline at end of file 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"]}, From 39b6a7087863d5210e031809a3c4220602a8baa6 Mon Sep 17 00:00:00 2001 From: omg-xtao <100690902+omg-xtao@users.noreply.github.com> Date: Sun, 23 Jun 2024 19:59:32 +0800 Subject: [PATCH 39/42] Add Bonus Synchronicity Points to HSR notes (#194) --- genshin/models/starrail/chronicle/notes.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/genshin/models/starrail/chronicle/notes.py b/genshin/models/starrail/chronicle/notes.py index a0990277..08ef7dfd 100644 --- a/genshin/models/starrail/chronicle/notes.py +++ b/genshin/models/starrail/chronicle/notes.py @@ -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") From c13d724473a64d7e708c394324c4353a09335935 Mon Sep 17 00:00:00 2001 From: omg-xtao <100690902+omg-xtao@users.noreply.github.com> Date: Tue, 25 Jun 2024 05:41:38 +0800 Subject: [PATCH 40/42] Fix APCShadowFloorNode model when floor data is empty (#197) --- genshin/models/starrail/chronicle/challenge.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/genshin/models/starrail/chronicle/challenge.py b/genshin/models/starrail/chronicle/challenge.py index a765cee2..cf4304b7 100644 --- a/genshin/models/starrail/chronicle/challenge.py +++ b/genshin/models/starrail/chronicle/challenge.py @@ -155,10 +155,16 @@ def __unnest_groups(cls, values: Dict[str, Any]) -> Dict[str, Any]: 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.""" From 3a0a16d0a87d4e4f483ef8757ee16295cbf84f8d Mon Sep 17 00:00:00 2001 From: jokelbaf <60827680+JokelBaf@users.noreply.github.com> Date: Wed, 26 Jun 2024 15:11:56 +0300 Subject: [PATCH 41/42] fix: Use new aliased prop name --- genshin/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From 8424e1d4304497cbb077ce149687fa196f7c94c6 Mon Sep 17 00:00:00 2001 From: jokelbaf <60827680+JokelBaf@users.noreply.github.com> Date: Wed, 26 Jun 2024 15:14:44 +0300 Subject: [PATCH 42/42] chore(auth): Use en rpc language --- genshin/utility/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 = {