diff --git a/.flake8 b/.flake8 new file mode 100644 index 00000000..5686ac62 --- /dev/null +++ b/.flake8 @@ -0,0 +1,27 @@ +[flake8] +exclude = tests, test.py +ignore = + A001, A002, A003, # `id` variable/parameter/attribute + C408, # dict() with keyword arguments + D105, # Missing docstring in magic method + D106, # Missing docstring Model.Config + S303, # Use of md5 + S311, # Use of pseudo-random generators + + +# F401: unused import. +# F403: cannot detect unused vars if we use starred import +# D10*: docstrings +# S10*: hardcoded passwords +# F841: unused variable +per-file-ignores = + **/__init__.py: F401, F403 + tests/**: D10, S10, F841 + +max-complexity = 20 +max-function-length = 100 +max-line-length = 130 + +accept-encodings = utf-8 +docstring-convention = numpy +ignore-decorators = property diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 00000000..b76797e7 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,95 @@ +name: Run checks + +on: [push, pull_request] + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python 3.9 + uses: actions/setup-python@v2 + with: + python-version: 3.9 + + - name: install nox + run: | + python -m pip install nox + - name: lint + run: | + python -m nox -s lint + + test: + runs-on: ubuntu-latest + if: github.event_name == 'push' + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10"] + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: install nox + run: | + python -m pip install nox + - name: Run tests + env: + LTUID: ${{ secrets.LTUID }} + LTOKEN: ${{ secrets.LTOKEN }} + CN_LTUID: ${{ secrets.CN_LTUID }} + CN_LTOKEN: ${{ secrets.CN_LTOKEN }} + run: | + python -m nox -s test + + type-check: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python 3.9 + uses: actions/setup-python@v2 + with: + python-version: 3.9 + + - name: install nox + run: | + python -m pip install nox + - name: Run type checker + run: | + python -m nox -s type-check + + verify-types: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python 3.9 + uses: actions/setup-python@v2 + with: + python-version: 3.9 + + - name: install nox + run: | + python -m pip install nox + - name: Run type checker + run: | + python -m nox -s verify-types + + prettier: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Run prettier + run: | + npx prettier --check *.md docs/*.md *.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..a97f3cf1 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,29 @@ +name: Build docs + +on: + push: + branches: + - master + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.9 + + - name: Install dependencies + run: | + python -m pip install nox mkdocs-material + + - name: Generate API Documentation + run: | + python -m nox -s docs + - name: Deploy docs + run: | + mkdocs gh-deploy --force diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..4e7d63a8 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,28 @@ +name: Publish to PyPI + +on: + release: + types: [published] + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.9 + + - name: Install pypa/build + run: | + python -m pip install build --user + - name: Build a binary wheel and a source tarball + run: | + python -m build --sdist --wheel --outdir dist/ . + - name: Publish distribution to PyPI + uses: pypa/gh-action-pypi-publish@master + with: + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..de7df898 --- /dev/null +++ b/.gitignore @@ -0,0 +1,61 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# PyCharm/IntelliJ-generated files +*.iml +.idea/ + +# Visual Studio Code-generated files +.settings/ +.project +.vscode/ +.vs/ + +# Distribution / packaging +build/ +develop-eggs/ +dist/ +eggs/ +sdist/ +wheels/ +*.egg-info/ +*.egg +MANIFEST + +# Temporary test files +test.py + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# mkdocs documentation +/site +docs/pdoc + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..4f1658d2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,120 @@ +# Changelog + +# 1.0.0 + +## What's new + +- Added honkai endpoints. +- Added login with username and password (`Client.login_with_password`) +- Made the entire project be mypy and pyright strict compliant. + +## Changes + +- Caching is now handled through `Client.cache` +- Moved `MultiCookieClient` functionality to `Client.cookie_manager` + +## Fixes + +- Reduced the amount of unexpected ratelimit exceptions +- Made every single model be re-serializable. + +## Deprecation + +- `GenshinClient.cookies` were removed in favor of `cookie_manager` +- `GenshinClient` and subclasses were merged into `Client` +- `genshin_accounts` -> `get_game_accounts` +- `get_record_card` -> `get_record_cards` +- `get_[partial|full]_user` -> `get_[partial|full]_genshin_user` + +# 0.4.0 (2022-02-03) + +## What's new + +- Added Serenitea Pot's Jar of Riches to Real-Time Notes +- Implemented `set_top_characters` +- Added models for A Study in Potions + +## Changes + +- Made the Enhancement Progression Calculator use the builder pattern + +# 0.3.1 (2022-01-10) + +## Deprecation + +- Removed all_characters since the API no longer supports this feature + +## Fixes + +- Images are now accounted for during character data completion +- Diary log no longer repeatedly returns the first page in some cases + +# 0.3.0 (2021-12-25) + +## What's new + +- Added full support for the Genshin Impact Enhancement Progression Calculator +- Improved debug mode to be slightly more descriptive + +## Fixes + +- Fixed minor API inconsistencies including domain mismatches +- Ensured some specific models no longer break when being revalidated + +# 0.2.0 (2021-12-03) + +## What's new + +- Added partial support for i18n +- Added a way to specify the characters you want to get with `get_user` +- Improved rate limit handling for certain endpoints +- Made paginators awaitable + +## Fixes + +- Fixed breaking API changes caused by the second banner +- Deprecated authkeys in support pages +- Fixed pydantic bug with ClassVar not being recognized properly + +# 0.1.0 (2021-11-05) + +## What's new + +- Implemented the Traveler's Diary +- Cache uids for daily rewards and similar endpoints. +- Support artifact levels +- Add an `enabled` field for artifact set effects + +## Fixes + +- Migrate server domains in accordance with the recent HoYoLAB server migration +- Remove invalid authkey validation +- Make permanent caches persist +- No longer attempt to close non-existent sessions in `MultiCookieClient` +- Fix minor problems with model validation + +# 0.0.2 (2021-10-25) + +## What's new + +- Implemented Real-Time notes +- Added Labyrinth Warriors to activities +- Made all `datetime` objects timezone aware. +- Added public privacy settings to record cards. +- Added basic support for Redis caches +- Added new CLI commands +- Added pdoc-generated API documentation + - Started using ReST-style docstrings + - Added module docstrings +- Made `debug` a property instead of an `__init__` param + +## Fixes + +- Chinese daily reward claiming will no longer consistently raise errors due to invalid headers. +- `get_banner_details` no longer requires gacha ids. They will be fetched from a user-maintained database from now on. +- `genshin.models.base.BaseCharacter` is now a string instead of `CharacterIcon` +- `genshin.models.base.GenshinModel.dict()` now also includes properties as it is immutable. + +## Documentation + +- Documented a large part of the library with at least simple examples diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..cb6f3fc1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,89 @@ +# Genshin.py contributing guidelines + +Contributions are always welcome and any amount of time spent on this project is greatly appreciated. + +But before you get started contributing, it's recommended that you read through the following guide in-order to ensure that any pull-requests you open can be at their best from the start. + +### Pipelines + +The most important thing to consider while contributing towards Genshin.py is the checks the library's run against. +While these are run against all PRs by Github Actions, you can run these locally before any PR is opened using Nox. + +To run the tests and checks locally you'll have to go through the following steps. + +1. Ensure your current working directory is Genshin.py's top-level directory. +2. `pip install nox` to install Nox. +3. Use `nox -s` to run the default tasks. + +A list of all the available tasks can be found by running `nox -l` with blue names being the tasks which are run by default when `nox -s` is called alone. +To call specific tasks you just call `nox -s name1 name2` where any number of tasks can be called at once by specifying their name after `-s`. + +It's worth noting that the reformat nox task (which is run by default) will reformat additions to the project in-order to make them match the expected style and that nox will generate virtual environments for each task instead of pollution the environment it was installed into. + +You may use `nox --no-install` to avoid updating dependencies every run. + +### Tests + +All changes contributed to this project should be tested. This repository uses pytest and `nox -s test` for an easier and less likely to be problematic way to run the tests. + +If the tests are way too slow on your machine you may want to filter them using `nox -s test -- -k "foo"`. (For example `nox -s test -- -k "wish"` for only wish-related tests.) +In the rare case that filtering is not an option there's `nox -s test -- --cooperative` to run all tests at the same time. This is very unstable. + +### Type checking + +All contributions to this project will have to be "type-complete" and, while [the nox tasks](###Pipelines) let you check that the type hints you've added/changed are type safe, +[pyright's type-completness guidelines](https://github.com/microsoft/pyright/blob/main/docs/typed-libraries.md) and +[standard typing library's type-completness guidelines](https://github.com/python/typing/blob/master/docs/libraries.md) are +good references for how projects should be type-hinted to be type-complete. + +--- + +**NOTES** + +- This project deviates from the common convention of importing types from the typing module and instead + imports the typing module itself to use generics and types in it like `typing.Union` and `typing.Optional`. +- Since this project supports python 3.8+, the `typing` module takes priority over `collections.abc`. +- All exported symbols should have docstrings. + +--- + +### General enforced style + +- All modules should start with imports followed by declaration of `__all__`. +- [pep8](https://www.python.org/dev/peps/pep-0008/) should be followed as much as possible with notable cases where its ignored being that [black](https://github.com/psf/black) style may override this. +- The maximum character count for a line is 120 characters. +- Only entire modules may be imported with the exception of `Aliased` and constants. +- All public modules should be explicitly imported into its packages' `__init__.py` except for utilities and individual components which should only be exposed as an entire module. +- Features should be split by API endpoint in components and by game and category in models. +- Only abstract methods may be overwritten. + +### Project structure + +``` +genshin +│ +│ constants.py = global constants like supported languages +│ errors.py = all errors raised in the library +│ types.py = enums required in some endpoint parameters +│ +├───client = client used for requests +│ │ client.py = final client made from +│ │ cache.py = client cache +│ │ compat.py = reverse-compatibility layer +│ │ manager.py = cookie and auth managers +│ │ ratelimit.py = ratelimit handler +│ │ routes.py = routes for various endpoints +│ │ +│ └───components = separate client components separated by category +│ │ base.py = base client without any specific routes +│ └ anything = file or module that exports a single component +│ +├───paginators = paginators used in the library +│ +├───models = models used in the library +│ │ model.py = base model and helper fields +│ │ +│ └───any dir = separate module for each game or category +│ +└───utility = utilities for the library +``` diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..aebcbb04 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 sadru + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..a5353f32 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# genshin.py + +Modern API wrapper for Genshin Impact built on asyncio and pydantic. + +--- + +Documentation: https://thesadru.github.io/genshin.py + +Source Code: https://github.com/thesadru/genshin.py + +--- + +The primary focus of genshin.py is convenience. The entire project is fully type-hinted and abstracts a large amount of the api to be easier to use. + +Key features: + +- All data is in the form of Pydantic Models which means full autocompletion and linter support. +- Requests are significantly faster thanks to proper usage of asyncio. +- Chinese and Engrish names returned by the API are renamed to simpler English fields. +- Supports the majority of the popular endpoints. +- Cleanly integrates with frameworks like FastAPI out of the box. + +> Note: This library is a successor to [genshinstats](https://github.com/thesadru/genshinstats) - an unofficial wrapper for the Genshin Impact api. + +## Requirements + +- Python 3.8+ +- aiohttp +- Pydantic + +```console +pip install genshin +``` + +## Example + +A very simple example of how genshin.py would be used: + +```py +import asyncio +import genshin + +async def main(): + cookies = {"ltuid": 119480035, "ltoken": "cnF7TiZqHAAvYqgCBoSPx5EjwezOh1ZHoqSHf7dT"} + client = genshin.Client(cookies) + + data = await client.get_genshin_user(710785423) + print(f"User has a total of {data.stats.characters} characters") + +asyncio.run(main()) +``` + +## Contributing + +Any kind of contribution is welcome. +Please read [CONTRIBUTING.md](./CONTRIBUTING.md) to see what you need to do to make a contribution. diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 00000000..e39842ce --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,152 @@ +# Authentication + +## Cookies + +Cookies are the default form of authentication over the majority of Mihoyo APIs. These are used in web events and hoyolab utilities such as the Battle Chronicle. +The cookies used in these APIs are the same as the ones you use to log in to your hoyolab account and make payments. +This means it's highly recommended to use your own cookies only for local testing and to create alt accounts for actual API requests. + +For authentication, you will need to send two cookies: `ltuid` and `ltoken`. `ltuid` is your hoyolab UID and `ltoken` is a unique token used for the actual authentication. + +### Setting cookies + +There are several ways to set cookies but `set_cookies` is preferred. + +```py +# set as an __init__ parameter +client = genshin.Client({"ltuid": ..., "ltoken": ...}) + +# set dynamically +client = genshin.Client() +client.set_cookies({"ltuid": ..., "ltoken": ...}) # mapping +client.set_cookies(ltuid=..., ltoken=...) # kwargs +client.set_cookies("ltuid=...; ltoken=...") # cookie header +``` + +### How can I get my cookies? + +#### From the browser + +1. Go to [hoyolab.com](https://www.hoyolab.com/genshin/). +2. Login to your account. +3. Press `F12` to open Inspect Mode (ie. Developer Tools). +4. Go to `Application`, `Cookies`, `https://www.hoyolab.com`. +5. Copy `ltuid` and `ltoken`. + +#### Using username and password + +1. Run `python -m genshin login `. +2. Press the `Login` button and solve a captcha. +3. Copy cookies. + +### Setting cookies automatically + +For testing, you may want to use your own personal cookies. +As long as you are logged into your account on one of your browsers, you can get these dynamically with `genshin.get_browser_cookies()`. + +#### Installation + +```console +pip install genshin[cookies, rsa] +``` + +#### Example + +```py +# set browser cookies +client = genshin.Client() +client.set_browser_cookies() + + +# login with username and password +client = genshin.Client() +cookies = client.login_with_password("me@gmail.com", "EheTeNandayo") +print(cookies) +``` + +In case of conflicts/errors, you may specify the browser you want to use. + +```py +cookies = genshin.get_browser_cookies("chrome") +``` + +### Details + +Sadly not even this is inconsistent enough. For some endpoints like `redeem_code`, you might need to set `account_id` and `cookie_token` cookies instead. You can get them by going to [genshin.mihoyo.com](https://genshin.mihoyo.com/en/gift). + +## Authkey + +Authkeys are an alternative authentication used mostly for paginators like `client.wish_history()` and `client.transaction_log()`. They last only 24 hours, and it's impossible to do any write operations with them. That means authkeys, unlike cookies, are absolutely safe to share. + +These authkeys should always be a base64 encoded string and around 1024 characters long. + +### Setting authkeys + +Similar to cookies, you may set authkeys through multiple ways. + +```py +# set as an __init__ parameter +client = genshin.Client(authkey="...") + +# set dynamically +client.authkey = "..." +``` + +Since authkeys are safe to share, all functions which use authkeys also accept them as a parameter. + +```py +client = genshin.Client() +async for wish in client.wish_history(authkey="..."): + pass +``` + +### How can I get my authkey? + +To get your authkey manually from other platforms, you can use any of these approaches: + +#### PC + +- Open the wish history in the game and wait for it to load +- Open the file at `~\AppData\LocalLow\miHoYo\Genshin Impact\output_log.txt` +- Find the link which starts with `OnGetWebViewPageFinish` and copy it + +#### Android + +- Open the Paimon menu +- Click Feedback +- Wait for it to load, and a feedback page should open +- Turn off your Wi-Fi +- Refresh the page +- The page should display an error containing a link +- Copy the link + +#### PS + +- Open any event mail which contains a QR Code +- Scan the QR Code with your phone +- Copy the link + > You can only use this if you have an in-game mail with QR Code to open the web event + +After that, you can extract the authkey from the link using `genshin.extract_authkey`. + +```py +url = "https://webstatic-sea.mihoyo.com/ys/event/im-service/index.html?..." +authkey = genshin.extract_authkey(url) + +client = genshin.Client() +client.authkey = authkey +``` + +### Setting authkeys automatically + +If you open a wish history or a wish details page in genshin, then the authkey will show up in your logfiles. It's possible to dynamically get the authkey using `genshin.get_authkey()`. + +```py +# get the authkey from a logfile +client = genshin.Client() +client.authkey = genshin.get_authkey() + +# implicitly set the authkey +client = genshin.Client() +client.set_authkey() +``` diff --git a/docs/battle_chronicle.md b/docs/battle_chronicle.md new file mode 100644 index 00000000..cc2f5acd --- /dev/null +++ b/docs/battle_chronicle.md @@ -0,0 +1,36 @@ +# Battle Chronicle + +The main feature of genshin.py is the [Battle Chronicle](https://webstatic-sea.hoyolab.com/app/community-game-records-sea/index.html#/ys). It contains various features such as statistics, character equipment, spiral abyss runs, exploration progress, etc. + +To request any of the Battle Chronicle endpoints you must first be logged in. Refer to [the authentication section](authentication.md) for more information. + +## Quick example + +```py +# get general user info: +user = await client.get_genshin_user(710785423) +user = await client.get_honkai_user(710785423) + +# get abyss: +data = await client.get_spiral_abyss(710785423, previous=True) +data = await client.get_honkai_abyss(710785423) +``` + +## Optimizations + +Some methods implicitly make multiple requests at once: + +- instead of `get_genshin_user` you can use `get_partial_genshin_user` and `get_characters` +- instead of `get_honkai_abyss` you can use `get_old_abyss` or `get_superstring_abyss` + +```py +user = await client.get_partial_genshin_user(710785423) +print(user.stats.days_active) +``` + +On the other hand, if you want to request as much information as possible, you should use `get_full_genshin_user`/`get_full_honkai_user` which adds spiral abyss runs and activities to the user. + +```py +user = await client.get_full_genshin_user(710785423) +print(user.abyss.previous.total_stars) +``` diff --git a/docs/caching.md b/docs/caching.md new file mode 100644 index 00000000..62f5c072 --- /dev/null +++ b/docs/caching.md @@ -0,0 +1,67 @@ +# Caching + +Genshin.py caches data for you using a custom `genshin.BaseCache` object in `client.cache`. + +## Quick example + +```py +# create a cache +client = genshin.Client() +client.set_cache(maxsize=256, ttl=3600) + +# set a custom cache +client.cache = genshin.StaticCache() +``` + +## Custom caches + +Sometimes a simple mutable mapping won't do, for example with redis caches. In this case you can overwrite the cache with your own. + +Example: + +```py +import json +import typing + +import genshin + +class JsonCache(genshin.BaseCache): + """Terrible json cache without any expiration.""" + + def __init__(self, filename) -> None: + self.filename = filename + + async def get(self, key): + with open(self.filename, "r") as file: + data = json.load(file) + + return data.get(str(key)) + + async def set(self, key, value) -> None: + with open(self.filename, "r") as file: + data = json.load(file) + + data[str(key)] = value + + with open(self.filename, "w") as file: + json.dump(data, file) + + async def get_static(self, key): + return await self.get(key) + + async def set_static(self, key, value) -> None: + await self.set(key, value) + + +client.cache = JsonCache("cache.json") +``` + +### Redis cache + +A redis cache is provided by default with `RedisCache`. It is recommended to overwrite this class and modify the serialization methods since normal json may prove to be a bit too slow. + +```py +import aioredis + +client.cache = genshin.RedisCache(aioredis.Redis(...)) +``` diff --git a/docs/calculator.md b/docs/calculator.md new file mode 100644 index 00000000..b0508c95 --- /dev/null +++ b/docs/calculator.md @@ -0,0 +1,112 @@ +# Enhancment Progress Calculator + +A wrapper around the [Genshin Impact Enhancment Progress Calculator](https://webstatic-sea.mihoyo.com/ys/event/calculator-sea/index.html) page. +Contains a database of all characters, weapons and artifacts. Also the only way to recieve talents. + +To request any of the calculator endpoints you must first be logged in. Refer to [the authentication section](authentication.md) for more information. + +## Quick Example + +```py +# get a list of all characters +characters = await client.get_calculator_characters() + +# get a list of all weapons +weapons = await client.get_calculator_weapons() + +# get a list of all artifacts +artifacts = await client.get_calculator_artifacts() + +# search for a specific character/weapon/artifact +characters = await client.get_calculator_characters(query="Xi") + +# filter the returned characters/weapons/artifacts +weapons = await client.get_calculator_weapons(rarities=[5, 4]) + +# get all talents of a character +talents = await client.get_character_talents(10000002) + +# get all other artifacts in a set +artifacts = await client.get_complete_artifact_set(7554) +``` + +```py +# get a list of synced characters +# only returns the characters you have and ensures all level fields are provided +characters = await client.get_calculator_characters(sync=True) + +# get the details of a character +# includes their weapon, artifacts and talents +details = await client.get_character_details(10000002) +``` + +## Example Of Calculation + +### Basic Calculation + +The calculator uses builders to set data. All methods return `self` so they're chainable. + +```py +# create a builder object +builder = client.calculator() +# calculate resoources needed to level up Hu Tao from lvl 1 to lvl 90 +builder.set_character(10000046, current=1, target=90) +# calculate the amount of resources needed for a Staff of Homa from level 20 to level 70 +builder.set_weapon(13501, current=20, target=70) + +# execute the builder +cost = await builder.calculate() +print(cost) +``` + +```py +# you may also chain the builder (recommended) +cost = await ( + client.calculator() + .set_character(10000046, current=1, target=90) + .set_weapon(13501, current=20, target=70) +) + +``` + +```py +# calculate the amount needed for a 5* gladiator's nostalgia +artifact_id = 7554 +cost = await ( + client.calculator() + .add_artifact(artifact_id, current=0, target=20) +) + +# or calculate for a full set +cost = await ( + client.calculator() + .set_artifact_set(artifact_id, current=0, target=20) +) +``` + +### Calculation based off a character + +If we assume we're calculating resources for the currently logged in user we can simply get their weapon and artifact levels directly. + +```py +# Let's use the currently equipped weapon, artifacts and talents +cost = await ( + client.calculator() + .set_character(10000046, current=1, target=90) + .with_current_weapon(target=70) + .with_current_artifacts(target=20) # every artifact will be set to lvl 20 + .with_current_talents(target=7) # every artifact will be set to lvl 7 +) +``` + +```py +# you may want to upgrade only specific talent or artifact types +cost = await ( + client.calculator() + .set_character(10000046, current=80, target=90) + # upgrade only the flower and feather + .with_current_artifacts(flower=16, feather=20) + # upgrade only the burst + .with_current_talents(burst=10) +) +``` diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 00000000..e81279d8 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,67 @@ +# CLI + +Genshin.py is not only a library but also a CLI app. + +Authentication is required for most commands. Cookies can be provided either through `--cookies "ltoken=...; ltuid=..."` or gotten implicitly from the browser. + +## Installation + +```console +pip install genshin[cli] +``` + +## Usage + +### Get help + +```console +$ python -m genshin --help +Usage: python -m genshin [OPTIONS] COMMAND [ARGS]... + +Options: + --help Show this message and exit. + +Groups: + genshin Genshin-related commands. + honkai Honkai-related commands. + +Commands: + accounts Get all of your genshin accounts. + banner-ids Get the banner ids from logs. + login Login with a password. + pity Calculate the amount of pulls until pity. + wishes Show a nicely formatted wish history. +``` + +### Run a command + +```console +$ python -m genshin genshin stats 710785423 +User stats of 710785423 + +Stats: +Achievements: 436 +Days Active: 464 +Characters: 33 +Waypoints Unlocked: 169 +Domains Unlocked: 33 +Anemoculi: 66 +Geoculi: 131 +Electroculi: 180 +Common Chests Opened: 1162 +Exquisite Chests Opened: 924 +Precious Chests Opened: 262 +Luxurious Chests Opened: 106 +Remarkable Chests Opened: 42 + +Explorations: +Enkanomiya: explored 67.9% | Offering level 0 +Inazuma: explored 98.1% | Reputation level 10 +Dragonspine: explored 96.1% | Offering level 12 +Liyue: explored 93.5% | Reputation level 8 +Mondstadt: explored 100.0% | Reputation level 8 + +Teapot: +level 10 | comfort 21220 (Fit for a King) +Unlocked realms: Floating Abode, Emerald Peak, Cool Isle +``` diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 00000000..4abe0b9b --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,78 @@ +# Configuration + +## Language + +The API supports several languages. You can set what language you want the response to be in by either changing the client language `client.lang` or passing the language as a method argument. + +The default language is `en-us` for overseas and `zh-cn` for china. + +```py +client = genshin.Client(lang="fr-fr") +# or +client = genshin.Client() +user = await client.get_genshin_user(710785423, lang="zh-cn") +``` + +### Supported Languages + +| Code | Language | +| ----- | ---------- | +| de-de | Deutsch | +| en-us | English | +| es-es | Español | +| fr-fr | Français | +| id-id | Indonesia | +| ja-jp | 日本語 | +| ko-kr | 한국어 | +| pt-pt | Português | +| ru-ru | Pусский | +| th-th | ภาษาไทย | +| vi-vn | Tiếng Việt | +| zh-cn | 简体中文 | +| zh-tw | 繁體中文 | + +> This mapping is contained in `genshin.LANGS` + +## Cookie Manager + +By default `Client` uses a single cookie. This behavior may be changed by overwriting `client.cookie_manager` with a subclass of `BaseCookieManager`. + +For convenience, if a list of cookies is passed into `Client.set_cookies` the cookie manager will be automatically set to `genshin.RotatingCookieManager`. + +### Example + +```py +import genshin + +class RandomCookieManager(genshin.BaseCookieManager): + """Cookie Manager that provides random cookies fetched from a database.""" + + def __init__(self, database): + self.database = database + + async def request(self, url, *, method = "GET", **kwargs): + cookies = await self.database.get_random_cookies() + return await self._request(method, url, cookies=cookies, **kwargs) + +``` + +## Default Region + +By default all requests will be assumed to be meant to be used with overseas cookies. +If you wish to use chinese cookies and chinese endpoints you must change the default region. + +```py +client = genshin.Client(region=genshin.Region.CHINESE) + +client.region = genshin.Region.CHINESE +``` + +## Default Game + +Some endpoints may be the exact same for both genshin and honkai so they require a game to be specified. This can be also done by setting a default game for the client. + +```py +client = genshin.Client(game=genshin.Game.HONKAI) + +client.default_game = genshin.Game.HONKAI +``` diff --git a/docs/credits.md b/docs/credits.md new file mode 100644 index 00000000..4ff6ca15 --- /dev/null +++ b/docs/credits.md @@ -0,0 +1,12 @@ +# Credits + +Thanks to these amazing people genshin.py can be where it is now: + +- [Womsxd](https://github.com/Womsxd): The author of [YuanShen User Info](https://github.com/Womsxd/YuanShen_User_Info) - a predecesor to genshinstats +- [Lightczx](https://github.com/Lightczx): The author of [Snap.Genshin](https://github.com/DGP-Studio/Snap.Genshin), helped me figure out the chinese endpoints +- [Chromosomologist](https://github.com/Chromosomologist): Helped with honkai endpoints. +- [Pokurt](https://github.com/pokurt): Helped with capturing the requests coming from the hoyolab android app. +- [GrassSand](https://github.com/grasssand) & [molehzy](https://github.com/molehzy): Provided me with working chinese cookies to test chinese endpoints. +- [lulu666lulu](https://github.com/lulu666lulu): Figured out how the chinese dynamic secret is generated + +And finally me :^) - [thesadru](https://github.com/thesadru) diff --git a/docs/daily_rewards.md b/docs/daily_rewards.md new file mode 100644 index 00000000..567479eb --- /dev/null +++ b/docs/daily_rewards.md @@ -0,0 +1,35 @@ +# Daily Rewards + +Since hoyo forces users to claim their daily rewards through the website we can abuse that system and claim rewards automatically. + +To request any of the Battle Chronicle endpoints you must first be logged in. Refer to [the authentication section](authentication.md) for more information. + +These endpoints require a game to be specified. It's best to [configure the default game](configuration.md#default-game) or use the `game=` parameter. + +## Quick Example + +```py +# claim daily reward +try: + reward = await client.claim_daily_reward() +except genshin.AlreadyClaimed: + print("Daily reward already claimed") +else: + print(f"Claimed {reward.amount}x {reward.name}") +``` + +```py +# get all claimed rewards +async for reward in client.claimed_rewards(): + print(f"{reward.time} - {reward.amount}x {reward.name}") +``` + +```py +# get info about the current daily reward status +signed_in, claimed_rewards = await client.get_reward_info() +print(f"Signed in: {signed_in} | Total claimed rewards: {claimed_rewards}") +``` + +## Optimizations + +Under the hood, `client.claim_daily_reward` makes an additional request to get the claimed reward. If you don't want that you may disable the extra request with `client.claim_daily_reward(reward=False)` diff --git a/docs/debugging.md b/docs/debugging.md new file mode 100644 index 00000000..8eca940a --- /dev/null +++ b/docs/debugging.md @@ -0,0 +1,22 @@ +# Debugging + +## Interactive console + +Since genshin.py uses asyncio it's fairly hard to debug code in the interactive console. Instead, I highly recommend you to use [IPython](https://ipython.org/). + +## Requests + +Genshin.py automatically logs all requests using the `logging` module. You can make these logs show up in the console by setting the `debug` kwarg to `True` + +```ipython +In [1]: client = genshin.Client({...}, debug=True) + +In [2]: user = await client.get_genshin_user(710785423) +DEBUG:genshin.client.components.base:GET https://webstatic-sea.mihoyo.com/admin/mi18n/bbs_cn/m11241040191111/m11241040191111-en-us.json +DEBUG:genshin.client.components.base:GET https://bbs-api-os.hoyolab.com/game_record/genshin/api/index?role_id=710785423&server=os_euro +DEBUG:genshin.client.components.base:POST https://bbs-api-os.hoyolab.com/game_record/genshin/api/character +{"role_id":710785423,"server":"os_euro"} +DEBUG:genshin.client.components.base:GET https://bbs-api-os.hoyolab.com/game_record/genshin/api/spiralAbyss?schedule_type=1&role_id=710785423&server=os_euro +DEBUG:genshin.client.components.base:GET https://bbs-api-os.hoyolab.com/game_record/genshin/api/spiralAbyss?schedule_type=2&role_id=710785423&server=os_euro +DEBUG:genshin.client.components.base:GET https://bbs-api-os.hoyolab.com/game_record/genshin/api/activities?role_id=710785423&server=os_euro +``` diff --git a/docs/diary.md b/docs/diary.md new file mode 100644 index 00000000..f93b5fa1 --- /dev/null +++ b/docs/diary.md @@ -0,0 +1,26 @@ +# Traveler's Diary + +Contains statistics of earned primogems and mora in the last 3 months. + +To request any of the diary endpoints you must first be logged in. Refer to [the authentication section](authentication.md) for more information. + +# Quick Example + +```py +# get the diary +diary = await client.get_diary() + +print(f"Primogems earned this month: {diary.data.current_primogems}") +for category in diary.data.categories: + print(f"{category.percentage}% earned from {category.name} ({category.amount} primogems)") +``` + +```py +# get the log of actions which earned primogems +async for action in client.diary_log(limit=50): + print(f"{action.action} - {action.amount} primogems") + +# get the diary log for mora +async for action in client.diary_log(limit=50, type=genshin.models.DiaryType.MORA): + print(f"{action.action} - {action.amount} mora") +``` diff --git a/docs/hoyolab.md b/docs/hoyolab.md new file mode 100644 index 00000000..dd416685 --- /dev/null +++ b/docs/hoyolab.md @@ -0,0 +1,36 @@ +# Hoyolab + +Since the api genshin.py is requesting is made primarily for [hoyolab](https://www.hoyolab.com/) some minor utility functions related to it are also supported. + +## Quick example + +```py +# get the uid, nickname and level of a user from a hoyolab uid +card = await client.get_record_card(8366222) +print(card.uid, card.level, card.nickname) +``` + +```py +# list of all game accounts of the currently logged-in user +accounts = await client.get_game_accounts() +for account in accounts: + print(account.uid, account.level, account.nickname) +``` + +```py +# redeem a gift code for the currently logged-in user +await client.redeem_code("GENSHINGIFT") +``` + +```py +# search users +users = await client.search_users("sadru") +print(users[0].hoyolab_uid) + +# get a list of random recommended users (useful for data gathering) +users = await client.get_recommended_users() +print(users[0].hoyolab_uid) + +# to actually get any useful data: +card = await client.get_record_card(users[0].hoyolab_uid) +``` diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..a38c0d5a --- /dev/null +++ b/docs/index.md @@ -0,0 +1,53 @@ +# Overview + +Modern API wrapper for Genshin Impact built on [asyncio](https://docs.python.org/3/library/asyncio.html) and [pydantic](https://pydantic-docs.helpmanual.io/). + +The primary focus of genshin.py is convenience. The entire project is fully type-hinted and abstracts a large amount of the api to be easier to use. + +Key features: + +- All data is in the form of Pydantic Models which means full autocompletion and linter support. +- Requests are significantly faster thanks to proper usage of asyncio. +- Chinese and Engrish names returned by the API are renamed to simpler English fields. +- Supports the majority of the popular endpoints. +- Cleanly integrates with frameworks like FastAPI out of the box. + +## Installation + +From PyPI: + +```console +pip install genshin +``` + +From github: + +```console +pip install git+https://github.com/thesadru/genshin.py +``` + +### Requirements: + +- Python 3.8+ +- aiohttp +- Pydantic + +## Example + +A very simple example of how genshin.py would be used: + +```py +import asyncio +import genshin + +async def main(): + cookies = {"ltuid": 119480035, "ltoken": "cnF7TiZqHAAvYqgCBoSPx5EjwezOh1ZHoqSHf7dT"} + client = genshin.Client(cookies) + + data = await client.get_genshin_user(710785423) + print(f"User has a total of {len(data.characters)} characters") + + await client.close() + +asyncio.run(main()) +``` diff --git a/docs/transactions.md b/docs/transactions.md new file mode 100644 index 00000000..ea03c0e3 --- /dev/null +++ b/docs/transactions.md @@ -0,0 +1,47 @@ +# Transactions + +Contains logs of changes to primogems, crystals, resin, artifacts and weapons. + +To request any of the wish history endpoints you must set an authkey. Refer to [the authentication section](authentication.md) for more information. + +Transaction kinds: + +| kind | item | description | +| -------- | :--------------: | ---------------------------------------------------------- | +| primogem | :material-close: | Primogem rewards from daily commissions and events | +| crystal | :material-close: | Crystals gotten from top-up purchases | +| resin | :material-close: | Resin lost by claiming boss/domain/leyline rewards | +| artifact | :material-check: | Artifacts gained from domains or used as level up material | +| weapon | :material-check: | Weapons gained from wishes or used as level up material | + +> This enum is contained in `genshin.models.TransactionKind` + +# Quick example + +```py +# iterate over the logs for primogems +async for trans in client.transaction_log("primogem"): + print(trans) + +# set a limit for the iteration +async for trans in client.transaction_log("primogem", limit=100): + print(trans) + +# get and flatten the logs for resin +log = await client.transaction_log("resin", limit=100).flatten() +print(log[-1].time) + +# get the first log for artifacts +trans = await client.transaction_log("artifact").first() +print(trans.name) +``` + +```py +# get multiple transaction kinds combined together +async for trans in client.transaction_log(["artifact", "weapon"]): + print(trans) + +# get all transaction kinds combined together +async for trans in client.transaction_log(limit=20): + print(trans) +``` diff --git a/docs/wish_history.md b/docs/wish_history.md new file mode 100644 index 00000000..00f03988 --- /dev/null +++ b/docs/wish_history.md @@ -0,0 +1,85 @@ +# Wish History + +Contains the wish history and banner details. + +To request any of the wish history endpoints you must set an authkey. Refer to [the authentication section](authentication.md) for more information. + +## Quick example + +```py +# simply iterate over the wish history +async for wish in client.wish_history(): + print(f"{wish.time} - {wish.name} ({wish.rarity}* {wish.type})") + +# set a limit for the iteration +async for wish in client.wish_history(limit=100): + print(f"{wish.time} - {wish.name} ({wish.rarity}* {wish.type})") + +# get and flatten the wish history +history = await client.wish_history(limit=100).flatten() +print(history[-1].time) + +# get the first wish in the paginator (most recent one) +wish = await client.wish_history().next() +print(wish.uid) +``` + +## Filtering data by banner + +By default `client.wish_history()` gets data from all banners, you can filter the results by passing in a banner id. You may also call `client.get_banner_names()` to get the banner names in various languages. + +| Banner | ID | +| -------------------- | --- | +| Novice Wishes | 100 | +| Permanent Wish | 200 | +| Character Event Wish | 301 | +| Weapon Event Wish | 302 | + +> This enum is contained in `genshin.models.BannerType` + +```py +# get wishes only from the standard permanent banner +async for wish in client.wish_history(genshin.models.BannerType.STANDARD, limit=20): + print(f"{wish.time} - {wish.name} ({wish.rarity}* {wish.type})") + +# get wishes from both the character and the weapon banner +async for wish in client.wish_history([301, 302], limit=20): + print(f"{wish.time} - {wish.name} ({wish.rarity}* {wish.type})") +``` + +## Banner Details + +In the same way you can get data for your wish history you may also get data for the static banner details. + +### Quick example + +```py +# get all the current banners +banners = await client.get_banner_details() +for banner in banners: + print(banner.name) +``` + +```py +# get a list of all items that can be gotten from the gacha +items = await client.get_gacha_items() +``` + +## Optimizations + +You may start from any point in the paginator as long as you know the id of the previous item. + +```py +async for wish in client.wish_history(limit=20): + print(wish) + +async for wish in client.wish_history(limit=20, end_id=wish.id): + print(wish) +``` + +`get_banner_details` requires ids to get the banner details. These ids change with every new banner so for user experience they are hosted on a remote repository maintained by me. You may get them yourself by opening every single details page in genshin and then running `genshin.get_banner_ids()` + +```py +banner_ids = genshin.get_banner_ids() +banners = await client.get_banner_details(banner_ids) +``` diff --git a/genshin-dev/README.md b/genshin-dev/README.md new file mode 100644 index 00000000..be63b8bd --- /dev/null +++ b/genshin-dev/README.md @@ -0,0 +1,22 @@ +# genshin-dev + +This is a mock package to install development dependencies for `genshin`. The aim of this package is to provide specific versions for the development utilities to ensure cross-compatibility on all machines as well as in CI. + +### How to install + +The general syntax is: + +```bash +pip install ./genshin-dev[] +``` + +Where `` is a comma-separated list of what dependencies to install, which are automatically collected from the requirement files in this directory. Below you can find the list of available options. + +### Available options + +- `all`: all development dependencies +- `docs`: documentation generator dependencies +- `flake8`: flake8 and its plugins +- `pytest`: pytest and its plugins +- `reformat`: formatting tools +- `typecheck`: mypy and pyright diff --git a/genshin-dev/docs-requirements.txt b/genshin-dev/docs-requirements.txt new file mode 100644 index 00000000..187f7215 --- /dev/null +++ b/genshin-dev/docs-requirements.txt @@ -0,0 +1 @@ +pdoc3 diff --git a/genshin-dev/lint-requirements.txt b/genshin-dev/lint-requirements.txt new file mode 100644 index 00000000..1796db35 --- /dev/null +++ b/genshin-dev/lint-requirements.txt @@ -0,0 +1,17 @@ +flake8 + +bandit==1.7.2 # temporariry freeze to bandit due to incompatibility with flake8-bandit +flake8-bandit # runs bandit +flake8-black # runs black +flake8-broken-line # forbey "\" linebreaks +flake8-builtins # builtin shadowing checks +flake8-comprehensions # comprehension checks +flake8-deprecated # deprecated call checks +flake8-docstrings # pydocstyle support +flake8-executable # shebangs +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 lintings diff --git a/genshin-dev/pytest-requirements.txt b/genshin-dev/pytest-requirements.txt new file mode 100644 index 00000000..9288ef65 --- /dev/null +++ b/genshin-dev/pytest-requirements.txt @@ -0,0 +1,8 @@ +pytest +pytest-asyncio +pytest-asyncio-cooperative +pytest-cov +pytest-randomly + +coverage[toml] +devtools diff --git a/genshin-dev/reformat-requirements.txt b/genshin-dev/reformat-requirements.txt new file mode 100644 index 00000000..bee49858 --- /dev/null +++ b/genshin-dev/reformat-requirements.txt @@ -0,0 +1,3 @@ +black +isort +sort-all diff --git a/genshin-dev/setup.py b/genshin-dev/setup.py new file mode 100644 index 00000000..883d6b67 --- /dev/null +++ b/genshin-dev/setup.py @@ -0,0 +1,46 @@ +"""Mock package to install the dev requirements.""" +import pathlib +import typing + +import setuptools + + +def parse_requirements_file(path: pathlib.Path) -> typing.List[str]: + """Parse a requirements file into a list of requirements.""" + with open(path) as fp: + raw_dependencies = fp.readlines() + + dependencies: typing.List[str] = [] + for dependency in raw_dependencies: + comment_index = dependency.find("#") + if comment_index == 0: + continue + + if comment_index != -1: # Remove any comments after the requirement + dependency = dependency[:comment_index] + + if d := dependency.strip(): + dependencies.append(d) + + return dependencies + + +dev_directory = pathlib.Path(__file__).parent + +normal_requirements = parse_requirements_file(dev_directory / ".." / "requirements.txt") + +all_extras: typing.Set[str] = set() +extras: typing.Dict[str, typing.Sequence[str]] = {} + +for path in dev_directory.glob("*-requirements.txt"): + name = path.name.split("-")[0] + + requirements = parse_requirements_file(path) + + all_extras = all_extras.union(requirements) + extras[name] = requirements + +extras["all"] = list(all_extras) + + +setuptools.setup(name="genshin-dev", install_requires=normal_requirements, extras_require=extras) diff --git a/genshin-dev/typecheck-requirements.txt b/genshin-dev/typecheck-requirements.txt new file mode 100644 index 00000000..4a263a3f --- /dev/null +++ b/genshin-dev/typecheck-requirements.txt @@ -0,0 +1,4 @@ +mypy +pyright + +types-click diff --git a/genshin/__init__.py b/genshin/__init__.py new file mode 100644 index 00000000..ceee411c --- /dev/null +++ b/genshin/__init__.py @@ -0,0 +1,13 @@ +"""Modern API wrapper for Genshin Impact built on asyncio and pydantic. + +Documentation: https://thesadru.github.io/genshin.py + +Source Code: https://github.com/thesadru/genshin.py +""" +from . import models, utility +from .client import * +from .constants import * +from .errors import * +from .types import * + +__version__ = "1.0.0" diff --git a/genshin/__main__.py b/genshin/__main__.py new file mode 100644 index 00000000..9c90a82e --- /dev/null +++ b/genshin/__main__.py @@ -0,0 +1,275 @@ +"""CLI tools.""" +import asyncio +import functools +import http.cookies +import os +import typing + +import click + +import genshin + +if os.name == "nt": + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) # type: ignore + + +T = typing.TypeVar("T", bound=typing.Any) + +cli: click.Group = click.Group("cli") + + +def asynchronous(func: typing.Callable[..., typing.Awaitable[typing.Any]]) -> typing.Callable[..., typing.Any]: + """Make an asynchronous function runnable by click.""" + + @functools.wraps(func) + def wrapper(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: + return asyncio.run(func(*args, **kwargs)) + + return wrapper + + +def client_command(func: typing.Callable[..., typing.Awaitable[typing.Any]]) -> typing.Callable[..., typing.Any]: + """Make a click command that uses a Client.""" + + @click.option("--cookies", help="Cookie header to use in authentication.", default=None) + @click.option("--lang", help="Default language.", default="en-us") + @click.option("--debug", is_flag=True, hidden=True) + @functools.wraps(func) + @asynchronous + async def command( + cookies: typing.Optional[str] = None, + lang: str = "en-us", + debug: bool = False, + **kwargs: typing.Any, + ) -> typing.Any: + client = genshin.Client(cookies, lang=lang, debug=debug) + if cookies is None: + client.set_browser_cookies() + + return await func(client, **kwargs) + + return command + + +@cli.command() +@client_command +async def accounts(client: genshin.Client) -> None: + """Get all of your genshin accounts.""" + data = await client.get_game_accounts() + + for account in data: + click.echo( + f"{click.style(str(account.uid), bold=True)} - {account.nickname} AR {account.level} ({account.server_name})" + ) + + +genshin_group: click.Group = click.Group("genshin", help="Genshin-related commands.") +honkai_group: click.Group = click.Group("honkai", help="Honkai-related commands.") +cli.add_command(genshin_group) +cli.add_command(honkai_group) + + +@genshin_group.command("stats") +@click.argument("uid", type=int) +@client_command +async def genshin_stats(client: genshin.Client, uid: int) -> None: + """Show simple genshin statistics.""" + cuid = click.style(str(uid), fg="blue") + click.echo(f"User stats of {cuid}\n") + + data = await client.get_partial_genshin_user(uid) + + click.secho("Stats:", fg="yellow") + for k, v in data.stats.as_dict(lang=client.lang).items(): + value = click.style(str(v), bold=True) + click.echo(f"{k}: {value}") + + click.echo() + click.secho("Explorations:", fg="yellow") + for area in data.explorations: + perc = click.style(str(area.explored) + "%", bold=True) + click.echo(f"{area.name}: explored {perc} | {area.type} level {area.level}") + + if data.teapot is not None: + click.echo() + click.secho("Teapot:", fg="yellow") + level = click.style(str(data.teapot.level), bold=True) + comfort = click.style(str(data.teapot.comfort), bold=True) + click.echo(f"level {level} | comfort {comfort} ({data.teapot.comfort_name})") + click.echo(f"Unlocked realms: {', '.join(r.name for r in data.teapot.realms)}") + + +@honkai_group.command("stats") +@click.argument("uid", type=int) +@client_command +async def honkai_stats(client: genshin.Client, uid: int) -> None: + """Show simple honkai statistics.""" + cuid = click.style(str(uid), fg="blue") + click.echo(f"User stats of {cuid}\n") + + data = await client.get_honkai_user(uid) + + click.secho("Stats:", fg="yellow") + 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(): + click.echo(f" {nested_k}: {click.style(str(nested_v), bold=True)}") + else: + click.echo(f"{k}: {click.style(str(v), bold=True)}") + + +@genshin_group.command("characters") +@click.argument("uid", type=int) +@client_command +async def genshin_characters(client: genshin.Client, uid: int) -> None: + """Show genshin characters.""" + cuid = click.style(str(uid), fg="blue") + click.echo(f"Characters of {cuid}") + + characters = await client.get_genshin_characters(uid) + characters = sorted(characters, key=lambda c: (c.level, c.rarity), reverse=True) + + for char in characters: + color = { + "Anemo": "bright_green", + "Pyro": "red", + "Hydro": "bright_blue", + "Electro": "magenta", + "Cryo": "bright_cyan", + "Geo": "yellow", + "Dendro": "green", + }[char.element] + + click.echo() + name = click.style(char.name, bold=True) + element = click.style(char.element, fg=color) + click.echo(f"{name} ({'★' * char.rarity} {element})") + click.echo(f"lvl {char.level} C{char.constellation}, friendship lvl {char.friendship}") + click.echo( + f"Weapon: {char.weapon.name} ({'★' * char.weapon.rarity} {char.weapon.type}) - " + f"lvl {char.weapon.level} R{char.weapon.refinement}" + ) + + if char.artifacts: + click.echo("Artifacts:") + for arti in char.artifacts: + click.echo(f" - {arti.pos_name}: {arti.set.name} ({'★' * arti.rarity})") + + if char.outfits: + click.echo(f"Outfits: {', '.join(o.name for o in char.outfits)}") + + +@genshin_group.command("notes") +@client_command +async def genshin_notes(client: genshin.Client) -> None: + """Show real-Time notes.""" + click.echo("Real-Time notes.") + + data = await client.get_notes() + + click.echo(f"{click.style('Resin:', bold=True)} {data.current_resin}/{data.max_resin}") + click.echo( + f"{click.style('Commissions:', bold=True)} " f"{data.completed_commissions}/{data.max_commissions}", + nl=False, + ) + if data.completed_commissions == data.max_commissions and not data.claimed_commission_reward: + click.echo(f" | [{click.style('X', fg='red')}] Haven't claimed rewards") + else: + click.echo() + click.echo( + f"{click.style('Used resin cost-halving opportunities:', bold=True)} " + f"{data.max_resin_discounts - data.remaining_resin_discounts}/{data.max_resin_discounts}" + ) + + click.echo(f"\n{click.style('Expeditions:', bold=True)} " f"{len(data.expeditions)}/{data.max_expeditions}") + for expedition in data.expeditions: + if expedition.remaining_time > 0: + seconds = expedition.remaining_time + remaining = f"{seconds // 3600:02.0f}:{seconds % 3600 // 60:02.0f} remaining" + click.echo(f" - {expedition.status} | {remaining} - {expedition.character.name}") + else: + click.echo(f" - {expedition.status} | {expedition.character.name}") + + +@cli.command() +@click.option("--limit", help="The maximum amount of wishes to show.", default=None) +@client_command +async def wishes(client: genshin.Client, limit: typing.Optional[int] = None) -> None: + """Show a nicely formatted wish history.""" + client.set_authkey() + + banner_names = await client.get_banner_names() + longest = max(len(v) for v in banner_names.values()) + + async for wish in client.wish_history(limit=limit): + banner = click.style(wish.banner_name.ljust(longest), bold=True) + click.echo(f"{banner} | {wish.time.astimezone()} - {wish.name} ({'★' * wish.rarity} {wish.type})") + + +@cli.command() +@client_command +async def pity(client: genshin.Client) -> None: + """Calculate the amount of pulls until pity.""" + client.set_authkey() + + banners = await client.get_banner_names() + for banner, name in banners.items(): + # skip the novice banner + if banner == 100: + continue + + click.echo() + click.secho(name, fg="yellow") + + accum = 0 + async for wish in client.wish_history(banner): + accum += 1 + if wish.rarity == 5: + a = click.style(str(90 - accum), bold=True) + n = click.style(wish.name, fg="green") + click.secho(f"Pulled {n} {accum} pulls ago. {a} pulls left until pity.") + break + else: + a = click.style(str(90 - accum), bold=True) + click.secho(f"Never pulled a 5*. At most {a} pulls left until pity") + + +@cli.command() +@client_command +async def banner_ids(client: genshin.Client) -> None: + """Get the banner ids from logs.""" + ids = genshin.utility.get_banner_ids() + + click.echo("\n".join(ids) + "\n") + + if len(ids) < 3: + click.echo("Please open all detail pages!") + return + + for banner in await client.get_banner_details(ids): + click.echo(f"{banner.banner_id} - {banner.banner_type_name} ({banner.banner_type})") + + +@cli.command(hidden=True) +def authkey() -> None: + """Get an authkey from logfiles.""" + click.echo(genshin.utility.get_authkey()) + + +@cli.command() +@click.argument("account") +@click.argument("password") +@click.option("--port", help="Webserver port.", type=int, default=5000) +@asynchronous +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) + + base: http.cookies.BaseCookie[str] = http.cookies.BaseCookie(cookies) + click.echo(f"Your cookies are: {click.style(base.output(header='', sep=';'), bold=True)}") + + +if __name__ == "__main__": + cli() diff --git a/genshin/client/__init__.py b/genshin/client/__init__.py new file mode 100644 index 00000000..8170cde7 --- /dev/null +++ b/genshin/client/__init__.py @@ -0,0 +1,6 @@ +"""Default client implementation.""" +from .cache import * +from .client import * +from .compatibility import * +from .components import * +from .manager import * diff --git a/genshin/client/cache.py b/genshin/client/cache.py new file mode 100644 index 00000000..8d9b2540 --- /dev/null +++ b/genshin/client/cache.py @@ -0,0 +1,201 @@ +"""Cache for client.""" +from __future__ import annotations + +import abc +import dataclasses +import enum +import json +import sys +import time +import typing + +if typing.TYPE_CHECKING: + import aioredis + +__all__ = ["BaseCache", "Cache", "RedisCache", "StaticCache"] + +MINUTE = 60 +HOUR = MINUTE * 60 +DAY = HOUR * 24 +WEEK = DAY * 7 + + +def _separate(values: typing.Iterable[typing.Any], sep: str = ":") -> str: + """Separate a sequence by a separator into a single string.""" + parts: typing.List[str] = [] + for value in values: + if value is None: + parts.append("null") + elif isinstance(value, enum.Enum): + parts.append(str(value.value)) + elif isinstance(value, tuple): + if value: + parts.append(_separate(value)) # pyright: ignore[reportUnknownArgumentType] + else: + parts.append(str(value)) + + return sep.join(parts) + + +@dataclasses.dataclass(eq=False) +class CacheKey: + def __str__(self) -> str: + values = [getattr(self, field.name) for field in dataclasses.fields(self)] + return _separate(values) + + def __hash__(self) -> int: + return hash(str(self)) + + def __eq__(self, o: object) -> bool: + return isinstance(o, CacheKey) and str(self) == str(o) + + +def cache_key(key: str, **kwargs: typing.Any) -> CacheKey: + name = key.capitalize() + "CacheKey" + fields = ["key"] + list(kwargs.keys()) + cls = dataclasses.make_dataclass(name, fields, bases=(CacheKey,), eq=False) + return typing.cast("CacheKey", cls(key, **kwargs)) + + +class BaseCache(abc.ABC): + """Base cache for the client.""" + + @abc.abstractmethod + async def get(self, key: typing.Any) -> typing.Optional[typing.Any]: + """Get an object with a key.""" + + @abc.abstractmethod + async def set(self, key: typing.Any, value: typing.Any) -> None: + """Save an object with a key.""" + + @abc.abstractmethod + async def get_static(self, key: typing.Any) -> typing.Optional[typing.Any]: + """Get a static object with a key.""" + + @abc.abstractmethod + async def set_static(self, key: typing.Any, value: typing.Any) -> None: + """Save a static object with a key.""" + + +class Cache(BaseCache): + """Standard implementation of the cache.""" + + cache: typing.Dict[typing.Any, typing.Tuple[float, typing.Any]] + maxsize: int + ttl: float + static_ttl: float + + def __init__(self, maxsize: int = 1024, *, ttl: float = HOUR, static_ttl: float = DAY) -> None: + self.cache = {} + self.maxsize = maxsize + + self.ttl = ttl + self.static_ttl = static_ttl + + def __len__(self) -> int: + self._clear_cache() + return len(self.cache) + + def _clear_cache(self) -> None: + """Clear timed-out items.""" + # since this is always called from an async function we don't need locks + now = time.time() + + for key, value in self.cache.copy().items(): + if value[0] < now: + del self.cache[key] + + if len(self.cache) > self.maxsize: + overflow = len(self.cache) - self.maxsize + keys = list(self.cache.keys())[:overflow] + + for key in keys: + del self.cache[key] + + async def get(self, key: typing.Any) -> typing.Optional[typing.Any]: + """Get an object with a key.""" + self._clear_cache() + + if key not in self.cache: + return None + + return self.cache[key][1] + + async def set(self, key: typing.Any, value: typing.Any) -> None: + """Save an object with a key.""" + self.cache[key] = (time.time() + self.ttl, value) + + self._clear_cache() + + async def get_static(self, key: typing.Any) -> typing.Optional[typing.Any]: + """Get a static object with a key.""" + return await self.get(key) + + async def set_static(self, key: typing.Any, value: typing.Any) -> None: + """Save a static object with a key.""" + self.cache[key] = (time.time() + self.static_ttl, value) + + self._clear_cache() + + +class StaticCache(Cache): + """Cache for only static resources.""" + + def __init__(self, ttl: float = DAY) -> None: + super().__init__(maxsize=sys.maxsize, ttl=0, static_ttl=ttl) + + async def set(self, key: typing.Any, value: typing.Any) -> None: + """Do nothing.""" + + +class RedisCache(BaseCache): + """Redis implementation of the cache.""" + + redis: aioredis.Redis + ttl: int + static_ttl: int + + def __init__(self, redis: aioredis.Redis, *, ttl: int = HOUR, static_ttl: int = DAY) -> None: + self.redis = redis + self.ttl = ttl + self.static_ttl = static_ttl + + def serialize_key(self, key: typing.Any) -> str: + """Serialize a key by turning it into a string.""" + return str(key) + + def serialize_value(self, value: typing.Any) -> typing.Union[str, bytes]: + """Serialize a value by turning it into bytes.""" + return json.dumps(value) + + def deserialize_value(self, value: bytes) -> typing.Any: + """Deserialize a value back into data.""" + return json.loads(value) + + async def get(self, key: typing.Any) -> typing.Optional[typing.Any]: + """Get an object with a key.""" + value: typing.Optional[bytes] = await self.redis.get(self.serialize_key(key)) # pyright: ignore + if value is None: + return None + + return self.deserialize_value(value) + + async def set(self, key: typing.Any, value: typing.Any) -> None: + """Save an object with a key.""" + await self.redis.set( # pyright: ignore + self.serialize_key(key), + self.serialize_value(value), + ex=self.ttl, + ) + + async def get_static(self, key: typing.Any) -> typing.Optional[typing.Any]: + """Get a static object with a key.""" + return await self.get(key) + + async def set_static(self, key: typing.Any, value: typing.Any) -> None: + """Save a static object with a key.""" + await self.redis.set( # pyright: ignore + self.serialize_key(key), + self.serialize_value(value), + ex=self.static_ttl, + ) diff --git a/genshin/client/client.py b/genshin/client/client.py new file mode 100644 index 00000000..e9941caf --- /dev/null +++ b/genshin/client/client.py @@ -0,0 +1,26 @@ +"""A simple HTTP client for API endpoints.""" +from .components import ( + calculator, + chronicle, + daily, + diary, + geetest, + hoyolab, + transaction, + wish, +) + +__all__ = ["Client"] + + +class Client( + chronicle.BattleChronicleClient, + hoyolab.HoyolabClient, + daily.DailyRewardClient, + calculator.CalculatorClient, + diary.DiaryClient, + wish.WishClient, + transaction.TransactionClient, + geetest.GeetestClient, +): + """A simple HTTP client for API endpoints.""" diff --git a/genshin/client/compatibility.py b/genshin/client/compatibility.py new file mode 100644 index 00000000..21f043bc --- /dev/null +++ b/genshin/client/compatibility.py @@ -0,0 +1,188 @@ +"""Reverse-compatibility layer for previous versions.""" +from __future__ import annotations + +import typing + +import aiohttp + +from genshin import models, types +from genshin.utility import deprecation + +from . import client + +__all__ = ["ChineseClient", "ChineseMultiCookieClient", "GenshinClient", "MultiCookieClient"] + + +class GenshinClient(client.Client): + """A simple http client for genshin endpoints. + + !!! warning + This class is deprecated and will be removed in the following version. + Use `Client()` instead. + """ + + def __init__( + self, + cookies: typing.Optional[typing.Any] = None, + authkey: typing.Optional[str] = None, + *, + lang: str = "en-us", + region: types.Region = types.Region.OVERSEAS, + debug: bool = False, + ) -> None: + deprecation.warn_deprecated(self.__class__, alternative="Client") + super().__init__( + cookies=cookies, + authkey=authkey, + lang=lang, + region=region, + game=types.Game.GENSHIN, + debug=debug, + ) + + @property + def session(self) -> aiohttp.ClientSession: + """The current client session, created when needed.""" + deprecation.warn_deprecated(self.__class__.session) + return self.cookie_manager.create_session() + + @property + def cookies(self) -> typing.Mapping[str, str]: + """The cookie jar belonging to the current session.""" + deprecation.warn_deprecated(self.__class__.cookies, alternative="cookie_manager") + return getattr(self.cookie_manager, "cookies") + + @cookies.setter + def cookies(self, cookies: typing.Mapping[str, typing.Any]) -> None: + deprecation.warn_deprecated("Setting cookies with GenshinClient.cookies", alternative="set_cookies") + setattr(self.cookie_manager, "cookies", cookies) + + @property + def uid(self) -> typing.Optional[int]: + deprecation.warn_deprecated(self.__class__.uid, alternative="Client.uids[genshin.Game.GENSHIN]") + return self.uids[types.Game.GENSHIN] + + @uid.setter + def uid(self, uid: int) -> None: + deprecation.warn_deprecated( + "Setting uid with GenshinClient.uid", + alternative="Client.uids[genshin.Game.GENSHIN]", + ) + self.uids[types.Game.GENSHIN] = uid + + @deprecation.deprecated() + async def __aenter__(self) -> GenshinClient: + return self + + async def __aexit__(self, *exc_info: typing.Any) -> None: + pass + + @deprecation.deprecated("get_partial_genshin_user") + async def get_partial_user( + self, + uid: int, + *, + lang: typing.Optional[str] = None, + ) -> models.GenshinPartialUserStats: + """Get partial genshin user without character equipment.""" + return await self.get_partial_genshin_user(uid, lang=lang) + + @deprecation.deprecated("get_genshin_characters") + async def get_characters( + self, + uid: int, + *, + lang: typing.Optional[str] = None, + ) -> typing.Sequence[models.Character]: + """Get genshin user characters.""" + return await self.get_genshin_characters(uid, lang=lang) + + @deprecation.deprecated("get_genshin_user") + async def get_user( + self, + uid: int, + *, + lang: typing.Optional[str] = None, + ) -> models.GenshinUserStats: + """Get genshin user.""" + return await self.get_genshin_user(uid, lang=lang) + + @deprecation.deprecated("get_full_genshin_user") + async def get_full_user( + self, + uid: int, + *, + lang: typing.Optional[str] = None, + ) -> models.GenshinFullUserStats: + """Get a user with all their possible data.""" + return await self.get_full_genshin_user(uid, lang=lang) + + +class ChineseClient(GenshinClient): + """A Genshin Client for chinese endpoints. + + !!! warning + This class is deprecated and will be removed in the following version. + Use `Client(region=genshin.Region.CHINESE)` instead. + """ + + def __init__( + self, + cookies: typing.Optional[typing.Mapping[str, str]] = None, + authkey: typing.Optional[str] = None, + *, + lang: str = "zh-cn", + debug: bool = False, + ) -> None: + super().__init__( + cookies=cookies, + authkey=authkey, + lang=lang, + region=types.Region.CHINESE, + debug=debug, + ) + + +class MultiCookieClient(GenshinClient): + """A Genshin Client which allows setting multiple cookies. + + !!! warning + This class is deprecated and will be removed in the following version. + Use `Client(cookies=[...])` instead. + """ + + def __init__( + self, + cookie_list: typing.Optional[typing.Sequence[typing.Mapping[str, str]]] = None, + *, + lang: str = "en-us", + debug: bool = False, + ) -> None: + super().__init__( + cookies=cookie_list, + lang=lang, + debug=debug, + ) + + +class ChineseMultiCookieClient(GenshinClient): + """A Genshin Client for chinese endpoints which allows setting multiple cookies. + + !!! warning + This class is deprecated and will be removed in the following version. + Use `Client(cookies=[...], region=genshin.Region.CHINESE)` instead. + """ + + def __init__( + self, + cookie_list: typing.Optional[typing.Sequence[typing.Mapping[str, str]]] = None, + *, + lang: str = "en-us", + debug: bool = False, + ) -> None: + super().__init__( + cookies=cookie_list, + lang=lang, + region=types.Region.CHINESE, + debug=debug, + ) diff --git a/genshin/client/components/__init__.py b/genshin/client/components/__init__.py new file mode 100644 index 00000000..2705de33 --- /dev/null +++ b/genshin/client/components/__init__.py @@ -0,0 +1,4 @@ +"""Components of a client organized by endpoint. + +Abuses inheritance because it can. +""" diff --git a/genshin/client/components/base.py b/genshin/client/components/base.py new file mode 100644 index 00000000..2a1c4208 --- /dev/null +++ b/genshin/client/components/base.py @@ -0,0 +1,419 @@ +"""Base ABC Client.""" +import abc +import asyncio +import base64 +import json +import logging +import os +import typing +import urllib.parse + +import aiohttp.typedefs +import yarl + +from genshin import constants, errors, types +from genshin.client import cache as client_cache +from genshin.client import manager, routes +from genshin.models import hoyolab as hoyolab_models +from genshin.models import model as base_model +from genshin.utility import deprecation, ds +from genshin.utility import genshin as genshin_utility + +__all__ = ["BaseClient"] + + +class BaseClient(abc.ABC): + """Base ABC Client.""" + + __slots__ = ("cookie_manager", "cache", "_authkey", "_lang", "_region", "_default_game", "uids") + + 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 + + logger: logging.Logger = logging.getLogger(__name__) + + cookie_manager: manager.BaseCookieManager + cache: client_cache.BaseCache + _authkey: typing.Optional[str] + _lang: str + _region: types.Region + _default_game: typing.Optional[types.Game] + + uids: typing.Dict[types.Game, int] + + def __init__( + self, + cookies: typing.Optional[manager.AnyCookieOrHeader] = None, + *, + authkey: typing.Optional[str] = None, + lang: str = "en-us", + region: types.Region = types.Region.OVERSEAS, + game: typing.Optional[types.Game] = None, + cache: typing.Optional[client_cache.Cache] = None, + debug: bool = False, + ) -> None: + self.cookie_manager = manager.BaseCookieManager.from_cookies(cookies) + self.cache = cache or client_cache.StaticCache() + + self.authkey = authkey + self.lang = lang + self.region = region + self.default_game = game + self.debug = debug + + self.uids = {} + + def __repr__(self) -> str: + kwargs = dict( + lang=self.lang, + region=self.region.value, + default_game=self.default_game and self.default_game.value, + hoyolab_uid=self.hoyolab_uid, + authkey=self.authkey and self.authkey[:12] + "...", + debug=self.debug, + ) + return f"<{type(self).__name__} {', '.join(f'{k}={v!r}' for k, v in kwargs.items() if v)}>" + + @property + def hoyolab_uid(self) -> typing.Optional[int]: + """The logged-in user's hoyolab uid. + + Returns None if not found or not applicable. + """ + return self.cookie_manager.user_id + + @property + def lang(self) -> str: + """The default language, defaults to "en-us" """ + return self._lang + + @lang.setter + def lang(self, lang: str) -> None: + if lang not in constants.LANGS: + raise ValueError(f"{lang} is not a valid language, must be one of: " + ", ".join(constants.LANGS)) + + self._lang = lang + + @property + def region(self) -> types.Region: + """The default region.""" + return self._region + + @region.setter + def region(self, region: str) -> None: + self._region = types.Region(region) + + if region is types.Region.CHINESE: + self.lang = "zh-cn" + + @property + def default_game(self) -> typing.Optional[types.Game]: + """The default game.""" + return self._default_game + + @default_game.setter + def default_game(self, game: typing.Optional[str]) -> None: + self._default_game = types.Game(game) if game else None + + @property + def authkey(self) -> typing.Optional[str]: + """The default genshin authkey used for paginators.""" + return self._authkey + + @authkey.setter + def authkey(self, authkey: typing.Optional[str]) -> None: + if authkey is not None: + authkey = urllib.parse.unquote(authkey) + + try: + base64.b64decode(authkey, validate=True) + except Exception as e: + raise ValueError("authkey is not a valid base64 encoded string") from e + + self._authkey = authkey + + @property + def debug(self) -> bool: + """Whether the debug logs are being shown in stdout""" + return logging.getLogger("genshin").level == logging.DEBUG + + @debug.setter + def debug(self, debug: bool) -> None: + logging.basicConfig() + level = logging.DEBUG if debug else logging.NOTSET + logging.getLogger("genshin").setLevel(level) + + def set_cookies(self, cookies: typing.Optional[manager.AnyCookieOrHeader] = None, **kwargs: typing.Any) -> None: + """Parse and set cookies.""" + if not bool(cookies) ^ bool(kwargs): + raise TypeError("Cannot use both positional and keyword arguments at once") + + self.cookie_manager = manager.BaseCookieManager.from_cookies(cookies or kwargs) + + def set_browser_cookies(self, browser: typing.Optional[str] = None) -> None: + """Extract cookies from your browser and set them as client cookies. + + Available browsers: chrome, chromium, opera, edge, firefox. + """ + self.cookie_manager = manager.BaseCookieManager.from_browser_cookies(browser) + + def set_authkey(self, authkey: typing.Optional[str] = None) -> None: + """Set an authkey for wish & transaction logs. + + Accepts an authkey, a url containing an authkey or a path towards a logfile. + """ + if authkey is None or os.path.isfile(authkey): + authkey = genshin_utility.get_authkey(authkey) + else: + authkey = genshin_utility.extract_authkey(authkey) or authkey + + self.authkey = authkey + + def set_cache( + self, + maxsize: int = 1024, + *, + ttl: int = client_cache.HOUR, + static_ttl: int = client_cache.DAY, + ) -> None: + """Create and set a new cache.""" + self.cache = client_cache.Cache(maxsize, ttl=ttl, static_ttl=static_ttl) + + def set_redis_cache( + self, + url: str, + *, + ttl: int = client_cache.HOUR, + static_ttl: int = client_cache.DAY, + **redis_kwargs: typing.Any, + ) -> None: + """Create and set a new redis cache.""" + import aioredis + + redis = aioredis.Redis.from_url(url, **redis_kwargs) # pyright: ignore[reportUnknownMemberType] + self.cache = client_cache.RedisCache(redis, ttl=ttl, static_ttl=static_ttl) + + async def _request_hook( + self, + method: str, + url: aiohttp.typedefs.StrOrURL, + *, + params: typing.Optional[typing.Mapping[str, typing.Any]] = None, + data: typing.Any = None, + **kwargs: typing.Any, + ) -> None: + """Perform an action before a request. + + Debug logging by default. + """ + url = yarl.URL(url) + if params: + params = {k: v for k, v in params.items() if k != "authkey"} + url = url.update_query(params) + + if data: + self.logger.debug("%s %s\n%s", method, url, json.dumps(data, separators=(",", ":"))) + else: + self.logger.debug("%s %s", method, url) + + async def request( + self, + url: aiohttp.typedefs.StrOrURL, + *, + method: typing.Optional[str] = None, + params: typing.Optional[typing.Mapping[str, typing.Any]] = None, + data: typing.Any = None, + headers: typing.Optional[aiohttp.typedefs.LooseHeaders] = None, + cache: typing.Any = None, + static_cache: typing.Any = None, + **kwargs: typing.Any, + ) -> typing.Mapping[str, typing.Any]: + """Make a request and return a parsed json response.""" + if cache is not None: + value = await self.cache.get(cache) + if value is not None: + return value + elif static_cache is not None: + value = await self.cache.get_static(static_cache) + if value is not None: + return value + + # actual request + + headers = dict(headers or {}) + headers["User-Agent"] = self.USER_AGENT + + if method is None: + method = "POST" if data else "GET" + + if "json" in kwargs: + raise TypeError("Use data instead of json in request.") + + await self._request_hook(method, url, params=params, data=data, headers=headers, **kwargs) + + response = await self.cookie_manager.request( + url, + method=method, + params=params, + json=data, + headers=headers, + **kwargs, + ) + + # cache + + if cache is not None: + await self.cache.set(cache, response) + elif static_cache is not None: + await self.cache.set_static(static_cache, response) + + return response + + async def request_webstatic( + self, + url: aiohttp.typedefs.StrOrURL, + *, + headers: typing.Optional[aiohttp.typedefs.LooseHeaders] = None, + cache: typing.Any = None, + **kwargs: typing.Any, + ) -> typing.Any: + """Request a static json file.""" + if cache is not None: + value = await self.cache.get_static(cache) + if value is not None: + return value + + url = routes.WEBSTATIC_URL.get_url().join(yarl.URL(url)) + + headers = dict(headers or {}) + headers["User-Agent"] = self.USER_AGENT + + await self._request_hook("GET", url, headers=headers, **kwargs) + + async with self.cookie_manager.create_session() as session: + async with session.get(url, headers=headers, **kwargs) as r: + r.raise_for_status() + data = await r.json() + + if cache is not None: + await self.cache.set_static(cache, data) + + return data + + async def request_hoyolab( + self, + url: aiohttp.typedefs.StrOrURL, + *, + lang: typing.Optional[str] = None, + region: typing.Optional[types.Region] = None, + method: typing.Optional[str] = None, + params: typing.Optional[typing.Mapping[str, typing.Any]] = None, + data: typing.Any = None, + headers: typing.Optional[aiohttp.typedefs.LooseHeaders] = None, + **kwargs: typing.Any, + ) -> typing.Mapping[str, typing.Any]: + """Make a request any hoyolab endpoint.""" + if lang is not None and lang not in constants.LANGS: + raise ValueError(f"{lang} is not a valid language, must be one of: " + ", ".join(constants.LANGS)) + + lang = lang or self.lang + region = region or self.region + + url = routes.TAKUMI_URL.get_url(region).join(yarl.URL(url)) + + if region is types.Region.OVERSEAS: + headers = { + "x-rpc-app_version": "1.5.0", + "x-rpc-client_type": "4", + "x-rpc-language": lang, + "ds": ds.generate_dynamic_secret(), + } + elif region is types.Region.CHINESE: + headers = { + "x-rpc-app_version": "2.11.1", + "x-rpc-client_type": "5", + "ds": ds.generate_cn_dynamic_secret(data, params), + } + else: + raise TypeError(f"{region!r} is not a valid region.") + + data = await self.request(url, method=method, params=params, data=data, headers=headers, **kwargs) + return data + + @manager.no_multi + async def get_game_accounts( + self, + *, + lang: typing.Optional[str] = None, + ) -> typing.Sequence[hoyolab_models.GenshinAccount]: + """Get the game accounts of the currently logged-in user.""" + data = await self.request_hoyolab( + "binding/api/getUserGameRolesByCookie", + lang=lang, + cache=client_cache.cache_key("accounts"), + ) + return [hoyolab_models.GenshinAccount(**i) for i in data["list"]] + + @deprecation.deprecated("get_game_accounts") + async def genshin_accounts( + self, + *, + lang: typing.Optional[str] = None, + ) -> typing.Sequence[hoyolab_models.GenshinAccount]: + """Get the genshin accounts of the currently logged-in user.""" + accounts = await self.get_game_accounts(lang=lang) + return [account for account in accounts if account.game == types.Game.GENSHIN] + + async def _update_cached_uids(self) -> None: + """Update cached fallback uids.""" + mixed_accounts = await self.get_game_accounts() + + game_accounts: typing.Dict[types.Game, typing.List[hoyolab_models.GenshinAccount]] = {} + for account in mixed_accounts: + game_accounts.setdefault(account.game, []).append(account) + + self.uids = {game: max(accounts, key=lambda a: a.level).uid for game, accounts in game_accounts.items()} + + async def _get_uid(self, game: types.Game) -> int: + """Get a cached fallback uid.""" + # TODO: use lock + if uid := self.uids.get(game): + return uid + + if self.cookie_manager.multi: + raise RuntimeError("UID must be provided when using multi-cookie managers.") + + await self._update_cached_uids() + + if uid := self.uids.get(game): + return uid + + raise errors.AccountNotFound(msg="No UID provided and account has no game account bound to it.") + + async def _fetch_mi18n(self, key: str, lang: str, *, force: bool = False) -> None: + """Update mi18n for a single url.""" + if not force: + if key in base_model.APIModel._mi18n: # pyright: ignore[reportPrivateUsage] + return + + base_model.APIModel._mi18n[key] = {} # pyright: ignore[reportPrivateUsage] + + url = routes.MI18N[key] + data = await self.request_webstatic(url.format(lang=lang)) + for k, v in data.items(): + actual_key = str.lower(key + "/" + k) + base_model.APIModel._mi18n.setdefault(actual_key, {})[lang] = v # pyright: ignore[reportPrivateUsage] + + async def update_mi18n(self, langs: typing.Iterable[str] = constants.LANGS, *, force: bool = False) -> None: + """Fetch mi18n for partially localized endpoints.""" + if not force: + if base_model.APIModel._mi18n: # pyright: ignore[reportPrivateUsage] + return + + langs = tuple(langs) + + coros: typing.List[typing.Awaitable[None]] = [] + for key in routes.MI18N: + for lang in langs: + coros.append(self._fetch_mi18n(key, lang, force=force)) + + await asyncio.gather(*coros) diff --git a/genshin/client/components/calculator/__init__.py b/genshin/client/components/calculator/__init__.py new file mode 100644 index 00000000..8c47acf1 --- /dev/null +++ b/genshin/client/components/calculator/__init__.py @@ -0,0 +1,2 @@ +"""Calculator client.""" +from .client import * diff --git a/genshin/client/components/calculator/calculator.py b/genshin/client/components/calculator/calculator.py new file mode 100644 index 00000000..23e8129c --- /dev/null +++ b/genshin/client/components/calculator/calculator.py @@ -0,0 +1,396 @@ +"""Calculator builder object. + +Over-engineered for the sake of extendability and maintainability. +""" +from __future__ import annotations + +import abc +import asyncio +import typing + +import genshin.models.genshin as genshin_models +from genshin import types +from genshin.models.genshin import calculator as models + +if typing.TYPE_CHECKING: + from .client import CalculatorClient as Client + +__all__ = ["Calculator"] + +T = typing.TypeVar("T") +CallableT = typing.TypeVar("CallableT", bound="typing.Callable[..., typing.Awaitable[object]]") + + +def _cache(func: CallableT) -> CallableT: + """Cache a method.""" + + async def wrapper(self: CalculatorState, *args: typing.Any, **kwargs: typing.Any) -> typing.Any: + async with self.lock: + if value := self.cache.get(func.__name__): + return value + + value = await func(self, *args, **kwargs) + self.cache[func.__name__] = value + + return value + + return typing.cast("CallableT", wrapper) + + +class CalculatorState: + """Stores character details if multiple objects require them.""" + + client: Client + cache: typing.Dict[str, typing.Any] + lock: asyncio.Lock + + character_id: typing.Optional[int] = None + + def __init__(self, client: Client) -> None: + self.client = client + self.cache = {} + self.lock = asyncio.Lock() + + @_cache + async def get_character_details(self) -> models.CalculatorCharacterDetails: + """Get character details.""" + if self.character_id is None: + raise TypeError("No specified character.") + + return await self.client.get_character_details(self.character_id) + + @_cache + async def get_character_talents(self) -> typing.Sequence[models.CalculatorTalent]: + """Get talent ids.""" + if self.character_id is None: + raise TypeError("No specified character.") + + return await self.client.get_character_talents(self.character_id) + + @_cache + async def get_artifact_ids(self, artifact_id: int) -> typing.Sequence[int]: + """Get artifact ids.""" + others = await self.client.get_complete_artifact_set(artifact_id) + return [artifact_id] + [other.id for other in others] + + +class CalculatorResolver(abc.ABC, typing.Generic[T]): + """Auto-resolving calculator object.""" + + @abc.abstractmethod + async def __call__(self, state: CalculatorState) -> T: + """Resolve the object into concrete data.""" + + +class CharacterResolver(CalculatorResolver[typing.Mapping[str, typing.Any]]): + def __init__( + self, + character: types.IDOr[genshin_models.BaseCharacter], + current: typing.Optional[int] = None, + target: typing.Optional[int] = None, + *, + element: typing.Optional[int] = None, + ) -> None: + if isinstance(character, genshin_models.BaseCharacter): + current = current or getattr(character, "level", None) + character = character.id + + self.id = character + self.current = current + self.target = target + self.element = element + + async def __call__(self, state: CalculatorState) -> typing.Mapping[str, typing.Any]: + if self.current is None or self.target is None: + return {} + + data = dict( + avatar_id=self.id, + avatar_level_current=self.current, + avatar_level_target=self.target, + ) + if self.element: + data.update(element_attr_id=self.element) + + return data + + +class WeaponResolver(CalculatorResolver[typing.Mapping[str, typing.Any]]): + id: int + current: int + target: int + + def __init__(self, weapon: int, current: int, target: int) -> None: + self.id = weapon + self.current = current + self.target = target + + async def __call__(self, state: CalculatorState) -> typing.Mapping[str, typing.Any]: + return dict( + id=self.id, + level_current=self.current, + level_target=self.target, + ) + + +class CurrentWeaponResolver(WeaponResolver): + id: int + current: int + target: int + + def __init__(self, target: int): + self.target = target + + async def __call__(self, state: CalculatorState) -> typing.Mapping[str, typing.Any]: + details = await state.get_character_details() + self.id = details.weapon.id + self.current = details.weapon.level + return await super().__call__(state) + + +class ArtifactResolver(CalculatorResolver[typing.Sequence[typing.Mapping[str, typing.Any]]]): + data: typing.List[typing.Mapping[str, typing.Any]] + + def __init__(self) -> None: + self.data = [] + + def add_artifact(self, id: int, current: int, target: int) -> None: + self.data.append(dict(id=id, level_current=current, level_target=target)) + + async def __call__(self, state: CalculatorState) -> typing.Sequence[typing.Mapping[str, typing.Any]]: + return self.data + + +class ArtifactSetResolver(ArtifactResolver): + def __init__(self, any_artifact_id: int, current: int, target: int) -> None: + self.id = any_artifact_id + self.current = current + self.target = target + + super().__init__() + + async def __call__(self, state: CalculatorState) -> typing.Sequence[typing.Mapping[str, typing.Any]]: + artifact_ids = await state.get_artifact_ids(self.id) + + for artifact_id in artifact_ids: + self.add_artifact(artifact_id, self.current, self.target) + + return self.data + + +class CurrentArtifactResolver(ArtifactResolver): + artifacts: typing.Sequence[typing.Optional[int]] + + def __init__( + self, + target: typing.Optional[int] = None, + *, + flower: typing.Optional[int] = None, + feather: typing.Optional[int] = None, + sands: typing.Optional[int] = None, + goblet: typing.Optional[int] = None, + circlet: typing.Optional[int] = None, + ) -> None: + if target: + self.artifacts = (target,) * 5 + else: + self.artifacts = (flower, feather, sands, goblet, circlet) + + async def __call__(self, state: CalculatorState) -> typing.Sequence[typing.Mapping[str, typing.Any]]: + details = await state.get_character_details() + + for artifact in details.artifacts: + if target := self.artifacts[artifact.pos - 1]: + self.add_artifact(artifact.id, artifact.level, target) + + return self.data + + +class TalentResolver(CalculatorResolver[typing.Sequence[typing.Mapping[str, typing.Any]]]): + data: typing.List[typing.Mapping[str, typing.Any]] + + def __init__(self) -> None: + self.data = [] + + def add_talent(self, id: int, current: int, target: int) -> None: + self.data.append(dict(id=id, level_current=current, level_target=target)) + + async def __call__(self, state: CalculatorState) -> typing.Sequence[typing.Mapping[str, typing.Any]]: + return self.data + + +class CurrentTalentResolver(TalentResolver): + talents: typing.Mapping[str, typing.Optional[int]] + + def __init__( + self, + target: typing.Optional[int] = None, + current: typing.Optional[int] = None, + *, + attack: typing.Optional[int] = None, + skill: typing.Optional[int] = None, + burst: typing.Optional[int] = None, + ) -> None: + self.current = current + if target: + self.talents = { + "attack": target, + "skill": target, + "burst": target, + } + else: + self.talents = { + "attack": attack, + "skill": skill, + "burst": burst, + } + + super().__init__() + + async def __call__(self, state: CalculatorState) -> typing.Sequence[typing.Mapping[str, typing.Any]]: + if self.current: + talents = await state.get_character_talents() + else: + details = await state.get_character_details() + talents = details.talents + self.current = 0 + + for talent in talents: + if target := self.talents.get(talent.type): + self.add_talent(talent.group_id, talent.level or self.current, target) + + return self.data + + +class Calculator: + """Builder for the genshin impact enhancement calculator.""" + + client: Client + lang: typing.Optional[str] + + character: typing.Optional[CharacterResolver] + weapon: typing.Optional[WeaponResolver] + artifacts: typing.Optional[ArtifactResolver] + talents: typing.Optional[TalentResolver] + + _state: CalculatorState + + def __init__(self, client: Client, *, lang: typing.Optional[str] = None) -> None: + self.client = client + self.lang = lang + + self.character = None + self.weapon = None + self.artifacts = None + self.talents = None + + self._state = CalculatorState(client) + + def set_character( + self, + character: types.IDOr[genshin_models.BaseCharacter], + current: typing.Optional[int] = None, + target: typing.Optional[int] = None, + *, + element: typing.Optional[int] = None, + ) -> Calculator: + """Set the character.""" + self.character = CharacterResolver(character, current, target, element=element) + self._state.character_id = self.character.id + return self + + def set_weapon(self, id: int, current: int, target: int) -> Calculator: + """Set the weapon.""" + self.weapon = WeaponResolver(id, current, target) + return self + + def add_artifact(self, id: int, current: int, target: int) -> Calculator: + """Add an artifact.""" + if type(self.artifacts) is not ArtifactResolver: + self.artifacts = ArtifactResolver() + + self.artifacts.add_artifact(id, current, target) + return self + + def set_artifact_set(self, any_artifact_id: int, current: int, target: int) -> Calculator: + """Set an artifact set.""" + self.artifacts = ArtifactSetResolver(any_artifact_id, current, target) + return self + + def add_talent(self, group_id: int, current: int, target: int) -> Calculator: + """Add a talent.""" + if type(self.talents) is not TalentResolver: + self.talents = TalentResolver() + + self.talents.add_talent(group_id, current, target) + return self + + def with_current_weapon(self, target: int) -> Calculator: + """Set the weapon of the selected character.""" + self.weapon = CurrentWeaponResolver(target) + return self + + def with_current_artifacts( + self, + target: typing.Optional[int] = None, + *, + flower: typing.Optional[int] = None, + feather: typing.Optional[int] = None, + sands: typing.Optional[int] = None, + goblet: typing.Optional[int] = None, + circlet: typing.Optional[int] = None, + ) -> Calculator: + """Add all artifacts of the selected character.""" + self.artifacts = CurrentArtifactResolver( + target, + flower=flower, + feather=feather, + sands=sands, + goblet=goblet, + circlet=circlet, + ) + return self + + def with_current_talents( + self, + target: typing.Optional[int] = None, + current: typing.Optional[int] = None, + *, + attack: typing.Optional[int] = None, + skill: typing.Optional[int] = None, + burst: typing.Optional[int] = None, + ) -> Calculator: + """Add all talents of the currently selected character.""" + self.talents = CurrentTalentResolver( + target=target, + current=current, + attack=attack, + skill=skill, + burst=burst, + ) + return self + + async def build(self) -> typing.Mapping[str, typing.Any]: + """Build the calculator object.""" + data: typing.Dict[str, typing.Any] = {} + + if self.character: + data.update(await self.character(self._state)) + + if self.weapon: + data["weapon"] = await self.weapon(self._state) + + if self.artifacts: + data["reliquary_list"] = await self.artifacts(self._state) + + if self.talents: + data["skill_list"] = await self.talents(self._state) + + return data + + async def calculate(self) -> models.CalculatorResult: + """Execute the calculator.""" + return await self.client._execute_calculator(await self.build(), lang=self.lang) # pyright: ignore # noqa + + def __await__(self) -> typing.Generator[typing.Any, None, models.CalculatorResult]: + return self.calculate().__await__() diff --git a/genshin/client/components/calculator/client.py b/genshin/client/components/calculator/client.py new file mode 100644 index 00000000..84e4813b --- /dev/null +++ b/genshin/client/components/calculator/client.py @@ -0,0 +1,224 @@ +"""Calculator client.""" +from __future__ import annotations + +import typing + +import genshin.models.genshin as genshin_models +from genshin import types +from genshin.client import cache as client_cache +from genshin.client import routes +from genshin.client.components import base +from genshin.models.genshin import calculator as models +from genshin.utility import genshin as genshin_utility + +from .calculator import Calculator + +__all__ = ["CalculatorClient"] + + +class CalculatorClient(base.BaseClient): + """Calculator component.""" + + async def request_calculator( + self, + endpoint: str, + *, + method: str = "POST", + lang: typing.Optional[str] = None, + params: typing.Optional[typing.Mapping[str, typing.Any]] = None, + data: typing.Optional[typing.Mapping[str, typing.Any]] = None, + **kwargs: typing.Any, + ) -> typing.Mapping[str, typing.Any]: + """Make a request towards the calculator endpoint.""" + params = dict(params or {}) + + base_url = routes.CALCULATOR_URL.get_url(self.region) + url = base_url / endpoint + + if method == "GET": + params["lang"] = lang or self.lang + data = None + else: + data = dict(data or {}) + data["lang"] = lang or self.lang + + return await self.request(url, method=method, params=params, data=data, **kwargs) + + async def _execute_calculator( + self, + data: typing.Mapping[str, typing.Any], + *, + lang: typing.Optional[str] = None, + ) -> models.CalculatorResult: + """Calculate the results of a builder.""" + data = await self.request_calculator("compute", lang=lang, data=data) + return models.CalculatorResult(**data) + + def calculator(self, *, lang: typing.Optional[str] = None) -> Calculator: + """Create a calculator builder object.""" + return Calculator(self, lang=lang) + + async def _get_calculator_items( + self, + slug: str, + filters: typing.Mapping[str, typing.Any], + query: typing.Optional[str] = None, + *, + is_all: bool = False, + sync: bool = False, + lang: typing.Optional[str] = None, + ) -> typing.Sequence[typing.Mapping[str, typing.Any]]: + """Get all items of a specific slug from a calculator.""" + endpoint = f"sync/{slug}/list" if sync else f"{slug}/list" + + if query: + if any(filters.values()): + raise TypeError("Cannot specify a query and filter at the same time") + + filters = dict(keywords=query, **filters) + + payload: typing.Dict[str, typing.Any] = dict(page=1, size=69420, is_all=is_all, **filters) + + if sync: + uid = await self._get_uid(types.Game.GENSHIN) + payload["uid"] = uid + payload["region"] = genshin_utility.recognize_genshin_server(uid) + + cache: typing.Optional[client_cache.CacheKey] = None + if not any(filters.values()) and not sync: + cache = client_cache.cache_key("calculator", slug=slug, lang=lang or self.lang) + + data = await self.request_calculator(endpoint, lang=lang, data=payload, cache=cache) + return data["list"] + + async def get_calculator_characters( + self, + *, + query: typing.Optional[str] = None, + elements: typing.Optional[typing.Sequence[int]] = None, + weapon_types: typing.Optional[typing.Sequence[int]] = None, + include_traveler: bool = False, + sync: bool = False, + lang: typing.Optional[str] = None, + ) -> typing.Sequence[models.CalculatorCharacter]: + """Get all characters provided by the Enhancement Progression Calculator.""" + data = await self._get_calculator_items( + "avatar", + lang=lang, + is_all=include_traveler, + sync=sync, + query=query, + filters=dict( + element_attr_ids=elements or [], + weapon_cat_ids=weapon_types or [], + ), + ) + return [models.CalculatorCharacter(**i) for i in data] + + async def get_calculator_weapons( + self, + *, + query: typing.Optional[str] = None, + types: typing.Optional[typing.Sequence[int]] = None, + rarities: typing.Optional[typing.Sequence[int]] = None, + lang: typing.Optional[str] = None, + ) -> typing.Sequence[models.CalculatorWeapon]: + """Get all weapons provided by the Enhancement Progression Calculator.""" + data = await self._get_calculator_items( + "weapon", + lang=lang, + query=query, + filters=dict( + weapon_cat_ids=types or [], + weapon_levels=rarities or [], + ), + ) + return [models.CalculatorWeapon(**i) for i in data] + + async def get_calculator_artifacts( + self, + *, + query: typing.Optional[str] = None, + pos: int = 1, + rarities: typing.Optional[typing.Sequence[int]] = None, + lang: typing.Optional[str] = None, + ) -> typing.Sequence[models.CalculatorArtifact]: + """Get all artifacts provided by the Enhancement Progression Calculator.""" + data = await self._get_calculator_items( + "reliquary", + lang=lang, + query=query, + filters=dict( + reliquary_cat_id=pos, + reliquary_levels=rarities or [], + ), + ) + return [models.CalculatorArtifact(**i) for i in data] + + async def get_character_details( + self, + character: types.IDOr[genshin_models.BaseCharacter], + *, + lang: typing.Optional[str] = None, + ) -> models.CalculatorCharacterDetails: + """Get the weapon, artifacts and talents of a character. + + Not related to the Battle Chronicle. + This data is always private. + """ + uid = await self._get_uid(types.Game.GENSHIN) + + data = await self.request_calculator( + "sync/avatar/detail", + method="GET", + lang=lang, + params=dict( + avatar_id=int(character), + uid=uid, + region=genshin_utility.recognize_genshin_server(uid), + ), + ) + return models.CalculatorCharacterDetails(**data) + + async def get_character_talents( + self, + character: types.IDOr[genshin_models.BaseCharacter], + *, + lang: typing.Optional[str] = None, + ) -> typing.Sequence[models.CalculatorTalent]: + """Get the talents of a character. + + This only gets the talent names, not their levels. + Use `get_character_details` for precise information. + """ + data = await self.request_calculator( + "avatar/skill_list", + method="GET", + lang=lang, + params=dict(avatar_id=int(character)), + ) + return [models.CalculatorTalent(**i) for i in data["list"]] + + async def get_complete_artifact_set( + self, + artifact: types.IDOr[typing.Union[genshin_models.Artifact, genshin_models.CalculatorArtifact]], + *, + lang: typing.Optional[str] = None, + ) -> typing.Sequence[models.CalculatorArtifact]: + """Get all other artifacts that share a set with any given artifact. + + Doesn't return the artifact passed into this function. + """ + data = await self.request_calculator( + "reliquary/set", + method="GET", + lang=lang, + params=dict(reliquary_id=int(artifact)), + cache=client_cache.cache_key("calculator", slug="set", artifact=int(artifact), lang=lang or self.lang), + ) + return [models.CalculatorArtifact(**i) for i in data["reliquary_list"]] + + async def _get_all_artifact_ids(self, artifact_id: int) -> typing.Sequence[int]: + """Get all artifact ids in the same set as a given artifact id.""" + others = await self.get_complete_artifact_set(artifact_id) + return [artifact_id] + [other.id for other in others] diff --git a/genshin/client/components/chronicle/__init__.py b/genshin/client/components/chronicle/__init__.py new file mode 100644 index 00000000..30fb3fc8 --- /dev/null +++ b/genshin/client/components/chronicle/__init__.py @@ -0,0 +1,2 @@ +"""Battle chronicle client components.""" +from .client import * diff --git a/genshin/client/components/chronicle/base.py b/genshin/client/components/chronicle/base.py new file mode 100644 index 00000000..d24129e4 --- /dev/null +++ b/genshin/client/components/chronicle/base.py @@ -0,0 +1,109 @@ +"""Base battle chronicle component.""" + +import asyncio +import dataclasses +import typing + +from genshin import errors, models, types +from genshin.client import cache, routes +from genshin.client.components import base +from genshin.utility import deprecation + +__all__ = ["BaseBattleChronicleClient"] + + +@dataclasses.dataclass(unsafe_hash=True) +class HoyolabCacheKey(cache.CacheKey): + endpoint: str + hoyolab_uid: int + lang: str + + +@dataclasses.dataclass(unsafe_hash=True) +class ChronicleCacheKey(cache.CacheKey): + def __str__(self) -> str: + return "chronicle" + ":" + super().__str__() + + game: types.Game + endpoint: str + uid: int + lang: str + params: typing.Tuple[typing.Any, ...] = () + + +class BaseBattleChronicleClient(base.BaseClient): + """Base battle chronicle component.""" + + async def request_game_record( + self, + endpoint: str, + *, + lang: typing.Optional[str] = None, + region: typing.Optional[types.Region] = None, + game: typing.Optional[types.Game] = None, + **kwargs: typing.Any, + ) -> typing.Mapping[str, typing.Any]: + """Make a request towards the game record endpoint.""" + base_url = routes.RECORD_URL.get_url(region or self.region) + + if game: + base_url = base_url / game.value / "api" + + url = base_url / endpoint + + mi18n_task = asyncio.create_task(self._fetch_mi18n("bbs", lang=lang or self.lang)) + data = await self.request_hoyolab(url, lang=lang, region=region, **kwargs) + + await mi18n_task + return data + + async def get_record_cards( + self, + hoyolab_uid: typing.Optional[int] = None, + *, + lang: typing.Optional[str] = None, + ) -> typing.List[models.hoyolab.RecordCard]: + """Get a user's record cards.""" + hoyolab_uid = hoyolab_uid or self.cookie_manager.get_user_id() + + cache_key = cache.cache_key("records", hoyolab_uid=hoyolab_uid, lang=lang or self.lang) + if not (data := await self.cache.get(cache_key)): + data = await self.request_game_record( + "card/wapi/getGameRecordCard", + lang=lang, + params=dict(uid=hoyolab_uid), + ) + + if data["list"]: + await self.cache.set(cache_key, data) + else: + raise errors.DataNotPublic({"retcode": 10102}) + + return [models.hoyolab.RecordCard(**card) for card in data["list"]] + + @deprecation.deprecated("get_record_cards") + async def get_record_card( + self, + hoyolab_uid: typing.Optional[int] = None, + *, + lang: typing.Optional[str] = None, + ) -> models.hoyolab.RecordCard: + """Get a user's record card.""" + cards = await self.get_record_cards(hoyolab_uid, lang=lang) + return cards[0] + + async def set_visibility(self, public: bool, game: typing.Optional[types.Game] = None) -> None: + """Set your data to public or private.""" + if game is None: + if self.default_game is None: + raise RuntimeError("No default game set.") + + game = self.default_game + + game_id = {types.Game.HONKAI: 1, types.Game.GENSHIN: 2}[game] + + await self.request_game_record( + "genshin/wapi/publishGameRecord", + method="POST", + json=dict(is_public=public, game_id=game_id), + ) diff --git a/genshin/client/components/chronicle/client.py b/genshin/client/components/chronicle/client.py new file mode 100644 index 00000000..86c9b991 --- /dev/null +++ b/genshin/client/components/chronicle/client.py @@ -0,0 +1,11 @@ +"""Battle chronicle component.""" +from . import genshin, honkai + +__all__ = ["BattleChronicleClient"] + + +class BattleChronicleClient( + genshin.GenshinBattleChronicleClient, + honkai.HonkaiBattleChronicleClient, +): + """Battle chronicle component.""" diff --git a/genshin/client/components/chronicle/genshin.py b/genshin/client/components/chronicle/genshin.py new file mode 100644 index 00000000..f97e4375 --- /dev/null +++ b/genshin/client/components/chronicle/genshin.py @@ -0,0 +1,168 @@ +"""Genshin battle chronicle component.""" + +import asyncio +import typing + +from genshin import types +from genshin.models.genshin import character as character_models +from genshin.models.genshin import chronicle as models +from genshin.utility import genshin as genshin_utility + +from . import base + +__all__ = ["GenshinBattleChronicleClient"] + + +def _get_region(uid: int) -> types.Region: + return types.Region.CHINESE if genshin_utility.is_chinese(uid) else types.Region.OVERSEAS + + +class GenshinBattleChronicleClient(base.BaseBattleChronicleClient): + """Genshin battle chronicle component.""" + + async def __get_genshin( + self, + endpoint: str, + uid: typing.Optional[int] = None, + *, + method: str = "GET", + lang: typing.Optional[str] = None, + payload: typing.Optional[typing.Mapping[str, typing.Any]] = None, + cache: bool = True, + ) -> typing.Mapping[str, typing.Any]: + """Get an arbitrary honkai object.""" + payload = dict(payload or {}) + original_payload = payload.copy() + + uid = uid or await self._get_uid(types.Game.GENSHIN) + payload.update(role_id=uid, server=genshin_utility.recognize_genshin_server(uid)) + + data, params = None, None + if method == "POST": + data = payload + else: + params = payload + + cache_key: typing.Optional[base.ChronicleCacheKey] = None + if cache: + cache_key = base.ChronicleCacheKey( + types.Game.GENSHIN, + endpoint, + uid, + lang=lang or self.lang, + params=tuple(original_payload.values()), + ) + + return await self.request_game_record( + endpoint, + lang=lang, + game=types.Game.GENSHIN, + region=_get_region(uid), + params=params, + data=data, + cache=cache_key, + ) + + async def get_partial_genshin_user( + self, + uid: int, + *, + lang: typing.Optional[str] = None, + ) -> models.GenshinPartialUserStats: + """Get partial genshin user without character equipment.""" + data = await self.__get_genshin("index", uid, lang=lang) + return models.GenshinPartialUserStats(**data) + + async def get_genshin_characters( + self, + uid: int, + *, + lang: typing.Optional[str] = None, + ) -> typing.Sequence[models.Character]: + """Get genshin user characters.""" + data = await self.__get_genshin("character", uid, lang=lang, method="POST") + return [models.Character(**i) for i in data["avatars"]] + + async def get_genshin_user( + self, + uid: int, + *, + lang: typing.Optional[str] = None, + ) -> models.GenshinUserStats: + """Get genshin user.""" + data, character_data = await asyncio.gather( + self.__get_genshin("index", uid, lang=lang), + self.__get_genshin("character", uid, lang=lang, method="POST"), + ) + data = {**data, **character_data} + + return models.GenshinUserStats(**data) + + async def get_genshin_spiral_abyss( + self, + uid: int, + *, + previous: bool = False, + lang: typing.Optional[str] = None, + ) -> models.SpiralAbyss: + """Get genshin spiral abyss runs.""" + payload = dict(schedule_type=2 if previous else 1) + data = await self.__get_genshin("spiralAbyss", uid, lang=lang, payload=payload) + + return models.SpiralAbyss(**data) + + async def get_genshin_notes( + self, + uid: typing.Optional[int] = None, + *, + lang: typing.Optional[str] = None, + ) -> models.Notes: + """Get genshin real-time notes.""" + data = await self.__get_genshin("dailyNote", uid, lang=lang, cache=False) + return models.Notes(**data) + + async def get_genshin_activities(self, uid: int, *, lang: typing.Optional[str] = None) -> models.Activities: + """Get genshin activities.""" + data = await self.__get_genshin("activities", uid, lang=lang) + return models.Activities(**data) + + async def get_full_genshin_user( + self, + uid: int, + *, + lang: typing.Optional[str] = None, + ) -> models.GenshinFullUserStats: + """Get a genshin user with all their possible data.""" + user, abyss1, abyss2, activities = await asyncio.gather( + self.get_genshin_user(uid, lang=lang), + self.get_genshin_spiral_abyss(uid, lang=lang, previous=False), + self.get_genshin_spiral_abyss(uid, lang=lang, previous=True), + self.get_genshin_activities(uid, lang=lang), + ) + abyss = models.SpiralAbyssPair(current=abyss1, previous=abyss2) + + return models.GenshinFullUserStats(**user.dict(), abyss=abyss, activities=activities) + + async def set_top_genshin_characters( + self, + characters: typing.Sequence[types.IDOr[character_models.BaseCharacter]], + *, + uid: typing.Optional[int] = None, + ) -> None: + """Set the top 8 visible genshin characters for the current user.""" + uid = uid or await self._get_uid(types.Game.GENSHIN) + + await self.request_game_record( + "character/top", + game=types.Game.GENSHIN, + region=_get_region(uid), + data=dict( + avatar_ids=[int(character) for character in characters], + uid_key=uid, + server_key=genshin_utility.recognize_genshin_server(uid), + ), + ) + + get_spiral_abyss = get_genshin_spiral_abyss + get_notes = get_genshin_notes + get_activities = get_genshin_activities diff --git a/genshin/client/components/chronicle/honkai.py b/genshin/client/components/chronicle/honkai.py new file mode 100644 index 00000000..2588a631 --- /dev/null +++ b/genshin/client/components/chronicle/honkai.py @@ -0,0 +1,159 @@ +"""Honkai battle chronicle component.""" + +import asyncio +import typing + +from genshin import errors, types +from genshin.models.honkai import chronicle as models +from genshin.utility import honkai as honkai_utility + +from . import base + +__all__ = ["HonkaiBattleChronicleClient"] + + +class HonkaiBattleChronicleClient(base.BaseBattleChronicleClient): + """Honkai battle chronicle component.""" + + async def __get_honkai( + self, + endpoint: str, + uid: typing.Optional[int] = None, + *, + lang: typing.Optional[str] = None, + cache: bool = True, + ) -> typing.Mapping[str, typing.Any]: + """Get an arbitrary honkai object.""" + uid = uid or await self._get_uid(types.Game.HONKAI) + + cache_key: typing.Optional[base.ChronicleCacheKey] = None + if cache: + cache_key = base.ChronicleCacheKey( + types.Game.HONKAI, + endpoint, + uid, + lang=lang or self.lang, + ) + + return await self.request_game_record( + endpoint, + lang=lang, + game=types.Game.HONKAI, + region=types.Region.OVERSEAS, + params=dict(role_id=uid, server=honkai_utility.recognize_honkai_server(uid)), + cache=cache_key, + ) + + async def get_honkai_user( + self, + uid: int, + *, + lang: typing.Optional[str] = None, + ) -> models.HonkaiUserStats: + """Get honkai user stats.""" + data = await self.__get_honkai("index", uid, lang=lang) + return models.HonkaiUserStats(**data) + + async def get_honkai_battlesuits( + self, + uid: int, + *, + lang: typing.Optional[str] = None, + ) -> typing.Sequence[models.FullBattlesuit]: + """Get honkai battlesuits.""" + data = await self.__get_honkai("characters", uid, lang=lang) + return [models.FullBattlesuit(**char["character"]) for char in data["characters"]] + + async def get_honkai_old_abyss( + self, + uid: int, + *, + lang: typing.Optional[str] = None, + ) -> typing.Sequence[models.OldAbyss]: + """Get honkai old abyss. + + Only for level > 80. + """ + data = await self.__get_honkai("latestOldAbyssReport", uid, lang=lang) + return [models.OldAbyss(**x, abyss_lang=lang or self.lang) for x in data["reports"]] + + async def get_honkai_superstring_abyss( + self, + uid: int, + *, + lang: typing.Optional[str] = None, + ) -> typing.Sequence[models.SuperstringAbyss]: + """Get honkai superstring abyss. + + Only for level <= 80. + """ + data = await self.__get_honkai("newAbyssReport", uid, lang=lang) + return [models.SuperstringAbyss(**x, abyss_lang=lang or self.lang) for x in data["reports"]] + + async def get_honkai_abyss( + self, + uid: int, + *, + lang: typing.Optional[str] = None, + ) -> typing.Sequence[typing.Union[models.SuperstringAbyss, models.OldAbyss]]: + """Get honkai abyss.""" + possible = await asyncio.gather( + self.get_honkai_old_abyss(uid, lang=lang), + self.get_honkai_superstring_abyss(uid, lang=lang), + return_exceptions=True, + ) + for abyss in possible: + if not isinstance(abyss, BaseException): + return abyss + if not isinstance(abyss, errors.InternalDatabaseError): + raise abyss from None + + return [] + + async def get_honkai_elysian_realm( + self, + uid: int, + *, + lang: typing.Optional[str] = None, + ) -> typing.Sequence[models.ElysianRealm]: + """Get honkai elysian realm.""" + data = await self.__get_honkai("godWar", uid, lang=lang) + return [models.ElysianRealm(**x) for x in data["records"]] + + async def get_honkai_memorial_arena( + self, + uid: int, + *, + lang: typing.Optional[str] = None, + ) -> typing.Sequence[models.MemorialArena]: + """Get honkai memorial arena.""" + data = await self.__get_honkai("battleFieldReport", uid, lang=lang) + return [models.MemorialArena(**x, ma_lang=lang or self.lang) for x in data["reports"]] + + async def get_full_honkai_user( + self, + uid: int, + *, + lang: typing.Optional[str] = None, + ) -> models.HonkaiFullUserStats: + """Get a full honkai user.""" + user, battlesuits, abyss, mr, er = await asyncio.gather( + self.get_honkai_user(uid, lang=lang), + self.get_honkai_battlesuits(uid, lang=lang), + self.get_honkai_abyss(uid, lang=lang), + self.get_honkai_memorial_arena(uid, lang=lang), + self.get_honkai_elysian_realm(uid, lang=lang), + ) + + return models.HonkaiFullUserStats( + **user.dict(), + battlesuits=battlesuits, + abyss=abyss, + memorial_arena=mr, + elysian_realm=er, + ) + + get_old_abyss = get_honkai_old_abyss + get_superstring_abyss = get_honkai_superstring_abyss + get_elysian_realm = get_honkai_elysian_realm + get_memorial_arena = get_honkai_memorial_arena diff --git a/genshin/client/components/daily.py b/genshin/client/components/daily.py new file mode 100644 index 00000000..24eb420a --- /dev/null +++ b/genshin/client/components/daily.py @@ -0,0 +1,165 @@ +"""Daily reward component.""" +import asyncio +import datetime +import functools +import typing +import uuid + +import aiohttp.typedefs + +from genshin import constants, paginators, types +from genshin.client import cache, manager, routes +from genshin.client.components import base +from genshin.models.genshin import daily as models +from genshin.utility import ds as ds_utility +from genshin.utility import genshin as genshin_utility + +__all__ = ["DailyRewardClient"] + + +class DailyRewardClient(base.BaseClient): + """Daily reward component.""" + + @manager.no_multi + async def request_daily_reward( + self, + endpoint: str, + *, + game: typing.Optional[types.Game] = None, + method: str = "GET", + lang: typing.Optional[str] = None, + params: typing.Optional[typing.Mapping[str, typing.Any]] = None, + headers: typing.Optional[aiohttp.typedefs.LooseHeaders] = None, + **kwargs: typing.Any, + ) -> typing.Mapping[str, typing.Any]: + """Make a request towards the daily reward endpoint.""" + params = dict(params or {}) + headers = dict(headers or {}) + + if game is None: + if self.default_game is None: + raise RuntimeError("No default game set.") + + game = self.default_game + + base_url = routes.REWARD_URL.get_url(self.region, game) + url = (base_url / endpoint).update_query(base_url.query) + + if self.region is types.Region.OVERSEAS: + params["lang"] = lang or self.lang + + elif self.region is types.Region.CHINESE: + # TODO: Support cn honkai + uid = await self._get_uid(types.Game.GENSHIN) + + params["uid"] = uid + params["region"] = genshin_utility.recognize_genshin_server(uid) + + headers["x-rpc-app_version"] = "2.10.1" + headers["x-rpc-client_type"] = "5" + headers["x-rpc-device_id"] = str(uuid.uuid4()) + + headers["ds"] = ds_utility.generate_dynamic_secret(constants.DS_SALT["cn_signin"]) + + else: + raise TypeError(f"{self.region!r} is not a valid region.") + + return await self.request(url, method=method, params=params, headers=headers, **kwargs) + + async def get_reward_info( + self, + *, + game: typing.Optional[types.Game] = None, + lang: typing.Optional[str] = None, + ) -> models.DailyRewardInfo: + """Get the daily reward info for the current user.""" + data = await self.request_daily_reward("info", game=game, lang=lang) + return models.DailyRewardInfo(data["is_sign"], data["total_sign_day"]) + + async def get_monthly_rewards( + self, + *, + game: typing.Optional[types.Game] = None, + lang: typing.Optional[str] = None, + ) -> typing.Sequence[models.DailyReward]: + """Get a list of all availible rewards for the current month.""" + data = await self.request_daily_reward( + "home", + game=game, + static_cache=cache.cache_key( + "rewards", + month=datetime.datetime.utcnow().month, + region=self.region, + game=typing.cast("types.Game", game or self.default_game), # (resolved later) + lang=lang or self.lang, + ), + ) + return [models.DailyReward(**i) for i in data["awards"]] + + async def _get_claimed_rewards_page( + self, + page: int, + *, + game: typing.Optional[types.Game] = None, + lang: typing.Optional[str] = None, + ) -> typing.Sequence[models.ClaimedDailyReward]: + """Get a single page of claimed rewards for the current user.""" + data = await self.request_daily_reward("award", params=dict(current_page=page), game=game, lang=lang) + return [models.ClaimedDailyReward(**i) for i in data["list"]] + + def claimed_rewards( + self, + *, + limit: typing.Optional[int] = None, + game: typing.Optional[types.Game] = None, + lang: typing.Optional[str] = None, + ) -> paginators.Paginator[models.ClaimedDailyReward]: + """Get all claimed rewards for the current user.""" + return paginators.PagedPaginator( + functools.partial( + self._get_claimed_rewards_page, + game=game, + lang=lang, + ), + limit=limit, + page_size=10, + ) + + @typing.overload + async def claim_daily_reward( # noqa: D102 missing docstring in overload? + self, + *, + game: typing.Optional[types.Game] = None, + lang: typing.Optional[str] = None, + reward: typing.Literal[True] = ..., + ) -> models.DailyReward: + ... + + @typing.overload + async def claim_daily_reward( # noqa: D102 missing docstring in overload? + self, + *, + game: typing.Optional[types.Game] = None, + lang: typing.Optional[str] = None, + reward: typing.Literal[False], + ) -> None: + ... + + async def claim_daily_reward( + self, + *, + game: typing.Optional[types.Game] = None, + lang: typing.Optional[str] = None, + reward: bool = True, + ) -> typing.Optional[models.DailyReward]: + """Signs into hoyolab and claims the daily reward.""" + await self.request_daily_reward("sign", method="POST", game=game, lang=lang) + + if not reward: + return None + + info, rewards = await asyncio.gather( + self.get_reward_info(game=game, lang=lang), + self.get_monthly_rewards(game=game, lang=lang), + ) + return rewards[info.claimed_rewards - 1] diff --git a/genshin/client/components/diary.py b/genshin/client/components/diary.py new file mode 100644 index 00000000..64f9a5e6 --- /dev/null +++ b/genshin/client/components/diary.py @@ -0,0 +1,122 @@ +"""Diary component.""" +import datetime +import functools +import typing + +from genshin import paginators, types +from genshin.client import manager, routes +from genshin.client.components import base +from genshin.models.genshin import diary as models +from genshin.utility import genshin as genshin_utility + +__all__ = ["DiaryClient"] + + +class DiaryCallback(typing.Protocol): + """Callback which requires a diary page.""" + + async def __call__(self, page: int, /) -> models.DiaryPage: + """Return a diary page.""" + ... + + +class DiaryPaginator(paginators.PagedPaginator[models.DiaryAction]): + """Paginator for diary.""" + + _data: typing.Optional[models.DiaryPage] + """Metadata of the paginator""" + + def __init__(self, getter: DiaryCallback, *, limit: typing.Optional[int] = None) -> None: + self._getter = getter + self._data = None + + super().__init__(self._get_page, limit=limit, page_size=10) + + async def _get_page(self, page: int) -> typing.Sequence[models.DiaryAction]: + self._data = await self._getter(page) + return self._data.actions + + @property + def data(self) -> models.BaseDiary: + """Get data bound to the diary. + + This requires at least one page to have been fetched + """ + if self._data is None: + raise RuntimeError("At least one item must be fetched before data can be gotten.") + + return self._data + + +class DiaryClient(base.BaseClient): + """Diary component.""" + + @manager.no_multi + async def request_ledger( + self, + *, + detail: bool = False, + month: typing.Optional[int] = None, + lang: typing.Optional[str] = None, + params: typing.Optional[typing.Mapping[str, typing.Any]] = None, + **kwargs: typing.Any, + ) -> typing.Mapping[str, typing.Any]: + """Make a request towards the ys ledger endpoint.""" + # TODO: Do not separate urls? + params = dict(params or {}) + + url = routes.DETAIL_LEDGER_URL.get_url(self.region) if detail else routes.INFO_LEDGER_URL.get_url(self.region) + + uid = await self._get_uid(types.Game.GENSHIN) + params["uid"] = uid + params["region"] = genshin_utility.recognize_genshin_server(uid) + + params["month"] = month or datetime.datetime.now().month + params["lang"] = lang or self.lang + + return await self.request(url, params=params, **kwargs) + + async def get_diary( + self, + *, + month: typing.Optional[int] = None, + lang: typing.Optional[str] = None, + ) -> models.Diary: + """Get a traveler's diary with earning details for the month.""" + data = await self.request_ledger(month=month, lang=lang) + return models.Diary(**data) + + async def _get_diary_page( + self, + page: int, + *, + type: int = models.DiaryType.PRIMOGEMS, + month: typing.Optional[int] = None, + lang: typing.Optional[str] = None, + ) -> models.DiaryPage: + data = await self.request_ledger( + detail=True, + month=month, + lang=lang, + params=dict(type=type, current_page=page, limit=10), + ) + return models.DiaryPage(**data) + + def diary_log( + self, + *, + limit: typing.Optional[int] = None, + type: int = models.DiaryType.PRIMOGEMS, + month: typing.Optional[int] = None, + lang: typing.Optional[str] = None, + ) -> DiaryPaginator: + """Create a new daily reward paginator.""" + return DiaryPaginator( + functools.partial( + self._get_diary_page, + type=type, + month=month, + lang=lang, + ), + limit=limit, + ) diff --git a/genshin/client/components/geetest/__init__.py b/genshin/client/components/geetest/__init__.py new file mode 100644 index 00000000..97d622bd --- /dev/null +++ b/genshin/client/components/geetest/__init__.py @@ -0,0 +1,5 @@ +"""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 new file mode 100644 index 00000000..6d3a3b2d --- /dev/null +++ b/genshin/client/components/geetest/client.py @@ -0,0 +1,65 @@ +"""Geetest client component.""" +import typing + +import aiohttp +import aiohttp.web +import yarl + +from genshin import errors +from genshin.client.components import base +from genshin.utility import geetest as geetest_utility + +from . import server + +__all__ = ["GeetestClient"] + + +WEB_LOGIN_URL = yarl.URL("https://api-account-os.hoyolab.com/account/auth/api/webLoginByPassword") + + +class GeetestClient(base.BaseClient): + """Geetest client component.""" + + async def login_with_geetest( + self, + account: str, + password: str, + mmt_key: str, + geetest: typing.Dict[str, str], + token_type: int = 4, + ) -> typing.Mapping[str, str]: + """Login with a password and a solved geetest.""" + payload = dict( + account=account, + password=geetest_utility.encrypt_geetest_password(password), + is_crypto="true", + source="account.mihoyo.com", + mmt_key=mmt_key, + token_type=token_type, + ) + payload.update(geetest) + + # we do not want to use the previous cookie manager sessions + + async with aiohttp.ClientSession() as session: + async with session.post(WEB_LOGIN_URL, json=payload) as r: + data = await r.json() + + if not data["data"]: + errors.raise_for_retcode(data) + + account_id: str = data["data"]["account_info"]["account_id"] + + cookies = {cookie.key: cookie.value for cookie in r.cookies.values()} + cookies.update(account_id=account_id, ltuid=account_id) + + self.set_cookies(cookies) + + return cookies + + async def login_with_password(self, account: str, password: str, *, port: int = 5000) -> typing.Mapping[str, str]: + """Login with a password. + + This will start a webserver. + """ + return await server.login_with_app(self, account, password, port=port) diff --git a/genshin/client/components/geetest/server.py b/genshin/client/components/geetest/server.py new file mode 100644 index 00000000..627cdab3 --- /dev/null +++ b/genshin/client/components/geetest/server.py @@ -0,0 +1,124 @@ +"""Aiohttp webserver used for login.""" +from __future__ import annotations + +import asyncio +import typing +import webbrowser + +import aiohttp +from aiohttp import web + +from genshin.utility import geetest + +from . import client + +__all__ = ["login_with_app"] + +INDEX = """ + + + + + + + + +""" + +GT_URL = "https://raw.githubusercontent.com/GeeTeam/gt3-node-sdk/master/demo/static/libs/gt.js" + + +async def login_with_app(client: client.GeetestClient, account: str, password: str, *, port: int = 5000) -> typing.Any: + """Create and run an application for handling login.""" + routes = web.RouteTableDef() + future: asyncio.Future[typing.Any] = asyncio.Future() + + mmt_key: str = "" + + @routes.get("/") + async def index(request: web.Request) -> web.StreamResponse: + return web.Response(body=INDEX, 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: + nonlocal mmt_key + + mmt = await geetest.create_mmt() + mmt_key = mmt["mmt_key"] + return web.json_response(mmt) + + @routes.post("/login") + async def login_endpoint(request: web.Request) -> web.Response: + body = await request.json() + + try: + data = await client.login_with_geetest( + account=account, + password=password, + mmt_key=mmt_key, + geetest=body, + ) + except Exception as e: + future.set_exception(e) + return web.json_response({}, status=500) + + future.set_result(data) + + return web.json_response(data) + + app = web.Application() + app.add_routes(routes) + + runner = web.AppRunner(app) + await runner.setup() + + site = web.TCPSite(runner, host="localhost", port=port) + print(f"Opened browser in http://localhost:{port}") # 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() + + return data diff --git a/genshin/client/components/hoyolab.py b/genshin/client/components/hoyolab.py new file mode 100644 index 00000000..5826a98d --- /dev/null +++ b/genshin/client/components/hoyolab.py @@ -0,0 +1,55 @@ +"""Hoyolab component.""" +import typing + +from genshin import types +from genshin.client import cache as client_cache +from genshin.client import manager, routes +from genshin.client.components import base +from genshin.models import hoyolab as models +from genshin.utility import genshin as genshin_utility + +__all__ = ["HoyolabClient"] + + +class HoyolabClient(base.BaseClient): + """Hoyolab component.""" + + async def search_users(self, keyword: str) -> typing.Sequence[models.SearchUser]: + """Search hoyolab users.""" + data = await self.request_hoyolab( + "community/search/wapi/search/user", + params=dict(keyword=keyword, page_size=20), + cache=client_cache.cache_key("search", keyword=keyword), + ) + return [models.SearchUser(**i["user"]) for i in data["list"]] + + async def get_recommended_users(self, *, limit: int = 200) -> typing.Sequence[models.SearchUser]: + """Get a list of recommended active users.""" + data = await self.request_hoyolab( + "community/user/wapi/recommendActive", + params=dict(page_size=limit), + cache=client_cache.cache_key("recommended"), + ) + return [models.SearchUser(**i["user"]) for i in data["list"]] + + @manager.no_multi + async def redeem_code( + self, + code: str, + uid: typing.Optional[int] = None, + *, + lang: typing.Optional[str] = None, + ) -> None: + """Redeems a gift code for the current genshin user.""" + uid = uid or await self._get_uid(types.Game.GENSHIN) + + await self.request( + routes.CODE_URL.get_url(), + params=dict( + uid=uid, + region=genshin_utility.recognize_genshin_server(uid), + cdkey=code, + game_biz="hk4e_global", + lang=genshin_utility.create_short_lang_code(lang or self.lang), + ), + ) diff --git a/genshin/client/components/transaction.py b/genshin/client/components/transaction.py new file mode 100644 index 00000000..b38242d6 --- /dev/null +++ b/genshin/client/components/transaction.py @@ -0,0 +1,109 @@ +"""Transaction client.""" +import asyncio +import functools +import typing +import urllib.parse + +from genshin import paginators +from genshin.client import routes +from genshin.client.components import base +from genshin.models.genshin import transaction as models +from genshin.utility import genshin as genshin_utility + +__all__ = ["TransactionClient"] + + +class TransactionClient(base.BaseClient): + """Transaction component.""" + + async def request_transaction( + self, + endpoint: str, + *, + method: str = "GET", + lang: typing.Optional[str] = None, + authkey: typing.Optional[str] = None, + params: typing.Optional[typing.Mapping[str, typing.Any]] = None, + **kwargs: typing.Any, + ) -> typing.Mapping[str, typing.Any]: + """Make a request towards the transaction log endpoint.""" + params = dict(params or {}) + authkey = authkey or self.authkey + + if authkey is None: + raise RuntimeError("No authkey provided") + + base_url = routes.YSULOG_URL.get_url(self.region) + url = base_url / endpoint + + params["authkey_ver"] = 1 + params["sign_type"] = 2 + params["authkey"] = urllib.parse.unquote(authkey) + params["lang"] = genshin_utility.create_short_lang_code(lang or self.lang) + + return await self.request(url, method=method, params=params, **kwargs) + + async def _get_transaction_page( + self, + end_id: int, + kind: str, + *, + lang: typing.Optional[str] = None, + authkey: typing.Optional[str] = None, + ) -> typing.Sequence[models.BaseTransaction]: + """Get a single page of transactions.""" + kind = models.TransactionKind(kind) + endpoint = "get" + kind.value.capitalize() + "Log" + + mi18n_task = asyncio.create_task(self._fetch_mi18n("inquiry", lang=lang or self.lang)) + + data = await self.request_transaction( + endpoint, + lang=lang, + authkey=authkey, + params=dict(end_id=end_id, size=20), + ) + await mi18n_task # we need the mi18n before making the object + + 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) + transactions.append(model(**trans, kind=kind, reason_lang=lang or self.lang)) + + return transactions + + def transaction_log( + self, + kind: typing.Optional[typing.Union[str, typing.Sequence[str]]] = None, + *, + limit: typing.Optional[int] = None, + lang: typing.Optional[str] = None, + authkey: typing.Optional[str] = None, + end_id: int = 0, + ) -> paginators.Paginator[models.BaseTransaction]: + """Get the transaction log of a user.""" + kinds = kind or ["primogem", "crystal", "resin", "artifact", "weapon"] + + if isinstance(kinds, str): + kinds = [kinds] + + iterators: typing.List[paginators.Paginator[models.BaseTransaction]] = [] + for kind in kinds: + iterators.append( + paginators.CursorPaginator( + functools.partial( + self._get_transaction_page, + kind=kind, + lang=lang, + authkey=authkey, + ), + limit=limit, + end_id=end_id, + ) + ) + + if len(iterators) == 1: + return iterators[0] + + return paginators.MergedPaginator(iterators, key=lambda trans: trans.time.timestamp()) diff --git a/genshin/client/components/wish.py b/genshin/client/components/wish.py new file mode 100644 index 00000000..1ec14187 --- /dev/null +++ b/genshin/client/components/wish.py @@ -0,0 +1,172 @@ +"""Wish component.""" +import asyncio +import functools +import typing +import urllib.parse + +import aiohttp + +from genshin import paginators +from genshin.client import cache as client_cache +from genshin.client import routes +from genshin.client.components import base +from genshin.models.genshin import wish as models +from genshin.utility import genshin as genshin_utility + +__all__ = ["WishClient"] + + +class WishClient(base.BaseClient): + """Wish component.""" + + async def request_gacha_info( + self, + endpoint: str, + *, + lang: typing.Optional[str] = None, + authkey: typing.Optional[str] = None, + params: typing.Optional[typing.Mapping[str, typing.Any]] = None, + **kwargs: typing.Any, + ) -> typing.Mapping[str, typing.Any]: + """Make a request towards the gacha info endpoint.""" + params = dict(params or {}) + authkey = authkey or self.authkey + + if authkey is None: + raise RuntimeError("No authkey provided") + + base_url = routes.GACHA_INFO_URL.get_url(self.region) + url = base_url / endpoint + + params["authkey_ver"] = 1 + params["authkey"] = urllib.parse.unquote(authkey) + params["lang"] = genshin_utility.create_short_lang_code(lang or self.lang) + + return await self.request(url, params=params, **kwargs) + + async def _get_wish_page( + self, + end_id: int, + banner_type: int, + *, + lang: typing.Optional[str] = None, + authkey: typing.Optional[str] = None, + ) -> typing.Sequence[models.Wish]: + """Get a single page of wishes.""" + data = await self.request_gacha_info( + "getGachaLog", + lang=lang, + authkey=authkey, + params=dict(gacha_type=banner_type, size=20, end_id=end_id), + ) + + banner_names = await self.get_banner_names(lang=lang, authkey=authkey) + + return [models.Wish(**i, banner_name=banner_names[banner_type]) for i in data["list"]] + + def wish_history( + self, + banner_type: typing.Optional[typing.Union[int, typing.Sequence[int]]] = None, + *, + limit: typing.Optional[int] = None, + lang: typing.Optional[str] = None, + authkey: typing.Optional[str] = None, + end_id: int = 0, + ) -> paginators.Paginator[models.Wish]: + """Get the wish history of a user.""" + banner_types = banner_type or [100, 200, 301, 302] + + if not isinstance(banner_types, typing.Sequence): + banner_types = [banner_types] + + iterators: typing.List[paginators.Paginator[models.Wish]] = [] + for banner in banner_types: + iterators.append( + paginators.CursorPaginator( + functools.partial( + self._get_wish_page, + banner_type=banner, + lang=lang, + authkey=authkey, + ), + limit=limit, + end_id=end_id, + ) + ) + + if len(iterators) == 1: + return iterators[0] + + return paginators.MergedPaginator(iterators, key=lambda wish: wish.time.timestamp()) + + async def get_banner_names( + self, + *, + lang: typing.Optional[str] = None, + authkey: typing.Optional[str] = None, + ) -> typing.Mapping[int, str]: + """Get a list of banner names.""" + data = await self.request_gacha_info( + "getConfigList", + lang=lang, + authkey=authkey, + static_cache=client_cache.cache_key("banner", endpoint="names", lang=lang or self.lang), + ) + return {int(i["key"]): i["name"] for i in data["gacha_type_list"]} + + async def _get_banner_details( + self, + banner_id: str, + *, + lang: typing.Optional[str] = None, + ) -> models.BannerDetails: + """Get details of a specific banner using its id.""" + lang = lang or self.lang + data = await self.request_webstatic( + f"/hk4e/gacha_info/os_asia/{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) + + async def get_banner_details( + self, + banner_ids: typing.Optional[typing.Sequence[str]] = None, + *, + lang: typing.Optional[str] = None, + ) -> typing.Sequence[models.BannerDetails]: + """Get all banner details at once in a batch.""" + if not banner_ids: + try: + banner_ids = genshin_utility.get_banner_ids() + except FileNotFoundError: + banner_ids = [] + + if len(banner_ids) < 3: + banner_ids = await self.fetch_banner_ids() + + coros = (self._get_banner_details(i, lang=lang) for i in banner_ids) + data = await asyncio.gather(*coros) + return list(data) + + async def get_gacha_items( + self, + *, + server: str = "os_asia", + lang: typing.Optional[str] = None, + ) -> typing.Sequence[models.GachaItem]: + """Get the list of characters and weapons that can be gotten from the gacha.""" + lang = lang or self.lang + data = await self.request_webstatic( + f"/hk4e/gacha_info/{server}/items/{lang}.json", + cache=client_cache.cache_key("banner", endpoint="items", lang=lang), + ) + return [models.GachaItem(**i) for i in data] + + async def fetch_banner_ids(self) -> typing.Sequence[str]: + """Fetch banner ids from a user-mantained github repository.""" + url = "https://raw.githubusercontent.com/thesadru/genshindata/master/banner_ids.txt" + async with aiohttp.ClientSession() as session: + async with session.get(url) as r: + data = await r.text() + + return data.splitlines() diff --git a/genshin/client/manager.py b/genshin/client/manager.py new file mode 100644 index 00000000..e53a5d3b --- /dev/null +++ b/genshin/client/manager.py @@ -0,0 +1,310 @@ +"""Cookie managers for making authenticated requests.""" +from __future__ import annotations + +import abc +import functools +import http.cookies +import logging +import typing + +import aiohttp +import aiohttp.typedefs + +from genshin import errors +from genshin.utility import fs as fs_utility + +from . import ratelimit + +_LOGGER = logging.getLogger(__name__) + +__all__ = ["BaseCookieManager", "CookieManager", "RotatingCookieManager"] + +CookieOrHeader = typing.Union["http.cookies.BaseCookie[typing.Any]", typing.Mapping[typing.Any, typing.Any], str] +AnyCookieOrHeader = typing.Union[CookieOrHeader, typing.Sequence[CookieOrHeader]] +CallableT = typing.TypeVar("CallableT", bound="typing.Callable[..., object]") + + +def parse_cookie(cookie: typing.Optional[CookieOrHeader]) -> typing.Dict[str, str]: + """Parse a cookie or header into a cookie mapping.""" + if cookie is None: + return {} + + if isinstance(cookie, str): + cookie = http.cookies.SimpleCookie(cookie) + + return {str(k): v.value if isinstance(v, http.cookies.Morsel) else str(v) for k, v in cookie.items()} + + +class BaseCookieManager(abc.ABC): + """A cookie manager for making requests.""" + + @classmethod + def from_cookies(cls, cookies: typing.Optional[AnyCookieOrHeader] = None) -> BaseCookieManager: + """Create an arbitrary cookie manager implementation instance.""" + if isinstance(cookies, typing.Sequence) and not isinstance(cookies, str): + return RotatingCookieManager(cookies) + + return CookieManager(cookies) + + @classmethod + def from_browser_cookies(cls, browser: typing.Optional[str] = None) -> CookieManager: + """Create a cookie manager with browser cookies.""" + manager = CookieManager() + manager.set_browser_cookies(browser) + + return manager + + @property + def available(self) -> bool: + """Whether the authentication cookies are available.""" + return True + + @property + def multi(self) -> bool: + """Whether the cookie manager contains multiple cookies and therefore should not cache private data.""" + return False + + @property + def user_id(self) -> typing.Optional[int]: + """The id of the user that owns cookies. + + Returns None if not found or not applicable. + """ + return None + + def get_user_id(self) -> int: + """Get the id of the user that owns cookies. + + Raises an error if not found and fallback is not provided. + """ + if self.user_id: + return self.user_id + + if self.available: + raise ValueError(f"Hoyolab ID must be provided when using {self.__class__}") + + raise ValueError("No cookies have been provided.") + + def create_session(self, **kwargs: typing.Any) -> aiohttp.ClientSession: + """Create a client session.""" + return aiohttp.ClientSession( + cookie_jar=aiohttp.DummyCookieJar(), + **kwargs, + ) + + @ratelimit.handle_ratelimits() + async def _request( + self, + method: str, + str_or_url: aiohttp.typedefs.StrOrURL, + **kwargs: typing.Any, + ) -> typing.Any: + """Make a request towards any json resource.""" + async with self.create_session() as session: + async with session.request(method, str_or_url, **kwargs) as response: + data = await response.json() + + if data["retcode"] == 0: + return data["data"] + + errors.raise_for_retcode(data) + + @abc.abstractmethod + async def request( + self, + url: aiohttp.typedefs.StrOrURL, + *, + method: str = "GET", + params: typing.Optional[typing.Mapping[str, typing.Any]] = None, + data: typing.Any = None, + json: typing.Any = None, + cookies: typing.Optional[aiohttp.typedefs.LooseCookies] = None, + headers: typing.Optional[aiohttp.typedefs.LooseHeaders] = None, + **kwargs: typing.Any, + ) -> typing.Any: + """Make an authenticated request.""" + + +class CookieManager(BaseCookieManager): + """Standard implementation of the cookie manager.""" + + _cookies: typing.Dict[str, str] + + def __init__( + self, + cookies: typing.Optional[CookieOrHeader] = None, + ) -> None: + self.cookies = parse_cookie(cookies) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.cookies})" + + @property + def cookies(self) -> typing.Mapping[str, str]: + """Cookies used for authentication.""" + return self._cookies + + @cookies.setter + def cookies(self, cookies: typing.Optional[CookieOrHeader]) -> None: + if not cookies: + self._cookies = {} + return + + self._cookies = parse_cookie(cookies) + + @property + def available(self) -> bool: + return bool(self._cookies) + + @property + def multi(self) -> bool: + return False + + @property + def jar(self) -> http.cookies.SimpleCookie[str]: + """SimpleCookie containing the cookies.""" + return http.cookies.SimpleCookie(self.cookies) + + @property + def header(self) -> str: + """Header representation of cookies. + + This representation is reparsable by the manager. + """ + return self.jar.output(header="", sep=";").strip() + + def set_cookies( + self, + cookies: typing.Optional[CookieOrHeader] = None, + **kwargs: typing.Any, + ) -> typing.Mapping[str, str]: + """Parse and set cookies.""" + if not bool(cookies) ^ bool(kwargs): + raise TypeError("Cannot use both positional and keyword arguments at once") + + self.cookies = parse_cookie(cookies or kwargs) + return self.cookies + + def set_browser_cookies(self, browser: typing.Optional[str] = None) -> typing.Mapping[str, str]: + """Extract cookies from your browser and set them as client cookies. + + Available browsers: chrome, chromium, opera, edge, firefox. + """ + self.cookies = fs_utility.get_browser_cookies(browser) + return self.cookies + + @property + def user_id(self) -> typing.Optional[int]: + """The id of the user that owns cookies. + + Returns None if cookies are not set. + """ + for name, value in self.cookies.items(): + if name in ("ltuid", "account_id"): + return int(value) + + return None + + async def request( + self, + url: aiohttp.typedefs.StrOrURL, + *, + method: str = "GET", + **kwargs: typing.Any, + ) -> typing.Any: + """Make an authenticated request.""" + if not self.cookies: + raise RuntimeError("Tried to make a request before setting cookies") + + return await self._request(method, url, cookies=self.cookies, **kwargs) + + +class RotatingCookieManager(BaseCookieManager): + """Cookie Manager with rotating cookies.""" + + MAX_USES: typing.ClassVar[int] = 30 + + _cookies: typing.List[typing.Tuple[typing.Dict[str, str], int]] + + def __init__(self, cookies: typing.Optional[typing.Sequence[CookieOrHeader]] = None) -> None: + self.cookies = [parse_cookie(cookie) for cookie in cookies or []] + + @property + def cookies(self) -> typing.Sequence[typing.Mapping[str, str]]: + """Cookies used for authentication""" + self._sort_cookies() + return [cookie for cookie, _ in self._cookies] + + @cookies.setter + def cookies(self, cookies: typing.Optional[typing.Sequence[CookieOrHeader]]) -> None: + if not cookies: + self._cookies = [] + return + + self._cookies = [(parse_cookie(cookie), 0) for cookie in cookies] + self._sort_cookies() + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} len={len(self._cookies)}>" + + @property + def available(self) -> bool: + return bool(self._cookies) + + @property + def multi(self) -> bool: + return True + + def set_cookies( + self, + cookies: typing.Optional[typing.Sequence[CookieOrHeader]] = None, + ) -> typing.Sequence[typing.Mapping[str, str]]: + """Parse and set cookies.""" + self.cookies = [parse_cookie(cookie) for cookie in cookies or []] + return self.cookies + + def _sort_cookies(self) -> None: + """Sort cookies by remaining uses.""" + # TODO: different strategy + self._cookies.sort(key=lambda x: 0 if x[1] >= self.MAX_USES else x[1], reverse=True) + + async def request( + self, + url: aiohttp.typedefs.StrOrURL, + *, + method: str = "GET", + **kwargs: typing.Any, + ) -> typing.Any: + """Make an authenticated request.""" + if not self.cookies: + raise RuntimeError("Tried to make a request before setting cookies") + + self._sort_cookies() + + for index, (cookie, uses) in enumerate(self._cookies.copy()): + try: + data = await self._request(method, url, cookies=cookie, **kwargs) + except errors.TooManyRequests: + _LOGGER.debug("Putting cookie %s on cooldown.", cookie.get("account_id") or cookie.get("ltuid")) + self._cookies[index] = (cookie, self.MAX_USES) + else: + self._cookies[index] = (cookie, 1 if uses >= self.MAX_USES else uses + 1) + return data + + msg = "All cookies have hit their request limit of 30 accounts per day." + raise errors.TooManyRequests({"retcode": 10101}, msg) + + +def no_multi(func: CallableT) -> CallableT: + """Prevent function to be ran with a multi-cookie manager.""" + + @functools.wraps(func) + def wrapper(self: typing.Any, *args: typing.Any, **kwargs: typing.Any) -> typing.Any: + if not hasattr(self, "cookie_manager"): + raise TypeError("Cannot use @no_multi on a plain function.") + if self.cookie_manager.multi: + raise RuntimeError(f"Cannot use {func.__name__} with multi-cookie managers - data is private.") + + return func(self, *args, **kwargs) + + return typing.cast("CallableT", wrapper) diff --git a/genshin/client/ratelimit.py b/genshin/client/ratelimit.py new file mode 100644 index 00000000..c5385400 --- /dev/null +++ b/genshin/client/ratelimit.py @@ -0,0 +1,34 @@ +"""Ratelimit handlers.""" +import asyncio +import functools +import typing + +from genshin import errors + +CallableT = typing.TypeVar("CallableT", bound=typing.Callable[..., typing.Awaitable[typing.Any]]) + + +def handle_ratelimits( + tries: int = 5, + exception: typing.Type[errors.GenshinException] = errors.VisitsTooFrequently, + delay: float = 0.3, +) -> typing.Callable[[CallableT], CallableT]: + """Handle ratelimits for requests.""" + # TODO: Support exponential backoff + + def wrapper(func: typing.Callable[..., typing.Awaitable[typing.Any]]) -> typing.Any: + @functools.wraps(func) + async def inner(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: + for _ in range(tries): + try: + x = await func(*args, **kwargs) + except exception: + await asyncio.sleep(delay) + else: + return x + else: + raise exception({}, f"Got ratelimited {tries} times in a row") + + return inner + + return wrapper diff --git a/genshin/client/routes.py b/genshin/client/routes.py new file mode 100644 index 00000000..5f48f10a --- /dev/null +++ b/genshin/client/routes.py @@ -0,0 +1,136 @@ +"""API routes.""" +import abc +import typing + +import yarl + +from genshin import types + +__all__ = [ + "CALCULATOR_URL", + "DETAIL_LEDGER_URL", + "GACHA_INFO_URL", + "INFO_LEDGER_URL", + "MI18N", + "RECORD_URL", + "REWARD_URL", + "Route", + "TAKUMI_URL", + "WEBSTATIC_URL", + "YSULOG_URL", +] + + +class BaseRoute(abc.ABC): + """A route which provides useful metadata.""" + + +class Route(BaseRoute): + """Standard route.""" + + url: yarl.URL + + def __init__(self, url: str) -> None: + self.url = yarl.URL(url) + + def get_url(self) -> yarl.URL: + """Attempt to get a URL.""" + return self.url + + +class InternationalRoute(BaseRoute): + """Standard international route.""" + + urls: typing.Mapping[types.Region, yarl.URL] + + def __init__(self, overseas: str, chinese: str) -> None: + self.urls = { + types.Region.OVERSEAS: yarl.URL(overseas), + types.Region.CHINESE: yarl.URL(chinese), + } + + def get_url(self, region: types.Region) -> yarl.URL: + """Attempt to get a URL.""" + if not self.urls[region]: + raise RuntimeError(f"URL does not support {region.name} region.") + + return self.urls[region] + + +class GameRoute(BaseRoute): + """Standard international game URL.""" + + urls: typing.Mapping[types.Region, typing.Mapping[types.Game, yarl.URL]] + + def __init__( + self, + overseas: typing.Mapping[str, str], + chinese: typing.Mapping[str, str], + ) -> None: + self.urls = { + types.Region.OVERSEAS: {types.Game(game): yarl.URL(url) for game, url in overseas.items()}, + types.Region.CHINESE: {types.Game(game): yarl.URL(url) for game, url in chinese.items()}, + } + + def get_url(self, region: types.Region, game: types.Game) -> yarl.URL: + """Attempt to get a URL.""" + if not self.urls[region]: + raise RuntimeError(f"URL does not support {region.name} region.") + + if not self.urls[region][game]: + raise RuntimeError(f"URL does not support {game.name} game for {region.name} region.") + + return self.urls[region][game] + + +WEBSTATIC_URL = Route("https://webstatic-sea.hoyoverse.com/") + +TAKUMI_URL = InternationalRoute( + overseas="https://api-os-takumi.mihoyo.com/", + chinese="https://api-takumi.mihoyo.com/", +) +RECORD_URL = InternationalRoute( + overseas="https://bbs-api-os.hoyolab.com/game_record/", + chinese="https://api-takumi-record.mihoyo.com/game_record/app/", +) + +INFO_LEDGER_URL = InternationalRoute( + overseas="https://hk4e-api-os.hoyoverse.com/event/ysledgeros/month_info", + chinese="https://hk4e-api.mihoyo.com/event/ys_ledger/monthInfo", +) +DETAIL_LEDGER_URL = InternationalRoute( + overseas="https://hk4e-api-os.hoyoverse.com/event/ysledgeros/month_detail", + chinese="https://hk4e-api.mihoyo.com/event/ys_ledger/monthDetail", +) + +CALCULATOR_URL = InternationalRoute( + overseas="https://sg-public-api.hoyoverse.com/event/calculateos/", + chinese="", +) + +REWARD_URL = GameRoute( + overseas=dict( + genshin="https://sg-hk4e-api.hoyolab.com/event/sol?act_id=e202102251931481", + honkai3rd="https://sg-public-api.hoyolab.com/event/mani?act_id=e202110291205111", + ), + chinese=dict( + genshin="https://api-takumi.mihoyo.com/event/bbs_sign_reward/?act_id=e202009291139501", + honkai3rd="", + ), +) + +CODE_URL = Route("https://hk4e-api-os.mihoyo.com/common/apicdkey/api/webExchangeCdkey") + +GACHA_INFO_URL = InternationalRoute( + overseas="https://hk4e-api-os.hoyoverse.com/event/gacha_info/api/", + chinese="https://hk4e-api.mihoyo.com/event/gacha_info/api", +) +YSULOG_URL = InternationalRoute( + overseas="https://hk4e-api-os.hoyoverse.com/ysulog/api/", + chinese="", +) + +MI18N = dict( + bbs="https://webstatic-sea.mihoyo.com/admin/mi18n/bbs_cn/m11241040191111/m11241040191111-{lang}.json", + inquiry="https://mi18n-os.hoyoverse.com/webstatic/admin/mi18n/hk4e_global/m02251421001311/m02251421001311-{lang}.json", +) diff --git a/genshin/constants.py b/genshin/constants.py new file mode 100644 index 00000000..cd2e15ec --- /dev/null +++ b/genshin/constants.py @@ -0,0 +1,29 @@ +"""Constants hardcoded for optimizations.""" +from . import types + +__all__ = ["LANGS"] + + +LANGS = { + "zh-cn": "简体中文", + "zh-tw": "繁體中文", + "de-de": "Deutsch", + "en-us": "English", + "es-es": "Español", + "fr-fr": "Français", + "id-id": "Indonesia", + "ja-jp": "日本語", + "ko-kr": "한국어", + "pt-pt": "Português", + "ru-ru": "Pусский", + "th-th": "ภาษาไทย", + "vi-vn": "Tiếng Việt", +} +"""Languages supported by the API.""" + +DS_SALT = { + types.Region.OVERSEAS: "6cqshh5dhw73bzxn20oexa9k516chk7s", + types.Region.CHINESE: "xV8v4Qu54lUKrEYFZkJhB8cuOh9Asafs", + "cn_signin": "4a8knnbk5pbjqsrudp3dq484m9axoc5g", +} +"""Dynamic Secret Salts.""" diff --git a/genshin/errors.py b/genshin/errors.py new file mode 100644 index 00000000..1cdcd8bc --- /dev/null +++ b/genshin/errors.py @@ -0,0 +1,223 @@ +"""Errors received from the API.""" +import typing + +__all__ = [ + "AccountNotFound", + "AlreadyClaimed", + "AuthkeyException", + "AuthkeyTimeout", + "CookieException", + "DataNotPublic", + "ERRORS", + "GenshinException", + "InvalidAuthkey", + "InvalidCookies", + "RedemptionClaimed", + "RedemptionCooldown", + "RedemptionException", + "RedemptionInvalid", + "TooManyRequests", + "raise_for_retcode", +] + + +class GenshinException(Exception): + """A base genshin exception.""" + + retcode: int = 0 + original: str = "" + msg: str = "" + + 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 + + if self.retcode: + msg = f"[{self.retcode}] {self.msg}" + else: + msg = self.msg + + super().__init__(msg) + + def __repr__(self) -> str: + response = {"retcode": self.retcode, "message": self.original} + args = [repr(response)] + if self.msg != self.original: + args.append(repr(self.msg)) + + return f"{type(self).__name__}({', '.join(args)})" + + @property + def response(self) -> typing.Mapping[str, typing.Any]: + return {"retcode": self.retcode, "message": self.original, "data": None} + + +class InternalDatabaseError(GenshinException): + """Internal database error.""" + + retcode = -1 + + +class AccountNotFound(GenshinException): + """Tried to get data with an invalid uid.""" + + msg = "Could not find user; uid may be invalid." + + +class DataNotPublic(GenshinException): + """User hasn't set their data to public.""" + + msg = "User's data is not public." + + +class CookieException(GenshinException): + """Base error for cookies.""" + + +class InvalidCookies(CookieException): + """Cookies weren't valid.""" + + retcode = -100 + msg = "Cookies are not valid." + + +class TooManyRequests(CookieException): + """Made too many requests and got ratelimited.""" + + retcode = 10101 + msg = "Cannot get data for more than 30 accounts per cookie per day." + + +class VisitsTooFrequently(GenshinException): + """Visited a page too frequently. + + Must be handled with exponential backoff. + """ + + retcode = -110 + msg = "Visits too frequently." + + +class AlreadyClaimed(GenshinException): + """Already claimed the daily reward today.""" + + retcode = -5003 + msg = "Already claimed the daily reward today." + + +class AuthkeyException(GenshinException): + """Base error for authkeys.""" + + +class InvalidAuthkey(AuthkeyException): + """Authkey is not valid.""" + + retcode = -100 + msg = "Authkey is not valid." + + +class AuthkeyTimeout(AuthkeyException): + """Authkey has timed out.""" + + retcode = -101 + msg = "Authkey has timed out." + + +class RedemptionException(GenshinException): + """Exception caused by redeeming a code.""" + + +class RedemptionInvalid(RedemptionException): + """Invalid redemption code.""" + + msg = "Invalid redemption code." + + +class RedemptionCooldown(RedemptionException): + """Redemption is on cooldown.""" + + msg = "Redemption is on cooldown." + + +class RedemptionClaimed(RedemptionException): + """Redemption code has been claimed already.""" + + msg = "Redemption code has been claimed already." + + +_TGE = typing.Type[GenshinException] +_errors: typing.Dict[int, typing.Union[_TGE, str, typing.Tuple[_TGE, typing.Optional[str]]]] = { + # misc hoyolab + -100: InvalidCookies, + -108: "Invalid language.", + -110: VisitsTooFrequently, + # game record + 10001: InvalidCookies, + -10001: "Malformed request.", + -10002: "No genshin account associated with cookies.", + # database game record + 10101: TooManyRequests, + 10102: DataNotPublic, + 10103: (InvalidCookies, "Cookies are valid but do not have a hoyolab account bound to them."), + 10104: "Tried to use a beta feature in an invalid context.", + # calculator + -500001: "Invalid fields in calculation.", + -500004: VisitsTooFrequently, + -502001: "User does not have this character.", + # mixin + -1: InternalDatabaseError, + 1009: AccountNotFound, + # redemption + -1071: InvalidCookies, + -1073: (AccountNotFound, "Account has no game account bound to it."), + -2001: (RedemptionInvalid, "Redemption code has expired."), + -2004: RedemptionInvalid, + -2016: RedemptionCooldown, + -2017: RedemptionClaimed, + -2018: RedemptionClaimed, + -2021: (RedemptionException, "Cannot claim codes for accounts with adventure rank lower than 10."), + # rewards + -5003: AlreadyClaimed, + # chinese + 1008: AccountNotFound, + -1104: "This action must be done in the app.", +} + +ERRORS: typing.Dict[int, typing.Tuple[_TGE, typing.Optional[str]]] = { + retcode: ((exc, None) if isinstance(exc, type) else (GenshinException, exc) if isinstance(exc, str) else exc) + for retcode, exc in _errors.items() +} + + +def raise_for_retcode(data: typing.Dict[str, typing.Any]) -> typing.NoReturn: + """Raise an equivalent error to a response. + + game record: + 10001 = invalid cookie + 101xx = generic errors + authkey: + -100 = invalid authkey + -101 = authkey timed out + code redemption: + 20xx = invalid code or state + -107x = invalid cookies + daily reward: + -500x = already claimed the daily reward + """ + r, m = data.get("retcode", 0), data.get("message", "") + + if m.startswith("authkey"): + if r == -100: + raise InvalidAuthkey(data) + elif r == -101: + raise AuthkeyTimeout(data) + else: + raise AuthkeyException(data) + + elif r in ERRORS: + exctype, msg = ERRORS[r] + raise exctype(data, msg) + + else: + raise GenshinException(data) diff --git a/genshin/models/__init__.py b/genshin/models/__init__.py new file mode 100644 index 00000000..8f4855f4 --- /dev/null +++ b/genshin/models/__init__.py @@ -0,0 +1,5 @@ +"""API models.""" +from .genshin import * +from .honkai import * +from .hoyolab import * +from .model import * diff --git a/genshin/models/genshin/__init__.py b/genshin/models/genshin/__init__.py new file mode 100644 index 00000000..a5b4312b --- /dev/null +++ b/genshin/models/genshin/__init__.py @@ -0,0 +1,8 @@ +"""Genshin models.""" +from .calculator import * +from .character import * +from .chronicle import * +from .constants import * +from .daily import * +from .transaction import * +from .wish import * diff --git a/genshin/models/genshin/calculator.py b/genshin/models/genshin/calculator.py new file mode 100644 index 00000000..72779744 --- /dev/null +++ b/genshin/models/genshin/calculator.py @@ -0,0 +1,218 @@ +"""Genshin calculator models.""" +from __future__ import annotations + +import collections +import typing + +import pydantic + +from genshin.models.model import Aliased, APIModel, Unique + +from . import character + +__all__ = [ + "CALCULATOR_ARTIFACTS", + "CALCULATOR_ELEMENTS", + "CALCULATOR_WEAPON_TYPES", + "CalculatorArtifact", + "CalculatorArtifactResult", + "CalculatorCharacter", + "CalculatorCharacterDetails", + "CalculatorConsumable", + "CalculatorResult", + "CalculatorTalent", + "CalculatorWeapon", +] + +CALCULATOR_ELEMENTS: typing.Mapping[int, str] = { + 1: "Pyro", + 2: "Anemo", + 3: "Geo", + 4: "Dendro", + 5: "Electro", + 6: "Hydro", + 7: "Cryo", +} +CALCULATOR_WEAPON_TYPES: typing.Mapping[int, str] = { + 1: "Sword", + 10: "Catalyst", + 11: "Claymore", + 12: "Bow", + 13: "Polearm", +} +CALCULATOR_ARTIFACTS: typing.Mapping[int, str] = { + 1: "Flower of Life", + 2: "Plume of Death", + 3: "Sands of Eon", + 4: "Goblet of Eonothem", + 5: "Circlet of Logos", +} + + +class CalculatorCharacter(character.BaseCharacter): + """Character meant to be used with calculators.""" + + rarity: int = Aliased("avatar_level") + element: str = Aliased("element_attr_id") + weapon_type: str = Aliased("weapon_cat_id") + level: int = Aliased("level_current", default=0) + max_level: int + + @pydantic.validator("element", pre=True) + def __parse_element(cls, v: typing.Any) -> str: + if isinstance(v, str): + return v + + return CALCULATOR_ELEMENTS[int(v)] + + @pydantic.validator("weapon_type", pre=True) + def __parse_weapon_type(cls, v: typing.Any) -> str: + if isinstance(v, str): + return v + + return CALCULATOR_WEAPON_TYPES[int(v)] + + +class CalculatorWeapon(APIModel, Unique): + """Weapon meant to be used with calculators.""" + + id: int + name: str + icon: str + rarity: int = Aliased("weapon_level") + type: str = Aliased("weapon_cat_id") + level: int = Aliased("level_current", default=0) + max_level: int + + @pydantic.validator("type", pre=True) + def __parse_weapon_type(cls, v: typing.Any) -> str: + if isinstance(v, str): + return v + + return CALCULATOR_WEAPON_TYPES[int(v)] + + +class CalculatorArtifact(APIModel, Unique): + """Artifact meant to be used with calculators.""" + + id: int + name: str + icon: str + rarity: int = Aliased("reliquary_level") + pos: int = Aliased("reliquary_cat_id") + level: int = Aliased("level_current", default=0) + max_level: int + + @property + def pos_name(self) -> str: + return CALCULATOR_ARTIFACTS[self.pos] + + +class CalculatorTalent(APIModel, Unique): + """Talent of a character meant to be used with calculators.""" + + id: int + group_id: int + name: str + icon: str + level: int = Aliased("level_current", default=0) + max_level: int + + @property + def type(self) -> typing.Literal["attack", "skill", "burst", "passive", "dash"]: + """The type of the talent, parsed from the group id""" + # It's Possible to parse this from the id too but group id feels more reliable + + # 4139 -> group=41 identifier=3 order=9 + group, relevant = divmod(self.group_id, 100) + identifier, order = divmod(relevant, 10) + + if identifier == 2: + return "passive" + elif order == 1: + return "attack" + elif order == 2: + return "skill" + elif order == 9: + return "burst" + elif order == 3: + return "dash" + else: + raise ValueError(f"Cannot parse type for talent {self.group_id!r} (group {group})") + + @property + def upgradeable(self) -> bool: + """Whether this talent can be leveled up.""" + return self.type not in ("passive", "dash") + + def __int__(self) -> int: + return self.group_id + + +class CalculatorConsumable(APIModel, Unique): + """Item consumed when upgrading.""" + + id: int + name: str + icon: str + amount: int = Aliased("num") + + +class CalculatorCharacterDetails(APIModel): + """Details of a synced calculator character.""" + + weapon: CalculatorWeapon = Aliased("weapon") + talents: typing.Sequence[CalculatorTalent] = Aliased("skill_list") + artifacts: typing.Sequence[CalculatorArtifact] = Aliased("reliquary_list") + + @pydantic.validator("talents") + def __correct_talent_current_level(cls, v: typing.Sequence[CalculatorTalent]) -> typing.Sequence[CalculatorTalent]: + # passive talent have current levels at 0 for some reason + talents: typing.List[CalculatorTalent] = [] + + for talent in v: + if talent.max_level == 1 and talent.level == 0: + raw = talent.dict() + raw["level"] = 1 + talent = CalculatorTalent(**raw) + + talents.append(talent) + + return v + + +class CalculatorArtifactResult(APIModel): + """Calculation result for a specific artifact.""" + + artifact_id: int = Aliased("reliquary_id") + list: typing.Sequence[CalculatorConsumable] = Aliased("id_consume_list") + + +class CalculatorResult(APIModel): + """Calculation result.""" + + character: typing.List[CalculatorConsumable] = Aliased("avatar_consume") + weapon: typing.List[CalculatorConsumable] = Aliased("weapon_consume") + talents: typing.List[CalculatorConsumable] = Aliased("avatar_skill_consume") + artifacts: typing.List[CalculatorArtifactResult] = Aliased("reliquary_consume") + + @property + def total(self) -> typing.Sequence[CalculatorConsumable]: + artifacts = [i for a in self.artifacts for i in a.list] + combined = self.character + self.weapon + self.talents + artifacts + + grouped: typing.Dict[int, typing.List[CalculatorConsumable]] = collections.defaultdict(list) + for i in combined: + grouped[i.id].append(i) + + total = [ + CalculatorConsumable( + id=x[0].id, + name=x[0].name, + icon=x[0].icon, + amount=sum(i.amount for i in x), + ) + for x in grouped.values() + ] + + return total diff --git a/genshin/models/genshin/character.py b/genshin/models/genshin/character.py new file mode 100644 index 00000000..c7ad46e4 --- /dev/null +++ b/genshin/models/genshin/character.py @@ -0,0 +1,132 @@ +"""Genshin character model.""" + +import re +import typing + +import pydantic + +from genshin.models.model import APIModel, Unique + +from .constants import CHARACTER_NAMES, DBChar + +__all__ = ["BaseCharacter"] + +ICON_BASE = "https://upload-os-bbs.mihoyo.com/game_record/genshin/" + + +def _parse_icon(icon: typing.Union[str, int]) -> str: + if isinstance(icon, int): + char = CHARACTER_NAMES.get(icon) + if char is None: + raise ValueError(f"Invalid character id {icon}") + + return char.icon_name + + match = re.search(r"UI_AvatarIcon(?:_Side)?_(.*).png", icon) + if match: + return match[1] + + return icon + + +def _create_icon(icon: str, specifier: str, scale: int = 0) -> str: + icon_name = _parse_icon(icon) + return ICON_BASE + f"{specifier}_{icon_name}{f'@{scale}x' if scale else ''}.png" + + +def _get_db_char( + id: typing.Optional[int] = None, + name: typing.Optional[str] = None, + icon: typing.Optional[str] = None, + element: typing.Optional[str] = None, + rarity: typing.Optional[int] = None, +) -> DBChar: + """Get the appropriate DBChar object from specific fields.""" + if id and id in CHARACTER_NAMES: + return CHARACTER_NAMES[id] + + if icon and "genshin" in icon: + icon_name = _parse_icon(icon) + + for char in CHARACTER_NAMES.values(): + if char.icon_name == icon_name: + return char + + # might as well just update the CHARACTER_NAMES if we have all required data + if id and name and icon and element and rarity: + char = DBChar(id, icon_name, name, element, rarity, guessed=True) + CHARACTER_NAMES[char.id] = char + return char + + return DBChar(id or 0, icon_name, name or icon_name, element or "Anemo", rarity or 5, guessed=True) + + if name: + for char in CHARACTER_NAMES.values(): + if char.name == name: + return char + + return DBChar(id or 0, icon or name, name, element or "Anemo", rarity or 5, guessed=True) + + raise ValueError("Character data incomplete") + + +class BaseCharacter(APIModel, Unique): + """Base character model.""" + + id: int + name: str + element: str + rarity: int + icon: str + + collab: bool = False + + @pydantic.root_validator(pre=True) + def __autocomplete(cls, values: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + """Complete missing data.""" + id, name, icon, element, rarity = (values.get(x) for x in ("id", "name", "icon", "element", "rarity")) + + char = _get_db_char(id, name, icon, element, rarity) + icon = _create_icon(char.icon_name, "character_icon/UI_AvatarIcon") + + values["id"] = char.id + values["name"] = char.name + values["element"] = char.element + values["rarity"] = char.rarity + + if values.get("icon"): + # there is an icon + if "genshin" not in values["icon"] and char.id != 0: + # corrupted icon that completion should be able to fix + values["icon"] = icon + else: + # there wasn't an icon so no need for special handling + values["icon"] = icon + + # collab characters are stored as 105 to show a red background + if values["rarity"] > 100: + values["rarity"] -= 100 + values["collab"] = True + + elif values["id"] == 10000062: + # sometimes Aloy has 5* if no background is needed + values["collab"] = True + + return values + + @property + def image(self) -> str: + return _create_icon(self.icon, "character_image/UI_AvatarIcon", scale=2) + + @property + def side_icon(self) -> str: + return _create_icon(self.icon, "character_side_icon/UI_AvatarIcon_Side") + + @property + def traveler_name(self) -> str: + if self.id == 10000005: + return "Aether" + elif self.id == 10000007: + return "Lumine" + else: + return "" diff --git a/genshin/models/genshin/chronicle/__init__.py b/genshin/models/genshin/chronicle/__init__.py new file mode 100644 index 00000000..9cd32f9c --- /dev/null +++ b/genshin/models/genshin/chronicle/__init__.py @@ -0,0 +1,6 @@ +"""Battle chronicle models.""" +from .abyss import * +from .activities import * +from .characters import * +from .notes import * +from .stats import * diff --git a/genshin/models/genshin/chronicle/abyss.py b/genshin/models/genshin/chronicle/abyss.py new file mode 100644 index 00000000..1addde90 --- /dev/null +++ b/genshin/models/genshin/chronicle/abyss.py @@ -0,0 +1,114 @@ +import datetime +import typing + +import pydantic + +from genshin.models.genshin import character +from genshin.models.model import Aliased, APIModel + +__all__ = [ + "AbyssCharacter", + "AbyssRankCharacter", + "Battle", + "Chamber", + "CharacterRanks", + "Floor", + "SpiralAbyss", + "SpiralAbyssPair", +] + + +class AbyssRankCharacter(character.BaseCharacter): + """Character with a value of a rank.""" + + id: int = Aliased("avatar_id") + icon: str = Aliased("avatar_icon") + + value: int + + +class AbyssCharacter(character.BaseCharacter): + """Character with just a level.""" + + level: int + + +# flake8: noqa: E222 +class CharacterRanks(APIModel): + """Collection of rankings achieved during spiral abyss runs.""" + + # fmt: off + 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") + # fmt: on + + def as_dict(self, lang: str = "en-us") -> typing.Mapping[str, typing.Any]: + """Helper function which turns fields into properly named ones""" + return {self._get_mi18n(field, lang): getattr(self, field.name) for field in self.__fields__.values()} + + +class Battle(APIModel): + """Battle in the spiral abyss.""" + + half: int = Aliased("index") + timestamp: datetime.date + characters: typing.Sequence[AbyssCharacter] = Aliased("avatars") + + +class Chamber(APIModel): + """Chamber of the spiral abyss.""" + + chamber: int = Aliased("index") + stars: int = Aliased("star") + max_stars: typing.Literal[3] = Aliased("max_star") + battles: typing.Sequence[Battle] + + +class Floor(APIModel): + """Floor of the spiral abyss.""" + + floor: int = Aliased("index") + # icon: str - unused + # settle_time: int - appsample might be using this? + unlocked: typing.Literal[True] = Aliased("is_unlock") + stars: int = Aliased("star") + max_stars: typing.Literal[9] = Aliased("max_star") # maybe one day + chambers: typing.Sequence[Chamber] = Aliased("levels") + + +class SpiralAbyss(APIModel): + """Information about Spiral Abyss runs during a specific season.""" + + unlocked: bool = Aliased("is_unlock") + season: int = Aliased("schedule_id") + start_time: datetime.datetime + end_time: datetime.datetime + + total_battles: int = Aliased("total_battle_times") + total_wins: str = Aliased("total_win_times") + max_floor: str + total_stars: int = Aliased("total_star") + + ranks: CharacterRanks + + floors: typing.Sequence[Floor] + + @pydantic.root_validator(pre=True) + def __nest_ranks(cls, values: typing.Dict[str, typing.Any]) -> typing.Dict[str, AbyssCharacter]: + """By default ranks are for some reason on the same level as the rest of the abyss.""" + values.setdefault("ranks", {}).update(values) + return values + + +class SpiralAbyssPair(APIModel): + """Pair of both current and previous spiral abyss. + + This may not be a namedtuple due to how pydantic handles them. + """ + + current: SpiralAbyss + previous: SpiralAbyss diff --git a/genshin/models/genshin/chronicle/activities.py b/genshin/models/genshin/chronicle/activities.py new file mode 100644 index 00000000..1ab7241b --- /dev/null +++ b/genshin/models/genshin/chronicle/activities.py @@ -0,0 +1,164 @@ +"""Chronicle activities models.""" +import datetime +import re +import typing + +import pydantic + +from genshin.models.genshin import character +from genshin.models.model import Aliased, APIModel + +__all__ = [ + "Activities", + "ChineseActivity", + "HyakuninIkki", + "HyakuninIkkiBattle", + "HyakuninIkkiChallenge", + "HyakuninIkkiCharacter", + "HyakuninIkkiSkill", + "LabyrinthWarriors", + "LabyrinthWarriorsChallenge", + "LabyrinthWarriorsCharacter", + "LabyrinthWarriorsRune", +] + +# --------------------------------------------------------- +# Hyakunin Ikki: + + +class HyakuninIkkiCharacter(character.BaseCharacter): + """Possibly trial Hyakunin Ikki character.""" + + level: int + trial: bool = Aliased("is_trail_avatar") + + +class HyakuninIkkiSkill(APIModel): + """Hyakunin Ikki skill.""" + + id: int + name: str + icon: str + description: str = Aliased("desc") + + +class HyakuninIkkiBattle(APIModel): + """Hyakunin Ikki battle.""" + + characters: typing.Sequence[HyakuninIkkiCharacter] = Aliased("avatars") + skills: typing.Sequence[HyakuninIkkiSkill] = Aliased("skills") + + +class HyakuninIkkiChallenge(APIModel): + """Hyakunin Ikki challenge.""" + + id: int = Aliased("challenge_id") + name: str = Aliased("challenge_name") + difficulty: int + multiplier: int = Aliased("score_multiple") + score: int = Aliased("max_score") + medal_icon: str = Aliased("heraldry_icon") + + battles: typing.Sequence[HyakuninIkkiBattle] = Aliased("lineups") + + @property + def medal(self) -> str: + match = re.search(r"heraldry_(\w+)\.png", self.medal_icon) + return match.group(1) if match else "" + + +class HyakuninIkki(APIModel): + """Hyakunin Ikki event.""" + + challenges: typing.Sequence[HyakuninIkkiChallenge] = Aliased("records") + + +# --------------------------------------------------------- +# Labyrinth Warriors: + + +class LabyrinthWarriorsCharacter(character.BaseCharacter): + """Labyrinth Warriors character.""" + + level: int + + +class LabyrinthWarriorsRune(APIModel): + """Labyrinth Warriors rune.""" + + id: int + icon: str + name: str + description: str = Aliased("desc") + element: str + + +class LabyrinthWarriorsChallenge(APIModel): + """Labyrinth Warriors challenge.""" + + id: int = Aliased("challenge_id") + name: str = Aliased("challenge_name") + passed: bool = Aliased("is_passed") + level: int = Aliased("settled_level") + + main_characters: typing.Sequence[LabyrinthWarriorsCharacter] = Aliased("main_avatars") + support_characters: typing.Sequence[LabyrinthWarriorsCharacter] = Aliased("support_avatars") + runes: typing.Sequence[LabyrinthWarriorsRune] + + +class LabyrinthWarriors(APIModel): + """Labyrinth Warriors event.""" + + challenges: typing.Sequence[LabyrinthWarriorsChallenge] = Aliased("records") + + +# --------------------------------------------------------- +# Chinese activities: + + +class ChineseActivity(APIModel): + """Srbitrary activity for chinese events.""" + + start_time: datetime.datetime + end_time: datetime.datetime + total_score: int + total_times: int + records: typing.Sequence[typing.Any] + + +# --------------------------------------------------------- +# Activities: + + +class Activities(APIModel): + """Collection of genshin activities.""" + + hyakunin_ikki: typing.Optional[HyakuninIkki] = pydantic.Field(None, gslug="sumo") + labyrinth_warriors: typing.Optional[LabyrinthWarriors] = pydantic.Field(None, gslug="rogue") + + effigy: typing.Optional[ChineseActivity] = None + mechanicus: typing.Optional[ChineseActivity] = None + challenger_slab: typing.Optional[ChineseActivity] = None + martial_legend: typing.Optional[ChineseActivity] = None + chess: typing.Optional[ChineseActivity] = None + + @pydantic.root_validator(pre=True) + def __flatten_activities(cls, values: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + if not values.get("activities"): + return values + + slugs = { + field.field_info.extra["gslug"]: name + for name, field in cls.__fields__.items() + if field.field_info.extra.get("gslug") + } + + for activity in values["activities"]: + for name, value in activity.items(): + if "exists_data" not in value: + continue + + name = slugs.get(name, name) + values[name] = value if value["exists_data"] else None + + return values diff --git a/genshin/models/genshin/chronicle/characters.py b/genshin/models/genshin/chronicle/characters.py new file mode 100644 index 00000000..f993570c --- /dev/null +++ b/genshin/models/genshin/chronicle/characters.py @@ -0,0 +1,120 @@ +"""Genshin chronicle character.""" + +import typing + +import pydantic + +from genshin.models.genshin import character +from genshin.models.model import Aliased, APIModel, Unique + +__all__ = [ + "Artifact", + "ArtifactSet", + "ArtifactSetEffect", + "Character", + "CharacterWeapon", + "Constellation", + "Outfit", + "PartialCharacter", +] + + +class PartialCharacter(character.BaseCharacter): + """Character without any equipment.""" + + level: int + friendship: int = Aliased("fetter") + constellation: int = Aliased("actived_constellation_num") + + +class CharacterWeapon(APIModel, Unique): + """Character's equipped weapon.""" + + id: int + icon: str + name: str + rarity: int + description: str = Aliased("desc") + level: int + type: str = Aliased("type_name") + ascension: int = Aliased("promote_level") + refinement: int = Aliased("affix_level") + + +class ArtifactSetEffect(APIModel): + """Effect of an artifact set.""" + + pieces: int = Aliased("activation_number") + effect: str + enabled: bool = False + + class Config: + # this is for the "enabled" field, hopefully nobody abuses this + allow_mutation = True + + +class ArtifactSet(APIModel, Unique): + """Artifact set.""" + + id: int + name: str + effects: typing.Sequence[ArtifactSetEffect] = Aliased("affixes") + + +class Artifact(APIModel, Unique): + """Character's equipped artifact.""" + + id: int + icon: str + name: str + pos_name: str + pos: int + rarity: int + level: int + set: ArtifactSet + + +class Constellation(APIModel, Unique): + """Character constellation.""" + + id: int + icon: str + pos: int + name: str + effect: str + activated: bool = Aliased("is_actived") + + @property + def scaling(self) -> bool: + """Whether the constellation is simply for talent scaling""" + return "U" in self.icon + + +class Outfit(APIModel, Unique): + """Outfit of a character.""" + + id: int + icon: str + name: str + + +class Character(PartialCharacter): + """Character with equipment.""" + + weapon: CharacterWeapon + artifacts: typing.Sequence[Artifact] = Aliased("reliquaries") + constellations: typing.Sequence[Constellation] + outfits: typing.Sequence[Outfit] = Aliased("costumes") + + @pydantic.validator("artifacts") + def __add_artifact_effect_enabled(cls, artifacts: typing.Sequence[Artifact]) -> typing.Sequence[Artifact]: + sets: typing.Dict[int, typing.List[Artifact]] = {} + for arti in artifacts: + sets.setdefault(arti.set.id, []).append(arti) + + for artifact in artifacts: + for effect in artifact.set.effects: + if effect.pieces <= len(sets[artifact.set.id]): + effect.enabled = True + + return artifacts diff --git a/genshin/models/genshin/chronicle/notes.py b/genshin/models/genshin/chronicle/notes.py new file mode 100644 index 00000000..a877d718 --- /dev/null +++ b/genshin/models/genshin/chronicle/notes.py @@ -0,0 +1,90 @@ +"""Genshin chronicle notes.""" +import datetime +import typing + +import pydantic + +from genshin.models.genshin import character +from genshin.models.model import Aliased, APIModel + +__all__ = ["Expedition", "ExpeditionCharacter", "Notes"] + + +def _process_timedelta(time: typing.Union[int, datetime.datetime]) -> datetime.datetime: + if isinstance(time, int): + time = datetime.datetime.fromtimestamp(time).astimezone() + + if time < datetime.datetime(2000, 1, 1).astimezone(): + delta = datetime.timedelta(seconds=int(time.timestamp())) + time = datetime.datetime.now().astimezone() + delta + + return time + + +class ExpeditionCharacter(character.BaseCharacter): + """Expedition character.""" + + +class Expedition(APIModel): + """Real-Time note expedition.""" + + character: ExpeditionCharacter = Aliased("avatar_side_icon") + status: typing.Literal["Ongoing", "Finished"] + completion_time: datetime.datetime = Aliased("remained_time") + + @property + def finished(self) -> bool: + """Whether the expedition has finished.""" + return self.remaining_time == 0 + + @property + def remaining_time(self) -> float: + """The remaining time until expedition completion in seconds.""" + remaining = self.completion_time - datetime.datetime.now().astimezone() + return max(remaining.total_seconds(), 0) + + __fix_time = pydantic.validator("completion_time", allow_reuse=True)(_process_timedelta) + + @pydantic.validator("character", pre=True) + def __complete_character(cls, v: typing.Any) -> ExpeditionCharacter: + if isinstance(v, str): + return ExpeditionCharacter(icon=v) # type: ignore + + return v + + +class Notes(APIModel): + """Real-Time notes.""" + + current_resin: int + max_resin: int + resin_recovery_time: datetime.datetime = Aliased("resin_recovery_time") + + current_realm_currency: int = Aliased("current_home_coin") + max_realm_currency: int = Aliased("max_home_coin") + realm_currency_recovery_time: datetime.datetime = Aliased("home_coin_recovery_time") + + completed_commissions: int = Aliased("finished_task_num") + max_commissions: int = Aliased("total_task_num") + claimed_commission_reward: bool = Aliased("is_extra_task_reward_received") + + remaining_resin_discounts: int = Aliased("remain_resin_discount_num") + max_resin_discounts: int = Aliased("resin_discount_num_limit") + + expeditions: typing.Sequence[Expedition] + max_expeditions: int = Aliased("max_expedition_num") + + @property + def remaining_resin_recovery_time(self) -> float: + """The remaining time until resin recovery in seconds.""" + remaining = self.resin_recovery_time - datetime.datetime.now().astimezone() + return max(remaining.total_seconds(), 0) + + @property + def remaining_realm_currency_recovery_time(self) -> float: + """The remaining time until realm currency recovery in seconds.""" + remaining = self.realm_currency_recovery_time - datetime.datetime.now().astimezone() + return max(remaining.total_seconds(), 0) + + __fix_resin_time = pydantic.validator("resin_recovery_time", allow_reuse=True)(_process_timedelta) + __fix_realm_time = pydantic.validator("realm_currency_recovery_time", allow_reuse=True)(_process_timedelta) diff --git a/genshin/models/genshin/chronicle/stats.py b/genshin/models/genshin/chronicle/stats.py new file mode 100644 index 00000000..7815559b --- /dev/null +++ b/genshin/models/genshin/chronicle/stats.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import re +import typing + +from pydantic import validator + +from genshin.models.model import Aliased, APIModel + +from . import abyss, activities, characters + +__all__ = [ + "Exploration", + "GenshinFullUserStats", + "GenshinPartialUserStats", + "GenshinUserStats", + "Offering", + "Stats", + "Teapot", + "TeapotRealm", +] + +# flake8: noqa: E222 +class Stats(APIModel): + """Overall user stats.""" + + # This is such fucking bullshit, just why? + # fmt: off + achievements: int = Aliased("achievement_number", mi18n="bbs/achievement_complete_count") + days_active: int = Aliased("active_day_number", mi18n="bbs/active_day") + characters: int = Aliased("avatar_number", mi18n="bbs/other_people_character") + spiral_abyss: str = Aliased("spiral_abyss", mi18n="bbs/unlock_portal") + anemoculi: int = Aliased("anemoculus_number", mi18n="bbs/wind_god") + geoculi: int = Aliased("geoculus_number", mi18n="bbs/rock_god") + electroculi: int = Aliased("electroculus_number", mi18n="bbs/electroculus_god") + common_chests: int = Aliased("common_chest_number", mi18n="bbs/general_treasure_box_count") + exquisite_chests: int = Aliased("exquisite_chest_number", mi18n="bbs/delicacy_treasure_box_count") + precious_chests: int = Aliased("precious_chest_number", mi18n="bbs/rarity_treasure_box_count") + luxurious_chests: int = Aliased("luxurious_chest_number", mi18n="bbs/magnificent_treasure_box_count") + remarkable_chests: int = Aliased("magic_chest_number", mi18n="bbs/magic_chest_number") + unlocked_waypoints: int = Aliased("way_point_number", mi18n="bbs/unlock_portal") + unlocked_domains: int = Aliased("domain_number", mi18n="bbs/unlock_secret_area") + # fmt: on + + def as_dict(self, lang: str = "en-us") -> typing.Mapping[str, typing.Any]: + """Helper function which turns fields into properly named ones""" + return {self._get_mi18n(field, lang): getattr(self, field.name) for field in self.__fields__.values()} + + +class Offering(APIModel): + """Exploration offering.""" + + name: str + level: int + + +class Exploration(APIModel): + """Exploration data.""" + + id: int + icon: str + name: str + type: str + level: int + raw_explored: int = Aliased("exploration_percentage") + offerings: typing.Sequence[Offering] + + @property + def explored(self) -> float: + """The percentage explored""" + return self.raw_explored / 10 + + +class TeapotRealm(APIModel): + """A specific teapot realm.""" + + name: str + icon: str + + @property + def id(self) -> int: + match = re.search(r"\d", self.icon) + return int(match.group()) if match else 0 + + +class Teapot(APIModel): + """User's Serenitea Teapot.""" + + realms: typing.Sequence[TeapotRealm] + level: int + visitors: int = Aliased("visit_num") + comfort: int = Aliased("comfort_num") + items: int = Aliased("item_num") + comfort_name: str = Aliased("comfort_level_name") + comfort_icon: str = Aliased("comfort_level_icon") + + +class GenshinPartialUserStats(APIModel): + """User stats with characters without equipment.""" + + stats: Stats + characters: typing.Sequence[characters.PartialCharacter] = Aliased("avatars") + explorations: typing.Sequence[Exploration] = Aliased("world_explorations") + teapot: typing.Optional[Teapot] = Aliased("homes") + + @validator("teapot", pre=True) + def __format_teapot(cls, v: typing.Any) -> typing.Optional[typing.Dict[str, typing.Any]]: + if not v: + return None + if isinstance(v, dict): + return typing.cast("dict[str, typing.Any]", v) + return {**v[0], "realms": v} + + +class GenshinUserStats(GenshinPartialUserStats): + """User stats with characters with equipment""" + + characters: typing.Sequence[characters.Character] = Aliased("avatars") + + +class GenshinFullUserStats(GenshinUserStats): + """User stats with all data a user can have""" + + abyss: abyss.SpiralAbyssPair + activities: activities.Activities diff --git a/genshin/models/genshin/constants.py b/genshin/models/genshin/constants.py new file mode 100644 index 00000000..562e00e4 --- /dev/null +++ b/genshin/models/genshin/constants.py @@ -0,0 +1,92 @@ +"""Genshin model constants.""" +import typing + +__all__ = ["CHARACTER_NAMES", "DBChar"] + + +class DBChar(typing.NamedTuple): + """Partial genshin character data.""" + + id: int + icon_name: str # standardized icon name + name: str # english name + element: str + rarity: int + + guessed: bool = False + + +_RAW_DB_CHAR = typing.Union[typing.Tuple[str, str, str, int], typing.Tuple[str, str, int]] +_character_names: typing.Mapping[int, typing.Optional[_RAW_DB_CHAR]] = { + 10000001: None, + 10000002: ("Ayaka", "Kamisato Ayaka", "Cryo", 5), + 10000003: ("Qin", "Jean", "Anemo", 5), + 10000004: None, + 10000005: ("PlayerBoy", "Traveler", "", 5), + 10000006: ("Lisa", "Electro", 4), + 10000007: ("PlayerGirl", "Traveler", "", 5), + 10000008: None, + 10000009: None, + 10000010: None, + 10000011: None, + 10000012: None, + 10000013: None, + 10000014: ("Barbara", "Hydro", 4), + 10000015: ("Kaeya", "Cryo", 4), + 10000016: ("Diluc", "Pyro", 5), + 10000017: None, + 10000018: None, + 10000019: None, + 10000020: ("Razor", "Electro", 4), + 10000021: ("Ambor", "Amber", "Pyro", 4), + 10000022: ("Venti", "Anemo", 5), + 10000023: ("Xiangling", "Pyro", 4), + 10000024: ("Beidou", "Electro", 4), + 10000025: ("Xingqiu", "Hydro", 4), + 10000026: ("Xiao", "Anemo", 5), + 10000027: ("Ningguang", "Geo", 4), + 10000028: None, + 10000029: ("Klee", "Pyro", 5), + 10000030: ("Zhongli", "Geo", 5), + 10000031: ("Fischl", "Electro", 4), + 10000032: ("Bennett", "Pyro", 4), + 10000033: ("Tartaglia", "Hydro", 5), + 10000034: ("Noel", "Noelle", "Geo", 4), + 10000035: ("Qiqi", "Cryo", 5), + 10000036: ("Chongyun", "Cryo", 4), + 10000037: ("Ganyu", "Cryo", 5), + 10000038: ("Albedo", "Geo", 5), + 10000039: ("Diona", "Cryo", 4), + 10000040: None, + 10000041: ("Mona", "Mona", "Hydro", 5), + 10000042: ("Keqing", "Keqing", "Electro", 5), + 10000043: ("Sucrose", "Sucrose", "Anemo", 4), + 10000044: ("Xinyan", "Xinyan", "Pyro", 4), + 10000045: ("Rosaria", "Rosaria", "Cryo", 4), + 10000046: ("Hutao", "Hu Tao", "Pyro", 5), + 10000047: ("Kazuha", "Kaedehara Kazuha", "Anemo", 5), + 10000048: ("Feiyan", "Yanfei", "Pyro", 4), + 10000049: ("Yoimiya", "Yoimiya", "Pyro", 5), + 10000050: ("Tohma", "Thoma", "Pyro", 4), + 10000051: ("Eula", "Cryo", 5), + 10000052: ("Shougun", "Raiden Shogun", "Electro", 5), + 10000053: ("Sayu", "Anemo", 4), + 10000054: ("Kokomi", "Sangonomiya Kokomi", "Hydro", 5), + 10000055: ("Gorou", "Geo", 4), + 10000056: ("Sara", "Kujou Sara", "Electro", 4), + 10000057: ("Itto", "Arataki Ito", "Geo", 5), + 10000058: ("YaeMiko", "Yae Miko", "Electro", 5), + 10000059: None, + 10000060: None, + 10000061: None, + 10000062: ("Aloy", "Cryo", 105), + 10000063: ("Shenhe", "Cryo", 5), + 10000064: ("YunJin", "Yun Jin", "Geo", 4), + 10000065: None, + 10000066: ("Ayato", "Hydro", 5), +} +CHARACTER_NAMES: typing.Dict[int, DBChar] = { + id: (DBChar(id, *data) if len(data) == 4 else DBChar(id, data[0], *data)) + for id, data in _character_names.items() + if data is not None +} diff --git a/genshin/models/genshin/daily.py b/genshin/models/genshin/daily.py new file mode 100644 index 00000000..c21854b0 --- /dev/null +++ b/genshin/models/genshin/daily.py @@ -0,0 +1,40 @@ +"""Daily reward models.""" +import calendar +import datetime +import typing + +from genshin.models.model import Aliased, APIModel, Unique + +__all__ = ["ClaimedDailyReward", "DailyReward", "DailyRewardInfo"] + + +class DailyRewardInfo(typing.NamedTuple): + """Information about the current daily reward status.""" + + signed_in: bool + claimed_rewards: int + + @property + def missed_rewards(self) -> int: + cn_timezone = datetime.timezone(datetime.timedelta(hours=8)) + now = datetime.datetime.now(cn_timezone) + month_days = calendar.monthrange(now.year, now.month)[1] + return month_days - self.claimed_rewards + + +class DailyReward(APIModel): + """Claimable daily reward.""" + + name: str + amount: int = Aliased("cnt") + icon: str + + +class ClaimedDailyReward(APIModel, Unique): + """Claimed daily reward.""" + + id: int + name: str + amount: int = Aliased("cnt") + icon: str = Aliased("img") + time: datetime.datetime = Aliased("created_at", timezone=8) diff --git a/genshin/models/genshin/diary.py b/genshin/models/genshin/diary.py new file mode 100644 index 00000000..36ebbf7b --- /dev/null +++ b/genshin/models/genshin/diary.py @@ -0,0 +1,86 @@ +"""Genshin diary models.""" +import datetime +import enum +import typing + +from genshin.models.model import Aliased, APIModel + +__all__ = [ + "BaseDiary", + "DayDiaryData", + "Diary", + "DiaryAction", + "DiaryActionCategory", + "DiaryPage", + "DiaryType", + "MonthDiaryData", +] + + +class DiaryType(enum.IntEnum): + """Type of diary pages.""" + + PRIMOGEMS = 1 + """Primogems.""" + + MORA = 2 + """Mora.""" + + +class BaseDiary(APIModel): + """Base model for diary and diary page.""" + + uid: int + region: str + nickname: str + month: int = Aliased("data_month") + + +class DiaryActionCategory(APIModel): + """Diary category for primogems.""" + + id: int = Aliased("action_id") + name: str = Aliased("action") + amount: int = Aliased("num") + percentage: int = Aliased("percent") + + +class MonthDiaryData(APIModel): + """Diary data for a month.""" + + current_primogems: int + current_mora: int + last_primogems: int + last_mora: int + primogems_rate: int = Aliased("primogem_rate") + mora_rate: int + categories: typing.Sequence[DiaryActionCategory] = Aliased("group_by") + + +class DayDiaryData(APIModel): + """Diary data for a day.""" + + current_primogems: int + current_mora: int + + +class Diary(BaseDiary): + """Traveler's diary.""" + + data: MonthDiaryData = Aliased("month_data") + day_data: DayDiaryData + + +class DiaryAction(APIModel): + """Action which earned currency.""" + + action_id: int + action: str + time: datetime.datetime = Aliased(timezone=8) + amount: int = Aliased("num") + + +class DiaryPage(BaseDiary): + """Page of a diary.""" + + actions: typing.Sequence[DiaryAction] = Aliased("list") diff --git a/genshin/models/genshin/transaction.py b/genshin/models/genshin/transaction.py new file mode 100644 index 00000000..4ebc0587 --- /dev/null +++ b/genshin/models/genshin/transaction.py @@ -0,0 +1,66 @@ +"""Genshin transaction models.""" + +import datetime +import enum +import typing + +from genshin.models.model import Aliased, APIModel, Unique + +__all__ = ["BaseTransaction", "ItemTransaction", "Transaction", "TransactionKind"] + + +class TransactionKind(str, enum.Enum): + """Possible kind of transaction.""" + + PRIMOGEM = "primogem" + """Primogem currency.""" + + CRYSTAL = "crystal" + """Genesis crystal currency.""" + + RESIN = "resin" + """Resin currency.""" + + ARTIFACT = "artifact" + """Artifact items from domains.""" + + WEAPON = "weapon" + """Weapon items from domains and wishes.""" + + +class BaseTransaction(APIModel, Unique): + """Genshin transaction.""" + + reason_lang: str = "en-us" + + kind: TransactionKind + + id: int + uid: int + time: datetime.datetime + amount: int = Aliased("add_num") + reason_id: int = Aliased("reason") + + @property + def reason_name(self) -> str: + return self.get_reason_name() + + def get_reason_name(self, lang: typing.Optional[str] = None) -> str: + """Get the name of the reason in a specific language.""" + key = f"inquiry/selfinquiry_general_reason_{self.reason_id}" + return self._get_mi18n(key, lang or self.reason_lang, default=str(self.reason_id)) + + +class Transaction(BaseTransaction): + """Genshin transaction of currency.""" + + kind: typing.Literal[TransactionKind.PRIMOGEM, TransactionKind.CRYSTAL, TransactionKind.RESIN] + + +class ItemTransaction(BaseTransaction): + """Genshin transaction of artifacts or weapons.""" + + kind: typing.Literal[TransactionKind.ARTIFACT, TransactionKind.WEAPON] + + name: str + rarity: int = Aliased("rank") diff --git a/genshin/models/genshin/wish.py b/genshin/models/genshin/wish.py new file mode 100644 index 00000000..44b0bf42 --- /dev/null +++ b/genshin/models/genshin/wish.py @@ -0,0 +1,179 @@ +"""Genshin wish models.""" +import datetime +import enum +import re +import typing + +import pydantic + +from genshin.models.model import Aliased, APIModel, Unique + +__all__ = [ + "BannerDetailItem", + "BannerDetails", + "BannerDetailsUpItem", + "BannerType", + "GachaItem", + "Wish", +] + + +class BannerType(enum.IntEnum): + """Banner types in wish histories.""" + + NOVICE = 100 + """Temporary novice banner.""" + + STANDARD = PERMANENT = 200 + """Permanent standard banner.""" + + CHARACTER = 301 + """Rotating character banner.""" + + WEAPON = 302 + """Rotating weapon banner.""" + + # these are special cases + # they exist inside the history but should be counted as the same + + CHARACTER1 = 301 + """Character banner #1.""" + + CHARACTER2 = 400 + """Character banner #2.""" + + +class Wish(APIModel, Unique): + """Wish made on any banner.""" + + uid: int + + id: int + type: str = Aliased("item_type") + name: str + rarity: int = Aliased("rank_type") + time: datetime.datetime + + banner_type: BannerType = Aliased("gacha_type") + banner_name: str + + @pydantic.validator("banner_type", pre=True) + def __cast_banner_type(cls, v: typing.Any) -> int: + return int(v) + + +class BannerDetailItem(APIModel): + """Item that may be gotten from a banner.""" + + name: str = Aliased("item_name") + type: str = Aliased("item_type") + rarity: int = Aliased("rank") + + up: bool = Aliased("is_up") + order: int = Aliased("order_value") + + +class BannerDetailsUpItem(APIModel): + """Item that has a rate-up on a banner.""" + + name: str = Aliased("item_name") + type: str = Aliased("item_type") + element: str = Aliased("item_attr") + icon: str = Aliased("item_img") + + @pydantic.validator("element", pre=True) + def __parse_element(cls, v: str) -> str: + return { + "风": "Anemo", + "火": "Pyro", + "水": "Hydro", + "雷": "Electro", + "冰": "Cryo", + "岩": "Geo", + "草": "Dendro", + "": "", + }.get(v, v) + + +class BannerDetails(APIModel): + """Details of a banner.""" + + banner_id: str + banner_type: int = Aliased("gacha_type") + title: str + content: str + date_range: str + + r5_up_prob: typing.Optional[float] + r4_up_prob: typing.Optional[float] + r5_prob: typing.Optional[float] + r4_prob: typing.Optional[float] + r3_prob: typing.Optional[float] + r5_guarantee_prob: typing.Optional[float] = Aliased("r5_baodi_prob") + r4_guarantee_prob: typing.Optional[float] = Aliased("r4_baodi_prob") + r3_guarantee_prob: typing.Optional[float] = Aliased("r3_baodi_prob") + + r5_up_items: typing.Sequence[BannerDetailsUpItem] + r4_up_items: typing.Sequence[BannerDetailsUpItem] + + r5_items: typing.List[BannerDetailItem] = Aliased("r5_prob_list") + r4_items: typing.List[BannerDetailItem] = Aliased("r4_prob_list") + r3_items: typing.List[BannerDetailItem] = Aliased("r3_prob_list") + + @pydantic.validator("r5_up_items", "r4_up_items", pre=True) + def __replace_none(cls, v: typing.Optional[typing.Sequence[typing.Any]]) -> typing.Sequence[typing.Any]: + return v or [] + + @pydantic.validator( + "r5_up_prob", + "r4_up_prob", + "r5_prob", + "r4_prob", + "r3_prob", + "r5_guarantee_prob", + "r4_guarantee_prob", + "r3_guarantee_prob", + pre=True, + ) + def __parse_percentage(cls, v: str) -> typing.Optional[float]: + if v is None or isinstance(v, (int, float)): + return v + + return None if v == "0%" else float(v[:-1].replace(",", ".")) + + @property + def name(self) -> str: + return re.sub(r"<.*?>", "", self.title).strip() + + @property + def banner_type_name(self) -> str: + banners = { + 100: "Novice Wishes", + 200: "Permanent Wish", + 301: "Character Event Wish", + 302: "Weapon Event Wish", + 400: "Character Event Wish", + } + return banners[self.banner_type] + + @property + def items(self) -> typing.Sequence[BannerDetailItem]: + items = self.r5_items + self.r4_items + self.r3_items + return sorted(items, key=lambda x: x.order) + + +class GachaItem(APIModel, Unique): + """Item that can be gotten from the gacha.""" + + name: str + type: str = Aliased("item_type") + rarity: int = Aliased("rank_type") + id: int = Aliased("item_id") + + @pydantic.validator("id") + def __format_id(cls, v: int) -> int: + return 10000000 + v - 1000 if len(str(v)) == 4 else v + + def is_character(self) -> bool: + """Whether this item is a character.""" + return len(str(self.id)) == 8 diff --git a/genshin/models/honkai/__init__.py b/genshin/models/honkai/__init__.py new file mode 100644 index 00000000..36ec2f19 --- /dev/null +++ b/genshin/models/honkai/__init__.py @@ -0,0 +1,4 @@ +"""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 new file mode 100644 index 00000000..cba7fe67 --- /dev/null +++ b/genshin/models/honkai/battlesuit.py @@ -0,0 +1,97 @@ +"""Honkai battlesuit model.""" +import re +import typing + +import pydantic + +from genshin.models.model import Aliased, APIModel, Unique + +from .constants import BATTLESUIT_IDENTIFIERS + +__all__ = ["Battlesuit"] + +BATTLESUIT_TYPES = { + "ShengWu": "BIO", + "JiXie": "MECH", + "YiNeng": "PSY", + "LiangZi": "QUA", + "XuShu": "IMG", +} +ICON_BASE = "https://upload-os-bbs.mihoyo.com/game_record/honkai3rd/global/SpriteOutput/" + + +class Battlesuit(APIModel, Unique): + """Represents a battlesuit without equipment or level.""" + + id: int + name: str + rarity: int = Aliased("star") + closeup_icon_background: str = Aliased("avatar_background_path") + tall_icon: str = Aliased("figure_path") + + @pydantic.validator("tall_icon") + def __autocomplete_figpath(cls, tall_icon: str, values: typing.Dict[str, typing.Any]) -> str: + """figure_path is empty for gamemode endpoints, and cannot be inferred from other fields.""" + if tall_icon: + # might as well just update the BATTLESUIT_IDENTIFIERS if we have the data + if values["id"] not in BATTLESUIT_IDENTIFIERS: + BATTLESUIT_IDENTIFIERS[values["id"]] = tall_icon.split("/")[-1].split(".")[0] + + return tall_icon + + suit_identifier = BATTLESUIT_IDENTIFIERS.get(values["id"]) + return ICON_BASE + f"AvatarTachie/{suit_identifier or 'Unknown'}.png" + + @property + def character(self) -> str: + match = re.match(r".*/(\w+C\d+).png", self.tall_icon) + char_raw = match.group(1) if match else "" + + if "Twin" in char_raw: + # Rozaliya and Liliya share the same identifier + return {"TwinC1": "Liliya", "TwinC2": "Rozaliya"}[char_raw] + + char_name_raw = char_raw.rsplit("C", 1)[0] + # fix mislocalized names (currently only one) + if char_name_raw == "Fuka": + char_name_raw = "Fu Hua" + + return char_name_raw + + @property + def rank(self) -> str: + """Display character rarity with letters ranging from A to SSS, as is done in-game.""" + return ("A", "B", "S", "SS", "SSS")[self.rarity - 1] + + @property + def _type_cn(self) -> str: + match = re.match(r".*/Attr(\w+?)(?:Small)?.png", self.closeup_icon_background) + return match[1] if match else "ShengWu" # random default just so images keep working + + @property + def type(self) -> str: + return BATTLESUIT_TYPES[self._type_cn] + + @property + def closeup_icon(self) -> str: + return f"{ICON_BASE}AvatarCardIcons/{60000 + self.id}.png" + + @property + def icon(self) -> str: + return f"{ICON_BASE}AvatarIcon/{self.id}.png" + + @property + def icon_background(self) -> str: + return f"{ICON_BASE}AvatarIcon/Attr{self._type_cn}.png" + + @property + def image(self) -> str: + return f"{ICON_BASE}AvatarCardFigures/{60000 + self.id}.png" + + @property + def cropped_icon(self) -> str: + return f"{self.image[:-4]}@1.png" + + @property + def banner(self) -> str: + return f"{self.image[:-4]}@2.png" diff --git a/genshin/models/honkai/chronicle/__init__.py b/genshin/models/honkai/chronicle/__init__.py new file mode 100644 index 00000000..fcf46466 --- /dev/null +++ b/genshin/models/honkai/chronicle/__init__.py @@ -0,0 +1,4 @@ +"""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 new file mode 100644 index 00000000..6e9ec85b --- /dev/null +++ b/genshin/models/honkai/chronicle/battlesuits.py @@ -0,0 +1,62 @@ +"""Honkai chronicle battlesuits.""" +import re +import typing + +import pydantic + +from genshin.models.honkai import battlesuit +from genshin.models.model import Aliased, APIModel, Unique + + +class Equipment(APIModel, Unique): + """Battlesuit equipment.""" + + id: int + name: str + rarity: int + max_rarity: int + icon: str + + @property + def type(self) -> str: + """The type of the equipment""" + match = re.search(r"/(\w+)Icons/", self.icon) + base_type = match[1] if match else "" + if not base_type or base_type == "Stigmata": + return base_type + + match = re.search(r"/Weapon_([A-Za-z]+?)_", self.icon) + return match[1] if match else "Weapon" + + +class Stigma(Equipment): + """Battlesuit stigma.""" + + +class BattlesuitWeapon(Equipment): + """Battlesuit weapon.""" + + +class FullBattlesuit(battlesuit.Battlesuit): + """Battlesuit complete with equipped weapon and stigmata.""" + + level: int + weapon: BattlesuitWeapon + stigmata: typing.Sequence[Stigma] = Aliased("stigmatas") + + @pydantic.root_validator(pre=True) + def __unnest_char_data(cls, values: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + if isinstance(values.get("character"), typing.Mapping): + values.update(values["character"]) + + values.update(values.get("avatar", {})) + + return values + + @pydantic.validator("stigmata") + def __remove_unequipped_stigmata(cls, value: typing.Sequence[Stigma]) -> typing.Sequence[Stigma]: + return [stigma for stigma in value if stigma.id != 0] + + +# shuffle validators around because of nesting +FullBattlesuit.__pre_root_validators__.reverse() diff --git a/genshin/models/honkai/chronicle/modes.py b/genshin/models/honkai/chronicle/modes.py new file mode 100644 index 00000000..39c83733 --- /dev/null +++ b/genshin/models/honkai/chronicle/modes.py @@ -0,0 +1,339 @@ +"""Honkai battle chronicle models.""" +from __future__ import annotations + +import datetime +import re +import typing + +import pydantic + +from genshin.models.honkai import battlesuit +from genshin.models.model import Aliased, APIModel, Unique + +__all__ = [ + "Boss", + "ELF", + "ElysianRealm", + "MemorialArena", + "MemorialBattle", + "OldAbyss", + "SuperstringAbyss", +] + +REMEMBRANCE_SIGILS: typing.Dict[int, typing.Tuple[str, int]] = { + 119301: ("The MOTH Insignia", 1), + 119302: ("Home Lost", 1), + 119303: ("False Hope", 1), + 119304: ("Tin Flask", 1), + 119305: ("Ruined Legacy", 1), + 119306: ("Burden", 2), + 119307: ("Gold Goblet", 2), + 119308: ("Mad King's Mask", 2), + 119309: ("Light as a Bodhi Leaf", 2), + 119310: ("Forget-Me-Not", 2), + 119311: ("Forbidden Seed", 2), + 119312: ("Memory", 2), + 119313: ("Crystal Rose", 2), + 119314: ("Abandoned", 3), + 119315: ("Good Old Days", 3), + 119316: ("Shattered Shackles", 3), + 119317: ("Heavy as a Million Lives", 3), + 119318: ("Stained Sakura", 3), + 119319: ("The First Scale", 3), + 119320: ("Resolve", 3), + 119321: ("Thorny Crown", 3), +} + + +# GENERIC + + +def get_competitive_tier_mi18n(tier: int) -> str: + """Turn the tier returned by the API into the respective tier name displayed in-game.""" + return "bbs/" + ("area1", "area2", "area3", "area4")[tier - 1] + + +class Boss(APIModel, Unique): + """Represents a Boss encountered in Abyss or Memorial Arena.""" + + id: int + name: str + icon: str = Aliased("avatar") + + @pydantic.validator("icon") + def __fix_url(cls, url: str) -> str: + # I noticed that sometimes the urls are returned incorrectly, which appears to be + # a problem on the hoyolab website too, so I expect this to be fixed sometime. + # For now, this hotfix seems to work. + return re.sub(r"/boss_\d+\.", lambda m: str.upper(m[0]), url, flags=re.IGNORECASE) + + +class ELF(APIModel, Unique): + """Represents an ELF equipped for a battle.""" + + id: int + name: str + icon: str = Aliased("avatar") + rarity: str + upgrade_level: int = Aliased("star") + + @pydantic.validator("rarity", pre=True) + def __fix_rank(cls, rarity: typing.Union[int, str]) -> str: + if isinstance(rarity, str): + return rarity + + # ELFs come in rarities A and S, API returns 3 and 4, respectively + return ["A", "S"][rarity - 3] + + +# ABYSS + + +def get_abyss_rank_mi18n(rank: int, tier: int) -> str: + """Turn the rank returned by the API into the respective rank name displayed in-game.""" + if tier == 4: + mod = ("1", "2_1", "2_2", "2_3", "3_1", "3_2", "3_3", "4", "5")[rank - 1] + else: + mod = str(rank) + return f"bbs/level{mod}" + + +class BaseAbyss(APIModel): + """Represents one cycle of abyss. + + (3 days per cycle, 2 cycles per week) + """ + + # somewhat feel like this is overkill + + abyss_lang: str = "en-us" + + raw_tier: int = Aliased("area") + score: int + lineup: typing.Sequence[battlesuit.Battlesuit] + boss: Boss + elf: typing.Optional[ELF] + + @property + def tier(self) -> str: + """The user's Abyss tier as displayed in-game.""" + return self.get_tier() + + def get_tier(self, lang: typing.Optional[str] = None) -> str: + """Get the user's Abyss tier in a specific language.""" + key = get_competitive_tier_mi18n(self.raw_tier) + return self._get_mi18n(key, lang or self.abyss_lang) + + +class OldAbyss(BaseAbyss): + """Represents once cycle of Quantum Singularis or Dirac Sea. + + Exclusive to players of level 80 and below. + """ + + end_time: datetime.datetime = Aliased("time_second") + raw_type: str = Aliased("type") + result: str = Aliased("reward_type") + raw_rank: int = Aliased("level") + + @pydantic.validator("raw_rank", pre=True) + def __normalize_level(cls, rank: str) -> int: + # The latestOldAbyssReport endpoint returns ranks as D/C/B/A, + # while newAbyssReport returns them as 1/2/3/4(/5) respectively. + # Having them as ints at base seems more useful than strs. + # (in-game, they use the same names (Sinful, Agony, etc.)) + return 69 - ord(rank) + + @property + def rank(self) -> str: + """The user's Abyss rank as displayed in-game.""" + return self.get_rank() + + def get_rank(self, lang: typing.Optional[str] = None) -> str: + """Get the user's Abyss rank in a specific language.""" + key = get_abyss_rank_mi18n(self.raw_rank, self.raw_tier) + return self._get_mi18n(key, lang or self.abyss_lang) + + @property + def type(self) -> str: + """The name of this cycle's abyss type.""" + return self.get_type() + + def get_type(self, lang: typing.Optional[str] = None) -> str: + """Get the name of this cycle's abyss type in a specific language.""" + key = "bbs/" + ("level_of_ow" if self.raw_type == "OW" else self.raw_type) + return self._get_mi18n(key, lang or self.abyss_lang) + + +class SuperstringAbyss(BaseAbyss): + """Represents one cycle of Superstring Abyss, exclusive to players of level 81 and up.""" + + # NOTE endpoint: game_record/honkai3rd/api/latestOldAbyssReport + + end_time: datetime.datetime = Aliased("updated_time_second") + raw_tier: int = 4 # Not returned by API, always the case + placement: int = Aliased("rank") + trophies_gained: int = Aliased("settled_cup_number") + end_trophies: int = Aliased("cup_number") + raw_start_rank: int = Aliased("level") + raw_end_rank: int = Aliased("settled_level") + + @property + def start_rank(self) -> str: + """The rank the user started the abyss cycle with, as displayed in-game.""" + return self.get_start_rank() + + def get_start_rank(self, lang: typing.Optional[str] = None) -> str: + """Get the rank the user started the abyss cycle with in a specific language.""" + key = get_abyss_rank_mi18n(self.raw_start_rank, self.raw_tier) + return self._get_mi18n(key, lang or self.abyss_lang) + + @property + def end_rank(self) -> str: + """The rank the user ended the abyss cycle with, as displayed in-game.""" + return self.get_end_rank() + + def get_end_rank(self, lang: typing.Optional[str] = None) -> str: + """Get the rank the user ended the abyss cycle with in a specific language.""" + key = get_abyss_rank_mi18n(self.raw_end_rank, self.raw_tier) + return self._get_mi18n(key, lang or self.abyss_lang) + + @property + def start_trophies(self) -> int: + return self.end_trophies - self.trophies_gained + + +# MEMORIAL ARENA + + +def prettify_MA_rank(rank: int) -> str: # Independent of mi18n + """Turn the rank returned by the API into the respective rank name displayed in-game.""" + brackets = (0, 0.20, 2, 7, 17, 35, 65) + return f"{brackets[rank - 1]:1.2f} ~ {brackets[rank]:1.2f}" + + +class MemorialBattle(APIModel): + """Represents weekly performance against a single Memorial Arena boss.""" + + score: int + lineup: typing.Sequence[battlesuit.Battlesuit] + elf: typing.Optional[ELF] + boss: Boss + + +class MemorialArena(APIModel): + """Represents aggregate weekly performance for the entire Memorial Arena rotation.""" + + def __init__(self, **data: typing.Any) -> None: + super().__init__(**data) + + ma_lang: str = "en-us" + + score: int + ranking: float = Aliased("ranking_percentage") + raw_rank: int = Aliased("rank") + raw_tier: int = Aliased("area") + end_time: datetime.datetime = Aliased("time_second") + battle_data: typing.Sequence[MemorialBattle] = Aliased("battle_infos") + + @property + def rank(self) -> str: + """The user's Memorial Arena rank as displayed in-game.""" + return prettify_MA_rank(self.raw_rank) + + @property + def tier(self) -> str: + """The user's Memorial Arena tier as displayed in-game.""" + return self.get_tier() + + def get_tier(self, lang: typing.Optional[str] = None) -> str: + """Get the user's Memorial Arena tier in a specific language.""" + key = get_competitive_tier_mi18n(self.raw_tier) + return self._get_mi18n(key, lang or self.ma_lang) + + +# ELYSIAN REALMS +# TODO: Implement a way to link response_json["avatar_transcript"] data to be added to +# ER lineup data; will require new Battlesuit subclass. + + +class Condition(APIModel): + """Represents a debuff picked at the beginning of an Elysian Realms run.""" + + name: str + description: str = Aliased("desc") + difficulty: int + + +class Signet(APIModel): + """Represents a buff Signet picked in an Elysian Realms run.""" + + id: int + icon: str + number: int + + @property + def name(self) -> str: + return [ + "Deliverance", + "Gold", + "Decimation", + "Bodhi", + "Setsuna", + "Infinity", + "Vicissitude", + "■■", + ][self.id - 1] + + def get_scaled_icon(self, scale: typing.Literal[1, 2, 3] = 2) -> str: + if not 1 <= scale <= 3: + raise ValueError("Scale must lie between 1 and 3.") + + return self.icon.replace("@2x", "" if scale == 1 else f"@{scale}x") + + +class RemembranceSigil(APIModel): + """Represents a Remembrance Sigil from Elysian Realms.""" + + icon: str + + @property + def id(self) -> int: + match = re.match(r".*/(\d+).png", self.icon) + return int(match[1]) if match else 0 + + @property + def name(self) -> str: + sigil = REMEMBRANCE_SIGILS.get(self.id) + return sigil[0] if sigil else "Unknown" + + @property + def rarity(self) -> int: + sigil = REMEMBRANCE_SIGILS.get(self.id) + return sigil[1] if sigil else 1 + + +class ElysianRealm(APIModel): + """Represents one completed run of Elysean Realms.""" + + completed_at: datetime.datetime = Aliased("settle_time_second") + floors_cleared: int = Aliased("level") + score: int + difficulty: int = Aliased("punish_level") + conditions: typing.Sequence[Condition] + signets: typing.Sequence[Signet] = Aliased("buffs") + leader: battlesuit.Battlesuit = Aliased("main_avatar") + supports: typing.Sequence[battlesuit.Battlesuit] = Aliased("support_avatars") + elf: typing.Optional[ELF] + remembrance_sigil: RemembranceSigil = Aliased("extra_item_icon") + + @pydantic.validator("remembrance_sigil", pre=True) + def __extend_sigil(cls, sigil: typing.Any) -> RemembranceSigil: + if isinstance(sigil, str): + return RemembranceSigil(icon=sigil) + + return sigil + + @property + def lineup(self) -> typing.Sequence[battlesuit.Battlesuit]: + return [self.leader, *self.supports] diff --git a/genshin/models/honkai/chronicle/stats.py b/genshin/models/honkai/chronicle/stats.py new file mode 100644 index 00000000..a5ff13c6 --- /dev/null +++ b/genshin/models/honkai/chronicle/stats.py @@ -0,0 +1,253 @@ +"""Honkai stats models.""" +import typing + +import pydantic + +from genshin.models.model import Aliased, APIModel + +from . import battlesuits, modes + +__all__ = [ + "HonkaiFullUserStats", + "HonkaiStats", + "HonkaiUserStats", +] + + +def _model_to_dict(model: APIModel, lang: str = "en-us") -> typing.Mapping[str, typing.Any]: + """Helper function which turns fields into properly named ones""" + ret: typing.Dict[str, typing.Any] = {} + for field in model.__fields__.values(): + mi18n = model._get_mi18n(field, lang) # pyright: ignore[reportPrivateUsage] + val = getattr(model, field.name) + if isinstance(val, APIModel): + ret[mi18n] = _model_to_dict(val, lang) + else: + ret[mi18n] = val + + return ret + + +# flake8: noqa: E222 +class MemorialArenaStats(APIModel): + """Represents a user's stats regarding the Memorial Arena gamemodes.""" + + _stat_lang: str = "en-us" + + # fmt: off + ranking: float = Aliased("battle_field_ranking_percentage", mi18n="bbs/battle_field_ranking_percentage") + raw_rank: int = Aliased("battle_field_rank", mi18n="bbs/rank") + score: int = Aliased("battle_field_score", mi18n="bbs/score") + raw_tier: int = Aliased("battle_field_area", mi18n="bbs/settled_level") + # fmt: on + + def as_dict(self, lang: str = "en-us") -> typing.Mapping[str, typing.Any]: + return _model_to_dict(self, lang) + + @property + def rank(self) -> str: + """The user's Memorial Arena rank as displayed in-game.""" + return modes.prettify_MA_rank(self.raw_rank) + + @property + def tier(self) -> str: + """The user's Memorial Arena tier as displayed in-game.""" + return self.get_tier() + + def get_tier(self, lang: typing.Optional[str] = None) -> str: + """Get the user's Memorial Arena tier in a specific language.""" + key = modes.get_competitive_tier_mi18n(self.raw_tier) + return self._get_mi18n(key, lang or self._stat_lang) + + +# flake8: noqa: E222 +class SuperstringAbyssStats(APIModel): + """Represents a user's stats regarding Superstring Abyss.""" + + _stat_lang: str = "en-us" + + # fmt: off + raw_rank: int = Aliased("level", mi18n="bbs/rank") + trophies: int = Aliased("cup_number", mi18n="bbs/cup_number") + score: int = Aliased("abyss_score", mi18n="bbs/explain_text_2") + raw_tier: int = Aliased("battle_field_area", mi18n="bbs/settled_level") + # fmt: on + + # for consistency between types; also allows us to forego the mi18n fuckery + latest_type: typing.ClassVar[str] = "Superstring" + + def as_dict(self, lang: str = "en-us") -> typing.Mapping[str, typing.Any]: + return _model_to_dict(self, lang) + + @property + def rank(self) -> str: + """The user's Abyss rank as displayed in-game.""" + return self.get_rank() + + def get_rank(self, lang: typing.Optional[str] = None) -> str: + """Get the user's Abyss rank in a specific language.""" + key = modes.get_abyss_rank_mi18n(self.raw_rank, self.raw_tier) + return self._get_mi18n(key, lang or self._stat_lang) + + @property + def tier(self) -> str: + """The user's Abyss tier as displayed in-game.""" + return self.get_tier() + + def get_tier(self, lang: typing.Optional[str] = None) -> str: + """Get the user's Abyss tier in a specific language.""" + key = modes.get_competitive_tier_mi18n(self.raw_tier) + return self._get_mi18n(key, lang or self._stat_lang) + + +# flake8: noqa: E222 +class OldAbyssStats(APIModel): + """Represents a user's stats regarding Q-Singularis and Dirac Sea.""" + + _stat_lang: str = "en-us" + + # fmt: off + raw_q_singularis_rank: int = Aliased("level_of_quantum", mi18n="bbs/Quantum") + raw_dirac_sea_rank: int = Aliased("level_of_ow", mi18n="bbs/level_of_ow") + score: int = Aliased("abyss_score", mi18n="bbs/explain_text_2") + raw_tier: int = Aliased("latest_area", mi18n="bbs/settled_level") + raw_latest_rank: int = Aliased("latest_level", mi18n="bbs/rank") + latest_type: str + # fmt: on + + @pydantic.validator("raw_q_singularis_rank", "raw_dirac_sea_rank", "raw_latest_rank", pre=True) + def __normalize_rank(cls, rank: str) -> int: # modes.OldAbyss.__normalize_rank + return 69 - ord(rank) + + @property + def q_singularis_rank(self) -> str: + """The user's latest Q-Singularis rank as displayed in-game.""" + return self.get_rank(self.raw_q_singularis_rank) + + @property + def dirac_sea_rank(self) -> str: + """The user's latest Dirac Sea rank as displayed in-game.""" + return self.get_rank(self.raw_dirac_sea_rank) + + @property + def latest_rank(self) -> str: + """The user's latest Abyss rank as displayed in-game. Seems to apply after weekly reset, + so this may differ from the user's Dirac Sea/Q-Singularis ranks if their rank changed. + """ + return self.get_rank(self.raw_latest_rank) + + def get_rank(self, rank: int, lang: typing.Optional[str] = None) -> str: + """Get the user's Abyss rank in a specific language. + + Must be supplied with one of the raw ranks stored on this class. + """ + key = modes.get_abyss_rank_mi18n(rank, self.raw_tier) + return self._get_mi18n(key, lang or self._stat_lang) + + @property + def tier(self) -> str: + """The user's Abyss tier as displayed in-game.""" + return modes.get_competitive_tier_mi18n(self.raw_tier) + + def get_tier(self, lang: typing.Optional[str] = None) -> str: + """Get the user's Abyss tier in a specific language.""" + key = modes.get_competitive_tier_mi18n(self.raw_tier) + return self._get_mi18n(key, lang or self._stat_lang) + + def as_dict(self, lang: str = "en-us") -> typing.Mapping[str, typing.Any]: + return _model_to_dict(self, lang) + + +# flake8: noqa: E222 +class ElysianRealmStats(APIModel): + """Represents a user's stats regarding Elysian Realms.""" + + # fmt: off + highest_difficulty: int = Aliased("god_war_max_punish_level", mi18n="bbs/god_war_max_punish_level") + remembrance_sigils: int = Aliased("god_war_extra_item_number", mi18n="bbs/god_war_extra_item_number") + highest_score: int = Aliased("god_war_max_challenge_score", mi18n="bbs/god_war_max_challenge_score") + highest_floor: int = Aliased("god_war_max_challenge_level", mi18n="bbs/rogue_setted_level") + max_level_suits: int = Aliased("god_war_max_level_avatar_number", mi18n="bbs/explain_text_6") + # fmt: on + + def as_dict(self, lang: str = "en-us") -> typing.Mapping[str, typing.Any]: + return _model_to_dict(self, lang) + + +class HonkaiStats(APIModel): + """Represents a user's stat page""" + + # fmt: off + active_days: int = Aliased("active_day_number", mi18n="bbs/active_day_number") + achievements: int = Aliased("achievement_number", mi18n="bbs/achievment_complete_count") + + battlesuits: int = Aliased("armor_number", mi18n="bbs/armor_number") + battlesuits_SSS: int = Aliased("sss_armor_number", mi18n="bbs/sss_armor_number") + stigmata: int = Aliased("stigmata_number", mi18n="bbs/stigmata_number") + stigmata_5star: int = Aliased("five_star_stigmata_number", mi18n="bbs/stigmata_number_5") + weapons: int = Aliased("weapon_number", mi18n="bbs/weapon_number") + weapons_5star: int = Aliased("five_star_weapon_number", mi18n="bbs/weapon_number_5") + outfits: int = Aliased("suit_number", mi18n="bbs/suit_number") + # fmt: on + + abyss: typing.Union[SuperstringAbyssStats, OldAbyssStats] = Aliased(mi18n="bbs/explain_text_1") + memorial_arena: MemorialArenaStats = Aliased(mi18n="bbs/battle_field_ranking_percentage") + elysian_realm: ElysianRealmStats = Aliased(mi18n="bbs/godwor") + + @pydantic.root_validator(pre=True) + def __pack_gamemode_stats(cls, values: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + if "new_abyss" in values: + values["abyss"] = SuperstringAbyssStats(**values["new_abyss"], **values) + elif "old_abyss" in values: + values["abyss"] = OldAbyssStats(**values["old_abyss"], **values) + + if "memorial_arena" not in values: + values["memorial_arena"] = MemorialArenaStats(**values) + + if "elysian_realm" not in values: + values["elysian_realm"] = ElysianRealmStats(**values) + + return values + + def as_dict(self, lang: str = "en-us") -> typing.Mapping[str, typing.Any]: + return _model_to_dict(self, lang) + + +class UserInfo(APIModel): + """Honkai user info.""" + + nickname: str + region: str + level: int + icon: str = Aliased("AvatarUrl") + + +class HonkaiUserStats(APIModel): + """Represents basic user stats, showing only generic user data and stats.""" + + info: UserInfo = Aliased("role") + stats: HonkaiStats + + +class HonkaiFullUserStats(HonkaiUserStats): + """Represents a user's full stats, including characters, gear, and gamemode data""" + + battlesuits: typing.Sequence[battlesuits.FullBattlesuit] + abyss: typing.Sequence[typing.Union[modes.SuperstringAbyss, modes.OldAbyss]] + memorial_arena: typing.Sequence[modes.MemorialArena] + elysian_realm: typing.Sequence[modes.ElysianRealm] + + @property + def abyss_superstring(self) -> typing.Sequence[modes.SuperstringAbyss]: + """Filter `self.abyss` to only return instances of Superstring Abyss.""" + return [entry for entry in self.abyss if isinstance(entry, modes.SuperstringAbyss)] + + @property + def abyss_q_singularis(self) -> typing.Sequence[modes.OldAbyss]: + """Filter `self.abyss` to only return instances of Q-Singularis.""" + return [entry for entry in self.abyss if isinstance(entry, modes.OldAbyss) and entry.type == "Q-Singularis"] + + @property + def abyss_dirac_sea(self) -> typing.Sequence[modes.OldAbyss]: + """Filter `self.abyss` to only return instances of Dirac Sea.""" + return [entry for entry in self.abyss if isinstance(entry, modes.OldAbyss) and entry.type == "Dirac Sea"] diff --git a/genshin/models/honkai/constants.py b/genshin/models/honkai/constants.py new file mode 100644 index 00000000..c39f7b6a --- /dev/null +++ b/genshin/models/honkai/constants.py @@ -0,0 +1,89 @@ +"""Honkai model constants.""" +import typing + +__all__ = ["BATTLESUIT_IDENTIFIERS"] + +# TODO: Make this more dynamic +# fmt: off +BATTLESUIT_IDENTIFIERS: typing.Dict[int, str] = { + 101: "KianaC2", + 102: "KianaC1", + 103: "KianaC4", + 104: "KianaC3", + 105: "KianaC6", + + 111: "KallenC1", + 112: "KallenC2", + 113: "KianaC5", + 114: "KallenC3", + + 201: "MeiC2", + 202: "MeiC3", + 203: "MeiC1", + 204: "MeiC4", + 205: "MeiC5", + + 211: "SakuraC1", + 212: "SakuraC2", + 213: "SakuraC3", + 214: "SakuraC4", + + 301: "BronyaC1", + 302: "BronyaC2", + 303: "BronyaC3", + 304: "BronyaC4", + 311: "BronyaC5", + 312: "BronyaC6", + 313: "BronyaC7", + 314: "BronyaC8", + 315: "BronyaC10", + + 401: "HimekoC1", + 402: "HimekoC2", + 403: "HimekoC3", + 404: "HimekoC4", + 411: "HimekoC5", + 412: "HimekoC6", + + 421: "TwinC1", + 422: "TwinC2", + + 501: "TheresaC1", + 502: "TheresaC2", + 503: "TheresaC3", + 504: "TheresaC4", + 506: "TheresaC6", + 511: "TheresaC5", + + 601: "FukaC1", + 602: "FukaC2", + 603: "FukaC3", + 604: "FukaC4", + 611: "FukaC5", + 612: "FukaC6", + + 702: "RitaC2", + 703: "RitaC3", + 704: "RitaC4", + 705: "RitaC5", + 706: "RitaC6", + + 711: "SeeleC1", + 712: "SeeleC2", + 713: "SeeleC3", + + 801: "DurandalC1", + 802: "DurandalC2", + 803: "DurandalC3", + 804: "DurandalC4", + + 901: "AsukaC1", + + 2101: "FischlC1", + 2201: "ElysiaC1", + 2301: "MobiusC1", + 2401: "RavenC1", + 2501: "CaroleC1", + 2601: "PardofelisC1" +} +# fmt: on diff --git a/genshin/models/hoyolab/__init__.py b/genshin/models/hoyolab/__init__.py new file mode 100644 index 00000000..a54a90a6 --- /dev/null +++ b/genshin/models/hoyolab/__init__.py @@ -0,0 +1,3 @@ +"""Hoyolab models.""" +from .private import * +from .record import * diff --git a/genshin/models/hoyolab/private.py b/genshin/models/hoyolab/private.py new file mode 100644 index 00000000..b181f53b --- /dev/null +++ b/genshin/models/hoyolab/private.py @@ -0,0 +1,20 @@ +"""Private and confidential models. + +All models will lack private data by design. +""" + +from genshin.models.model import APIModel + +__all__ = ["AccountInfo"] + + +class AccountInfo(APIModel): + """Account info.""" + + account_id: int + account_name: str + weblogin_token: str + + @property + def login_ticket(self) -> str: + return self.weblogin_token diff --git a/genshin/models/hoyolab/record.py b/genshin/models/hoyolab/record.py new file mode 100644 index 00000000..d1b69be8 --- /dev/null +++ b/genshin/models/hoyolab/record.py @@ -0,0 +1,157 @@ +"""Base hoyolab APIModels.""" +from __future__ import annotations + +import enum +import re +import typing + +import pydantic + +from genshin import types +from genshin.models.model import Aliased, APIModel + +__all__ = [ + "Gender", + "GenshinAccount", + "RecordCard", + "RecordCardData", + "RecordCardSetting", + "SearchUser", +] + + +class GenshinAccount(APIModel): + """Genshin account.""" + + game_biz: str + uid: int = Aliased("game_uid") + level: int + nickname: str + server: str = Aliased("region") + server_name: str = Aliased("region_name") + + @property + def game(self) -> types.Game: + if "hk4e" in self.game_biz: + return types.Game.GENSHIN + elif "bh3" in self.game_biz: + return types.Game.HONKAI + else: + return types.Game(self.game_biz) + + +class RecordCardData(APIModel): + """Data entry of a record card.""" + + name: str + value: str + + +class RecordCardSetting(APIModel): + """Privacy setting of a record card.""" + + id: int = Aliased("switch_id") + description: str = Aliased("switch_name") + public: bool = Aliased("is_public") + + +class Gender(enum.IntEnum): + """Gender used on hoyolab.""" + + unknown = 0 + male = 1 + female = 2 + other = 3 + + +class SearchUser(APIModel): + """User from a search result.""" + + hoyolab_uid: int = Aliased("uid") + nickname: str + introduction: str = Aliased("introduce") + avatar_id: int = Aliased("avatar") + gender: Gender + icon: str = Aliased("avatar_url") + + @pydantic.validator("nickname") + def __remove_highlight(cls, v: str) -> str: + return re.sub(r"<.+?>", "", v) + + +class RecordCard(GenshinAccount): + """Hoyolab record card.""" + + def __new__(cls, **kwargs: typing.Any) -> RecordCard: + """Create the appropriate record card.""" + game_id = kwargs.get("game_id", 0) + if game_id == 1: + cls = HonkaiRecordCard + elif game_id == 2: + cls = GenshinRecordCard + + return super().__new__(cls) + + game_id: int + game_biz: str = "" + uid: int = Aliased("game_role_id") + + data: typing.Sequence[RecordCardData] + settings: typing.Sequence[RecordCardSetting] = Aliased("data_switches") + + public: bool = Aliased("is_public") + background_image: str + has_uid: bool = Aliased("has_role") + url: str + + def as_dict(self) -> typing.Dict[str, typing.Any]: + """Return data as a dictionary.""" + return {d.name: (int(d.value) if d.value.isdigit() else d.value) for d in self.data} + + +class GenshinRecordCard(RecordCard): + """Genshin record card.""" + + @property + def game(self) -> types.Game: + return types.Game.GENSHIN + + @property + def days_active(self) -> int: + return int(self.data[0].value) + + @property + def characters(self) -> int: + return int(self.data[1].value) + + @property + def achievements(self) -> int: + return int(self.data[2].value) + + @property + def spiral_abyss(self) -> str: + return self.data[3].value + + +class HonkaiRecordCard(RecordCard): + """Honkai record card.""" + + @property + def game(self) -> types.Game: + return types.Game.HONKAI + + @property + def days_active(self) -> int: + return int(self.data[0].value) + + @property + def stigmata(self) -> int: + return int(self.data[1].value) + + @property + def battlesuits(self) -> int: + return int(self.data[2].value) + + @property + def outfits(self) -> int: + return int(self.data[3].value) diff --git a/genshin/models/model.py b/genshin/models/model.py new file mode 100644 index 00000000..39528368 --- /dev/null +++ b/genshin/models/model.py @@ -0,0 +1,163 @@ +"""Modified pydantic model.""" +from __future__ import annotations + +import abc +import datetime +import typing + +import pydantic + +__all__ = ["APIModel", "Aliased", "Unique"] + +_SENTINEL = object() + + +def _get_init_fields(cls: typing.Type[APIModel]) -> typing.Tuple[typing.Set[str], typing.Set[str]]: + api_init_fields: typing.Set[str] = set() + model_init_fields: typing.Set[str] = set() + + for name, field in cls.__fields__.items(): + alias = field.field_info.extra.get("galias") + if alias: + api_init_fields.add(alias) + model_init_fields.add(name) + + for name in dir(cls): + obj = getattr(cls, name, None) + if isinstance(obj, property): + model_init_fields.add(name) + + return api_init_fields, model_init_fields + + +class APIModel(pydantic.BaseModel, abc.ABC): + """Modified pydantic model.""" + + __api_init_fields__: typing.ClassVar[typing.Set[str]] + __model_init_fields__: typing.ClassVar[typing.Set[str]] + + # nasty pydantic bug fixed only on the master branch - waiting for pypi release + if typing.TYPE_CHECKING: + _mi18n: typing.ClassVar[typing.Dict[str, typing.Dict[str, str]]] + else: + _mi18n = {} + + def __init__(self, **data: typing.Any) -> None: + """""" + # clear the docstring for pdoc + super().__init__(**data) + + def __init_subclass__(cls) -> None: + cls.__api_init_fields__, cls.__model_init_fields__ = _get_init_fields(cls) + + @pydantic.root_validator(pre=True) + def __parse_galias(cls, values: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + """Due to alias being reserved for actual aliases we use a custom alias.""" + if cls.__model_init_fields__: + # has all model init fields + if cls.__model_init_fields__.issubset(set(values)): + return values + + # has some model init fields but no api init fields + if set(values) & cls.__model_init_fields__ and not set(values) & cls.__api_init_fields__: + return values + + aliases: typing.Dict[str, str] = {} + for name, field in cls.__fields__.items(): + alias = field.field_info.extra.get("galias") + if alias is not None: + aliases[alias] = name + + return {aliases.get(name, name): value for name, value in values.items()} + + @pydantic.root_validator() + def __parse_timezones(cls, values: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + """Timezones are a pain to deal with so we at least allow a plain hour offset.""" + for name, field in cls.__fields__.items(): + if isinstance(values.get(name), datetime.datetime) and values[name].tzinfo is None: + timezone = field.field_info.extra.get("timezone", 0) + if not isinstance(timezone, datetime.timezone): + timezone = datetime.timezone(datetime.timedelta(hours=timezone)) + + values[name] = values[name].replace(tzinfo=timezone) + + return values + + def dict(self, **kwargs: typing.Any) -> typing.Dict[str, typing.Any]: + """Generate a dictionary representation of the model. + + Takes the liberty of also giving properties as fields. + """ + for name in dir(type(self)): + obj = getattr(type(self), name) + if isinstance(obj, property): + value = getattr(self, name, _SENTINEL) + + if name[0] == "_" or value is _SENTINEL or value == "": + continue + + self.__dict__[name] = value + + return super().dict(**kwargs) + + def _get_mi18n( + self, + field: typing.Union[pydantic.fields.ModelField, str], + lang: str, + *, + default: typing.Optional[str] = None, + ) -> str: + """Get localized name of a field.""" + if isinstance(field, str): + key = field.lower() + default = default or key + else: + if not field.field_info.extra.get("mi18n"): + raise TypeError(f"{field!r} does not have mi18n.") + + key = field.field_info.extra["mi18n"] + default = default or field.name + + if key not in self._mi18n: + return default + + if lang not in self._mi18n[key]: + raise TypeError(f"mi18n not loaded for {lang}") + + return self._mi18n[key][lang] + + if not typing.TYPE_CHECKING: + + class Config: + allow_mutation = False + + +class Unique(abc.ABC): + """A hashable model with an id.""" + + id: int + + def __int__(self) -> int: + return self.id + + def __hash__(self) -> int: + return hash(self.id) + + +def Aliased( + galias: typing.Optional[str] = None, + default: typing.Any = pydantic.main.Undefined, # type: ignore + *, + timezone: typing.Optional[typing.Union[int, datetime.datetime]] = None, + mi18n: typing.Optional[str] = None, + **kwargs: typing.Any, +) -> typing.Any: + """Create an aliased field.""" + if galias is not None: + kwargs.update(galias=galias) + if timezone is not None: + kwargs.update(timezone=timezone) + if mi18n is not None: + kwargs.update(mi18n=mi18n) + + return pydantic.Field(default, **kwargs) diff --git a/genshin/paginators/__init__.py b/genshin/paginators/__init__.py new file mode 100644 index 00000000..8afc4e8a --- /dev/null +++ b/genshin/paginators/__init__.py @@ -0,0 +1,3 @@ +"""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 new file mode 100644 index 00000000..6c26c69c --- /dev/null +++ b/genshin/paginators/api.py @@ -0,0 +1,131 @@ +"""Base paginators made specifically for interaction with the api.""" +from __future__ import annotations + +import abc +import typing +import warnings + +from . import base + +if typing.TYPE_CHECKING: + from genshin import models + + +__all__ = ["CursorPaginator", "PagedPaginator"] + +T = typing.TypeVar("T") +T_co = typing.TypeVar("T_co", covariant=True) +UniqueT = typing.TypeVar("UniqueT", bound="models.Unique") + + +class GetterCallback(typing.Protocol[T_co]): + """Callback for returning resources based on a page or cursor.""" + + async def __call__(self, page: int, /) -> typing.Sequence[T_co]: + """Return a sequence of resources.""" + ... + + +class APIPaginator(typing.Generic[T], base.BufferedPaginator[T], abc.ABC): + """Paginator for interaction with the api.""" + + __slots__ = ("getter",) + + getter: GetterCallback[T] + """Underlying getter that yields the next page.""" + + +class PagedPaginator(typing.Generic[T], APIPaginator[T]): + """Paginator for resources which only require a page number. + + Due to ratelimits the requests must be sequential. + """ + + __slots__ = ("_page_size", "current_page") + + getter: GetterCallback[T] + """Underlying getter that yields the next page.""" + + _page_size: typing.Optional[int] + """Expected non-zero page size to be able to tell the end.""" + + current_page: typing.Optional[int] + """Current page counter..""" + + def __init__( + self, + getter: GetterCallback[T], + *, + limit: typing.Optional[int] = None, + page_size: typing.Optional[int] = None, + ) -> None: + super().__init__(limit=limit) + self.getter = getter + self._page_size = page_size + + self.current_page = 1 + + async def next_page(self) -> typing.Optional[typing.Iterable[T]]: + """Get the next page of the paginator.""" + if self.current_page is None: + return None + + data = await self.getter(self.current_page) + + if self._page_size is None: + warnings.warn("No page size specified for resource, having to guess.") + self._page_size = len(data) + + if len(data) < self._page_size: + self.current_page = None + return data + + self.current_page += 1 + return data + + +class CursorPaginator(typing.Generic[UniqueT], APIPaginator[UniqueT]): + """Paginator based on end_id cursors.""" + + __slots__ = ("_page_size", "end_id") + + getter: GetterCallback[UniqueT] + """Underlying getter that yields the next page.""" + + end_id: typing.Optional[int] + """Current end id. If none then exhausted.""" + + _page_size: typing.Optional[int] + """Expected non-zero page size to be able to tell the end.""" + + def __init__( + self, + getter: GetterCallback[UniqueT], + *, + limit: typing.Optional[int] = None, + end_id: int = 0, + page_size: typing.Optional[int] = 20, + ) -> None: + super().__init__(limit=limit) + self.getter = getter + self.end_id = end_id + + self._page_size = page_size + + async def next_page(self) -> typing.Optional[typing.Iterable[UniqueT]]: + """Get the next page of the paginator.""" + if self.end_id is None: + self._complete() + + data = await self.getter(self.end_id) + + if self._page_size is None: + warnings.warn("No page size specified for resource, having to guess.") + self._page_size = len(data) + + if len(data) < self._page_size: + self.end_id = None + return data + + self.end_id = data[-1].id + return data diff --git a/genshin/paginators/base.py b/genshin/paginators/base.py new file mode 100644 index 00000000..2d8aac68 --- /dev/null +++ b/genshin/paginators/base.py @@ -0,0 +1,292 @@ +"""Base paginators.""" + +from __future__ import annotations + +import abc +import asyncio +import heapq +import random +import typing + +__all__ = ["BufferedPaginator", "MergedPaginator", "Paginator"] + +T = typing.TypeVar("T") + + +async def flatten(iterable: typing.AsyncIterable[T]) -> typing.Sequence[T]: + """Flatten an async iterable.""" + if isinstance(iterable, Paginator): + return await iterable.flatten() + + return [x async for x in iterable] + + +async def aiterate(iterable: typing.Iterable[T]) -> typing.AsyncIterator[T]: + """Turn a plain iterable into an async iterator.""" + for i in iterable: + yield i + + +class Paginator(typing.Generic[T], abc.ABC): + """Base paginator.""" + + __slots__ = () + + @property + def _repr_attributes(self) -> typing.Sequence[str]: + """Attributes to be used in repr.""" + return [ + attribute + for subclass in self.__class__.__mro__ + for attribute in getattr(subclass, "__slots__", ()) + if not attribute.startswith("_") + ] + + def __repr__(self) -> str: + kwargs = ", ".join(f"{name}={getattr(self, name, 'undefined')!r}" for name in self._repr_attributes) + return f"{self.__class__.__name__}({kwargs})" + + def __pretty__( + self, + fmt: typing.Callable[[typing.Any], str], + **kwargs: typing.Any, + ) -> typing.Iterator[typing.Any]: + """Devtools pretty formatting.""" + yield self.__class__.__name__ + yield "(" + yield 1 + + for name in self._repr_attributes: + yield name + yield "=" + if hasattr(self, name): + yield fmt(getattr(self, name)) + else: + yield "" + + yield 0 + + yield -1 + yield ")" + + async def next(self) -> T: + """Return the next element.""" + try: + return await self.__anext__() + except StopAsyncIteration: + raise LookupError("No elements were found") from None + + def _complete(self) -> typing.NoReturn: + """Mark paginator as complete and clear memory.""" + raise StopAsyncIteration("No more items exist in this paginator. It has been exhausted.") from None + + def __aiter__(self) -> Paginator[T]: + return self + + async def flatten(self) -> typing.Sequence[T]: + """Flatten the paginator.""" + return [item async for item in self] + + def __await__(self) -> typing.Generator[None, None, typing.Sequence[T]]: + return self.flatten().__await__() + + @abc.abstractmethod + async def __anext__(self) -> T: + ... + + +class BasicPaginator(typing.Generic[T], Paginator[T], abc.ABC): + """Paginator that simply iterates over an iterable.""" + + __slots__ = ("iterator",) + + iterator: typing.AsyncIterator[T] + """Underlying iterator.""" + + def __init__(self, iterable: typing.Union[typing.Iterable[T], typing.AsyncIterable[T]]) -> None: + if isinstance(iterable, typing.AsyncIterable): + self.iterator = iterable.__aiter__() + else: + self.iterator = aiterate(iterable) + + async def __anext__(self) -> T: + try: + return await self.iterator.__anext__() + except StopAsyncIteration: + self._complete() + + +class BufferedPaginator(typing.Generic[T], Paginator[T], abc.ABC): + """Paginator with a support for buffers.""" + + __slots__ = ("limit", "_buffer", "_counter") + + limit: typing.Optional[int] + """Limit of items to be yielded.""" + + _buffer: typing.Optional[typing.Iterator[T]] + """Item buffer. If none then exhausted.""" + + _counter: int + """Amount of yielded items so far. No guarantee to be synchronized.""" + + def __init__(self, *, limit: typing.Optional[int] = None) -> None: + self.limit = limit + + self._buffer = iter(()) + self._counter = 0 + + @property + def exhausted(self) -> bool: + """Whether all pages have been fetched.""" + return self._buffer is None + + def _complete(self) -> typing.NoReturn: + self._buffer = None + + super()._complete() + raise # pyright bug + + @abc.abstractmethod + async def next_page(self) -> typing.Optional[typing.Iterable[T]]: + """Get the next page of the paginator.""" + + async def __anext__(self) -> T: + if not self._buffer: + self._complete() + + if self.limit and self._counter >= self.limit: + self._complete() + + self._counter += 1 + + try: + return next(self._buffer) + except StopIteration: + pass + + buffer = await self.next_page() + if not buffer: + self._complete() + + self._buffer = iter(buffer) + return next(self._buffer) + + +class MergedPaginator(typing.Generic[T], Paginator[T]): + """A paginator merging a collection of iterators.""" + + __slots__ = ("iterators", "_heap", "limit", "_key", "_prepared", "_counter") + + # TODO: Use named tuples for the heap + + iterators: typing.Sequence[typing.AsyncIterator[T]] + """Entry iterators. + + Only used as pointers to a heap. + """ + + _heap: typing.List[typing.Tuple[typing.Any, int, T, typing.AsyncIterator[T]]] + """Underlying heap queue. + + List of (comparable, unique order id, value, iterator) + """ + + limit: typing.Optional[int] + """Limit of items to be yielded""" + + _key: typing.Optional[typing.Callable[[T], typing.Any]] + """Sorting key.""" + + _prepared: bool + """Whether the paginator is prepared""" + + _counter: int + """Amount of yielded items so far. No guarantee to be synchronized.""" + + def __init__( + self, + iterables: typing.Collection[typing.AsyncIterable[T]], + *, + key: typing.Optional[typing.Callable[[T], typing.Any]] = None, + limit: typing.Optional[int] = None, + ) -> None: + self.iterators = [iterable.__aiter__() for iterable in iterables] + self._key = key + self.limit = limit + + self._prepared = False + self._counter = 0 + + def _complete(self) -> typing.NoReturn: + """Mark paginator as complete and clear memory.""" + # free memory in heaps + self._heap = [] + self.iterators = [] + + super()._complete() + raise # pyright bug + + def _create_heap_item( + self, + value: T, + iterator: typing.AsyncIterator[T], + order: typing.Optional[int] = None, + ) -> typing.Tuple[typing.Any, int, T, typing.AsyncIterator[T]]: + """Create a new item for the heap queue.""" + sort_value = self._key(value) if self._key else value + if order is None: + order = random.getrandbits(16) + + return (sort_value, order, value, iterator) + + async def _prepare(self) -> None: + """Prepare the heap queue by filling it with initial values.""" + coros = (it.__anext__() for it in self.iterators) + first_values = await asyncio.gather(*coros, return_exceptions=True) + + self._heap = [] + for order, (it, value) in enumerate(zip(self.iterators, first_values)): + if isinstance(value, BaseException): + if isinstance(value, StopAsyncIteration): + continue + + raise value + + heapq.heappush(self._heap, self._create_heap_item(value, iterator=it, order=order)) + + self._prepared = True + + async def __anext__(self) -> T: + if not self._prepared: + await self._prepare() + + if not self._heap: + self._complete() + + if self.limit and self._counter >= self.limit: + self._complete() + + self._counter += 1 + + _, order, value, it = self._heap[0] + + try: + new_value = await it.__anext__() + except StopAsyncIteration: + heapq.heappop(self._heap) + return value + + heapq.heapreplace(self._heap, self._create_heap_item(new_value, iterator=it, order=order)) + + return value + + async def flatten(self, *, lazy: bool = False) -> typing.Sequence[T]: + """Flatten the paginator.""" + if self.limit is not None and lazy: + return [item async for item in self] + + coros = (flatten(i) for i in self.iterators) + lists = await asyncio.gather(*coros) + + return list(heapq.merge(*lists, key=self._key))[: self.limit] diff --git a/genshin/py.typed b/genshin/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/genshin/types.py b/genshin/types.py new file mode 100644 index 00000000..a1b10d24 --- /dev/null +++ b/genshin/types.py @@ -0,0 +1,34 @@ +"""Types used in the library.""" +import enum +import typing + +if typing.TYPE_CHECKING: + from genshin.models.model import Unique + +__all__ = ["Game", "Region"] + +UniqueT = typing.TypeVar("UniqueT", bound="Unique") + + +class Region(str, enum.Enum): + """Region to get data from.""" + + OVERSEAS = "os" + """Applies to all overseas APIs.""" + + CHINESE = "cn" + """Applies to all chinese mainland APIs.""" + + +class Game(str, enum.Enum): + """Hoyoverse game.""" + + GENSHIN = "genshin" + """Genshin Impact""" + + HONKAI = "honkai3rd" + """Honkai Impact 3rd""" + + +IDOr = typing.Union[int, UniqueT] +"""Allows partial objects.""" diff --git a/genshin/utility/__init__.py b/genshin/utility/__init__.py new file mode 100644 index 00000000..37db9bc8 --- /dev/null +++ b/genshin/utility/__init__.py @@ -0,0 +1,6 @@ +"""Utilities for genshin.py.""" +from . import geetest +from .ds import * +from .fs import * +from .genshin import * +from .honkai import * diff --git a/genshin/utility/deprecation.py b/genshin/utility/deprecation.py new file mode 100644 index 00000000..d7fd0984 --- /dev/null +++ b/genshin/utility/deprecation.py @@ -0,0 +1,52 @@ +"""Deprecation decorator.""" +import functools +import inspect +import typing +import warnings + +__all__: typing.List[str] = ["deprecated", "warn_deprecated"] + +T = typing.TypeVar("T", bound=typing.Callable[..., typing.Any]) + + +def warn_deprecated( + obj: typing.Any, + *, + alternative: typing.Optional[str] = None, + stack_level: int = 3, +) -> None: + """Raise a deprecated warning.""" + if inspect.isclass(obj) or inspect.isfunction(obj): + obj = f"{obj.__qualname__}" + + message = f"{obj} is deprecated and will be removed in the following version." + + if alternative is not None: + message += f" You can use '{alternative}' instead." + + warnings.warn(message, category=DeprecationWarning, stacklevel=stack_level) + + +def deprecated(alternative: typing.Optional[str] = None) -> typing.Callable[[T], T]: + """Mark a function as deprecated.""" + + def decorator(obj: T) -> T: + alternative_str = f"You can use `{alternative}` instead." if alternative else "" + + doc = inspect.getdoc(obj) or "" + doc += ( + "\n\n" + "!!! warning\n" + f" This function is deprecated and will be removed in the following version.\n" + f" {alternative_str}\n" + ) + obj.__doc__ = doc + + @functools.wraps(obj) + def wrapper(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: + warn_deprecated(obj, alternative=alternative, stack_level=3) + return obj(*args, **kwargs) + + return typing.cast("T", wrapper) + + return decorator diff --git a/genshin/utility/ds.py b/genshin/utility/ds.py new file mode 100644 index 00000000..45f3fe82 --- /dev/null +++ b/genshin/utility/ds.py @@ -0,0 +1,38 @@ +"""Dynamic secret generation.""" +import hashlib +import json +import random +import string +import time +import typing + +from genshin import constants, types + +__all__ = [ + "generate_cn_dynamic_secret", + "generate_dynamic_secret", +] + + +def generate_dynamic_secret(salt: str = constants.DS_SALT[types.Region.OVERSEAS]) -> str: + """Create a new overseas dynamic secret.""" + t = int(time.time()) + r = "".join(random.choices(string.ascii_letters, k=6)) + h = hashlib.md5(f"salt={salt}&t={t}&r={r}".encode()).hexdigest() + return f"{t},{r},{h}" + + +def generate_cn_dynamic_secret( + body: typing.Any = None, + query: typing.Optional[typing.Mapping[str, typing.Any]] = None, + *, + salt: str = constants.DS_SALT[types.Region.CHINESE], +) -> str: + """Create a new chinese dynamic secret.""" + t = int(time.time()) + r = random.randint(100001, 200000) + b = json.dumps(body) if body else "" + q = "&".join(f"{k}={v}" for k, v in sorted(query.items())) if query else "" + + h = hashlib.md5(f"salt={salt}&t={t}&r={r}&b={b}&q={q}".encode()).hexdigest() + return f"{t},{r},{h}" diff --git a/genshin/utility/fs.py b/genshin/utility/fs.py new file mode 100644 index 00000000..2b0745d9 --- /dev/null +++ b/genshin/utility/fs.py @@ -0,0 +1,83 @@ +"""File system related utilities.""" +import functools +import os +import pathlib +import tempfile +import typing + +__all__ = ["get_browser_cookies"] + +DOMAINS: typing.Final[typing.Sequence[str]] = ("mihoyo", "hoyolab", "hoyoverse") +ALLOWED_COOKIES: typing.Final[typing.Sequence[str]] = ("ltuid", "ltoken", "account_id", "cookie_token") + + +def _fix_windows_chrome_temp() -> None: + # temporary non-invasive fix for chrome + # https://github.com/borisbabic/browser_cookie3/issues/106#issuecomment-1000200958 + try: + local_appdata = pathlib.Path(os.environ["LOCALAPPDATA"]) + chrome_dir = local_appdata / "Google/Chrome/User Data/Default" + + os.link(chrome_dir / "Network/Cookies", chrome_dir / "Cookies") + + except (OSError, KeyError): + pass + + +def _get_browser_cookies( + browser: typing.Optional[str] = None, + *, + cookie_file: typing.Optional[str] = None, + domains: typing.Optional[typing.Sequence[str]] = None, +) -> typing.Mapping[str, str]: + """Get cookies using browser-cookie3 from several domains. + + Available browsers: chrome, chromium, opera, edge, firefox. + """ + import browser_cookie3 # pyright: ignore + + _fix_windows_chrome_temp() + + loader: typing.Callable[..., typing.Any] + + if browser is None: + if cookie_file is not None: + raise TypeError("Cannot use a cookie_file without a specified browser.") + + loader = browser_cookie3.load # pyright: ignore + + else: + if browser not in ("chrome", "chromium", "opera", "brave", "edge", "firefox"): + raise ValueError(f"Unsupported browser: {browser}") + + loader = getattr(browser_cookie3, browser) # pyright: ignore + loader = functools.partial(loader, cookie_file=cookie_file) + + domains = domains or [""] + return {cookie.name: cookie.value for domain in domains for cookie in loader(domain_name=domain)} + + +def get_browser_cookies( + browser: typing.Optional[str] = None, + *, + cookie_file: typing.Optional[str] = None, + domains: typing.Sequence[str] = DOMAINS, + allowed_cookies: typing.Sequence[str] = ALLOWED_COOKIES, +) -> typing.Mapping[str, str]: + """Get hoyolab authentication cookies from your browser for later storing. + + Available browsers: chrome, chromium, opera, edge, firefox. + """ + cookies = _get_browser_cookies(browser, cookie_file=cookie_file, domains=domains) + return {name: value for name, value in cookies.items() if name in allowed_cookies} + + +def get_tempdir() -> str: + """Get the temporary directory to be used by genshin.py.""" + directory = os.path.join(tempfile.gettempdir(), "genshin.py") + os.makedirs(directory, exist_ok=True) + return directory + + +if __name__ == "__main__": + print(get_browser_cookies("chrome")) # noqa diff --git a/genshin/utility/geetest.py b/genshin/utility/geetest.py new file mode 100644 index 00000000..630d7068 --- /dev/null +++ b/genshin/utility/geetest.py @@ -0,0 +1,40 @@ +"""Geetest utilities.""" +import base64 +import time +import typing + +import aiohttp + +__all__ = ["create_mmt", "encrypt_geetest_password"] + + +LOGIN_KEY_CERT = b""" +-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDDvekdPMHN3AYhm/vktJT+YJr7 +cI5DcsNKqdsx5DZX0gDuWFuIjzdwButrIYPNmRJ1G8ybDIF7oDW2eEpm5sMbL9zs +9ExXCdvqrn51qELbqj0XxtMTIpaCHFSI50PfPpTFV9Xt/hmyVwokoOXFlAEgCn+Q +CgGs52bFoYMtyi+xEQIDAQAB +-----END PUBLIC KEY----- +""" + + +async def create_mmt(now: typing.Optional[int] = None) -> typing.Mapping[str, typing.Any]: + """Create a new hoyolab mmt.""" + async with aiohttp.ClientSession() as session: + r = await session.get( + "https://webapi-os.account.hoyolab.com/Api/create_mmt", + params=dict(scene_type=1, now=now or int(time.time()), region="os"), + ) + + data = await r.json() + + return data["data"]["mmt_data"] + + +def encrypt_geetest_password(text: str) -> str: + """Encrypt text for geetest.""" + import rsa + + public_key = rsa.PublicKey.load_pkcs1_openssl_pem(LOGIN_KEY_CERT) + crypto = rsa.encrypt(text.encode("utf-8"), public_key) + return base64.b64encode(crypto).decode("utf-8") diff --git a/genshin/utility/genshin/__init__.py b/genshin/utility/genshin/__init__.py new file mode 100644 index 00000000..c2c87d44 --- /dev/null +++ b/genshin/utility/genshin/__init__.py @@ -0,0 +1,3 @@ +"""Genshin-related utilities.""" +from .logfile import * +from .uid import * diff --git a/genshin/utility/genshin/logfile.py b/genshin/utility/genshin/logfile.py new file mode 100644 index 00000000..b8530958 --- /dev/null +++ b/genshin/utility/genshin/logfile.py @@ -0,0 +1,65 @@ +"""Search logfile for authkeys.""" +import os +import re +import typing +import urllib.parse + +from genshin.utility import fs + +__all__ = ["extract_authkey", "get_authkey", "get_banner_ids"] + +AUTHKEY_FILE = os.path.join(fs.get_tempdir(), "genshin_authkey.txt") + + +def get_logfile() -> typing.Optional[str]: + """Find a Genshin Impact logfile.""" + mihoyo_dir = os.path.expanduser("~/AppData/LocalLow/miHoYo/") + for name in ["Genshin Impact", "原神", "YuanShen"]: + output_log = os.path.join(mihoyo_dir, name, "output_log.txt") + if os.path.isfile(output_log): + return output_log + + return None # no genshin installation + + +def _read_logfile(logfile: typing.Optional[str] = None) -> str: + """Return the contents of a logfile.""" + logfile = logfile or get_logfile() + if logfile is None: + raise FileNotFoundError("No Genshin Installation was found, could not get gacha data.") + with open(logfile) as file: + return file.read() + + +def extract_authkey(string: str) -> typing.Optional[str]: + """Extract an authkey from the provided string.""" + match = re.search(r"https://.+?authkey=([^&#]+)", string, re.MULTILINE) + if match is not None: + return urllib.parse.unquote(match.group(1)) + return None + + +def get_authkey(logfile: typing.Optional[str] = None) -> str: + """Get an authkey contained in a logfile.""" + authkey = extract_authkey(_read_logfile(logfile)) + if authkey is not None: + with open(AUTHKEY_FILE, "w") as file: + file.write(authkey) + return authkey + + # otherwise try the tempfile (may be expired!) + if os.path.isfile(AUTHKEY_FILE): + with open(AUTHKEY_FILE) as file: + return file.read() + + raise ValueError( + "No authkey could be found in the logs or in a tempfile. " + "Open the history in-game first before attempting to request it." + ) + + +def get_banner_ids(logfile: typing.Optional[str] = None) -> typing.Sequence[str]: + """Get all banner ids from a log file.""" + log = _read_logfile(logfile) + ids = re.findall(r"OnGetWebViewPageFinish:https://.+?gacha_id=([^&#]+)", log) + return list(set(ids)) diff --git a/genshin/utility/genshin/uid.py b/genshin/utility/genshin/uid.py new file mode 100644 index 00000000..44348381 --- /dev/null +++ b/genshin/utility/genshin/uid.py @@ -0,0 +1,36 @@ +"""Utility functions related to genshin.""" +import typing + +__all__ = [ + "create_short_lang_code", + "is_chinese", + "recognize_genshin_server", +] + + +def create_short_lang_code(lang: str) -> str: + """Create an alternative short lang code.""" + return lang if "zh" in lang else lang.split("-")[0] + + +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 + + raise ValueError(f"UID {uid} isn't associated with any server") + + +def is_chinese(x: typing.Union[int, str]) -> bool: + """Recognize whether the server/uid is chinese.""" + return str(x).startswith(("cn", "1", "2", "5")) diff --git a/genshin/utility/honkai/__init__.py b/genshin/utility/honkai/__init__.py new file mode 100644 index 00000000..245b4215 --- /dev/null +++ b/genshin/utility/honkai/__init__.py @@ -0,0 +1,2 @@ +"""Honkai-related utilities.""" +from .uid import * diff --git a/genshin/utility/honkai/uid.py b/genshin/utility/honkai/uid.py new file mode 100644 index 00000000..93ad5c59 --- /dev/null +++ b/genshin/utility/honkai/uid.py @@ -0,0 +1,18 @@ +"""Utility functions related to honkai.""" +__all__ = ["recognize_honkai_server"] + + +def recognize_honkai_server(uid: int) -> str: + """Recognizes which server a Honkai UID is from.""" + if 10000000 < uid < 100000000: + return "overseas01" + elif 100000000 < uid < 200000000: + return "usa01" + elif 200000000 < uid < 300000000: + return "eur01" + + # From what I can tell, CN UIDs are all over the place, + # seemingly even overlapping with overseas UIDs... + # Probably gonna need some input from actual CN players here, but I know none. + # It could be that e.g. global range is 2e8 ~ 2.5e8 + raise ValueError(f"UID {uid} isn't associated with any server") diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..f5d726e8 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,52 @@ +site_name: Genshin.py +site_description: Modern API wrapper for Genshin Impact built on asyncio and pydantic. +site_url: https://thesadru.github.io/genshin.py +theme: + name: material + palette: + - scheme: default + primary: blue grey + accent: amber + toggle: + icon: material/toggle-switch-off-outline + name: Switch to light mode + - scheme: slate + primary: blue grey + accent: amber + toggle: + icon: material/toggle-switch + name: Switch to dark mode + features: + - navigation.instant + - navigation.expand + language: en +repo_name: thesadru/genshin.py +repo_url: https://github.com/thesadru/genshin.py +plugins: + - search +markdown_extensions: + - tables + - pymdownx.highlight + - pymdownx.inlinehilite + - pymdownx.superfences + - pymdownx.snippets + - pymdownx.emoji: + emoji_index: !!python/name:materialx.emoji.twemoji + emoji_generator: !!python/name:materialx.emoji.to_svg +nav: + - index.md + - credits.md + - Usage: + - authentication.md + - battle_chronicle.md + - hoyolab.md + - daily_rewards.md + - calculator.md + - diary.md + - wish_history.md + - transactions.md + - Advanced Usage: + - configuration.md + - caching.md + - debugging.md + - cli.md diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 00000000..1033760b --- /dev/null +++ b/noxfile.py @@ -0,0 +1,103 @@ +"""Nox file.""" +from __future__ import annotations + +import logging +import pathlib +import typing + +import nox + +nox.options.sessions = ["reformat", "lint", "type-check", "test", "verify-types"] +nox.options.reuse_existing_virtualenvs = True +PACKAGE = "genshin" +GENERAL_TARGETS = ["./noxfile.py", "./genshin", "./tests"] +PYRIGHT_ENV = {"PYRIGHT_PYTHON_FORCE_VERSION": "latest"} + +LOGGER = logging.getLogger("nox") + + +def install_requirements(session: nox.Session, *requirements: str, literal: bool = False) -> None: + """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 = tuple(arg for file in files for arg in ("-r", file)) + + verbose = LOGGER.getEffectiveLevel() == logging.DEBUG - 1 # OUTPUT + + session.install("--upgrade", *requirements, silent=not verbose) + + +@nox.session() +def docs(session: nox.Session) -> None: + """Generate docs for this project using Pdoc.""" + install_requirements(session, "docs") + + output_directory = pathlib.Path("./docs/pdoc/") + session.log("Building docs into %s", output_directory) + + session.run("pdoc3", "--html", PACKAGE, "-o", str(output_directory), "--force") + session.log("Docs generated: %s", output_directory / "index.html") + + +@nox.session() +def lint(session: nox.Session) -> None: + """Run this project's modules against the pre-defined flake8 linters.""" + install_requirements(session, "lint") + session.run("flake8", *GENERAL_TARGETS) + + +@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) + session.run("isort", *GENERAL_TARGETS) + + 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 + + +@nox.session(name="test") +def test(session: nox.Session) -> None: + """Run this project's tests using pytest.""" + install_requirements(session, "pytest") + + args: typing.List[str] = session.posargs.copy() + if "--cooperative" in args: + args += ["-p", "no:asyncio"] + else: + args += ["--asyncio-mode=auto"] + + session.run( + "pytest", + "--cov=" + PACKAGE, + "--cov-report", + "html:coverage_html", + "--cov-report", + "xml:coverage.xml", + *args, + ) + + +@nox.session(name="type-check") +def type_check(session: nox.Session) -> None: + """Statically analyse and veirfy this project using pyright and mypy.""" + install_requirements(session, "typecheck") + session.run("python", "-m", "pyright", PACKAGE, env=PYRIGHT_ENV) + session.run("python", "-m", "mypy", PACKAGE) + + +@nox.session(name="verify-types") +def verify_types(session: nox.Session) -> None: + """Verify the "type completeness" of types exported by the library using pyright.""" + install_requirements(session, ".", "--force-reinstall", "--no-deps") + install_requirements(session, "typecheck") + session.run("python", "-m", "pyright", "--verifytypes", PACKAGE, "--ignoreexternal", env=PYRIGHT_ENV) + + +@nox.session(python=False) +def prettier(session: nox.Session) -> None: + """Run prettier on markdown files.""" + session.run("prettier", "-w", "*.md", "docs/*.md", "*.yml") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..235b1e6b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,39 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[tool.black] +line-length = 120 +include = ".*pyi?$" +target-version = ["py39"] + +[tool.isort] +profile = "black" + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + +[tool.pyright] +include = ["genshin"] +exclude = ["**/__init__.py", "tests/**"] + +typeCheckingMode = "strict" +reportMissingTypeStubs = "none" +reportImportCycles = "none" +reportIncompatibleMethodOverride = "none" # This relies on ordering for keyword-only arguments +reportUnusedFunction = "none" # Makes usage of validators impossible + +[tool.mypy] +warn_unreachable = false + +disallow_untyped_defs = true +ignore_missing_imports = true +install_types = true +non_interactive = true + +# pyright +warn_unused_ignores = false +warn_redundant_casts = false +allow_redefinition = true +disable_error_code = ["return-value"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..259efb2c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +aiohttp +pydantic + +# optional: +browser-cookie3 +rsa +aioredis +click diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..a6b95fac --- /dev/null +++ b/setup.py @@ -0,0 +1,44 @@ +"""Run setuptools.""" +from setuptools import find_packages, setup + +setup( + name="genshin", + version="1.0.0", + author="thesadru", + author_email="thesadru@gmail.com", + description="An API wrapper for Genshin Impact.", + keywords="hoyoverse mihoyo genshin genshin-impact honkai".split(), + url="https://github.com/thesadru/genshin.py", + project_urls={ + "Documentation": "https://thesadru.github.io/genshin.py", + "Issue tracker": "https://github.com/thesadru/genshin.py/issues", + }, + packages=find_packages(exclude=["tests.*"]), + python_requires=">=3.8", + install_requires=["aiohttp", "pydantic"], + extras_require={ + "all": ["cachetools", "browser-cookie3", "rsa", "click"], + "cookies": ["browser-cookie3"], + "cache": ["cachetools"], + "geetest": ["rsa"], + "cli": ["click"], + }, + include_package_data=True, + package_data={"genshin": ["py.typed"]}, + long_description=open("README.md", encoding="utf-8").read(), + long_description_content_type="text/markdown", + license="MIT", + classifiers=[ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: MIT License", + "Intended Audience :: Developers", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Utilities", + "Typing :: Typed", + ], +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/client/__init__.py b/tests/client/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/client/components/__init__.py b/tests/client/components/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/client/components/test_calculator.py b/tests/client/components/test_calculator.py new file mode 100644 index 00000000..8444bfad --- /dev/null +++ b/tests/client/components/test_calculator.py @@ -0,0 +1,99 @@ +import genshin + + +async def test_calculator_characters(client: genshin.Client): + characters = await client.get_calculator_characters() + assert len(characters) >= 43 + + character = min(characters, key=lambda character: character.id) + assert character.name == "Kamisato Ayaka" + assert "genshin" in character.icon + assert character.max_level == 90 + assert character.level == 0 + assert not character.collab + + +async def test_calculator_weapons(client: genshin.Client): + weapons = await client.get_calculator_weapons() + assert len(weapons) >= 126 + + weapon = min(weapons, key=lambda weapon: weapon.id) + assert weapon.name == "Dull Blade" + assert weapon.max_level == 70 + assert weapon.level == 0 + + +async def test_calculator_artifacts(client: genshin.Client): + artifacts = await client.get_calculator_artifacts() + assert len(artifacts) >= 69 + + artifact = min(artifacts, key=lambda artifact: artifact.id) + assert artifact.name == "Heart of Comradeship" + assert artifact.max_level == 12 + assert artifact.level == 0 + + +# noqa: PT018, E277 +async def test_character_talents(client: genshin.Client): + talents = await client.get_character_talents(10000002) + assert len(talents) == 7 + + talents = sorted(talents, key=lambda t: t.group_id) + + # special character - has dash + # fmt: off + assert talents[0].type == "passive" and talents[0].name == "Amatsumi Kunitsumi Sanctification" + assert talents[1].type == "passive" and talents[1].name == "Kanten Senmyou Blessing" + assert talents[2].type == "passive" and talents[2].name == "Fruits of Shinsa" + assert talents[3].type == "attack" and talents[3].name == "Normal Attack: Kamisato Art - Kabuki" + assert talents[4].type == "skill" and talents[4].name == "Kamisato Art: Hyouka" + assert talents[5].type == "dash" and talents[5].name == "Kamisato Art: Senho" + assert talents[6].type == "burst" and talents[6].name == "Kamisato Art: Soumetsu" + # fmt: on + + assert talents[0].max_level == 1 + assert talents[6].max_level == 10 + + +# noqa: PT018 +async def test_complete_artifact_set(client: genshin.Client): + artifact_id = 7554 # Gladiator's Nostalgia (feather / pos #1) + + artifacts = await client.get_complete_artifact_set(artifact_id) + artifacts = sorted(artifacts, key=lambda artifact: artifact.pos) + + assert len(artifacts) == 4 + assert artifact_id not in (a.id for a in artifacts) + + assert artifacts[0].id == 7552 and artifacts[0].name == "Gladiator's Destiny" + assert artifacts[1].id == 7555 and artifacts[1].name == "Gladiator's Longing" + assert artifacts[2].id == 7551 and artifacts[2].name == "Gladiator's Intoxication" + assert artifacts[3].id == 7553 and artifacts[3].name == "Gladiator's Triumphus" + + +async def test_calculate(client: genshin.Client): + cost = await ( + client.calculator() + .set_character(10000052, current=1, target=90) + .set_weapon(11509, current=1, target=90) + .set_artifact_set(9651, current=0, target=20) + .with_current_talents(current=1, target=10) + ) + + assert len(cost.character) == 11 + assert len(cost.weapon) == 12 + assert len(cost.artifacts) == 5 and all(len(i.list) == 2 for i in cost.artifacts) + assert len(cost.talents) == 9 + assert len(cost.total) == 25 + assert cost.total[0].name == "Mora" and cost.total[0].amount == 9_533_850 + + +async def test_calculator_characters_synced(lclient: genshin.Client): + characters = await lclient.get_calculator_characters(sync=True) + assert characters[0].level != 0 + + +async def test_character_details(lclient: genshin.Client): + # Hu Tao + details = await lclient.get_character_details(10000046) + assert details.weapon.level >= 80 diff --git a/tests/client/components/test_daily.py b/tests/client/components/test_daily.py new file mode 100644 index 00000000..18dbf75c --- /dev/null +++ b/tests/client/components/test_daily.py @@ -0,0 +1,30 @@ +import calendar +import datetime + +import genshin + + +async def test_daily_reward(lclient: genshin.Client): + signed_in, claimed_rewards = await lclient.get_reward_info() + + try: + reward = await lclient.claim_daily_reward() + except genshin.AlreadyClaimed: + assert signed_in + return + else: + assert not signed_in + + rewards = await lclient.get_monthly_rewards() + assert rewards[claimed_rewards].name == reward.name + + +async def test_monthly_rewards(lclient: genshin.Client): + rewards = await lclient.get_monthly_rewards() + now = datetime.datetime.utcnow() + assert len(rewards) == calendar.monthrange(now.year, now.month)[1] + + +async def test_claimed_rewards(lclient: genshin.Client): + claimed = await lclient.claimed_rewards(limit=10).flatten() + assert claimed[0].time <= datetime.datetime.now().astimezone() diff --git a/tests/client/components/test_diary.py b/tests/client/components/test_diary.py new file mode 100644 index 00000000..05550b4d --- /dev/null +++ b/tests/client/components/test_diary.py @@ -0,0 +1,22 @@ +import datetime + +import genshin + + +async def test_diary(lclient: genshin.Client, genshin_uid: int): + diary = await lclient.get_diary() + assert diary.uid == genshin_uid == lclient.uids[genshin.Game.GENSHIN] + assert diary.nickname == "sadru" + assert diary.month == datetime.datetime.now().month + assert diary.data.current_mora > 0 + + +async def test_diary_log(lclient: genshin.Client, genshin_uid: int): + log = lclient.diary_log(limit=10) + data = await log.flatten() + + assert data[0].amount > 0 + + assert log.data.uid == genshin_uid == lclient.uids[genshin.Game.GENSHIN] + assert log.data.nickname == "sadru" + assert log.data.month == datetime.datetime.now().month diff --git a/tests/client/components/test_genshin_chronicle.py b/tests/client/components/test_genshin_chronicle.py new file mode 100644 index 00000000..9a82894b --- /dev/null +++ b/tests/client/components/test_genshin_chronicle.py @@ -0,0 +1,55 @@ +import pytest + +import genshin + + +async def test_record_cards(client: genshin.Client, hoyolab_uid: int): + data = await client.get_record_cards(hoyolab_uid) + + assert data + + assert data[0].level >= 40 + + +async def test_genshin_user(client: genshin.Client, genshin_uid: int): + data = await client.get_genshin_user(genshin_uid) + + assert data + + +async def test_partial_genshin_user(client: genshin.Client, genshin_uid: int): + data = await client.get_partial_genshin_user(genshin_uid) + + assert data + + +async def test_spiral_abyss(client: genshin.Client, genshin_uid: int): + data = await client.get_spiral_abyss(genshin_uid, previous=True) + + assert data + + +async def test_notes(lclient: genshin.Client, genshin_uid: int): + data = await lclient.get_notes(genshin_uid) + + assert data + + +async def test_genshin_activities(client: genshin.Client, genshin_uid: int): + data = await client.get_activities(genshin_uid) + + assert data + + +async def test_full_genshin_user(client: genshin.Client, genshin_uid: int): + data = await client.get_full_genshin_user(genshin_uid) + + assert data + + +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/client/components/test_honkai_chronicle.py b/tests/client/components/test_honkai_chronicle.py new file mode 100644 index 00000000..1b49053c --- /dev/null +++ b/tests/client/components/test_honkai_chronicle.py @@ -0,0 +1,39 @@ +import genshin + + +async def test_record_cards(client: genshin.Client, hoyolab_uid: int): + data = await client.get_record_cards(hoyolab_uid) + + assert data + + assert data[0].level >= 40 + + +async def test_honkai_user(client: genshin.Client, honkai_uid: int): + data = await client.get_honkai_user(honkai_uid) + + assert data + + +async def test_honkai_abyss(client: genshin.Client, honkai_uid: int): + data = await client.get_honkai_abyss(honkai_uid) + + assert data + + +async def test_elysian_realm(client: genshin.Client, honkai_uid: int): + data = await client.get_elysian_realm(honkai_uid) + + assert data is not None + + +async def test_memorial_arena(client: genshin.Client, honkai_uid: int): + data = await client.get_memorial_arena(honkai_uid) + + assert data is not None + + +async def test_full_honkai_user(client: genshin.Client, honkai_uid: int): + data = await client.get_full_honkai_user(honkai_uid) + + assert data diff --git a/tests/client/components/test_hoyolab.py b/tests/client/components/test_hoyolab.py new file mode 100644 index 00000000..57f23e30 --- /dev/null +++ b/tests/client/components/test_hoyolab.py @@ -0,0 +1,33 @@ +import contextlib + +import genshin + + +async def test_game_accounts(lclient: genshin.Client): + data = await lclient.get_game_accounts() + + assert data + + +async def test_search(client: genshin.Client, hoyolab_uid: int): + users = await client.search_users("sadru") + + for user in users: + if user.hoyolab_uid == hoyolab_uid: + break + else: + raise AssertionError("Search did not return the correct users") + + assert user.nickname == "sadru" + + +async def test_recommended_users(client: genshin.Client): + users = await client.get_recommended_users() + + assert len(users) > 80 + + +async def test_redeem_code(lclient: genshin.Client): + # inconsistent + with contextlib.suppress(genshin.RedemptionClaimed): + await lclient.redeem_code("genshingift") diff --git a/tests/client/components/test_transaction.py b/tests/client/components/test_transaction.py new file mode 100644 index 00000000..e47ec9d3 --- /dev/null +++ b/tests/client/components/test_transaction.py @@ -0,0 +1,15 @@ +import genshin + + +async def test_transactions(lclient: genshin.Client): + log = await lclient.transaction_log("resin", limit=20) + assert log[0].kind == "resin" + assert log[0].get_reason_name() != str(log[0].reason_id) + + +async def test_merged_transactions(lclient: genshin.Client): + async for trans in lclient.transaction_log(limit=30): + if trans.kind in ("primogem", "crystal", "resin"): + assert not hasattr(trans, "name") + else: + assert hasattr(trans, "name") diff --git a/tests/client/components/test_wish.py b/tests/client/components/test_wish.py new file mode 100644 index 00000000..da4e7e5c --- /dev/null +++ b/tests/client/components/test_wish.py @@ -0,0 +1,30 @@ +import genshin + + +async def test_wish_history(lclient: genshin.Client): + history = await lclient.wish_history(200, limit=20).flatten() + + assert history[0].banner_type == 200 + assert history[0].banner_name == "Permanent Wish" + + +async def test_merged_wish_history(lclient: genshin.Client): + async for wish in lclient.wish_history([200, 302], limit=20): + assert wish.banner_type in [200, 302] + + +async def test_banner_types(lclient: genshin.Client): + banner_types = await lclient.get_banner_names() + assert sorted(banner_types.keys()) == [100, 200, 301, 302] + + +async def test_banner_details(lclient: genshin.Client): + banners = await lclient.get_banner_details() + for details in banners: + assert details.banner_type in [100, 200, 301, 302, 400] + + +async def test_gacha_items(lclient: genshin.Client): + items = await lclient.get_gacha_items() + assert items[0].is_character() + assert not items[-1].is_character() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..1e88a1b4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,176 @@ +import asyncio +import json +import os +import typing +import warnings + +import pytest + +import genshin + + +@pytest.fixture(scope="session") +def event_loop(): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + + loop = asyncio.get_event_loop() + + yield loop + loop.close() + + +@pytest.fixture(scope="session") +def cookies() -> typing.Mapping[str, str]: + try: + return {"ltuid": os.environ["LTUID"], "ltoken": os.environ["LTOKEN"]} + except KeyError: + pytest.exit("No cookies set", 1) + return {} + + +@pytest.fixture(scope="session") +def browser_cookies() -> typing.Mapping[str, str]: + try: + return genshin.utility.get_browser_cookies() + except Exception: + return {} + + +@pytest.fixture(scope="session") +def chinese_cookies() -> typing.Mapping[str, str]: + try: + return {"ltuid": os.environ["CN_LTUID"], "ltoken": os.environ["CN_LTOKEN"]} + except KeyError: + warnings.warn("No chinese cookies were set for tests") + return {} + + +@pytest.fixture(scope="session") +def local_chinese_cookies() -> typing.Mapping[str, str]: + try: + return { + "account_id": os.environ["LCN_ACCOUNT_ID"], + "cookie_token": os.environ["LCN_COOKIE_TOKEN"], + } + except KeyError: + return {} + + +@pytest.fixture(scope="session") +async def cache(): + """Return a session that gets its contents dumped into a log file.""" + cache = genshin.Cache() + yield cache + + cache = {str(key): value for key, (_, value) in cache.cache.items()} + + cache["CHARACTER_NAMES"] = [c._asdict() for c in genshin.models.CHARACTER_NAMES.values()] + cache["BATTLESUIT_IDENTIFIERS"] = genshin.models.BATTLESUIT_IDENTIFIERS + + os.makedirs(".pytest_cache", exist_ok=True) + with open(".pytest_cache/hoyo_cache.json", "w", encoding="utf-8") as file: + json.dump(cache, file, indent=4, ensure_ascii=False) + + +@pytest.fixture(scope="session") +async def client(cookies: typing.Mapping[str, str], cache: genshin.Cache): + """Return a client with environment cookies.""" + client = genshin.Client() + client.debug = True + client.set_cookies(cookies) + client.cache = cache + + return client + + +@pytest.fixture(scope="session") +async def lclient(browser_cookies: typing.Mapping[str, str], cache: genshin.Cache): + """Return the local client.""" + if not browser_cookies: + pytest.skip("Skipped local test") + + client = genshin.Client() + client.debug = True + client.default_game = genshin.Game.GENSHIN + client.set_cookies(browser_cookies) + client.set_authkey() + client.cache = cache + + return client + + +@pytest.fixture(scope="session") +async def cnclient(chinese_cookies: typing.Mapping[str, str]): + """Return the client with chinese cookies.""" + if not chinese_cookies: + pytest.skip("Skipped chinese test") + + client = genshin.Client() + client.region = genshin.types.Region.CHINESE + client.debug = True + client.set_cookies(chinese_cookies) + + return client + + +@pytest.fixture(scope="session") +async def lcnclient(local_chinese_cookies: typing.Mapping[str, str]): + """Return the local client with chinese cookies.""" + if not local_chinese_cookies: + pytest.skip("Skipped local chinese test") + return + + client = genshin.Client() + client.region = genshin.types.Region.CHINESE + client.debug = True + client.set_cookies(local_chinese_cookies) + + return client + + +@pytest.fixture(scope="session") +def genshin_uid(): + return 710785423 + + +@pytest.fixture(scope="session") +def honkai_uid(): + return 200365120 + + +@pytest.fixture(scope="session") +def hoyolab_uid(): + return 8366222 + + +@pytest.fixture(scope="session") +def genshin_cnuid(): + return 101322963 + + +@pytest.fixture(scope="session") +def miyoushe_uid(): + return 75276539 + + +def pytest_addoption(parser: pytest.Parser): + parser.addoption("--cooperative", action="store_true") + + +def pytest_collection_modifyitems(items: typing.List[pytest.Item], config: pytest.Config): + if config.option.cooperative: + for item in items: + if isinstance(item, pytest.Function) and asyncio.iscoroutinefunction(item.obj): + item.add_marker("asyncio_cooperative") + + for index, item in enumerate(items): + if "reserialization" in item.name: + break + else: + return items + + item = items.pop(index) + items.append(item) + + return items diff --git a/tests/models/__init__.py b/tests/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/models/test_model.py b/tests/models/test_model.py new file mode 100644 index 00000000..2409d7c3 --- /dev/null +++ b/tests/models/test_model.py @@ -0,0 +1,33 @@ +import os +import typing + +import genshin + +all_models: typing.Dict[typing.Type[genshin.models.APIModel], genshin.models.APIModel] = {} + + +def APIModel___new__(cls: typing.Type[genshin.models.APIModel], *args: typing.Any, **kwargs: typing.Any): + self = object.__new__(cls) + all_models[cls] = self + return self + + +genshin.models.APIModel.__new__ = APIModel___new__ + + +def test_model_reserialization(): + for cls, model in sorted(all_models.items(), key=lambda pair: pair[0].__name__): + cls(**model.dict()) + + if hasattr(model, "as_dict"): + getattr(model, "as_dict")() + + # dump all parsed models + data = ",\n".join( + f'"{cls.__name__}": {model.json(indent=4, ensure_ascii=False, models_as_dict=True)}' + for cls, model in all_models.items() + ) + data = "{" + data + "}" + os.makedirs(".pytest_cache", exist_ok=True) + with open(".pytest_cache/hoyo_parsed.json", "w", encoding="utf-8") as file: + file.write(data) diff --git a/tests/test_paginators.py b/tests/test_paginators.py new file mode 100644 index 00000000..183dfb78 --- /dev/null +++ b/tests/test_paginators.py @@ -0,0 +1,81 @@ +import typing + +import pytest + +from genshin import paginators + + +class CountingPaginator(paginators.Paginator[int]): + _index = 0 + + async def __anext__(self) -> int: + if self._index >= 5: + self._complete() + + self._index += 1 + return self._index + + +@pytest.fixture(name="counting_paginator") +def counting_paginator_fixture(): + return CountingPaginator() + + +async def test_paginator_iter(counting_paginator: paginators.Paginator[int]): + async for value in counting_paginator: + assert 1 <= value <= 5 + + +async def test_paginator_flatten(): + paginator = CountingPaginator() + assert await paginator.flatten() == [1, 2, 3, 4, 5] + + paginator = CountingPaginator() + assert await paginator == [1, 2, 3, 4, 5] + + +async def test_paginator_next(counting_paginator: paginators.Paginator[int]): + assert await counting_paginator.next() == 1 + + +async def test_paginator_next_empty(): + paginator = paginators.base.BasicPaginator(()) + + with pytest.raises(StopAsyncIteration): + await paginator.__anext__() + + with pytest.raises(LookupError): + await paginator.next() + + +async def test_buffered_paginator(): + class MockBufferedPaginator(paginators.BufferedPaginator[int]): + async def next_page(self) -> typing.Sequence[int]: + index = self._counter - 1 + return list(range(index, index + 5)) + + paginator = MockBufferedPaginator(limit=12) + assert not paginator.exhausted + + values = await paginator.flatten() + assert values == list(range(0, 12)) + + assert paginator.exhausted + + +async def test_merged_paginator(): + # from heapq.merge doc + sequences = [[1, 3, 5, 7], [0, 2, 4, 8], [5, 10, 15, 20], [], [25]] + iterators = [paginators.base.aiterate(x) for x in sequences] + + paginator = paginators.MergedPaginator(iterators) + assert await paginator.flatten() == [0, 1, 2, 3, 4, 5, 5, 7, 8, 10, 15, 20, 25] + + +async def test_merged_paginator_with_key(): + # from heapq.merge doc + sequences = [["dog", "horse"], [], ["cat", "fish", "kangaroo"], ["rhinoceros"]] + iterators = [paginators.base.aiterate(x) for x in sequences] + + paginator = paginators.MergedPaginator(iterators, key=len, limit=5) + assert await paginator.flatten(lazy=True) == ["dog", "cat", "fish", "horse", "kangaroo"]