➕ Add as submodule
This commit is contained in:
parent
5c56c644f5
commit
ac760bcee9
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[submodule "genshinstats"]
|
||||||
|
path = genshinstats
|
||||||
|
url = https://github.com/thesadru/genshinstats.git
|
@ -3,7 +3,7 @@ import re
|
|||||||
import traceback
|
import traceback
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from shutil import copyfile
|
from shutil import copyfile
|
||||||
import genshinstats as gs
|
import genshinstats.genshinstats as gs
|
||||||
|
|
||||||
|
|
||||||
async def cookiesDB(uid, Cookies, qid):
|
async def cookiesDB(uid, Cookies, qid):
|
||||||
|
@ -13,7 +13,7 @@ from pyrogram.types import Message
|
|||||||
|
|
||||||
from defs.db2 import MysSign, GetDaily, cacheDB, GetMysInfo, errorDB, GetInfo, GetSpiralAbyssInfo, GetAward
|
from defs.db2 import MysSign, GetDaily, cacheDB, GetMysInfo, errorDB, GetInfo, GetSpiralAbyssInfo, GetAward
|
||||||
from defs.event import ys_font
|
from defs.event import ys_font
|
||||||
from genshinstats.daily import DailyRewardInfo
|
from genshinstats.genshinstats.daily import DailyRewardInfo
|
||||||
|
|
||||||
WEAPON_PATH = os.path.join("assets", 'weapon')
|
WEAPON_PATH = os.path.join("assets", 'weapon')
|
||||||
BG_PATH = os.path.join("assets", "bg")
|
BG_PATH = os.path.join("assets", "bg")
|
||||||
|
1
genshinstats
Submodule
1
genshinstats
Submodule
@ -0,0 +1 @@
|
|||||||
|
Subproject commit 5760a38384e6dfdf95ec4ebdf410c3bee0537003
|
@ -1,18 +0,0 @@
|
|||||||
"""Wrapper for the Genshin Impact's api.
|
|
||||||
|
|
||||||
This is an unofficial wrapper for the Genshin Impact gameRecord and wish history api.
|
|
||||||
Majority of the endpoints are implemented, documented and typehinted.
|
|
||||||
|
|
||||||
All endpoints require to be logged in with either a cookie or an authkey, read the README.md for more info.
|
|
||||||
|
|
||||||
https://github.com/thesadru/genshinstats
|
|
||||||
"""
|
|
||||||
from .caching import *
|
|
||||||
from .daily import *
|
|
||||||
from .errors import *
|
|
||||||
from .genshinstats import *
|
|
||||||
from .hoyolab import *
|
|
||||||
from .map import *
|
|
||||||
from .transactions import *
|
|
||||||
from .utils import *
|
|
||||||
from .wishes import *
|
|
@ -1,206 +0,0 @@
|
|||||||
"""Install a cache into genshinstats"""
|
|
||||||
import inspect
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from functools import update_wrapper
|
|
||||||
from itertools import islice
|
|
||||||
from typing import Any, Callable, Dict, List, MutableMapping, Tuple, TypeVar
|
|
||||||
|
|
||||||
import genshinstats as gs
|
|
||||||
|
|
||||||
__all__ = ["permanent_cache", "install_cache", "uninstall_cache"]
|
|
||||||
|
|
||||||
C = TypeVar("C", bound=Callable[..., Any])
|
|
||||||
|
|
||||||
|
|
||||||
def permanent_cache(*params: str) -> Callable[[C], C]:
|
|
||||||
"""Like lru_cache except permanent and only caches based on some parameters"""
|
|
||||||
cache: Dict[Any, Any] = {}
|
|
||||||
|
|
||||||
def wrapper(func):
|
|
||||||
sig = inspect.signature(func)
|
|
||||||
|
|
||||||
def inner(*args, **kwargs):
|
|
||||||
bound = sig.bind(*args, **kwargs)
|
|
||||||
bound.apply_defaults()
|
|
||||||
# since the amount of arguments is constant we can just save the values
|
|
||||||
key = tuple(v for k, v in bound.arguments.items() if k in params)
|
|
||||||
|
|
||||||
if key in cache:
|
|
||||||
return cache[key]
|
|
||||||
r = func(*args, **kwargs)
|
|
||||||
if r is not None:
|
|
||||||
cache[key] = r
|
|
||||||
return r
|
|
||||||
|
|
||||||
inner.cache = cache
|
|
||||||
return update_wrapper(inner, func)
|
|
||||||
|
|
||||||
return wrapper # type: ignore
|
|
||||||
|
|
||||||
|
|
||||||
def cache_func(func: C, cache: MutableMapping[Tuple[Any, ...], Any]) -> C:
|
|
||||||
"""Caches a normal function"""
|
|
||||||
# prevent possible repeated cachings
|
|
||||||
if hasattr(func, "__cache__"):
|
|
||||||
return func
|
|
||||||
|
|
||||||
sig = inspect.signature(func)
|
|
||||||
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
# create key (func name, *arguments)
|
|
||||||
bound = sig.bind(*args, **kwargs)
|
|
||||||
bound.apply_defaults()
|
|
||||||
key = tuple(v for k, v in bound.arguments.items() if k != "cookie")
|
|
||||||
key = (func.__name__,) + key
|
|
||||||
|
|
||||||
if key in cache:
|
|
||||||
return cache[key]
|
|
||||||
|
|
||||||
r = func(*args, **kwargs)
|
|
||||||
if r is not None:
|
|
||||||
cache[key] = r
|
|
||||||
return r
|
|
||||||
|
|
||||||
setattr(wrapper, "__cache__", cache)
|
|
||||||
setattr(wrapper, "__original__", func)
|
|
||||||
return update_wrapper(wrapper, func) # type: ignore
|
|
||||||
|
|
||||||
|
|
||||||
def cache_paginator(
|
|
||||||
func: C, cache: MutableMapping[Tuple[Any, ...], Any], strict: bool = False
|
|
||||||
) -> C:
|
|
||||||
"""Caches an id generator such as wish history
|
|
||||||
|
|
||||||
Respects size and authkey.
|
|
||||||
If strict mode is on then the first item of the paginator will no longer be requested every time.
|
|
||||||
"""
|
|
||||||
if hasattr(func, "__cache__"):
|
|
||||||
return func
|
|
||||||
|
|
||||||
sig = inspect.signature(func)
|
|
||||||
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
# create key (func name, end id, *arguments)
|
|
||||||
bound = sig.bind(*args, **kwargs)
|
|
||||||
bound.apply_defaults()
|
|
||||||
arguments = bound.arguments
|
|
||||||
|
|
||||||
# remove arguments that might cause problems
|
|
||||||
size, authkey, end_id = [arguments.pop(k) for k in ("size", "authkey", "end_id")]
|
|
||||||
partial_key = tuple(arguments.values())
|
|
||||||
|
|
||||||
# special recursive case must be ignored
|
|
||||||
# otherwise an infinite recursion due to end_id resets will occur
|
|
||||||
if "banner_type" in arguments and arguments["banner_type"] is None:
|
|
||||||
return func(*args, **kwargs)
|
|
||||||
|
|
||||||
def make_key(end_id: int) -> Tuple[Any, ...]:
|
|
||||||
return (
|
|
||||||
func.__name__,
|
|
||||||
end_id,
|
|
||||||
) + partial_key
|
|
||||||
|
|
||||||
def helper(end_id: int):
|
|
||||||
while True:
|
|
||||||
# yield new items from the cache
|
|
||||||
key = make_key(end_id)
|
|
||||||
while key in cache:
|
|
||||||
yield cache[key]
|
|
||||||
end_id = cache[key]["id"]
|
|
||||||
key = make_key(end_id)
|
|
||||||
|
|
||||||
# look ahead and add new items to the cache
|
|
||||||
# since the size limit is always 20 we use that to make only a single request
|
|
||||||
new = list(func(size=20, authkey=authkey, end_id=end_id, **arguments))
|
|
||||||
if not new:
|
|
||||||
break
|
|
||||||
# the head may not want to be cached so it must be handled separately
|
|
||||||
if end_id != 0 or strict:
|
|
||||||
cache[make_key(end_id)] = new[0]
|
|
||||||
if end_id == 0:
|
|
||||||
yield new[0]
|
|
||||||
end_id = new[0]["id"]
|
|
||||||
|
|
||||||
for p, n in zip(new, new[1:]):
|
|
||||||
cache[make_key(p["id"])] = n
|
|
||||||
|
|
||||||
return islice(helper(end_id), size)
|
|
||||||
|
|
||||||
setattr(wrapper, "__cache__", cache)
|
|
||||||
setattr(wrapper, "__original__", func)
|
|
||||||
return update_wrapper(wrapper, func) # type: ignore
|
|
||||||
|
|
||||||
|
|
||||||
def install_cache(cache: MutableMapping[Tuple[Any, ...], Any], strict: bool = False) -> None:
|
|
||||||
"""Installs a cache into every cacheable function in genshinstats
|
|
||||||
|
|
||||||
If strict mode is on then the first item of the paginator will no longer be requested every time.
|
|
||||||
That can however cause a variety of problems and it's therefore recommend to use it only with TTL caches.
|
|
||||||
|
|
||||||
Please do note that hundreds of accesses may be made per call so your cache shouldn't be doing heavy computations during accesses.
|
|
||||||
"""
|
|
||||||
functions: List[Callable] = [
|
|
||||||
# genshinstats
|
|
||||||
gs.get_user_stats,
|
|
||||||
gs.get_characters,
|
|
||||||
gs.get_spiral_abyss,
|
|
||||||
# wishes
|
|
||||||
gs.get_banner_details,
|
|
||||||
gs.get_gacha_items,
|
|
||||||
# hoyolab
|
|
||||||
gs.search,
|
|
||||||
gs.get_record_card,
|
|
||||||
gs.get_recommended_users,
|
|
||||||
]
|
|
||||||
paginators: List[Callable] = [
|
|
||||||
# wishes
|
|
||||||
gs.get_wish_history,
|
|
||||||
# transactions
|
|
||||||
gs.get_artifact_log,
|
|
||||||
gs.get_crystal_log,
|
|
||||||
gs.get_primogem_log,
|
|
||||||
gs.get_resin_log,
|
|
||||||
gs.get_weapon_log,
|
|
||||||
]
|
|
||||||
invalid: List[Callable] = [
|
|
||||||
# normal generator
|
|
||||||
gs.get_claimed_rewards,
|
|
||||||
# cookie dependent
|
|
||||||
gs.get_daily_reward_info,
|
|
||||||
gs.get_game_accounts,
|
|
||||||
]
|
|
||||||
|
|
||||||
wrapped = []
|
|
||||||
for func in functions:
|
|
||||||
wrapped.append(cache_func(func, cache))
|
|
||||||
for func in paginators:
|
|
||||||
wrapped.append(cache_paginator(func, cache, strict=strict))
|
|
||||||
|
|
||||||
for func in wrapped:
|
|
||||||
# ensure we only replace actual functions from the genshinstats directory
|
|
||||||
for module in sys.modules.values():
|
|
||||||
if not hasattr(module, func.__name__):
|
|
||||||
continue
|
|
||||||
orig_func = getattr(module, func.__name__)
|
|
||||||
if (
|
|
||||||
os.path.split(orig_func.__globals__["__file__"])[0]
|
|
||||||
!= os.path.split(func.__globals__["__file__"])[0] # type: ignore
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
|
|
||||||
setattr(module, func.__name__, func)
|
|
||||||
|
|
||||||
|
|
||||||
def uninstall_cache() -> None:
|
|
||||||
"""Uninstalls the cache from all functions"""
|
|
||||||
modules = sys.modules.copy()
|
|
||||||
for module in modules.values():
|
|
||||||
try:
|
|
||||||
members = inspect.getmembers(module)
|
|
||||||
except ModuleNotFoundError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
for name, func in members:
|
|
||||||
if hasattr(func, "__cache__"):
|
|
||||||
setattr(module, name, getattr(func, "__original__", func))
|
|
@ -1,106 +0,0 @@
|
|||||||
"""Automatic sign-in for hoyolab's daily rewards.
|
|
||||||
|
|
||||||
Automatically claims the next daily reward in the daily check-in rewards.
|
|
||||||
"""
|
|
||||||
from typing import Any, Dict, Iterator, List, Mapping, NamedTuple, Optional
|
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
from .caching import permanent_cache
|
|
||||||
from .genshinstats import fetch_endpoint
|
|
||||||
from .hoyolab import get_game_accounts
|
|
||||||
from .utils import recognize_server
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"fetch_daily_endpoint",
|
|
||||||
"get_daily_reward_info",
|
|
||||||
"get_claimed_rewards",
|
|
||||||
"get_monthly_rewards",
|
|
||||||
"claim_daily_reward",
|
|
||||||
]
|
|
||||||
|
|
||||||
OS_URL = "https://hk4e-api-os.mihoyo.com/event/sol/" # overseas
|
|
||||||
OS_ACT_ID = "e202102251931481"
|
|
||||||
CN_URL = "https://api-takumi.mihoyo.com/event/bbs_sign_reward/" # chinese
|
|
||||||
CN_ACT_ID = "e202009291139501"
|
|
||||||
|
|
||||||
|
|
||||||
class DailyRewardInfo(NamedTuple):
|
|
||||||
signed_in: bool
|
|
||||||
claimed_rewards: int
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_daily_endpoint(endpoint: str, chinese: bool = False, **kwargs) -> Dict[str, Any]:
|
|
||||||
"""Fetch an enpoint for daily rewards"""
|
|
||||||
url, act_id = (CN_URL, CN_ACT_ID) if chinese else (OS_URL, OS_ACT_ID)
|
|
||||||
kwargs.setdefault("params", {})["act_id"] = act_id
|
|
||||||
url = urljoin(url, endpoint)
|
|
||||||
|
|
||||||
return fetch_endpoint(url, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def get_daily_reward_info(
|
|
||||||
chinese: bool = False, cookie: Mapping[str, Any] = None
|
|
||||||
) -> DailyRewardInfo:
|
|
||||||
"""Fetches daily award info for the currently logged-in user.
|
|
||||||
|
|
||||||
Returns a tuple - whether the user is logged in, how many total rewards the user has claimed so far
|
|
||||||
"""
|
|
||||||
data = fetch_daily_endpoint("info", chinese, cookie=cookie)
|
|
||||||
return DailyRewardInfo(data["is_sign"], data["total_sign_day"])
|
|
||||||
|
|
||||||
|
|
||||||
@permanent_cache("chinese", "lang")
|
|
||||||
def get_monthly_rewards(
|
|
||||||
chinese: bool = False, lang: str = "en-us", cookie: Mapping[str, Any] = None
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""Gets a list of avalible rewards for the current month"""
|
|
||||||
return fetch_daily_endpoint("home", chinese, cookie=cookie, params=dict(lang=lang))["awards"]
|
|
||||||
|
|
||||||
|
|
||||||
def get_claimed_rewards(
|
|
||||||
chinese: bool = False, cookie: Mapping[str, Any] = None
|
|
||||||
) -> Iterator[Dict[str, Any]]:
|
|
||||||
"""Gets all claimed awards for the currently logged-in user"""
|
|
||||||
current_page = 1
|
|
||||||
while True:
|
|
||||||
data = fetch_daily_endpoint(
|
|
||||||
"award", chinese, cookie=cookie, params=dict(current_page=current_page)
|
|
||||||
)["list"]
|
|
||||||
yield from data
|
|
||||||
if len(data) < 10:
|
|
||||||
break
|
|
||||||
current_page += 1
|
|
||||||
|
|
||||||
|
|
||||||
def claim_daily_reward(
|
|
||||||
uid: int = None, chinese: bool = False, lang: str = "en-us", cookie: Mapping[str, Any] = None
|
|
||||||
) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Signs into hoyolab and claims the daily rewards.
|
|
||||||
|
|
||||||
Chinese and overseas servers work a bit differently,
|
|
||||||
so you must specify whether you want to claim rewards for chinese accounts.
|
|
||||||
|
|
||||||
When claiming rewards for other users you may add a cookie argument.
|
|
||||||
|
|
||||||
Returns the claimed reward or None if the reward cannot be claimed yet.
|
|
||||||
"""
|
|
||||||
signed_in, claimed_rewards = get_daily_reward_info(chinese, cookie)
|
|
||||||
if signed_in:
|
|
||||||
return None
|
|
||||||
|
|
||||||
params = {}
|
|
||||||
|
|
||||||
if chinese:
|
|
||||||
if uid is None:
|
|
||||||
accounts = get_game_accounts(chinese=True, cookie=cookie)
|
|
||||||
params["game_uid"] = accounts[0]["game_uid"]
|
|
||||||
params["region"] = accounts[0]["region"]
|
|
||||||
else:
|
|
||||||
params["game_uid"] = uid
|
|
||||||
params["region"] = recognize_server(uid)
|
|
||||||
|
|
||||||
params["lang"] = lang
|
|
||||||
|
|
||||||
fetch_daily_endpoint("sign", chinese, cookie=cookie, method="POST", params=params)
|
|
||||||
rewards = get_monthly_rewards(chinese, lang, cookie)
|
|
||||||
return rewards[claimed_rewards]
|
|
@ -1,115 +0,0 @@
|
|||||||
"""Genshinstats errors.
|
|
||||||
|
|
||||||
These take in only a single argument: msg.
|
|
||||||
It's possible to add retcodes and the original api response message with `.set_reponse()`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class GenshinStatsException(Exception):
|
|
||||||
"""Base Exception for all genshinstats errors."""
|
|
||||||
|
|
||||||
retcode: int = 0
|
|
||||||
orig_msg: str = ""
|
|
||||||
|
|
||||||
def __init__(self, msg: str) -> None:
|
|
||||||
self.msg = msg
|
|
||||||
|
|
||||||
def set_response(self, response: dict) -> None:
|
|
||||||
"""Adds an optional response object to the error."""
|
|
||||||
self.retcode = response["retcode"]
|
|
||||||
self.orig_msg = response["message"]
|
|
||||||
self.msg = self.msg.format(self.retcode, self.orig_msg)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def msg(self) -> str:
|
|
||||||
return self.args[0]
|
|
||||||
|
|
||||||
@msg.setter
|
|
||||||
def msg(self, msg) -> None:
|
|
||||||
self.args = (msg,)
|
|
||||||
|
|
||||||
|
|
||||||
class TooManyRequests(GenshinStatsException):
|
|
||||||
"""Made too many requests and got ratelimited"""
|
|
||||||
|
|
||||||
|
|
||||||
class NotLoggedIn(GenshinStatsException):
|
|
||||||
"""Cookies have not been provided."""
|
|
||||||
|
|
||||||
|
|
||||||
class AccountNotFound(GenshinStatsException):
|
|
||||||
"""Tried to get data with an invalid uid."""
|
|
||||||
|
|
||||||
|
|
||||||
class DataNotPublic(GenshinStatsException):
|
|
||||||
"""User hasn't set their data to public."""
|
|
||||||
|
|
||||||
|
|
||||||
class CodeRedeemException(GenshinStatsException):
|
|
||||||
"""Code redemption failed."""
|
|
||||||
|
|
||||||
|
|
||||||
class SignInException(GenshinStatsException):
|
|
||||||
"""Sign-in failed"""
|
|
||||||
|
|
||||||
|
|
||||||
class AuthkeyError(GenshinStatsException):
|
|
||||||
"""Base GachaLog Exception."""
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidAuthkey(AuthkeyError):
|
|
||||||
"""An authkey is invalid."""
|
|
||||||
|
|
||||||
|
|
||||||
class AuthkeyTimeout(AuthkeyError):
|
|
||||||
"""An authkey has timed out."""
|
|
||||||
|
|
||||||
|
|
||||||
class MissingAuthKey(AuthkeyError):
|
|
||||||
"""No gacha authkey was found."""
|
|
||||||
|
|
||||||
|
|
||||||
def raise_for_error(response: dict):
|
|
||||||
"""Raises a custom genshinstats error from a response."""
|
|
||||||
# every error uses a different response code and message,
|
|
||||||
# but the codes are not unique so we must check the message at some points too.
|
|
||||||
error = {
|
|
||||||
# general
|
|
||||||
10101: TooManyRequests("Cannnot get data for more than 30 accounts per cookie per day."),
|
|
||||||
-100: NotLoggedIn("Login cookies have not been provided or are incorrect."),
|
|
||||||
10001: NotLoggedIn("Login cookies have not been provided or are incorrect."),
|
|
||||||
10102: DataNotPublic("User's data is not public"),
|
|
||||||
1009: AccountNotFound("Could not find user; uid may not be valid."),
|
|
||||||
-1: GenshinStatsException("Internal database error, see original message"),
|
|
||||||
-10002: AccountNotFound(
|
|
||||||
"Cannot get rewards info. Account has no game account binded to it."
|
|
||||||
),
|
|
||||||
-108: GenshinStatsException("Language is not valid."),
|
|
||||||
10103: NotLoggedIn("Cookies are correct but do not have a hoyolab account bound to them."),
|
|
||||||
# code redemption
|
|
||||||
-2003: CodeRedeemException("Invalid redemption code"),
|
|
||||||
-2007: CodeRedeemException("You have already used a redemption code of the same kind."),
|
|
||||||
-2017: CodeRedeemException("Redemption code has been claimed already."),
|
|
||||||
-2018: CodeRedeemException("This Redemption Code is already in use"),
|
|
||||||
-2001: CodeRedeemException("Redemption code has expired."),
|
|
||||||
-2021: CodeRedeemException(
|
|
||||||
"Cannot claim codes for account with adventure rank lower than 10."
|
|
||||||
),
|
|
||||||
-1073: CodeRedeemException("Cannot claim code. Account has no game account bound to it."),
|
|
||||||
-1071: NotLoggedIn(
|
|
||||||
"Login cookies from redeem_code() have not been provided or are incorrect. "
|
|
||||||
"Make sure you use account_id and cookie_token cookies."
|
|
||||||
),
|
|
||||||
# sign in
|
|
||||||
-5003: SignInException("Already claimed daily reward today."),
|
|
||||||
2001: SignInException("Already checked into hoyolab today."),
|
|
||||||
# gacha log
|
|
||||||
-100: InvalidAuthkey("Authkey is not valid.")
|
|
||||||
if response["message"] == "authkey error"
|
|
||||||
else NotLoggedIn("Login cookies have not been provided or are incorrect."),
|
|
||||||
-101: AuthkeyTimeout(
|
|
||||||
"Authkey has timed-out. Update it by opening the history page in Genshin."
|
|
||||||
),
|
|
||||||
}.get(response["retcode"], GenshinStatsException("{} Error ({})"))
|
|
||||||
error.set_response(response)
|
|
||||||
raise error
|
|
@ -1,357 +0,0 @@
|
|||||||
"""Wrapper for the hoyolab.com gameRecord api.
|
|
||||||
|
|
||||||
Can fetch data for a user's stats like stats, characters, spiral abyss runs...
|
|
||||||
"""
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import random
|
|
||||||
import string
|
|
||||||
import time
|
|
||||||
from http.cookies import SimpleCookie
|
|
||||||
from typing import Any, Dict, List, Mapping, MutableMapping, Union
|
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from requests.sessions import RequestsCookieJar, Session
|
|
||||||
|
|
||||||
from .errors import NotLoggedIn, TooManyRequests, raise_for_error
|
|
||||||
from .pretty import (
|
|
||||||
prettify_abyss,
|
|
||||||
prettify_activities,
|
|
||||||
prettify_characters,
|
|
||||||
prettify_notes,
|
|
||||||
prettify_stats,
|
|
||||||
)
|
|
||||||
from .utils import USER_AGENT, is_chinese, recognize_server, retry
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"set_cookie",
|
|
||||||
"set_cookies",
|
|
||||||
"get_browser_cookies",
|
|
||||||
"set_cookies_auto",
|
|
||||||
"set_cookie_auto",
|
|
||||||
"fetch_endpoint",
|
|
||||||
"get_user_stats",
|
|
||||||
"get_characters",
|
|
||||||
"get_spiral_abyss",
|
|
||||||
"get_notes",
|
|
||||||
"get_activities",
|
|
||||||
"get_all_user_data",
|
|
||||||
]
|
|
||||||
|
|
||||||
session = Session()
|
|
||||||
session.headers.update(
|
|
||||||
{
|
|
||||||
# required headers
|
|
||||||
"x-rpc-app_version": "",
|
|
||||||
"x-rpc-client_type": "",
|
|
||||||
"x-rpc-language": "en-us",
|
|
||||||
# authentications headers
|
|
||||||
"ds": "",
|
|
||||||
# recommended headers
|
|
||||||
"user-agent": USER_AGENT,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
cookies: List[RequestsCookieJar] = [] # a list of all avalible cookies
|
|
||||||
|
|
||||||
OS_DS_SALT = "6cqshh5dhw73bzxn20oexa9k516chk7s"
|
|
||||||
CN_DS_SALT = "xV8v4Qu54lUKrEYFZkJhB8cuOh9Asafs"
|
|
||||||
OS_TAKUMI_URL = "https://api-os-takumi.mihoyo.com/" # overseas
|
|
||||||
CN_TAKUMI_URL = "https://api-takumi.mihoyo.com/" # chinese
|
|
||||||
OS_GAME_RECORD_URL = "https://bbs-api-os.mihoyo.com/game_record/"
|
|
||||||
CN_GAME_RECORD_URL = "https://api-takumi.mihoyo.com/game_record/app/"
|
|
||||||
|
|
||||||
|
|
||||||
def set_cookie(cookie: Union[Mapping[str, Any], str] = None, **kwargs: Any) -> None:
|
|
||||||
"""Logs-in using a cookie.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
>>> set_cookie(ltuid=..., ltoken=...)
|
|
||||||
>>> set_cookie(account_id=..., cookie_token=...)
|
|
||||||
>>> set_cookie({'ltuid': ..., 'ltoken': ...})
|
|
||||||
>>> set_cookie("ltuid=...; ltoken=...")
|
|
||||||
"""
|
|
||||||
if bool(cookie) == bool(kwargs):
|
|
||||||
raise ValueError("Cannot use both positional and keyword arguments at once")
|
|
||||||
|
|
||||||
set_cookies(cookie or kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def set_cookies(*args: Union[Mapping[str, Any], str], clear: bool = True) -> None:
|
|
||||||
"""Sets multiple cookies at once to cycle between. Takes same arguments as set_cookie.
|
|
||||||
|
|
||||||
Unlike set_cookie, this function allows for multiple cookies to be used at once.
|
|
||||||
This is so far the only way to circumvent the rate limit.
|
|
||||||
|
|
||||||
If clear is set to False the previously set cookies won't be cleared.
|
|
||||||
"""
|
|
||||||
if clear:
|
|
||||||
cookies.clear()
|
|
||||||
|
|
||||||
for cookie in args:
|
|
||||||
if isinstance(cookie, Mapping):
|
|
||||||
cookie = {k: str(v) for k, v in cookie.items()} # SimpleCookie needs a string
|
|
||||||
cookie = SimpleCookie(cookie)
|
|
||||||
|
|
||||||
jar = RequestsCookieJar()
|
|
||||||
jar.update(cookie)
|
|
||||||
cookies.append(jar)
|
|
||||||
|
|
||||||
|
|
||||||
def get_browser_cookies(browser: str = None) -> Dict[str, str]:
|
|
||||||
"""Gets cookies from your browser for later storing.
|
|
||||||
|
|
||||||
If a specific browser is set, gets data from that browser only.
|
|
||||||
Avalible browsers: chrome, chromium, opera, edge, firefox
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
import browser_cookie3 # optional library
|
|
||||||
except ImportError:
|
|
||||||
raise ImportError(
|
|
||||||
"functions 'set_cookie_auto' and 'get_browser_cookie` require \"browser-cookie3\". "
|
|
||||||
'To use these function please install the dependency with "pip install browser-cookie3".'
|
|
||||||
)
|
|
||||||
load = getattr(browser_cookie3, browser.lower()) if browser else browser_cookie3.load
|
|
||||||
# For backwards compatibility we also get account_id and cookie_token
|
|
||||||
# however we can't just get every cookie because there's sensitive information
|
|
||||||
allowed_cookies = {"ltuid", "ltoken", "account_id", "cookie_token"}
|
|
||||||
return {
|
|
||||||
c.name: c.value
|
|
||||||
for domain in ("mihoyo", "hoyolab")
|
|
||||||
for c in load(domain_name=domain)
|
|
||||||
if c.name in allowed_cookies and c.value is not None
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def set_cookie_auto(browser: str = None) -> None:
|
|
||||||
"""Like set_cookie, but gets the cookies by itself from your browser.
|
|
||||||
|
|
||||||
Requires the module browser-cookie3
|
|
||||||
Be aware that this process can take up to 10 seconds.
|
|
||||||
To speed it up you may select a browser.
|
|
||||||
|
|
||||||
If a specific browser is set, gets data from that browser only.
|
|
||||||
Avalible browsers: chrome, chromium, opera, edge, firefox
|
|
||||||
"""
|
|
||||||
set_cookies(get_browser_cookies(browser), clear=True)
|
|
||||||
|
|
||||||
|
|
||||||
set_cookies_auto = set_cookie_auto # alias
|
|
||||||
|
|
||||||
|
|
||||||
def generate_ds(salt: str) -> str:
|
|
||||||
"""Creates a new ds for authentication."""
|
|
||||||
t = int(time.time()) # current seconds
|
|
||||||
r = "".join(random.choices(string.ascii_letters, k=6)) # 6 random chars
|
|
||||||
h = hashlib.md5(f"salt={salt}&t={t}&r={r}".encode()).hexdigest() # hash and get hex
|
|
||||||
return f"{t},{r},{h}"
|
|
||||||
|
|
||||||
|
|
||||||
def generate_cn_ds(salt: str, body: Any = None, query: Mapping[str, Any] = None) -> str:
|
|
||||||
"""Creates a new chinese ds for authentication."""
|
|
||||||
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}"
|
|
||||||
|
|
||||||
|
|
||||||
# sometimes a random connection error can just occur, mihoyo being mihoyo
|
|
||||||
@retry(3, requests.ConnectionError)
|
|
||||||
def _request(*args: Any, **kwargs: Any) -> Any:
|
|
||||||
"""Fancy requests.request"""
|
|
||||||
r = session.request(*args, **kwargs)
|
|
||||||
|
|
||||||
r.raise_for_status()
|
|
||||||
kwargs["cookies"].update(session.cookies)
|
|
||||||
session.cookies.clear()
|
|
||||||
data = r.json()
|
|
||||||
if data["retcode"] == 0:
|
|
||||||
return data["data"]
|
|
||||||
raise_for_error(data)
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_endpoint(
|
|
||||||
endpoint: str, chinese: bool = False, cookie: Mapping[str, Any] = None, **kwargs
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Fetch an enpoint from the API.
|
|
||||||
|
|
||||||
Takes in an endpoint url which is joined with the base url.
|
|
||||||
A request is then sent and returns a parsed response.
|
|
||||||
Includes error handling and ds token renewal.
|
|
||||||
|
|
||||||
Can specifically use the chinese base url and request data for chinese users,
|
|
||||||
but that requires being logged in as that user.
|
|
||||||
|
|
||||||
Supports handling ratelimits if multiple cookies are set with `set_cookies`
|
|
||||||
"""
|
|
||||||
# parse the arguments for requests.request
|
|
||||||
kwargs.setdefault("headers", {})
|
|
||||||
method = kwargs.pop("method", "get")
|
|
||||||
if chinese:
|
|
||||||
kwargs["headers"].update(
|
|
||||||
{
|
|
||||||
"ds": generate_cn_ds(CN_DS_SALT, kwargs.get("json"), kwargs.get("params")),
|
|
||||||
"x-rpc-app_version": "2.11.1",
|
|
||||||
"x-rpc-client_type": "5",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
url = urljoin(CN_TAKUMI_URL, endpoint)
|
|
||||||
else:
|
|
||||||
kwargs["headers"].update(
|
|
||||||
{
|
|
||||||
"ds": generate_ds(OS_DS_SALT),
|
|
||||||
"x-rpc-app_version": "1.5.0",
|
|
||||||
"x-rpc-client_type": "4",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
url = urljoin(OS_TAKUMI_URL, endpoint)
|
|
||||||
|
|
||||||
if cookie is not None:
|
|
||||||
if not isinstance(cookie, MutableMapping) or not all(
|
|
||||||
isinstance(v, str) for v in cookie.values()
|
|
||||||
):
|
|
||||||
cookie = {k: str(v) for k, v in cookie.items()}
|
|
||||||
return _request(method, url, cookies=cookie, **kwargs)
|
|
||||||
elif len(cookies) == 0:
|
|
||||||
raise NotLoggedIn("Login cookies have not been provided")
|
|
||||||
|
|
||||||
for cookie in cookies.copy():
|
|
||||||
try:
|
|
||||||
return _request(method, url, cookies=cookie, **kwargs)
|
|
||||||
except TooManyRequests:
|
|
||||||
# move the ratelimited cookie to the end to let the ratelimit wear off
|
|
||||||
cookies.append(cookies.pop(0))
|
|
||||||
|
|
||||||
# if we're here it means we used up all our cookies so we must handle that
|
|
||||||
if len(cookies) == 1:
|
|
||||||
raise TooManyRequests("Cannnot get data for more than 30 accounts per day.")
|
|
||||||
else:
|
|
||||||
raise TooManyRequests("All cookies have hit their request limit of 30 accounts per day.")
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_game_record_endpoint(
|
|
||||||
endpoint: str, chinese: bool = False, cookie: Mapping[str, Any] = None, **kwargs
|
|
||||||
):
|
|
||||||
"""A short-hand for fetching data for the game record"""
|
|
||||||
base_url = CN_GAME_RECORD_URL if chinese else OS_GAME_RECORD_URL
|
|
||||||
url = urljoin(base_url, endpoint)
|
|
||||||
return fetch_endpoint(url, chinese, cookie, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def get_user_stats(
|
|
||||||
uid: int, equipment: bool = False, lang: str = "en-us", cookie: Mapping[str, Any] = None
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Gets basic user information and stats.
|
|
||||||
|
|
||||||
If equipment is True an additional request will be made to get the character equipment
|
|
||||||
"""
|
|
||||||
server = recognize_server(uid)
|
|
||||||
data = fetch_game_record_endpoint(
|
|
||||||
"genshin/api/index",
|
|
||||||
chinese=is_chinese(uid),
|
|
||||||
cookie=cookie,
|
|
||||||
params=dict(server=server, role_id=uid),
|
|
||||||
headers={"x-rpc-language": lang},
|
|
||||||
)
|
|
||||||
data = prettify_stats(data)
|
|
||||||
if equipment:
|
|
||||||
data["characters"] = get_characters(
|
|
||||||
uid, [i["id"] for i in data["characters"]], lang, cookie
|
|
||||||
)
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def get_characters(
|
|
||||||
uid: int, character_ids: List[int] = None, lang: str = "en-us", cookie: Mapping[str, Any] = None
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""Gets characters of a user.
|
|
||||||
|
|
||||||
Characters contain info about their level, constellation, weapon, and artifacts.
|
|
||||||
Talents are not included.
|
|
||||||
|
|
||||||
If character_ids are provided then only characters with those ids are returned.
|
|
||||||
"""
|
|
||||||
if character_ids is None:
|
|
||||||
character_ids = [i["id"] for i in get_user_stats(uid)["characters"]]
|
|
||||||
|
|
||||||
server = recognize_server(uid)
|
|
||||||
data = fetch_game_record_endpoint(
|
|
||||||
"genshin/api/character",
|
|
||||||
chinese=is_chinese(uid),
|
|
||||||
cookie=cookie,
|
|
||||||
method="POST",
|
|
||||||
json=dict(
|
|
||||||
character_ids=character_ids, role_id=uid, server=server
|
|
||||||
), # POST uses the body instead
|
|
||||||
headers={"x-rpc-language": lang},
|
|
||||||
)["avatars"]
|
|
||||||
return prettify_characters(data)
|
|
||||||
|
|
||||||
|
|
||||||
def get_spiral_abyss(
|
|
||||||
uid: int, previous: bool = False, cookie: Mapping[str, Any] = None
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Gets spiral abyss runs of a user and details about them.
|
|
||||||
|
|
||||||
Every season these stats refresh and you can get the previous stats with `previous`.
|
|
||||||
"""
|
|
||||||
server = recognize_server(uid)
|
|
||||||
schedule_type = 2 if previous else 1
|
|
||||||
data = fetch_game_record_endpoint(
|
|
||||||
"genshin/api/spiralAbyss",
|
|
||||||
chinese=is_chinese(uid),
|
|
||||||
cookie=cookie,
|
|
||||||
params=dict(server=server, role_id=uid, schedule_type=schedule_type),
|
|
||||||
)
|
|
||||||
return prettify_abyss(data)
|
|
||||||
|
|
||||||
|
|
||||||
def get_activities(
|
|
||||||
uid: int, lang: str = "en-us", cookie: Mapping[str, Any] = None
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Gets the activities of the user
|
|
||||||
|
|
||||||
As of this time only Hyakunin Ikki is availible.
|
|
||||||
"""
|
|
||||||
server = recognize_server(uid)
|
|
||||||
data = fetch_game_record_endpoint(
|
|
||||||
"genshin/api/activities",
|
|
||||||
chinese=is_chinese(uid),
|
|
||||||
cookie=cookie,
|
|
||||||
params=dict(server=server, role_id=uid),
|
|
||||||
headers={"x-rpc-language": lang},
|
|
||||||
)
|
|
||||||
return prettify_activities(data)
|
|
||||||
|
|
||||||
|
|
||||||
def get_notes(uid: int, lang: str = "en-us", cookie: Mapping[str, Any] = None) -> Dict[str, Any]:
|
|
||||||
"""Gets the real-time notes of the user
|
|
||||||
|
|
||||||
Contains current resin, expeditions, daily commissions and similar.
|
|
||||||
"""
|
|
||||||
server = recognize_server(uid)
|
|
||||||
data = fetch_game_record_endpoint(
|
|
||||||
"genshin/api/dailyNote",
|
|
||||||
chinese=is_chinese(uid),
|
|
||||||
cookie=cookie,
|
|
||||||
params=dict(server=server, role_id=uid),
|
|
||||||
headers={"x-rpc-language": lang},
|
|
||||||
)
|
|
||||||
return prettify_notes(data)
|
|
||||||
|
|
||||||
|
|
||||||
def get_all_user_data(
|
|
||||||
uid: int, lang: str = "en-us", cookie: Mapping[str, Any] = None
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Fetches all data a user can has. Very slow.
|
|
||||||
|
|
||||||
A helper function that gets all avalible data for a user and returns it as one dict.
|
|
||||||
However that makes it fairly slow so it's not recommended to use it outside caching.
|
|
||||||
"""
|
|
||||||
data = get_user_stats(uid, equipment=True, lang=lang, cookie=cookie)
|
|
||||||
data["spiral_abyss"] = [get_spiral_abyss(uid, previous, cookie) for previous in [False, True]]
|
|
||||||
return data
|
|
@ -1,175 +0,0 @@
|
|||||||
"""Wrapper for the hoyolab.com community api.
|
|
||||||
|
|
||||||
Can search users, get record cards, redeem codes...
|
|
||||||
"""
|
|
||||||
import time
|
|
||||||
from typing import Any, Dict, List, Mapping, Optional
|
|
||||||
|
|
||||||
from .caching import permanent_cache
|
|
||||||
from .genshinstats import fetch_endpoint, fetch_game_record_endpoint
|
|
||||||
from .pretty import prettify_game_accounts
|
|
||||||
from .utils import deprecated, recognize_server
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"get_langs",
|
|
||||||
"search",
|
|
||||||
"set_visibility",
|
|
||||||
"hoyolab_check_in",
|
|
||||||
"get_game_accounts",
|
|
||||||
"get_record_card",
|
|
||||||
"get_uid_from_hoyolab_uid",
|
|
||||||
"redeem_code",
|
|
||||||
"get_recommended_users",
|
|
||||||
"get_hot_posts",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@permanent_cache()
|
|
||||||
def get_langs() -> Dict[str, str]:
|
|
||||||
"""Gets codes of all languages and their names"""
|
|
||||||
data = fetch_endpoint("community/misc/wapi/langs", cookie={}, params=dict(gids=2))["langs"]
|
|
||||||
return {i["value"]: i["name"] for i in data}
|
|
||||||
|
|
||||||
|
|
||||||
def search(keyword: str, size: int = 20, chinese: bool = False) -> List[Dict[str, Any]]:
|
|
||||||
"""Searches all users.
|
|
||||||
|
|
||||||
Can return up to 20 results, based on size.
|
|
||||||
"""
|
|
||||||
url = (
|
|
||||||
"https://bbs-api.mihoyo.com/" if chinese else "https://api-os-takumi.mihoyo.com/community/"
|
|
||||||
)
|
|
||||||
return fetch_endpoint(
|
|
||||||
url + "apihub/wapi/search",
|
|
||||||
cookie={}, # this endpoint does not require cookies
|
|
||||||
params=dict(keyword=keyword, size=size, gids=2),
|
|
||||||
)["users"]
|
|
||||||
|
|
||||||
|
|
||||||
def set_visibility(public: bool, cookie: Mapping[str, Any] = None) -> None:
|
|
||||||
"""Sets your data to public or private."""
|
|
||||||
fetch_endpoint(
|
|
||||||
"game_record/card/wapi/publishGameRecord",
|
|
||||||
cookie=cookie,
|
|
||||||
method="POST",
|
|
||||||
# what's game_id about???
|
|
||||||
json=dict(is_public=public, game_id=2),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@deprecated()
|
|
||||||
def hoyolab_check_in(chinese: bool = False, cookie: Mapping[str, Any] = None) -> None:
|
|
||||||
"""Checks in the currently logged-in user to hoyolab.
|
|
||||||
|
|
||||||
This function will not claim daily rewards!!!
|
|
||||||
"""
|
|
||||||
url = (
|
|
||||||
"https://bbs-api.mihoyo.com/" if chinese else "https://api-os-takumi.mihoyo.com/community/"
|
|
||||||
)
|
|
||||||
fetch_endpoint(
|
|
||||||
url + "apihub/api/signIn", chinese=chinese, cookie=cookie, method="POST", json=dict(gids=2)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_game_accounts(
|
|
||||||
chinese: bool = False, cookie: Mapping[str, Any] = None
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""Gets all game accounts of the currently signed in player.
|
|
||||||
|
|
||||||
Can get accounts both for overseas and china.
|
|
||||||
"""
|
|
||||||
url = "https://api-takumi.mihoyo.com/" if chinese else "https://api-os-takumi.mihoyo.com/"
|
|
||||||
data = fetch_endpoint(url + "binding/api/getUserGameRolesByCookie", cookie=cookie)["list"]
|
|
||||||
return prettify_game_accounts(data)
|
|
||||||
|
|
||||||
|
|
||||||
def get_record_card(
|
|
||||||
hoyolab_uid: int, chinese: bool = False, cookie: Mapping[str, Any] = None
|
|
||||||
) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Gets a game record card of a user based on their hoyolab uid.
|
|
||||||
|
|
||||||
A record card contains data regarding the stats of a user for their displayed server.
|
|
||||||
Their uid for a given server is also included.
|
|
||||||
In case the user hasn't set their data to public or you are ratelimited the function returns None.
|
|
||||||
|
|
||||||
You can get a hoyolab id with `search`.
|
|
||||||
"""
|
|
||||||
cards = fetch_game_record_endpoint(
|
|
||||||
"card/wapi/getGameRecordCard",
|
|
||||||
chinese=chinese,
|
|
||||||
cookie=cookie,
|
|
||||||
params=dict(uid=hoyolab_uid, gids=2),
|
|
||||||
)["list"]
|
|
||||||
return cards[0] if cards else None
|
|
||||||
|
|
||||||
|
|
||||||
def get_uid_from_hoyolab_uid(
|
|
||||||
hoyolab_uid: int, chinese: bool = False, cookie: Mapping[str, Any] = None
|
|
||||||
) -> Optional[int]:
|
|
||||||
"""Gets a uid with a community uid.
|
|
||||||
|
|
||||||
This is so it's possible to search a user and then directly get the uid.
|
|
||||||
In case the uid is private, returns None.
|
|
||||||
"""
|
|
||||||
card = get_record_card(hoyolab_uid, chinese, cookie)
|
|
||||||
return int(card["game_role_id"]) if card else None
|
|
||||||
|
|
||||||
|
|
||||||
def redeem_code(code: str, uid: int = None, cookie: Mapping[str, Any] = None) -> None:
|
|
||||||
"""Redeems a gift code for the currently signed in user.
|
|
||||||
|
|
||||||
Api endpoint for https://genshin.mihoyo.com/en/gift.
|
|
||||||
!!! This function requires account_id and cookie_token cookies !!!
|
|
||||||
|
|
||||||
The code will be redeemed for every avalible account,
|
|
||||||
specifying the uid will claim it only for that account.
|
|
||||||
Returns the amount of users it managed to claim codes for.
|
|
||||||
|
|
||||||
You can claim codes only every 5s so you must sleep between claims.
|
|
||||||
The function sleeps for you when claiming for every account
|
|
||||||
but you must sleep yourself when passing in a uid or when an error is encountered.
|
|
||||||
|
|
||||||
Currently codes can only be claimed for overseas accounts, not chinese.
|
|
||||||
"""
|
|
||||||
if uid is not None:
|
|
||||||
fetch_endpoint(
|
|
||||||
"https://hk4e-api-os.mihoyo.com/common/apicdkey/api/webExchangeCdkey",
|
|
||||||
cookie=cookie,
|
|
||||||
params=dict(
|
|
||||||
uid=uid, region=recognize_server(uid), cdkey=code, game_biz="hk4e_global", lang="en"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# cannot claim codes for accounts with ar lower than 10
|
|
||||||
accounts = [
|
|
||||||
account for account in get_game_accounts(cookie=cookie) if account["level"] >= 10
|
|
||||||
]
|
|
||||||
for i, account in enumerate(accounts):
|
|
||||||
if i:
|
|
||||||
time.sleep(5) # there's a ratelimit of 1 request every 5 seconds
|
|
||||||
redeem_code(code, account["uid"], cookie)
|
|
||||||
|
|
||||||
|
|
||||||
def get_recommended_users(page_size: int = None) -> List[Dict[str, Any]]:
|
|
||||||
"""Gets a list of recommended active users"""
|
|
||||||
return fetch_endpoint(
|
|
||||||
"community/user/wapi/recommendActive",
|
|
||||||
cookie={},
|
|
||||||
params=dict(page_size=page_size or 0x10000, offset=0, gids=2),
|
|
||||||
)["list"]
|
|
||||||
|
|
||||||
|
|
||||||
def get_hot_posts(forum_id: int = 1, size: int = 100, lang: str = "en-us") -> List[Dict[str, Any]]:
|
|
||||||
"""Fetches hot posts from the front page of hoyolabs
|
|
||||||
|
|
||||||
Posts are split into different forums set by ids 1-5.
|
|
||||||
There may be less posts returned than size.
|
|
||||||
"""
|
|
||||||
# the api is physically unable to return more than 2 ^ 24 bytes
|
|
||||||
# that's around 2 ^ 15 posts so we limit the amount to 2 ^ 14
|
|
||||||
# the user shouldn't be getting that many posts in the first place
|
|
||||||
return fetch_endpoint(
|
|
||||||
"community/post/api/forumHotPostFullList",
|
|
||||||
cookie={},
|
|
||||||
params=dict(forum_id=forum_id, page_size=min(size, 0x4000), lang=lang),
|
|
||||||
)["posts"]
|
|
@ -1,84 +0,0 @@
|
|||||||
"""The official genshin map
|
|
||||||
|
|
||||||
Gets data from the official genshin map such as categories, points and similar.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
from typing import Any, Dict, List
|
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
from .caching import permanent_cache
|
|
||||||
from .genshinstats import fetch_endpoint
|
|
||||||
|
|
||||||
OS_MAP_URL = "https://api-os-takumi-static.mihoyo.com/common/map_user/ys_obc/v1/map/"
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"fetch_map_endpoint",
|
|
||||||
"get_map_image",
|
|
||||||
"get_map_icons",
|
|
||||||
"get_map_labels",
|
|
||||||
"get_map_locations",
|
|
||||||
"get_map_points",
|
|
||||||
"get_map_tile",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_map_endpoint(endpoint: str, **kwargs) -> Dict[str, Any]:
|
|
||||||
"""Fetch an enpoint from mihoyo's webstatic map api.
|
|
||||||
|
|
||||||
Only currently liyue is supported.
|
|
||||||
|
|
||||||
Takes in an endpoint url which is joined with the base url.
|
|
||||||
A request is then sent and returns a parsed response.
|
|
||||||
"""
|
|
||||||
kwargs.setdefault("params", {}).update({"map_id": 2, "app_sn": "ys_obc", "lang": "en-us"})
|
|
||||||
url = urljoin(OS_MAP_URL, endpoint)
|
|
||||||
return fetch_endpoint(url, cookie={}, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
@permanent_cache()
|
|
||||||
def get_map_image() -> str:
|
|
||||||
"""Get the url to the entire map image"""
|
|
||||||
data = fetch_map_endpoint("info")["info"]["detail"]
|
|
||||||
return json.loads(data)["slices"][0][0]["url"]
|
|
||||||
|
|
||||||
|
|
||||||
@permanent_cache()
|
|
||||||
def get_map_icons() -> Dict[int, str]:
|
|
||||||
"""Get all icons for the map"""
|
|
||||||
data = fetch_map_endpoint("spot_kind/get_icon_list")["icons"]
|
|
||||||
return {i["id"]: i["url"] for i in data}
|
|
||||||
|
|
||||||
|
|
||||||
@permanent_cache()
|
|
||||||
def get_map_labels() -> List[Dict[str, Any]]:
|
|
||||||
"""Get labels and label categories"""
|
|
||||||
return fetch_map_endpoint("label/tree")["tree"]
|
|
||||||
|
|
||||||
|
|
||||||
def get_map_locations() -> List[Dict[str, Any]]:
|
|
||||||
"""Get all locations on the map"""
|
|
||||||
return fetch_map_endpoint("map_anchor/list")["list"]
|
|
||||||
|
|
||||||
|
|
||||||
def get_map_points() -> List[Dict[str, Any]]:
|
|
||||||
"""Get points on the map"""
|
|
||||||
return fetch_map_endpoint("point/list")["point_list"]
|
|
||||||
|
|
||||||
|
|
||||||
def get_map_tile(
|
|
||||||
x: int, y: int, width: int, height: int, resolution: int = 1, image: str = None
|
|
||||||
) -> str:
|
|
||||||
"""Gets a map tile at a position
|
|
||||||
|
|
||||||
You may set an x, y, width and height of the resulting image
|
|
||||||
however you shoudl prefer to use multiples of 256 because they are cached
|
|
||||||
on the mihoyo servers.
|
|
||||||
|
|
||||||
Resolution dictates the resolution of the image as a percentage. 100 is highest and 0 is lowest.
|
|
||||||
You should pick values from 100, 50, 25 and 12.5
|
|
||||||
"""
|
|
||||||
image = image or get_map_image()
|
|
||||||
return (
|
|
||||||
image
|
|
||||||
+ f"?x-oss-process=image/resize,p_{round(resolution)}/crop,x_{x},y_{y},w_{width},h_{height}"
|
|
||||||
)
|
|
@ -1,462 +0,0 @@
|
|||||||
"""Prettifiers for genshinstats api returns.
|
|
||||||
|
|
||||||
Fixes the huge problem of outdated field names in the api,
|
|
||||||
that were leftover from during development
|
|
||||||
"""
|
|
||||||
import re
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
character_icons = {
|
|
||||||
"PlayerGirl": "Traveler",
|
|
||||||
"PlayerBoy": "Traveler",
|
|
||||||
"Ambor": "Amber",
|
|
||||||
"Qin": "Jean",
|
|
||||||
"Hutao": "Hu Tao",
|
|
||||||
"Feiyan": "Yanfei",
|
|
||||||
"Kazuha": "Kadehara Kazuha",
|
|
||||||
"Sara": "Kujou Sara",
|
|
||||||
"Shougun": "Raiden Shogun",
|
|
||||||
"Tohma": "Thoma",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _recognize_character_icon(url: str) -> str:
|
|
||||||
"""Recognizes a character's icon url and returns its name."""
|
|
||||||
exp = r"game_record/genshin/character_.*_(\w+)(?:@\dx)?.png"
|
|
||||||
match = re.search(exp, url)
|
|
||||||
if match is None:
|
|
||||||
raise ValueError(f"{url!r} is not a character icon or image url")
|
|
||||||
character = match.group(1)
|
|
||||||
return character_icons.get(character) or character
|
|
||||||
|
|
||||||
|
|
||||||
def prettify_stats(data):
|
|
||||||
s = data["stats"]
|
|
||||||
h = data["homes"][0] if data["homes"] else None
|
|
||||||
return {
|
|
||||||
"stats": {
|
|
||||||
"achievements": s["achievement_number"],
|
|
||||||
"active_days": s["active_day_number"],
|
|
||||||
"characters": s["avatar_number"],
|
|
||||||
"spiral_abyss": s["spiral_abyss"],
|
|
||||||
"anemoculi": s["anemoculus_number"],
|
|
||||||
"geoculi": s["geoculus_number"],
|
|
||||||
"electroculi": s["electroculus_number"],
|
|
||||||
"common_chests": s["common_chest_number"],
|
|
||||||
"exquisite_chests": s["exquisite_chest_number"],
|
|
||||||
"precious_chests": s["precious_chest_number"],
|
|
||||||
"luxurious_chests": s["luxurious_chest_number"],
|
|
||||||
"unlocked_waypoints": s["way_point_number"],
|
|
||||||
"unlocked_domains": s["domain_number"],
|
|
||||||
},
|
|
||||||
"characters": [
|
|
||||||
{
|
|
||||||
"name": i["name"],
|
|
||||||
"rarity": i["rarity"]
|
|
||||||
if i["rarity"] < 100
|
|
||||||
else i["rarity"] - 100, # aloy has 105 stars
|
|
||||||
"element": i["element"],
|
|
||||||
"level": i["level"],
|
|
||||||
"friendship": i["fetter"],
|
|
||||||
"icon": i["image"],
|
|
||||||
"id": i["id"],
|
|
||||||
}
|
|
||||||
for i in data["avatars"]
|
|
||||||
],
|
|
||||||
"teapot": {
|
|
||||||
# only unique data between realms are names and icons
|
|
||||||
"realms": [{"name": s["name"], "icon": s["icon"]} for s in data["homes"]],
|
|
||||||
"level": h["level"],
|
|
||||||
"comfort": h["comfort_num"],
|
|
||||||
"comfort_name": h["comfort_level_name"],
|
|
||||||
"comfort_icon": h["comfort_level_icon"],
|
|
||||||
"items": h["item_num"],
|
|
||||||
"visitors": h["visit_num"], # currently not in use
|
|
||||||
}
|
|
||||||
if h
|
|
||||||
else None,
|
|
||||||
"explorations": [
|
|
||||||
{
|
|
||||||
"name": i["name"],
|
|
||||||
"explored": round(i["exploration_percentage"] / 10, 1),
|
|
||||||
"type": i["type"],
|
|
||||||
"level": i["level"],
|
|
||||||
"icon": i["icon"],
|
|
||||||
"offerings": i["offerings"],
|
|
||||||
}
|
|
||||||
for i in data["world_explorations"]
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def prettify_characters(data):
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"name": i["name"],
|
|
||||||
"rarity": i["rarity"] if i["rarity"] < 100 else i["rarity"] - 100, # aloy has 105 stars
|
|
||||||
"element": i["element"],
|
|
||||||
"level": i["level"],
|
|
||||||
"friendship": i["fetter"],
|
|
||||||
"constellation": sum(c["is_actived"] for c in i["constellations"]),
|
|
||||||
"icon": i["icon"],
|
|
||||||
"image": i["image"],
|
|
||||||
"id": i["id"],
|
|
||||||
"collab": i["rarity"] >= 100,
|
|
||||||
**(
|
|
||||||
{"traveler_name": "Aether" if "Boy" in i["icon"] else "Lumine"}
|
|
||||||
if "Player" in i["icon"]
|
|
||||||
else {}
|
|
||||||
),
|
|
||||||
"weapon": {
|
|
||||||
"name": i["weapon"]["name"],
|
|
||||||
"rarity": i["weapon"]["rarity"],
|
|
||||||
"type": i["weapon"]["type_name"],
|
|
||||||
"level": i["weapon"]["level"],
|
|
||||||
"ascension": i["weapon"]["promote_level"],
|
|
||||||
"refinement": i["weapon"]["affix_level"],
|
|
||||||
"description": i["weapon"]["desc"],
|
|
||||||
"icon": i["weapon"]["icon"],
|
|
||||||
"id": i["weapon"]["id"],
|
|
||||||
},
|
|
||||||
"artifacts": [
|
|
||||||
{
|
|
||||||
"name": a["name"],
|
|
||||||
"pos_name": {
|
|
||||||
1: "flower",
|
|
||||||
2: "feather",
|
|
||||||
3: "hourglass",
|
|
||||||
4: "goblet",
|
|
||||||
5: "crown",
|
|
||||||
}[a["pos"]],
|
|
||||||
"full_pos_name": a["pos_name"],
|
|
||||||
"pos": a["pos"],
|
|
||||||
"rarity": a["rarity"],
|
|
||||||
"level": a["level"],
|
|
||||||
"set": {
|
|
||||||
"name": a["set"]["name"],
|
|
||||||
"effect_type": ["none", "single", "classic"][len(a["set"]["affixes"])],
|
|
||||||
"effects": [
|
|
||||||
{
|
|
||||||
"pieces": e["activation_number"],
|
|
||||||
"effect": e["effect"],
|
|
||||||
}
|
|
||||||
for e in a["set"]["affixes"]
|
|
||||||
],
|
|
||||||
"set_id": int(re.search(r"UI_RelicIcon_(\d+)_\d+", a["icon"]).group(1)), # type: ignore
|
|
||||||
"id": a["set"]["id"],
|
|
||||||
},
|
|
||||||
"icon": a["icon"],
|
|
||||||
"id": a["id"],
|
|
||||||
}
|
|
||||||
for a in i["reliquaries"]
|
|
||||||
],
|
|
||||||
"constellations": [
|
|
||||||
{
|
|
||||||
"name": c["name"],
|
|
||||||
"effect": c["effect"],
|
|
||||||
"is_activated": c["is_actived"],
|
|
||||||
"index": c["pos"],
|
|
||||||
"icon": c["icon"],
|
|
||||||
"id": c["id"],
|
|
||||||
}
|
|
||||||
for c in i["constellations"]
|
|
||||||
],
|
|
||||||
"outfits": [
|
|
||||||
{"name": c["name"], "icon": c["icon"], "id": c["id"]} for c in i["costumes"]
|
|
||||||
],
|
|
||||||
}
|
|
||||||
for i in data
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def prettify_abyss(data):
|
|
||||||
fchars = lambda d: [
|
|
||||||
{
|
|
||||||
"value": a["value"],
|
|
||||||
"name": _recognize_character_icon(a["avatar_icon"]),
|
|
||||||
"rarity": a["rarity"] if a["rarity"] < 100 else a["rarity"] - 100, # aloy has 105 stars
|
|
||||||
"icon": a["avatar_icon"],
|
|
||||||
"id": a["avatar_id"],
|
|
||||||
}
|
|
||||||
for a in d
|
|
||||||
]
|
|
||||||
todate = lambda x: datetime.fromtimestamp(int(x)).strftime("%Y-%m-%d")
|
|
||||||
totime = lambda x: datetime.fromtimestamp(int(x)).isoformat(" ")
|
|
||||||
return {
|
|
||||||
"season": data["schedule_id"],
|
|
||||||
"season_start_time": todate(data["start_time"]),
|
|
||||||
"season_end_time": todate(data["end_time"]),
|
|
||||||
"stats": {
|
|
||||||
"total_battles": data["total_battle_times"],
|
|
||||||
"total_wins": data["total_win_times"],
|
|
||||||
"max_floor": data["max_floor"],
|
|
||||||
"total_stars": data["total_star"],
|
|
||||||
},
|
|
||||||
"character_ranks": {
|
|
||||||
"most_played": fchars(data["reveal_rank"]),
|
|
||||||
"most_kills": fchars(data["defeat_rank"]),
|
|
||||||
"strongest_strike": fchars(data["damage_rank"]),
|
|
||||||
"most_damage_taken": fchars(data["take_damage_rank"]),
|
|
||||||
"most_bursts_used": fchars(data["normal_skill_rank"]),
|
|
||||||
"most_skills_used": fchars(data["energy_skill_rank"]),
|
|
||||||
},
|
|
||||||
"floors": [
|
|
||||||
{
|
|
||||||
"floor": f["index"],
|
|
||||||
"stars": f["star"],
|
|
||||||
"max_stars": f["max_star"],
|
|
||||||
"icon": f["icon"],
|
|
||||||
"chambers": [
|
|
||||||
{
|
|
||||||
"chamber": l["index"],
|
|
||||||
"stars": l["star"],
|
|
||||||
"max_stars": l["max_star"],
|
|
||||||
"has_halves": len(l["battles"]) == 2,
|
|
||||||
"battles": [
|
|
||||||
{
|
|
||||||
"half": b["index"],
|
|
||||||
"timestamp": totime(b["timestamp"]),
|
|
||||||
"characters": [
|
|
||||||
{
|
|
||||||
"name": _recognize_character_icon(c["icon"]),
|
|
||||||
"rarity": c["rarity"]
|
|
||||||
if c["rarity"] < 100
|
|
||||||
else c["rarity"] - 100, # aloy has 105 stars
|
|
||||||
"level": c["level"],
|
|
||||||
"icon": c["icon"],
|
|
||||||
"id": c["id"],
|
|
||||||
}
|
|
||||||
for c in b["avatars"]
|
|
||||||
],
|
|
||||||
}
|
|
||||||
for b in l["battles"]
|
|
||||||
],
|
|
||||||
}
|
|
||||||
for l in f["levels"]
|
|
||||||
],
|
|
||||||
}
|
|
||||||
for f in data["floors"]
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def prettify_activities(data):
|
|
||||||
activities = {
|
|
||||||
k: v if v.get("exists_data") else {"records": []}
|
|
||||||
for activity in data["activities"]
|
|
||||||
for k, v in activity.items()
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
"hyakunin": [
|
|
||||||
{
|
|
||||||
"id": r["challenge_id"],
|
|
||||||
"name": r["challenge_name"],
|
|
||||||
"difficulty": r["difficulty"],
|
|
||||||
"medal_icon": r["heraldry_icon"],
|
|
||||||
"score": r["max_score"],
|
|
||||||
"multiplier": r["score_multiple"],
|
|
||||||
"lineups": [
|
|
||||||
{
|
|
||||||
"characters": [
|
|
||||||
{
|
|
||||||
"name": _recognize_character_icon(c["icon"]),
|
|
||||||
"rarity": c["rarity"]
|
|
||||||
if c["rarity"] < 100
|
|
||||||
else c["rarity"] - 100, # aloy has 105 stars
|
|
||||||
"level": c["level"],
|
|
||||||
"icon": c["icon"],
|
|
||||||
"id": c["id"],
|
|
||||||
"trial": c["is_trail_avatar"],
|
|
||||||
}
|
|
||||||
for c in l["avatars"]
|
|
||||||
],
|
|
||||||
"skills": [
|
|
||||||
{"name": s["name"], "desc": s["desc"], "icon": s["icon"], "id": s["id"]}
|
|
||||||
for s in l["skills"]
|
|
||||||
],
|
|
||||||
}
|
|
||||||
for l in r["lineups"]
|
|
||||||
],
|
|
||||||
}
|
|
||||||
for r in activities["sumo"]["records"]
|
|
||||||
],
|
|
||||||
"labyrinth": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def prettify_notes(data):
|
|
||||||
return {
|
|
||||||
"resin": data["current_resin"],
|
|
||||||
"until_resin_limit": data["resin_recovery_time"],
|
|
||||||
"max_resin": data["max_resin"],
|
|
||||||
"total_commissions": data["total_task_num"],
|
|
||||||
"completed_commissions": data["finished_task_num"],
|
|
||||||
"claimed_commission_reward": data["is_extra_task_reward_received"],
|
|
||||||
"max_boss_discounts": data["resin_discount_num_limit"],
|
|
||||||
"remaining_boss_discounts": data["remain_resin_discount_num"],
|
|
||||||
"expeditions": [
|
|
||||||
{
|
|
||||||
"icon": exp["avatar_side_icon"],
|
|
||||||
"remaining_time": exp["remained_time"],
|
|
||||||
"status": exp["status"],
|
|
||||||
}
|
|
||||||
for exp in data["expeditions"]
|
|
||||||
],
|
|
||||||
"max_expeditions": data["max_expedition_num"],
|
|
||||||
"realm_currency": data["current_home_coin"],
|
|
||||||
"max_realm_currency": data["max_home_coin"],
|
|
||||||
"until_realm_currency_limit": data["home_coin_recovery_time"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def prettify_game_accounts(data):
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"uid": int(a["game_uid"]),
|
|
||||||
"server": a["region_name"],
|
|
||||||
"level": a["level"],
|
|
||||||
"nickname": a["nickname"],
|
|
||||||
# idk what these are for:
|
|
||||||
"biz": a["game_biz"],
|
|
||||||
"is_chosen": a["is_chosen"],
|
|
||||||
"is_official": a["is_official"],
|
|
||||||
}
|
|
||||||
for a in data
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def prettify_wish_history(data, banner_name=None):
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"type": i["item_type"],
|
|
||||||
"name": i["name"],
|
|
||||||
"rarity": int(i["rank_type"]),
|
|
||||||
"time": i["time"],
|
|
||||||
"id": int(i["id"]),
|
|
||||||
"banner": banner_name,
|
|
||||||
"banner_type": int(i["gacha_type"]),
|
|
||||||
"uid": int(i["uid"]),
|
|
||||||
}
|
|
||||||
for i in data
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def prettify_gacha_items(data):
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"name": i["name"],
|
|
||||||
"type": i["item_type"],
|
|
||||||
"rarity": int(i["rank_type"]),
|
|
||||||
"id": 10000000 + int(i["item_id"]) - 1000
|
|
||||||
if len(i["item_id"]) == 4
|
|
||||||
else int(i["item_id"]),
|
|
||||||
}
|
|
||||||
for i in data
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def prettify_banner_details(data):
|
|
||||||
per = lambda p: None if p == "0%" else float(p[:-1].replace(",", "."))
|
|
||||||
fprobs = (
|
|
||||||
lambda l: [
|
|
||||||
{
|
|
||||||
"type": i["item_type"],
|
|
||||||
"name": i["item_name"],
|
|
||||||
"rarity": int(i["rank"]),
|
|
||||||
"is_up": bool(i["is_up"]),
|
|
||||||
"order_value": i["order_value"],
|
|
||||||
}
|
|
||||||
for i in l
|
|
||||||
]
|
|
||||||
if l
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
fitems = (
|
|
||||||
lambda l: [
|
|
||||||
{
|
|
||||||
"type": i["item_type"],
|
|
||||||
"name": i["item_name"],
|
|
||||||
"element": {
|
|
||||||
"风": "Anemo",
|
|
||||||
"火": "Pyro",
|
|
||||||
"水": "Hydro",
|
|
||||||
"雷": "Electro",
|
|
||||||
"冰": "Cryo",
|
|
||||||
"岩": "Geo",
|
|
||||||
"?": "Dendro",
|
|
||||||
"": None,
|
|
||||||
}[i["item_attr"]],
|
|
||||||
"icon": i["item_img"],
|
|
||||||
}
|
|
||||||
for i in l
|
|
||||||
]
|
|
||||||
if l
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"banner_type_name": {
|
|
||||||
100: "Novice Wishes",
|
|
||||||
200: "Permanent Wish",
|
|
||||||
301: "Character Event Wish",
|
|
||||||
302: "Weapon Event Wish",
|
|
||||||
}[int(data["gacha_type"])],
|
|
||||||
"banner_type": int(data["gacha_type"]),
|
|
||||||
"banner": re.sub(r"<.*?>", "", data["title"]).strip(),
|
|
||||||
"title": data["title"],
|
|
||||||
"content": data["content"],
|
|
||||||
"date_range": data["date_range"],
|
|
||||||
"r5_up_prob": per(data["r5_up_prob"]), # probability for rate-up 5*
|
|
||||||
"r4_up_prob": per(data["r4_up_prob"]), # probability for rate-up 4*
|
|
||||||
"r5_prob": per(data["r5_prob"]), # probability for 5*
|
|
||||||
"r4_prob": per(data["r4_prob"]), # probability for 4*
|
|
||||||
"r3_prob": per(data["r3_prob"]), # probability for 3*
|
|
||||||
"r5_guarantee_prob": per(data["r5_baodi_prob"]), # probability for 5* incl. guarantee
|
|
||||||
"r4_guarantee_prob": per(data["r4_baodi_prob"]), # probability for 4* incl. guarantee
|
|
||||||
"r3_guarantee_prob": per(data["r3_baodi_prob"]), # probability for 3* incl. guarantee
|
|
||||||
"r5_up_items": fitems(
|
|
||||||
data["r5_up_items"]
|
|
||||||
), # list of 5* rate-up items that you can get from banner
|
|
||||||
"r4_up_items": fitems(
|
|
||||||
data["r4_up_items"]
|
|
||||||
), # list of 4* rate-up items that you can get from banner
|
|
||||||
"r5_items": fprobs(data["r5_prob_list"]), # list 5* of items that you can get from banner
|
|
||||||
"r4_items": fprobs(data["r4_prob_list"]), # list 4* of items that you can get from banner
|
|
||||||
"r3_items": fprobs(data["r3_prob_list"]), # list 3* of items that you can get from banner
|
|
||||||
"items": fprobs(
|
|
||||||
sorted(
|
|
||||||
data["r5_prob_list"] + data["r4_prob_list"] + data["r3_prob_list"],
|
|
||||||
key=lambda x: x["order_value"],
|
|
||||||
)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def prettify_trans(data, reasons={}):
|
|
||||||
if data and "name" in data[0]:
|
|
||||||
# transaction item
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"time": i["time"],
|
|
||||||
"name": i["name"],
|
|
||||||
"rarity": int(i["rank"]),
|
|
||||||
"amount": int(i["add_num"]),
|
|
||||||
"reason": reasons.get(int(i["reason"]), ""),
|
|
||||||
"reason_id": int(i["reason"]),
|
|
||||||
"uid": int(i["uid"]),
|
|
||||||
"id": int(i["id"]),
|
|
||||||
}
|
|
||||||
for i in data
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
# transaction
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"time": i["time"],
|
|
||||||
"amount": int(i["add_num"]),
|
|
||||||
"reason": reasons.get(int(i["reason"]), ""),
|
|
||||||
"reason_id": int(i["reason"]),
|
|
||||||
"uid": int(i["uid"]),
|
|
||||||
"id": int(i["id"]),
|
|
||||||
}
|
|
||||||
for i in data
|
|
||||||
]
|
|
@ -1,194 +0,0 @@
|
|||||||
"""Logs for currency "transactions".
|
|
||||||
|
|
||||||
Logs for artifact, weapon, resin, genesis crystol and primogem "transactions".
|
|
||||||
You may view a history of everything you have gained in the last 3 months.
|
|
||||||
"""
|
|
||||||
import math
|
|
||||||
import sys
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any, Dict, Iterator, List, Optional
|
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
from .caching import permanent_cache
|
|
||||||
from .pretty import prettify_trans
|
|
||||||
from .wishes import fetch_gacha_endpoint, static_session
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"fetch_transaction_endpoint",
|
|
||||||
"get_primogem_log",
|
|
||||||
"get_resin_log",
|
|
||||||
"get_crystal_log",
|
|
||||||
"get_artifact_log",
|
|
||||||
"get_weapon_log",
|
|
||||||
"current_resin",
|
|
||||||
"approximate_current_resin",
|
|
||||||
]
|
|
||||||
|
|
||||||
YSULOG_URL = "https://hk4e-api-os.mihoyo.com/ysulog/api/"
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_transaction_endpoint(
|
|
||||||
endpoint: str, authkey: Optional[str] = None, **kwargs
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Fetch an enpoint from mihoyo's transaction logs api.
|
|
||||||
|
|
||||||
Takes in an endpoint url which is joined with the base url.
|
|
||||||
If an authkey is provided, it uses that authkey specifically.
|
|
||||||
A request is then sent and returns a parsed response.
|
|
||||||
"""
|
|
||||||
url = urljoin(YSULOG_URL, endpoint)
|
|
||||||
return fetch_gacha_endpoint(url, authkey, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
@permanent_cache("lang")
|
|
||||||
def _get_reasons(lang: str = "en-us") -> Dict[int, str]:
|
|
||||||
r = static_session.get(
|
|
||||||
f"https://mi18n-os.mihoyo.com/webstatic/admin/mi18n/hk4e_global/m02251421001311/m02251421001311-{lang}.json"
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
data = r.json()
|
|
||||||
|
|
||||||
return {
|
|
||||||
int(k.split("_")[-1]): v
|
|
||||||
for k, v in data.items()
|
|
||||||
if k.startswith("selfinquiry_general_reason_")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _get_transactions(
|
|
||||||
endpoint: str, size: int = None, authkey: str = None, lang: str = "en-us", end_id: int = 0
|
|
||||||
) -> Iterator[Dict[str, Any]]:
|
|
||||||
"""A paginator that uses mihoyo's id paginator algorithm to yield pages"""
|
|
||||||
if size is not None and size <= 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
page_size = 20
|
|
||||||
size = size or sys.maxsize
|
|
||||||
|
|
||||||
while True:
|
|
||||||
data = fetch_transaction_endpoint(
|
|
||||||
endpoint, authkey=authkey, params=dict(size=min(page_size, size), end_id=end_id)
|
|
||||||
)["list"]
|
|
||||||
data = prettify_trans(data, _get_reasons(lang))
|
|
||||||
yield from data
|
|
||||||
|
|
||||||
size -= page_size
|
|
||||||
if len(data) < page_size or size <= 0:
|
|
||||||
break
|
|
||||||
|
|
||||||
end_id = data[-1]["id"]
|
|
||||||
|
|
||||||
|
|
||||||
def get_primogem_log(
|
|
||||||
size: int = None, authkey: str = None, lang: str = "en-us", end_id: int = 0
|
|
||||||
) -> Iterator[Dict[str, Any]]:
|
|
||||||
"""Gets all transactions of primogems
|
|
||||||
|
|
||||||
This means stuff like getting primogems from rewards and explorations or making wishes.
|
|
||||||
Records go only 3 months back.
|
|
||||||
"""
|
|
||||||
return _get_transactions("getPrimogemLog", size, authkey, lang, end_id)
|
|
||||||
|
|
||||||
|
|
||||||
def get_crystal_log(
|
|
||||||
size: int = None, authkey: str = None, lang: str = "en-us", end_id: int = 0
|
|
||||||
) -> Iterator[Dict[str, Any]]:
|
|
||||||
"""Get all transactions of genesis crystals
|
|
||||||
|
|
||||||
Records go only 3 months back.
|
|
||||||
"""
|
|
||||||
return _get_transactions("getCrystalLog", size, authkey, lang, end_id)
|
|
||||||
|
|
||||||
|
|
||||||
def get_resin_log(
|
|
||||||
size: int = None, authkey: str = None, lang: str = "en-us", end_id: int = 0
|
|
||||||
) -> Iterator[Dict[str, Any]]:
|
|
||||||
"""Gets all usage of resin
|
|
||||||
|
|
||||||
This means using them in ley lines, domains, crafting and weekly bosses.
|
|
||||||
Records go only 3 months back.
|
|
||||||
"""
|
|
||||||
return _get_transactions("getResinLog", size, authkey, lang, end_id)
|
|
||||||
|
|
||||||
|
|
||||||
def get_artifact_log(
|
|
||||||
size: int = None, authkey: str = None, lang: str = "en-us", end_id: int = 0
|
|
||||||
) -> Iterator[Dict[str, Any]]:
|
|
||||||
"""Get the log of all artifacts gotten or destroyed in the last 3 months"""
|
|
||||||
return _get_transactions("getArtifactLog", size, authkey, lang, end_id)
|
|
||||||
|
|
||||||
|
|
||||||
def get_weapon_log(
|
|
||||||
size: int = None, authkey: str = None, lang: str = "en-us", end_id: int = 0
|
|
||||||
) -> Iterator[Dict[str, Any]]:
|
|
||||||
"""Get the log of all weapons gotten or destroyed in the last 3 months"""
|
|
||||||
return _get_transactions("getWeaponLog", size, authkey, lang, end_id)
|
|
||||||
|
|
||||||
|
|
||||||
def current_resin(
|
|
||||||
last_resin_time: datetime,
|
|
||||||
last_resin_amount: float,
|
|
||||||
current_time: datetime = None,
|
|
||||||
authkey: str = None,
|
|
||||||
):
|
|
||||||
"""Gets the current resin based off an amount of resin you've had at any time before
|
|
||||||
|
|
||||||
Works by getting all usages after the last resin time and emulating how the resin would be generated.
|
|
||||||
Keep in mind that this approach works only if the user hasn't played in the last hour.
|
|
||||||
"""
|
|
||||||
current_time = current_time or datetime.utcnow()
|
|
||||||
|
|
||||||
resin_usage: List[Dict[str, Any]] = [{"time": str(current_time), "amount": 0}]
|
|
||||||
for usage in get_resin_log(authkey=authkey):
|
|
||||||
if datetime.fromisoformat(usage["time"]) < last_resin_time:
|
|
||||||
break
|
|
||||||
resin_usage.append(usage)
|
|
||||||
resin_usage.reverse()
|
|
||||||
|
|
||||||
resin = last_resin_amount
|
|
||||||
|
|
||||||
for usage in resin_usage:
|
|
||||||
usage_time = datetime.fromisoformat(usage["time"])
|
|
||||||
recovered_resin = (usage_time - last_resin_time).total_seconds() / (8 * 60)
|
|
||||||
|
|
||||||
resin = min(resin + recovered_resin, 160) + usage["amount"]
|
|
||||||
|
|
||||||
last_resin_time = usage_time
|
|
||||||
# better raise an error than to leave users confused
|
|
||||||
if resin < 0:
|
|
||||||
raise ValueError("Last resin time is wrong or amount is too low")
|
|
||||||
|
|
||||||
return resin
|
|
||||||
|
|
||||||
|
|
||||||
def approximate_current_resin(time: datetime = None, authkey: str = None):
|
|
||||||
"""Roughly approximates how much resin using a minmax calculation
|
|
||||||
|
|
||||||
The result can have an offset of around 5 resin in some cases.
|
|
||||||
"""
|
|
||||||
# if any algorithm peeps can help with this one I'd appreciate it
|
|
||||||
recovery_rate = 8 * 60
|
|
||||||
|
|
||||||
current_max = shadow_max = 160.0
|
|
||||||
current_min = shadow_min = 0.0
|
|
||||||
time = time or datetime.utcnow()
|
|
||||||
last_amount = 0
|
|
||||||
|
|
||||||
for usage in get_resin_log(authkey=authkey):
|
|
||||||
usage_time = datetime.fromisoformat(usage["time"])
|
|
||||||
if time < usage_time:
|
|
||||||
continue
|
|
||||||
amount_recovered = (time - usage_time).total_seconds() / recovery_rate
|
|
||||||
cur_amount: int = usage["amount"]
|
|
||||||
shadow_max += cur_amount + amount_recovered
|
|
||||||
shadow_min += last_amount + amount_recovered
|
|
||||||
current_max = max(current_min, min(current_max, shadow_max))
|
|
||||||
current_min = max(current_min, min(current_max, shadow_min))
|
|
||||||
time = usage_time
|
|
||||||
last_amount = usage["amount"]
|
|
||||||
if math.isclose(current_max, current_min):
|
|
||||||
break
|
|
||||||
|
|
||||||
resin = (current_max + current_min) / 2
|
|
||||||
|
|
||||||
return resin
|
|
@ -1,120 +0,0 @@
|
|||||||
"""Various utility functions for genshinstats."""
|
|
||||||
import inspect
|
|
||||||
import os.path
|
|
||||||
import re
|
|
||||||
import warnings
|
|
||||||
from functools import wraps
|
|
||||||
from typing import Callable, Iterable, Optional, Type, TypeVar, Union
|
|
||||||
|
|
||||||
from .errors import AccountNotFound
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"USER_AGENT",
|
|
||||||
"recognize_server",
|
|
||||||
"recognize_id",
|
|
||||||
"is_game_uid",
|
|
||||||
"is_chinese",
|
|
||||||
"get_logfile",
|
|
||||||
]
|
|
||||||
|
|
||||||
T = TypeVar("T")
|
|
||||||
|
|
||||||
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"
|
|
||||||
|
|
||||||
|
|
||||||
def recognize_server(uid: int) -> str:
|
|
||||||
"""Recognizes which server a 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
|
|
||||||
else:
|
|
||||||
raise AccountNotFound(f"UID {uid} isn't associated with any server")
|
|
||||||
|
|
||||||
|
|
||||||
def recognize_id(id: int) -> Optional[str]:
|
|
||||||
"""Attempts to recognize what item type an id is"""
|
|
||||||
if 10000000 < id < 20000000:
|
|
||||||
return "character"
|
|
||||||
elif 1000000 < id < 10000000:
|
|
||||||
return "artifact_set"
|
|
||||||
elif 100000 < id < 1000000:
|
|
||||||
return "outfit"
|
|
||||||
elif 50000 < id < 100000:
|
|
||||||
return "artifact"
|
|
||||||
elif 10000 < id < 50000:
|
|
||||||
return "weapon"
|
|
||||||
elif 100 < id < 1000:
|
|
||||||
return "constellation"
|
|
||||||
elif 10 ** 17 < id < 10 ** 19:
|
|
||||||
return "transaction"
|
|
||||||
# not sure about these ones:
|
|
||||||
elif 1 <= id <= 4:
|
|
||||||
return "exploration"
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def is_game_uid(uid: int) -> bool:
|
|
||||||
"""Recognizes whether the uid is a game uid."""
|
|
||||||
return bool(re.fullmatch(r"[6789]\d{8}", str(uid)))
|
|
||||||
|
|
||||||
|
|
||||||
def is_chinese(x: Union[int, str]) -> bool:
|
|
||||||
"""Recognizes whether the server/uid is chinese."""
|
|
||||||
return str(x).startswith(("cn", "1", "5"))
|
|
||||||
|
|
||||||
|
|
||||||
def get_logfile() -> Optional[str]:
|
|
||||||
"""Find and return the Genshin Impact logfile. None if not found."""
|
|
||||||
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 retry(
|
|
||||||
tries: int = 3,
|
|
||||||
exceptions: Union[Type[BaseException], Iterable[Type[BaseException]]] = Exception,
|
|
||||||
) -> Callable[[T], T]:
|
|
||||||
"""A classic retry() decorator"""
|
|
||||||
|
|
||||||
def wrapper(func):
|
|
||||||
@wraps(func)
|
|
||||||
def inner(*args, **kwargs):
|
|
||||||
for _ in range(tries):
|
|
||||||
try:
|
|
||||||
return func(*args, **kwargs)
|
|
||||||
except exceptions as e:
|
|
||||||
exc = e
|
|
||||||
else:
|
|
||||||
raise Exception(f"Maximum tries ({tries}) exceeded: {exc}") from exc # type: ignore
|
|
||||||
|
|
||||||
return inner
|
|
||||||
|
|
||||||
return wrapper # type: ignore
|
|
||||||
|
|
||||||
|
|
||||||
def deprecated(
|
|
||||||
message: str = "{} is deprecated and will be removed in future versions",
|
|
||||||
) -> Callable[[T], T]:
|
|
||||||
"""Shows a warning when a function is attempted to be used"""
|
|
||||||
|
|
||||||
def wrapper(func):
|
|
||||||
@wraps(func)
|
|
||||||
def inner(*args, **kwargs):
|
|
||||||
warnings.warn(message.format(func.__name__), PendingDeprecationWarning)
|
|
||||||
return func(*args, **kwargs)
|
|
||||||
|
|
||||||
return inner
|
|
||||||
|
|
||||||
return wrapper # type: ignore
|
|
@ -1,277 +0,0 @@
|
|||||||
"""Genshin Impact wish history.
|
|
||||||
|
|
||||||
Gets wish history from the current banners in a clean api.
|
|
||||||
Requires an authkey that is fetched automatically from a logfile.
|
|
||||||
"""
|
|
||||||
import base64
|
|
||||||
import heapq
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from itertools import chain, islice
|
|
||||||
from tempfile import gettempdir
|
|
||||||
from typing import Any, Dict, Iterator, List, Optional
|
|
||||||
from urllib.parse import unquote, urljoin
|
|
||||||
|
|
||||||
from requests import Session
|
|
||||||
|
|
||||||
from .errors import AuthkeyError, MissingAuthKey, raise_for_error
|
|
||||||
from .pretty import *
|
|
||||||
from .utils import USER_AGENT, get_logfile
|
|
||||||
from .caching import permanent_cache
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"extract_authkey",
|
|
||||||
"get_authkey",
|
|
||||||
"set_authkey",
|
|
||||||
"get_banner_ids",
|
|
||||||
"fetch_gacha_endpoint",
|
|
||||||
"get_banner_types",
|
|
||||||
"get_wish_history",
|
|
||||||
"get_gacha_items",
|
|
||||||
"get_banner_details",
|
|
||||||
"get_uid_from_authkey",
|
|
||||||
"validate_authkey",
|
|
||||||
]
|
|
||||||
|
|
||||||
GENSHIN_LOG = get_logfile()
|
|
||||||
GACHA_INFO_URL = "https://hk4e-api-os.mihoyo.com/event/gacha_info/api/"
|
|
||||||
AUTHKEY_FILE = os.path.join(gettempdir(), "genshinstats_authkey.txt")
|
|
||||||
|
|
||||||
session = Session()
|
|
||||||
session.headers.update(
|
|
||||||
{
|
|
||||||
# recommended header
|
|
||||||
"user-agent": USER_AGENT
|
|
||||||
}
|
|
||||||
)
|
|
||||||
session.params = {
|
|
||||||
# required params
|
|
||||||
"authkey_ver": "1",
|
|
||||||
"lang": "en",
|
|
||||||
# authentications params
|
|
||||||
"authkey": "",
|
|
||||||
# transaction params
|
|
||||||
"sign_type": "2",
|
|
||||||
}
|
|
||||||
static_session = Session() # extra session for static resources
|
|
||||||
|
|
||||||
|
|
||||||
def _get_short_lang_code(lang: str) -> str:
|
|
||||||
"""Returns an alternative short lang code"""
|
|
||||||
return lang if "zh" in lang else lang.split("-")[0]
|
|
||||||
|
|
||||||
|
|
||||||
def _read_logfile(logfile: str = None) -> str:
|
|
||||||
"""Returns the contents of a logfile"""
|
|
||||||
if GENSHIN_LOG is None:
|
|
||||||
raise FileNotFoundError("No Genshin Installation was found, could not get gacha data.")
|
|
||||||
with open(logfile or GENSHIN_LOG) as file:
|
|
||||||
return file.read()
|
|
||||||
|
|
||||||
|
|
||||||
def extract_authkey(string: str) -> Optional[str]:
|
|
||||||
"""Extracts an authkey from the provided string. Returns None if not found."""
|
|
||||||
match = re.search(r"https://.+?authkey=([^&#]+)", string, re.MULTILINE)
|
|
||||||
if match is not None:
|
|
||||||
return unquote(match.group(1))
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_authkey(logfile: str = None) -> str:
|
|
||||||
"""Gets the query for log requests.
|
|
||||||
|
|
||||||
This will either be done from the logs or from a tempfile.
|
|
||||||
"""
|
|
||||||
# first try the log
|
|
||||||
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 MissingAuthKey(
|
|
||||||
"No authkey could be found in the logs or in a tempfile. "
|
|
||||||
"Open the history in-game first before attempting to request it."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def set_authkey(authkey: str = None) -> None:
|
|
||||||
"""Sets an authkey for log requests.
|
|
||||||
|
|
||||||
You may pass in an authkey, a url with an authkey
|
|
||||||
or a path to a logfile with the authkey.
|
|
||||||
"""
|
|
||||||
if authkey is None or os.path.isfile(authkey):
|
|
||||||
authkey = get_authkey(authkey)
|
|
||||||
else:
|
|
||||||
authkey = extract_authkey(authkey) or authkey
|
|
||||||
session.params["authkey"] = authkey # type: ignore
|
|
||||||
|
|
||||||
|
|
||||||
def get_banner_ids(logfile: str = None) -> List[str]:
|
|
||||||
"""Gets all banner ids from a log file.
|
|
||||||
|
|
||||||
You need to open the details of all banners for this to work.
|
|
||||||
"""
|
|
||||||
log = _read_logfile(logfile)
|
|
||||||
ids = re.findall(r"OnGetWebViewPageFinish:https://.+?gacha_id=([^&#]+)", log)
|
|
||||||
return list(set(ids))
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_gacha_endpoint(endpoint: str, authkey: str = None, **kwargs) -> Dict[str, Any]:
|
|
||||||
"""Fetch an enpoint from mihoyo's gacha info.
|
|
||||||
|
|
||||||
Takes in an endpoint url which is joined with the base url.
|
|
||||||
If an authkey is provided, it uses that authkey specifically.
|
|
||||||
A request is then sent and returns a parsed response.
|
|
||||||
Includes error handling and getting the authkey.
|
|
||||||
"""
|
|
||||||
if authkey is None:
|
|
||||||
session.params["authkey"] = session.params["authkey"] or get_authkey() # type: ignore
|
|
||||||
else:
|
|
||||||
kwargs.setdefault("params", {})["authkey"] = authkey
|
|
||||||
method = kwargs.pop("method", "get")
|
|
||||||
url = urljoin(GACHA_INFO_URL, endpoint)
|
|
||||||
|
|
||||||
r = session.request(method, url, **kwargs)
|
|
||||||
r.raise_for_status()
|
|
||||||
|
|
||||||
data = r.json()
|
|
||||||
if data["retcode"] == 0:
|
|
||||||
return data["data"]
|
|
||||||
|
|
||||||
raise_for_error(data)
|
|
||||||
|
|
||||||
|
|
||||||
@permanent_cache("lang")
|
|
||||||
def get_banner_types(authkey: str = None, lang: str = "en") -> Dict[int, str]:
|
|
||||||
"""Gets ids for all banners and their names"""
|
|
||||||
banners = fetch_gacha_endpoint(
|
|
||||||
"getConfigList", authkey=authkey, params=dict(lang=_get_short_lang_code(lang))
|
|
||||||
)["gacha_type_list"]
|
|
||||||
return {int(i["key"]): i["name"] for i in banners}
|
|
||||||
|
|
||||||
|
|
||||||
def get_wish_history(
|
|
||||||
banner_type: int = None,
|
|
||||||
size: int = None,
|
|
||||||
authkey: str = None,
|
|
||||||
end_id: int = 0,
|
|
||||||
lang: str = "en",
|
|
||||||
) -> Iterator[Dict[str, Any]]:
|
|
||||||
"""Gets wish history.
|
|
||||||
|
|
||||||
Note that pulls are yielded and not returned to account for pagination.
|
|
||||||
|
|
||||||
When a banner_type is set, only data from that banner type is retuned.
|
|
||||||
You can get banner types and their names from get_banner_types.
|
|
||||||
|
|
||||||
If a size is set the total returned amount of pulls will be equal to or lower than the size.
|
|
||||||
|
|
||||||
To be able to get history starting from somewhere other than the last pull
|
|
||||||
you may pass in the id of the pull right chronologically after the one you want to start from as end_id.
|
|
||||||
"""
|
|
||||||
if size is not None and size <= 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
if banner_type is None:
|
|
||||||
# we get data from all banners by getting data from every individual banner
|
|
||||||
# and then sorting it by pull date with heapq.merge
|
|
||||||
gens = [
|
|
||||||
get_wish_history(banner_type, None, authkey, end_id, lang)
|
|
||||||
for banner_type in get_banner_types(authkey)
|
|
||||||
]
|
|
||||||
yield from islice(heapq.merge(*gens, key=lambda x: x["time"], reverse=True), size)
|
|
||||||
return
|
|
||||||
|
|
||||||
# we create banner_name outside prettify so we don't make extra requests
|
|
||||||
banner_name = get_banner_types(authkey, lang)[banner_type]
|
|
||||||
lang = _get_short_lang_code(lang)
|
|
||||||
page_size = 20
|
|
||||||
size = size or sys.maxsize
|
|
||||||
|
|
||||||
while True:
|
|
||||||
data = fetch_gacha_endpoint(
|
|
||||||
"getGachaLog",
|
|
||||||
authkey=authkey,
|
|
||||||
params=dict(
|
|
||||||
gacha_type=banner_type, size=min(page_size, size), end_id=end_id, lang=lang
|
|
||||||
),
|
|
||||||
)["list"]
|
|
||||||
data = prettify_wish_history(data, banner_name)
|
|
||||||
yield from data
|
|
||||||
|
|
||||||
size -= page_size
|
|
||||||
if len(data) < page_size or size <= 0:
|
|
||||||
break
|
|
||||||
|
|
||||||
end_id = data[-1]["id"]
|
|
||||||
|
|
||||||
|
|
||||||
def get_gacha_items(lang: str = "en-us") -> List[Dict[str, Any]]:
|
|
||||||
"""Gets the list of characters and weapons that can be gotten from the gacha."""
|
|
||||||
r = static_session.get(
|
|
||||||
f"https://webstatic-sea.mihoyo.com/hk4e/gacha_info/os_asia/items/{lang}.json"
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
return prettify_gacha_items(r.json())
|
|
||||||
|
|
||||||
|
|
||||||
def get_banner_details(banner_id: str, lang: str = "en-us") -> Dict[str, Any]:
|
|
||||||
"""Gets details of a specific banner.
|
|
||||||
|
|
||||||
This requires the banner's id.
|
|
||||||
These keep rotating so you need to get them with get_banner_ids().
|
|
||||||
example standard wish: "a37a19624270b092e7250edfabce541a3435c2"
|
|
||||||
|
|
||||||
The newbie gacha has no json resource tied to it so you can't get info about it.
|
|
||||||
"""
|
|
||||||
r = static_session.get(
|
|
||||||
f"https://webstatic-sea.mihoyo.com/hk4e/gacha_info/os_asia/{banner_id}/{lang}.json"
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
return prettify_banner_details(r.json())
|
|
||||||
|
|
||||||
|
|
||||||
def get_uid_from_authkey(authkey: str = None) -> int:
|
|
||||||
"""Gets a uid from an authkey.
|
|
||||||
|
|
||||||
If an authkey is not passed in the function uses the currently set authkey.
|
|
||||||
"""
|
|
||||||
# for safety we use all banners, probably overkill
|
|
||||||
# they are sorted from most to least pulled on for speed
|
|
||||||
histories = [get_wish_history(i, 1, authkey) for i in (301, 200, 302, 100)]
|
|
||||||
pull = next(chain.from_iterable(histories), None)
|
|
||||||
if pull is None: # very rare but possible
|
|
||||||
raise Exception("User has never made a wish")
|
|
||||||
return pull["uid"]
|
|
||||||
|
|
||||||
|
|
||||||
def validate_authkey(authkey: Any, previous_authkey: str = None) -> bool:
|
|
||||||
"""Checks whether an authkey is valid by sending a request
|
|
||||||
|
|
||||||
If a previous authkey is provided the function also checks if the
|
|
||||||
authkey belongs to the same person as the previous one.
|
|
||||||
"""
|
|
||||||
if not isinstance(authkey, str) or len(authkey) != 1024:
|
|
||||||
return False # invalid format
|
|
||||||
|
|
||||||
try:
|
|
||||||
base64.b64decode(authkey)
|
|
||||||
except:
|
|
||||||
return False # invalid base64 format
|
|
||||||
|
|
||||||
if previous_authkey and authkey[:682] != previous_authkey[:682]:
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
fetch_gacha_endpoint("getConfigList", authkey=authkey)
|
|
||||||
except AuthkeyError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
Loading…
Reference in New Issue
Block a user