Add Star Rail Client

This commit is contained in:
洛水居室 2023-05-01 20:50:48 +08:00
parent 7294850914
commit cd97a751b9
No known key found for this signature in database
GPG Key ID: C9DE87DA724B88FC
15 changed files with 779 additions and 0 deletions

3
simnet/__init__.py Normal file
View File

@ -0,0 +1,3 @@
from simnet.client.starrail import StarRailClient
__all__ = ("StarRailClient",)

View File

@ -0,0 +1,56 @@
from typing import Optional, Any
from simnet.client.base import BaseClient
from simnet.client.routes import RECORD_URL
from simnet.utils.enum_ import Region, Game
from simnet.utils.types import QueryParamTypes
class BaseChronicleClient(BaseClient):
"""The base class for the Chronicle API client.
This class provides the basic functionality for making requests to the
Chronicle API endpoints. It is meant to be subclassed by other clients
that provide a more specific interface to the Chronicle API.
Attributes:
region (Region): The region associated with the API client.
"""
async def request_game_record(
self,
endpoint: str,
data: Optional[Any] = None,
params: Optional[QueryParamTypes] = None,
lang: Optional[str] = None,
region: Optional[Region] = None,
game: Optional[Game] = None,
):
"""Make a request towards the game record endpoint.
Args:
endpoint (str): The endpoint to send the request to.
data (Optional[Any], optional): The request payload.
params (Optional[QueryParamTypes], optional): The query parameters for the request.
lang (Optional[str], optional): The language for the response.
region (Optional[Region], optional): The region associated with the request.
game (Optional[Game], optional): The game associated with the request.
Returns:
The response from the server.
Raises:
NetworkError: If an HTTP error occurs while making the request.
TimedOut: If the request times out.
BadRequest: If the response contains an error.
"""
base_url = RECORD_URL.get_url(region or self.region)
if game:
base_url = base_url / game.value / "api"
url = base_url / endpoint
new_ds = self.region == Region.CHINESE
return await self.request_lab(
url, data=data, params=params, lang=lang, new_ds=new_ds
)

View File

@ -0,0 +1,143 @@
from typing import Optional, Mapping, Dict, Any
from simnet.client.chronicle.base import BaseChronicleClient
from simnet.errors import BadRequest, DataNotPublic
from simnet.models.starrail.chronicle.characters import StarShipDetailCharacters
from simnet.models.starrail.chronicle.notes import StarRailNote
from simnet.models.starrail.chronicle.stats import StarRailUserStats
from simnet.utils.enum_ import Game
from simnet.utils.player import recognize_starrail_server, recognize_region
class StarRailBattleChronicleClient(BaseChronicleClient):
"""A client for retrieving data from StarRail's battle chronicle component.
This class is used to retrieve various data objects from StarRail's battle chronicle component,
including real-time notes, user statistics, and character information.
"""
async def _request_starrail_record(
self,
endpoint: str,
player_id: Optional[int] = None,
method: str = "GET",
lang: Optional[str] = None,
payload: Optional[Dict[str, Any]] = None,
) -> Mapping[str, Any]:
"""Get an arbitrary object from StarRail's battle chronicle.
Args:
endpoint (str): The endpoint of the object to retrieve.
player_id (Optional[int], optional): The player ID. Defaults to None.
method (str, optional): The HTTP method to use. Defaults to "GET".
lang (Optional[str], optional): The language of the data. Defaults to None.
payload (Optional[Dict[str, Any]], optional): The request payload. Defaults to None.
Returns:
Mapping[str, Any]: The requested object.
Raises:
BadRequest: If the request is invalid.
DataNotPublic: If the requested data is not public.
"""
payload = dict(payload or {})
player_id = player_id or self.player_id
payload = dict(
role_id=player_id, server=recognize_starrail_server(player_id), **payload
)
data, params = None, None
if method == "POST":
data = payload
else:
params = payload
return await self.request_game_record(
endpoint,
lang=lang,
game=Game.STARRAIL,
region=recognize_region(player_id, game=Game.STARRAIL),
params=params,
data=data,
)
async def get_starrail_notes(
self,
player_id: Optional[int] = None,
lang: Optional[str] = None,
autoauth: bool = True,
) -> StarRailNote:
"""Get StarRail's real-time notes.
Args:
player_id (Optional[int], optional): The player ID. Defaults to None.
lang (Optional[str], optional): The language of the data. Defaults to None.
autoauth (bool, optional): Whether to automatically authenticate the user. Defaults to True.
Returns:
StarRailNote: The requested real-time notes.
Raises:
BadRequest: If the request is invalid.
DataNotPublic: If the requested data is not public.
"""
try:
data = await self._request_starrail_record("note", player_id, lang=lang)
except DataNotPublic as e:
# error raised only when real-time notes are not enabled
if player_id and self.player_id != player_id:
raise BadRequest(
e.response, "Cannot view real-time notes of other users."
) from e
if not autoauth:
raise BadRequest(e.response, "Real-time notes are not enabled.") from e
data = await self._request_starrail_record("note", player_id, lang=lang)
return StarRailNote(**data)
async def get_starrail_user(
self,
player_id: Optional[int] = None,
*,
lang: Optional[str] = None,
) -> StarRailUserStats:
"""Get StarRail user statistics.
Args:
player_id (Optional[int], optional): The player ID. Defaults to None.
lang (Optional[str], optional): The language of the data. Defaults to None.
Returns:
StarRailUserStats: The requested user statistics.
Raises:
BadRequest: If the request is invalid.
DataNotPublic: If the requested data is not public.
"""
data = await self._request_starrail_record("index", player_id, lang=lang)
return StarRailUserStats(**data)
async def get_starrail_characters(
self,
player_id: Optional[int] = None,
lang: Optional[str] = None,
) -> StarShipDetailCharacters:
"""Get StarRail character information.
Args:
player_id (Optional[int], optional): The player ID. Defaults to None.
lang (Optional[str], optional): The language of the data. Defaults to None.
Returns:
StarShipDetailCharacters: The requested character information.
Raises:
BadRequest: If the request is invalid.
DataNotPublic: If the requested data is not public.
"""
payload = {"need_wiki": "true"}
data = await self._request_starrail_record(
"avatar/info", player_id, lang=lang, payload=payload
)
return StarShipDetailCharacters(**data)

