diff --git a/simnet/client/components/diary/__init__.py b/simnet/client/components/diary/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/simnet/client/components/diary/base.py b/simnet/client/components/diary/base.py new file mode 100644 index 0000000..313e992 --- /dev/null +++ b/simnet/client/components/diary/base.py @@ -0,0 +1,99 @@ +from datetime import timedelta, timezone, datetime +from typing import Optional, Any, Dict + +from simnet.client.base import BaseClient +from simnet.client.routes import DETAIL_LEDGER_URL, INFO_LEDGER_URL +from simnet.models.diary import DiaryType +from simnet.models.genshin.diary import DiaryPage +from simnet.utils.enum_ import Region, Game +from simnet.utils.player import recognize_server + +__all__ = ("BaseDiaryClient",) + +CN_TIMEZONE = timezone(timedelta(hours=8)) + + +class BaseDiaryClient(BaseClient): + """Base diary component.""" + + async def request_ledger( + self, + player_id: Optional[int] = None, + *, + game: Optional[Game] = None, + detail: bool = False, + month: Optional[int] = None, + lang: Optional[str] = None, + params: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """Make a request towards the ys ledger endpoint. + + Args: + player_id (Optional[int], optional): The player ID to get the ledger for. + game (Optional[Game], optional): The game to get the ledger for. + detail (bool, optional): Whether to get the detailed ledger. + month (Optional[int], optional): The month to get the ledger for. + lang (Optional[str], optional): The language code to use for the request. + params (Optional[Dict[str, Any]], optional): The query parameters to use for the request. + + Returns: + Dict[str, Any]: The response data. + """ + game = game or self.game + player_id = player_id or self.player_id + params = params or {} + + url = ( + DETAIL_LEDGER_URL.get_url(self.region) + if detail + else INFO_LEDGER_URL.get_url( + self.region, + game, + ) + ) + + if self.region == Region.OVERSEAS or game == Game.STARRAIL: + params["uid"] = player_id + params["region"] = recognize_server(player_id, game) + elif self.region == Region.CHINESE: + params["bind_uid"] = player_id + params["bind_region"] = recognize_server(player_id, game) + else: + raise TypeError(f"{self.region!r} is not a valid region.") + params["month"] = month or (datetime.now().strftime("%Y%m") if game == Game.STARRAIL else datetime.now().month) + params["lang"] = lang or self.lang + + return await self.request_lab(url, params=params) + + async def _get_diary_page( + self, + page: int, + *, + game: Optional[Game] = None, + player_id: Optional[int] = None, + diary_type: int = DiaryType.PRIMOGEMS, + month: Optional[int] = None, + lang: Optional[str] = None, + ) -> DiaryPage: + """Get a diary page. + + Args: + page (int): The page number to get. + game (Optional[Game], optional): The game to get the diary page for. + player_id (Optional[int], optional): The player ID to get the diary page for. + diary_type (int, optional): The diary type to get the diary page for. + month (Optional[int], optional): The month to get the diary page for. + lang (Optional[str], optional): The language code to use for the request. + + Returns: + DiaryPage: The diary page. + """ + data = await self.request_ledger( + player_id, + game=game, + detail=True, + month=month, + lang=lang, + params=dict(type=diary_type, current_page=page, page_size=100), + ) + return DiaryPage(**data) diff --git a/simnet/client/components/diary/genshin.py b/simnet/client/components/diary/genshin.py new file mode 100644 index 0000000..58d4030 --- /dev/null +++ b/simnet/client/components/diary/genshin.py @@ -0,0 +1,29 @@ +from typing import Optional + +from simnet.client.components.diary.base import BaseDiaryClient +from simnet.models.genshin.diary import Diary +from simnet.utils.enum_ import Game + + +class GenshinDiaryClient(BaseDiaryClient): + """Genshin diary component.""" + + async def get_genshin_diary( + self, + player_id: Optional[int] = None, + *, + month: Optional[int] = None, + lang: Optional[str] = None, + ) -> Diary: + """Get a traveler's diary with earning details for the month. + + Args: + player_id (int, optional): The player's ID. Defaults to None. + month (int, optional): The month to get the diary for. Defaults to None. + lang (str, optional): The language to get the diary in. Defaults to None. + + Returns: + Diary: The diary for the month. + """ + data = await self.request_ledger(player_id, game=Game.GENSHIN, month=month, lang=lang) + return Diary(**data) diff --git a/simnet/client/components/diary/starrail.py b/simnet/client/components/diary/starrail.py new file mode 100644 index 0000000..6c041b0 --- /dev/null +++ b/simnet/client/components/diary/starrail.py @@ -0,0 +1,29 @@ +from typing import Optional + +from simnet.client.components.diary.base import BaseDiaryClient +from simnet.models.starrail.diary import StarRailDiary +from simnet.utils.enum_ import Game + + +class StarrailDiaryClient(BaseDiaryClient): + """Starrail diary component.""" + + async def get_starrail_diary( + self, + player_id: Optional[int] = None, + *, + month: Optional[int] = None, + lang: Optional[str] = None, + ) -> StarRailDiary: + """Get a traveler's diary with earning details for the month. + + Args: + player_id (int, optional): The player's ID. Defaults to None. + month (int, optional): The month to get the diary for. Defaults to None. + lang (str, optional): The language to get the diary in. Defaults to None. + + Returns: + Diary: The diary for the month. + """ + data = await self.request_ledger(player_id, game=Game.STARRAIL, month=month, lang=lang) + return StarRailDiary(**data) diff --git a/simnet/client/genshin.py b/simnet/client/genshin.py index 7466171..88c3b5c 100644 --- a/simnet/client/genshin.py +++ b/simnet/client/genshin.py @@ -3,6 +3,7 @@ from typing import Optional from simnet.client.components.auth import AuthClient from simnet.client.components.chronicle.genshin import GenshinBattleChronicleClient from simnet.client.components.daily import DailyRewardClient +from simnet.client.components.diary.genshin import GenshinDiaryClient from simnet.client.components.lab import LabClient from simnet.client.components.wish.genshin import GenshinWishClient from simnet.utils.enum_ import Game @@ -13,6 +14,7 @@ __all__ = ("GenshinClient",) class GenshinClient( GenshinBattleChronicleClient, GenshinWishClient, + GenshinDiaryClient, AuthClient, DailyRewardClient, LabClient, diff --git a/simnet/client/genshin.pyi b/simnet/client/genshin.pyi index fc64423..cc02e2f 100644 --- a/simnet/client/genshin.pyi +++ b/simnet/client/genshin.pyi @@ -3,13 +3,16 @@ from typing import Optional from simnet.client.components.auth import AuthClient from simnet.client.components.chronicle.genshin import GenshinBattleChronicleClient from simnet.client.components.daily import DailyRewardClient +from simnet.client.components.diary.genshin import GenshinDiaryClient from simnet.client.components.lab import LabClient from simnet.client.components.wish.genshin import GenshinWishClient from simnet.utils.enum_ import Region from simnet.utils.types import CookieTypes, HeaderTypes, TimeoutTypes -class GenshinClient(GenshinBattleChronicleClient, GenshinWishClient, AuthClient, DailyRewardClient, LabClient): +class GenshinClient( + GenshinBattleChronicleClient, GenshinWishClient, GenshinDiaryClient, AuthClient, DailyRewardClient, LabClient +): def __init__( self, cookies: Optional[CookieTypes] = None, diff --git a/simnet/client/routes.py b/simnet/client/routes.py index a4e2a42..1ea68e9 100644 --- a/simnet/client/routes.py +++ b/simnet/client/routes.py @@ -23,6 +23,8 @@ __all__ = ( "REWARD_URL", "TAKUMI_URL", "CALCULATOR_URL", + "DETAIL_LEDGER_URL", + "INFO_LEDGER_URL", "HK4E_URL", "CODE_URL", ) @@ -237,6 +239,22 @@ CALCULATOR_URL = InternationalRoute( chinese="https://api-takumi.mihoyo.com/event/e20200928calculate/v1/", ) +DETAIL_LEDGER_URL = InternationalRoute( + overseas="https://sg-hk4e-api.hoyolab.com/event/ysledgeros/month_detail", + chinese="https://hk4e-api.mihoyo.com/event/ys_ledger/monthDetail", +) + +INFO_LEDGER_URL = GameRoute( + overseas=dict( + genshin="https://sg-hk4e-api.hoyolab.com/event/ysledgeros/month_info", + hkrpg="", + ), + chinese=dict( + genshin="https://hk4e-api.mihoyo.com/event/ys_ledger/monthInfo", + hkrpg="https://api-takumi.mihoyo.com/event/srledger/month_info", + ), +) + HK4E_URL = Route("https://sg-hk4e-api.hoyoverse.com/common/hk4e_global/") diff --git a/simnet/client/starrail.py b/simnet/client/starrail.py index 6d42995..7d7690e 100644 --- a/simnet/client/starrail.py +++ b/simnet/client/starrail.py @@ -3,6 +3,7 @@ from typing import Optional from simnet.client.components.auth import AuthClient from simnet.client.components.chronicle.starrail import StarRailBattleChronicleClient from simnet.client.components.daily import DailyRewardClient +from simnet.client.components.diary.starrail import StarrailDiaryClient from simnet.client.components.lab import LabClient from simnet.client.components.wish.starrail import StarRailWishClient from simnet.utils.enum_ import Game @@ -13,6 +14,7 @@ __all__ = ("StarRailClient",) class StarRailClient( StarRailBattleChronicleClient, StarRailWishClient, + StarrailDiaryClient, DailyRewardClient, AuthClient, LabClient, diff --git a/simnet/client/starrail.pyi b/simnet/client/starrail.pyi index 3f73ae8..227eada 100644 --- a/simnet/client/starrail.pyi +++ b/simnet/client/starrail.pyi @@ -3,13 +3,16 @@ from typing import Optional from simnet.client.components.auth import AuthClient from simnet.client.components.chronicle.starrail import StarRailBattleChronicleClient from simnet.client.components.daily import DailyRewardClient +from simnet.client.components.diary.starrail import StarrailDiaryClient from simnet.client.components.lab import LabClient from simnet.client.components.wish.starrail import StarRailWishClient from simnet.utils.enum_ import Region from simnet.utils.types import CookieTypes, HeaderTypes, TimeoutTypes -class StarRailClient(StarRailBattleChronicleClient, StarRailWishClient,DailyRewardClient, AuthClient, LabClient): +class StarRailClient( + StarRailBattleChronicleClient, StarRailWishClient, StarrailDiaryClient, DailyRewardClient, AuthClient, LabClient +): def __init__( self, cookies: Optional[CookieTypes] = None, diff --git a/simnet/models/diary.py b/simnet/models/diary.py new file mode 100644 index 0000000..0c558a8 --- /dev/null +++ b/simnet/models/diary.py @@ -0,0 +1,43 @@ +from enum import IntEnum + +from pydantic import Field + +from simnet.models.base import APIModel + +__all__ = ( + "DiaryType", + "BaseDiary", +) + + +class DiaryType(IntEnum): + """Type of diary pages. + + 0: Unknown + 1: Primogems + 2: Mora + """ + + UNKNOWN = 0 + + PRIMOGEMS = 1 + """Primogems.""" + + MORA = 2 + """Mora.""" + + +class BaseDiary(APIModel): + """Base model for diary and diary page. + + Attributes: + uid: User ID. + server: Server name. + nickname: User nickname. + month: Month of the diary page. + """ + + uid: int + server: str = Field(alias="region") + nickname: str = "" + month: int = Field(alias="data_month") diff --git a/simnet/models/genshin/diary.py b/simnet/models/genshin/diary.py new file mode 100644 index 0000000..81f468e --- /dev/null +++ b/simnet/models/genshin/diary.py @@ -0,0 +1,113 @@ +from datetime import datetime +from typing import List + +from pydantic import Field + +from simnet.models.base import APIModel +from simnet.models.diary import BaseDiary + +__all__ = ( + "DayDiaryData", + "Diary", + "DiaryAction", + "DiaryActionCategory", + "DiaryPage", + "MonthDiaryData", +) + + +class DiaryActionCategory(APIModel): + """Diary category for primogems. + + Attributes: + id: Category ID. + name: Category name. + amount: Amount of primogems. + percentage: Percentage of primogems. + """ + + id: int = Field(alias="action_id") + name: str = Field(alias="action") + amount: int = Field(alias="num") + percentage: int = Field(alias="percent") + + +class MonthDiaryData(APIModel): + """Diary data for a month. + + Attributes: + current_primogems: Current amount of primogems. + current_mora: Current amount of mora. + last_primogems: Last amount of primogems. + last_mora: Last amount of mora. + primogems_rate: Primogems rate. + mora_rate: Mora rate. + categories: List of diary categories. + """ + + def __init__(self, **data): + if data.get("primogem_rate"): + data["primogems_rate"] = data.pop("primogem_rate") + super().__init__(**data) + + current_primogems: int + current_mora: int + last_primogems: int + last_mora: int + primogems_rate: int + mora_rate: int + categories: List[DiaryActionCategory] = Field(alias="group_by") + + +class DayDiaryData(APIModel): + """Diary data for a day. + + Attributes: + current_primogems: Current amount of primogems. + current_mora: Current amount of mora. + """ + + current_primogems: int + current_mora: int + + +class Diary(BaseDiary): + """Traveler's diary. + + Attributes: + data: Diary data for a month. + day_data: Diary data for a day. + """ + + data: MonthDiaryData = Field(alias="month_data") + day_data: DayDiaryData + + @property + def month_data(self) -> MonthDiaryData: + return self.data + + +class DiaryAction(APIModel): + """Action which earned currency. + + Attributes: + action_id: Action ID. + action: Action name. + time: Time of the action. + amount: Amount of the action. + """ + + action_id: int + action: str + time: datetime = Field(timezone=8) + amount: int = Field(alias="num") + + +class DiaryPage(BaseDiary): + """Page of a diary. + + Attributes: + actions: List of diary actions. + """ + + actions: List[DiaryAction] = Field(alias="list") diff --git a/simnet/models/starrail/diary.py b/simnet/models/starrail/diary.py new file mode 100644 index 0000000..b949bb3 --- /dev/null +++ b/simnet/models/starrail/diary.py @@ -0,0 +1,84 @@ +from typing import List + +from pydantic import Field + +from simnet.models.base import APIModel +from simnet.models.diary import BaseDiary + +__all__ = ( + "DiaryActionCategory", + "MonthDiaryData", + "DayDiaryData", + "StarRailDiary", +) + + +class DiaryActionCategory(APIModel): + """Diary category for rails_pass . + + Attributes: + id: Category ID. + name: Category name. + amount: Amount of rails_pass. + percentage: Percentage of rails_pass. + """ + + id: str = Field(alias="action") + name: str = Field(alias="action_name") + amount: int = Field(alias="num") + percentage: int = Field(alias="percent") + + +class MonthDiaryData(APIModel): + """Diary data for a month. + + Attributes: + current_hcoin: Current amount of hcoin. + current_rails_pass: Current amount of rails_pass. + last_hcoin: Last amount of hcoin. + last_rails_pass: Last amount of rails_pass. + hcoin_rate: hcoin rate. + rails_rate: rails_pass rate. + categories: List of diary categories. + """ + + current_hcoin: int + current_rails_pass: int + last_hcoin: int + last_rails_pass: int + hcoin_rate: int + rails_rate: int + categories: List[DiaryActionCategory] = Field(alias="group_by") + + +class DayDiaryData(APIModel): + """Diary data for a day. + + Attributes: + current_hcoin: Current amount of hcoin. + current_rails_pass: Current amount of rails_pass. + last_hcoin: Last amount of hcoin. + last_rails_pass: Last amount of rails_pass. + """ + + current_hcoin: int + current_rails_pass: int + last_hcoin: int + last_rails_pass: int + + +class StarRailDiary(BaseDiary): + """Traveler's diary. + + Attributes: + data: Diary data for a month. + day_data: Diary data for a day. + """ + + data: MonthDiaryData = Field(alias="month_data") + day_data: DayDiaryData + + @property + def month_data(self) -> MonthDiaryData: + """Diary data for a month.""" + return self.data diff --git a/tests/test_genshin_diary_client.py b/tests/test_genshin_diary_client.py new file mode 100644 index 0000000..327291e --- /dev/null +++ b/tests/test_genshin_diary_client.py @@ -0,0 +1,31 @@ +from typing import TYPE_CHECKING + +import pytest +import pytest_asyncio + +from simnet.client.components.diary.genshin import GenshinDiaryClient + +if TYPE_CHECKING: + from simnet.client.cookies import Cookies + from simnet.utils.enum_ import Region + + +@pytest_asyncio.fixture +async def diary_client(genshin_player_id: int, account_id: int, region: "Region", cookies: "Cookies"): + if genshin_player_id is None: + pytest.skip("Test case test_genshin_diary_client skipped: No genshin player id set.") + async with GenshinDiaryClient( + player_id=genshin_player_id, + cookies=cookies, + account_id=account_id, + region=region, + ) as client_instance: + yield client_instance + + +@pytest.mark.asyncio +class TestGenshinDiaryClient: + @staticmethod + async def test_get_genshin_diary(diary_client: "GenshinDiaryClient"): + genshin_diary = await diary_client.get_genshin_diary() + assert genshin_diary is not None diff --git a/tests/test_starrail_diary_client.py b/tests/test_starrail_diary_client.py new file mode 100644 index 0000000..ec03dac --- /dev/null +++ b/tests/test_starrail_diary_client.py @@ -0,0 +1,31 @@ +from typing import TYPE_CHECKING + +import pytest +import pytest_asyncio + +from simnet.client.components.diary.starrail import StarrailDiaryClient + +if TYPE_CHECKING: + from simnet.client.cookies import Cookies + from simnet.utils.enum_ import Region + + +@pytest_asyncio.fixture +async def diary_client(starrail_player_id: int, account_id: int, region: "Region", cookies: "Cookies"): + if starrail_player_id is None: + pytest.skip("Test case test_starrail_diary_client skipped: No starrail player id set.") + async with StarrailDiaryClient( + player_id=starrail_player_id, + cookies=cookies, + account_id=account_id, + region=region, + ) as client_instance: + yield client_instance + + +@pytest.mark.asyncio +class TestStarrailDiaryClient: + @staticmethod + async def test_get_starrail_diary(diary_client: "StarrailDiaryClient"): + genshin_diary = await diary_client.get_starrail_diary() + assert genshin_diary is not None