View File

@ -154,3 +154,20 @@ class GameRoute(BaseRoute):
) )
return self.urls[region][game] return self.urls[region][game]
RECORD_URL = InternationalRoute(
overseas="https://bbs-api-os.hoyolab.com/game_record",
chinese="https://api-takumi-record.mihoyo.com/game_record/app",
)
GACHA_INFO_URL = GameRoute(
overseas=dict(
genshin="https://hk4e-api-os.hoyoverse.com/event/gacha_info/api",
hkrpg="",
),
chinese=dict(
genshin="https://hk4e-api.mihoyo.com/event/gacha_info/api",
hkrpg="https://api-takumi.mihoyo.com/common/gacha_record/api",
),
)

View File

@ -0,0 +1,8 @@
from simnet.client.chronicle.starrail import StarRailBattleChronicleClient
from simnet.client.wish.starrail import WishClient
__all__ = ("StarRailClient",)
class StarRailClient(StarRailBattleChronicleClient, WishClient):
"""A simple http client for StarRail endpoints."""

View File

@ -0,0 +1,18 @@
from typing import Optional
from simnet.client.chronicle.starrail import StarRailBattleChronicleClient
from simnet.client.wish.starrail import WishClient
from simnet.utils.enum_ import Region
from simnet.utils.types import CookieTypes, HeaderTypes
class StarRailClient(StarRailBattleChronicleClient, WishClient):
def __init__(
self,
cookies: Optional[CookieTypes] = None,
headers: Optional[HeaderTypes] = None,
account_id: Optional[int] = None,
player_id: Optional[int] = None,
region: Region = Region.OVERSEAS,
lang: str = "en-us",
): ...

128
simnet/client/wish/base.py Normal file
View File

@ -0,0 +1,128 @@
from typing import Optional, Any, List, Dict
from urllib.parse import unquote
from simnet.client.base import BaseClient
from simnet.client.routes import GACHA_INFO_URL
from simnet.utils.enum_ import Game
from simnet.utils.lang import create_short_lang_code
class BaseWishClient(BaseClient):
"""The base class for the Wish API client."""
async def request_gacha_info(
self,
endpoint: str,
game: Game,
lang: Optional[str] = None,
authkey: Optional[str] = None,
params: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
Make a request towards the gacha info endpoint.
Args:
endpoint (str): The endpoint to request data from.
game (Game): The game to make the request for.
lang (Optional[str] , optional): The language code to use for the request.
If not provided, the class default will be used.
authkey (Optional[str] , optional): The authorization key for making the request.
params (Optional[Dict[str, Any]], optional): The query parameters for the request.
Returns:
Dict[str, Any]
The response data as a dictionary.
"""
params = dict(params or {})
if authkey is None:
raise RuntimeError("No authkey provided")
base_url = GACHA_INFO_URL.get_url(self.region, game)
url = base_url / endpoint
params["authkey_ver"] = 1
params["authkey"] = unquote(authkey)
params["lang"] = create_short_lang_code(lang or self.lang)
return await self.request_api("GET", url, params=params)
async def wish_history(
self,
banner_types: List[int],
limit: Optional[int] = None,
lang: Optional[str] = None,
authkey: Optional[str] = None,
end_id: int = 0,
) -> List[object]:
"""
Get the wish history for a list of banner types.
Args:
banner_types (List[int]): The list of banner types to get the wish history for.
limit (Optional[int] , optional): The maximum number of wishes to retrieve.
If not provided, all available wishes will be returned.
lang (Optional[str], optional): The language code to use for the request.
If not provided, the class default will be used.
authkey (Optional[str], optional): The authorization key for making the request.
end_id (int, optional): The ending ID of the last wish to retrieve.
Returns:
List[Any]: A list of Wish objects representing the retrieved wishes.
"""
async def get_wish_page(
self,
end_id: int,
banner_type: int,
game: Game,
lang: Optional[str] = None,
authkey: Optional[str] = None,
) -> Dict[str, Any]:
"""
Get a single page of wishes.
Args:
end_id (int): The ending ID of the last wish to retrieve.
banner_type (int): The type of banner to retrieve wishes from.
game (Game): The game to make the request for.
lang (Optional[str], optional): The language code to use for the request.
If not provided, the class default will be used.
authkey (Optional[str], optional): The authorization key for making the request.
Returns:
Dict[str, Any]: The response data as a dictionary.
"""
return await self.request_gacha_info(
"getGachaLog",
game=game,
lang=lang,
authkey=authkey,
params=dict(gacha_type=banner_type, size=20, end_id=end_id),
)
async def get_banner_names(
self,
game: Game,
lang: Optional[str] = None,
authkey: Optional[str] = None,
) -> Dict[int, str]:
"""
Get a list of banner names.
Args:
game (Game): The game to make the request for.
lang (Optional[str], optional): The language code to use for the request.
If not provided, the class default will be used.
authkey (Optional[str], optional): The authorization key for making the request.
Returns:
Dict[int, str]: A dictionary mapping banner type IDs to their corresponding names.
"""
data = await self.request_gacha_info(
"getConfigList",
game=game,
lang=lang,
authkey=authkey,
)
return {int(i["key"]): i["name"] for i in data["gacha_type_list"]}

View File

@ -0,0 +1,41 @@
from typing import Optional, List
from simnet.client.wish.base import BaseWishClient
from simnet.models.starrail.wish import StarRailWish
from simnet.utils.enum_ import Game
class WishClient(BaseWishClient):
"""The WishClient class for making requests towards the Wish API."""
async def wish_history(
self,
banner_types: List[int],
limit: Optional[int] = None,
lang: Optional[str] = None,
authkey: Optional[str] = None,
end_id: int = 0,
) -> List[StarRailWish]:
"""
Get the wish history for a list of banner types.
Args:
banner_types (List[int], optional): The list of banner types to get the wish history for.
limit (Optional[int] , optional): The maximum number of wishes to retrieve.
If not provided, all available wishes will be returned.
lang (Optional[str], optional): The language code to use for the request.
If not provided, the class default will be used.
authkey (Optional[str], optional): The authorization key for making the request.
end_id (int, optional): The ending ID of the last wish to retrieve.
Returns:
List[StarRailWish]: A list of StarRailWish objects representing the retrieved wishes.
"""
wish: List[StarRailWish] = []
banner_names = await self.get_banner_names(
game=Game.STARRAIL, lang=lang, authkey=authkey
)
for banner_type in banner_types:
data = await self.get_wish_page(end_id, banner_type, Game.STARRAIL)
banner_name = banner_names[banner_type]
wish = [StarRailWish(**i, banner_name=banner_name) for i in data["list"]]
return wish

View File

@ -0,0 +1,19 @@
from simnet.models.base import APIModel
class BaseCharacter(APIModel):
"""
A class representing a character in a game.
Attributes:
id (:obj:`int`): The unique identifier of the character.
name (:obj:`str`): The name of the character.
element (:obj:`str`): The element that the character represents (e.g. fire, water, etc.).
rarity (:obj:`int`): The rarity of the character (e.g. 1-5 stars).
"""
id: int
name: str
element: str
rarity: int
icon: str

View File

@ -0,0 +1,106 @@
from typing import Optional, List
from simnet.models.base import APIModel
from simnet.models.starrail.character import BaseCharacter
class PartialCharacter(BaseCharacter):
"""A character without any equipment.
Attributes:
level (int): The level of the character.
rank (int): The rank of the character.
"""
level: int
rank: int
class Equipment(APIModel):
"""An equipment model used in StarRailDetailCharacter.
Attributes:
id (int): The ID of the equipment.
level (int): The level of the equipment.
rank (int): The rank of the equipment.
name (str): The name of the equipment.
desc (str): The description of the equipment.
icon (str): The icon of the equipment.
"""
id: int
level: int
rank: int
name: str
desc: str
icon: str
class Relic(APIModel):
"""A relic model used in StarRailDetailCharacter.
Attributes:
id (int): The ID of the relic.
level (int): The level of the relic.
pos (int): The position of the relic.
name (str): The name of the relic.
desc (str): The description of the relic.
icon (str): The icon of the relic.
rarity (int): The rarity of the relic.
"""
id: int
level: int
pos: int
name: str
desc: str
icon: str
rarity: int
class Rank(APIModel):
"""A rank model used in StarRailDetailCharacter.
Attributes:
id (int): The ID of the rank.
pos (int): The position of the rank.
name (str): The name of the rank.
icon (str): The icon of the rank.
desc (str): The description of the rank.
is_unlocked (bool): Whether the rank is unlocked.
"""
id: int
pos: int
name: str
icon: str
desc: str
is_unlocked: bool
class StarRailDetailCharacter(PartialCharacter):
"""A detailed character model used in StarShipDetailCharacters.
Attributes:
image (str): The image of the character.
equip (Optional[Equipment]): The equipment of the character, if any.
relics (List[Relic]): The relics of the character.
ornaments (List[Relic]): The ornaments of the character.
ranks (List[Rank]): The ranks of the character.
"""
image: str
equip: Optional[Equipment]
relics: List[Relic]
ornaments: List[Relic]
ranks: List[Rank]
class StarShipDetailCharacters(APIModel):
"""A model containing a list of detailed characters used in the StarShipDetail API.
Attributes:
avatar_list (List[StarRailDetailCharacter]): The list of detailed characters.
"""
avatar_list: List[StarRailDetailCharacter]

View File

@ -0,0 +1,52 @@
from datetime import timedelta, datetime
from typing import List, Literal, Sequence
from simnet.models.base import APIModel
class StarRailExpedition(APIModel):
"""Represents a StarRail Expedition.
Attributes:
avatars (List[str]): A list of avatar names participating in the expedition.
status (Literal["Ongoing", "Finished"]): The status of the expedition.
remaining_time (timedelta): The time remaining for the expedition to finish.
name (str): The name of the expedition.
"""
avatars: List[str]
status: Literal["Ongoing", "Finished"]
remaining_time: timedelta
name: str
@property
def finished(self) -> bool:
"""Returns whether the expedition has finished."""
return self.remaining_time <= timedelta(0)
@property
def completion_time(self) -> datetime:
"""Returns the time at which the expedition will be completed."""
return datetime.now().astimezone() + self.remaining_time
class StarRailNote(APIModel):
"""Represents a StarRail Note.
Attributes:
current_stamina (int): The current stamina of the user.
max_stamina (int): The maximum stamina of the user.
stamina_recover_time (timedelta): The time it takes for one stamina to recover.
accepted_epedition_num (int): The number of expeditions the user has accepted.
total_expedition_num (int): The total number of expeditions the user has participated in.
expeditions (Sequence[StarRailExpedition]): A list of expeditions the user has participated in.
"""
current_stamina: int
max_stamina: int
stamina_recover_time: timedelta
accepted_epedition_num: int
total_expedition_num: int
expeditions: Sequence[StarRailExpedition]

View File

@ -0,0 +1,40 @@
from typing import List
from pydantic import Field
from simnet.models.base import APIModel
from simnet.models.starrail.chronicle.characters import PartialCharacter
class Stats(APIModel):
"""
Statistics of user data.
Attributes:
active_days (int): Number of days the user has been active.
avatar_num (int): Number of avatars the user has.
achievement_num (int): Number of achievements the user has earned.
chest_num (int): Number of chests the user has opened.
abyss_process (str): Progress of the user in the abyss mode.
"""
active_days: int
avatar_num: int
achievement_num: int
chest_num: int
abyss_process: str
class PartialStarRailUserStats(APIModel):
"""
Partial data of StarRail user, containing statistics and character information.
Attributes:
stats (Stats): Statistics of user data.
characters (List[PartialCharacter]): List of user's avatars/characters.
"""
stats: Stats
characters: List[PartialCharacter] = Field(alias="avatar_list")
class StarRailUserStats(PartialStarRailUserStats):
"""Complete data of StarRail user, containing statistics and character information."""

View File

@ -0,0 +1,56 @@
from datetime import datetime
from enum import IntEnum
from typing import Any
from pydantic import Field, validator
from simnet.models.base import APIModel
class StarRailBannerType(IntEnum):
"""Banner types in wish histories."""
NOVICE = 2
"""Temporary novice banner."""
STANDARD = PERMANENT = 1
"""Permanent standard banner."""
CHARACTER = 11
"""Rotating character banner."""
WEAPON = 12
"""Rotating weapon banner."""
class StarRailWish(APIModel):
"""Wish made on any banner."""
uid: int
"""User ID of the wish maker."""
id: int
"""ID of the wished item."""
type: str = Field(alias="item_type")
"""Type of the wished item."""
name: str
"""Name of the wished item."""
rarity: int = Field(alias="rank_type")
"""Rarity of the wished item."""
time: datetime
"""Time when the wish was made."""
banner_type: StarRailBannerType = Field(alias="gacha_type")
"""Type of the banner the wish was made on."""
banner_name: str
"""Name of the banner the wish was made on."""
@validator("banner_type", pre=True)
def cast_banner_type(cls, v: Any) -> int:
"""Converts the banner type from any type to int."""
return int(v)

62
tests/conftest.py Normal file
View File

@ -0,0 +1,62 @@
import asyncio
import os
import warnings
import pytest
import pytest_asyncio
from simnet.client.cookies import Cookies
from simnet.client.starrail import StarRailClient
from simnet.utils.cookies import parse_cookie
from simnet.utils.enum_ import Region
@pytest.fixture(scope="session")
def event_loop(): # skipcq: PY-D0003
with warnings.catch_warnings():
warnings.simplefilter("ignore")
loop = asyncio.get_event_loop()
yield loop
loop.close()
@pytest.fixture(scope="session")
def cookies() -> "Cookies": # skipcq: PY-D0003
cookies_str = os.environ.get("COOKIES")
if not cookies_str:
pytest.exit("No cookies set", 1)
_cookies = Cookies(parse_cookie(cookies_str))
return _cookies
@pytest.fixture(scope="session")
def player_id() -> int: # skipcq: PY-D0003
_player_id = os.environ.get("PLAYER_ID")
if not _player_id:
pytest.exit("No player id set", 1)
return int(_player_id)
@pytest.fixture(scope="session")
def account_id() -> int: # skipcq: PY-D0003
_account_id = os.environ.get("ACCOUNT_ID")
if not _account_id:
pytest.exit("No player id set", 1)
return int(_account_id)
@pytest_asyncio.fixture
async def starrail_client( # skipcq: PY-D0003
player_id: int, account_id: int, cookies: "Cookies" # skipcq: PYL-W0621
):
async with StarRailClient(
player_id=player_id,
cookies=cookies,
account_id=account_id,
region=Region.CHINESE,
) as client_instance:
yield client_instance

30
tests/test_starrail.py Normal file
View File

@ -0,0 +1,30 @@
from typing import TYPE_CHECKING
import pytest
if TYPE_CHECKING:
from simnet.client.starrail import StarRailClient
@pytest.mark.asyncio
class TestStarRailClient:
@staticmethod
async def test_get_starrail_user(starrail_client: "StarRailClient"):
user = await starrail_client.get_starrail_user()
assert user is not None
assert user.stats.chest_num > 0
assert len(user.characters) > 0
character = user.characters[-1]
assert character.id > 0
@staticmethod
async def test_get_starrail_notes(starrail_client: "StarRailClient"):
notes = await starrail_client.get_starrail_notes()
assert notes is not None
@staticmethod
async def test_get_starrail_characters(starrail_client: "StarRailClient"):
characters = await starrail_client.get_starrail_characters()
assert len(characters.avatar_list) > 0
character = characters.avatar_list[-1]
assert character.id > 0