mirror of
https://github.com/Kvan7/Exiled-Exchange-2.git
synced 2025-12-16 13:05:53 +00:00
* Price checking does not work when using a gamepad(Ctrl+D) #452 Uses parts from #454 to fix the issue. Co-authored-by: lawrsp <7957003+lawrsp@users.noreply.github.com> * Fix tests :( * Fix magic rarity item name parse in "cmn-Hant" language (#460) * Fix magic rarity item name parse in "cmn-Hant" language * Add by translated when ref is null --------- Co-authored-by: kvan7 <kvan.valve@gmail.com> * chore: yarn to npm and add missing step (#461) * fix: update game config path for linux to poe2 * fix tests * fix: MacOS crash on startup (#428) * fix: MacOS crash on startup * update for windows/linux * move main app startup into function Mac calls that in async, other platforms proceed in sync. * [PoE2] - Relics broken again #444 * test prettier and add npm script * add format/lint support to main * Fix defineProps macro * Run speed v0.8.0 - Russian. #447 * fix negative gold * Merge branch 'Kvan7:master' into master * fix: app-ready fixing before we're ready * Merge branch 'dev' into pr/larssn/428 * should work? * Merge commit 'ab1c8bfa3a31b06da9cf18db0273f6a92e407bc5' into pr/larssn/428 * fix being lazy on the merge * fix: add executable bit to compilation script (#465) * Item Images (#472) * image data stuff * ignore lookup file * update testing * sort items * change sort ot be by refname * more code * working pulling * Fixes #456 Create script to request item's images from trade site #456 * Fixes #457 Update ImageFix to use new saved images before poe1 ones #457 * add images to items * Add Spear as item category * Add Flail as category * Add "goodness" from upstream * Fix #474 Tier # missing from some defense stats #474 * minor oops * remove error for waystones * Add a bunch of images to the docs * Extra widgets docs * Update chat commands links docs * add stash search docs * Item info docs page * more docs * Update bug-report.yml * Version bump * extra version bump --------- Co-authored-by: lawrsp <7957003+lawrsp@users.noreply.github.com> Co-authored-by: Seth Falco <seth@falco.fun> Co-authored-by: Amir Zarrinkafsh <nightah@me.com> Co-authored-by: Lars <890725+larssn@users.noreply.github.com>
266 lines
9.3 KiB
Python
266 lines
9.3 KiB
Python
import logging
|
|
import math
|
|
from collections import defaultdict
|
|
from datetime import datetime
|
|
from time import sleep
|
|
from typing import Optional
|
|
|
|
import cloudscraper
|
|
from requests.models import CaseInsensitiveDict, Response
|
|
|
|
# Initial logging configuration
|
|
logging.basicConfig(
|
|
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def set_log_level(level):
|
|
"""Set the logging level."""
|
|
logger.setLevel(level)
|
|
logging.getLogger().setLevel(level) # Applies to all loggers
|
|
logger.info(f"Log level set to: {level}")
|
|
|
|
|
|
class RateLimitError(Exception):
|
|
pass
|
|
|
|
|
|
class RateLimit:
|
|
def __init__(self, limit: int, window: int, penalty: int):
|
|
self.max = limit
|
|
self.window = window
|
|
self.penalty = penalty
|
|
|
|
def __str__(self):
|
|
return (
|
|
f"RateLimiter<max={self.max}:window={self.window}:penalty={self.penalty}>"
|
|
)
|
|
|
|
def __repr__(self):
|
|
return (
|
|
f"RateLimiter<max={self.max}:window={self.window}:penalty={self.penalty}>"
|
|
)
|
|
|
|
def __eq__(self, other):
|
|
if not isinstance(other, RateLimit):
|
|
return False
|
|
return (
|
|
self.max == other.max
|
|
and self.window == other.window
|
|
and self.penalty == other.penalty
|
|
)
|
|
|
|
|
|
class RateLimiter:
|
|
def __init__(self, debug=False):
|
|
self.limits = defaultdict()
|
|
self.session = cloudscraper.create_scraper(interpreter="nodejs", debug=debug)
|
|
|
|
def wait(self, duration: int | float):
|
|
"""Wait for a specified duration.
|
|
|
|
Parameters
|
|
----------
|
|
duration : int | float
|
|
The duration to wait in seconds.
|
|
"""
|
|
assert isinstance(duration, int) or isinstance(duration, float)
|
|
assert duration > 0
|
|
sleep(duration)
|
|
|
|
def parse_window_limit(self, window: str) -> RateLimit:
|
|
"""Takes a string in the format "limit:interval:penalty" and returns a RateLimit object.
|
|
|
|
Parameters
|
|
----------
|
|
window : str
|
|
The string to parse.
|
|
|
|
Returns
|
|
-------
|
|
RateLimit
|
|
The parsed RateLimit object.
|
|
"""
|
|
assert isinstance(window, str)
|
|
split_window = window.split(":")
|
|
assert len(split_window) == 3
|
|
return RateLimit(
|
|
int(split_window[0]),
|
|
int(split_window[1]),
|
|
int(split_window[2]),
|
|
)
|
|
|
|
def parse_rate_limit(self, limit: str) -> list[RateLimit]:
|
|
"""Takes a csv string of rate limits and returns a list of RateLimit objects.
|
|
|
|
Parameters
|
|
----------
|
|
limit : str
|
|
csv string of rate limits
|
|
|
|
Returns
|
|
-------
|
|
list[RateLimit]
|
|
The parsed list of RateLimit objects.
|
|
"""
|
|
assert isinstance(limit, str)
|
|
split_limit = limit.split(",")
|
|
windows = []
|
|
for window in split_limit:
|
|
windows.append(self.parse_window_limit(window))
|
|
return windows
|
|
|
|
def get_limits(self, limit: str, state: str) -> list[tuple[RateLimit, RateLimit]]:
|
|
"""Takes a group of rate limits and the current state of rate limits from the server and returns a paired list of RateLimit objects.
|
|
|
|
Parameters
|
|
----------
|
|
limit : str
|
|
csv of rate limits
|
|
state : str
|
|
csv of the limits but with the current state according to the server
|
|
|
|
Returns
|
|
-------
|
|
list[tuple[RateLimit, RateLimit]]
|
|
The paired list of RateLimit objects. The first element in the tuple is the limit and the second element is the state.
|
|
"""
|
|
assert isinstance(limit, str)
|
|
assert isinstance(state, str)
|
|
# limits follow format: "limit:interval:penalty"
|
|
limits = self.parse_rate_limit(limit)
|
|
assert isinstance(limits, list)
|
|
assert len(limits) > 0
|
|
assert isinstance(limits[0], RateLimit)
|
|
states = self.parse_rate_limit(state)
|
|
assert isinstance(states, list)
|
|
assert len(states) > 0
|
|
assert isinstance(states[0], RateLimit)
|
|
|
|
limit_groups = []
|
|
for limit_group, state_group in zip(limits, states):
|
|
assert limit_group.window == state_group.window
|
|
limit_groups.append((limit_group, state_group))
|
|
return limit_groups
|
|
|
|
def wait_if_exceeded(self, limit: RateLimit, state: RateLimit):
|
|
"""Waits using time.sleep if the server says we are on a penalty currently. Will wait for the FULL penalty, not just what the server says is remaining.
|
|
|
|
Parameters
|
|
----------
|
|
limit : RateLimit
|
|
A specific period limit
|
|
state : RateLimit
|
|
The current state of the limit
|
|
"""
|
|
assert isinstance(limit, RateLimit)
|
|
assert isinstance(state, RateLimit)
|
|
assert isinstance(state.penalty, int)
|
|
assert isinstance(limit.penalty, int)
|
|
# If the server says we are on penalty
|
|
if state.penalty > 0:
|
|
# Wait for the full penalty
|
|
self.wait(limit.penalty)
|
|
# It should be safe to assume this will never occur, and thus should raise an error
|
|
# Since waiting though, we will just log an error
|
|
logger.error(
|
|
f"Rate limit exceeded. Waiting for full penalty of {limit.penalty} seconds. RateLimit: {limit}, State: {state}"
|
|
)
|
|
|
|
def wait_if_needed(self, limit: RateLimit, state: RateLimit, policy: str):
|
|
assert isinstance(limit, RateLimit)
|
|
assert isinstance(state, RateLimit)
|
|
assert isinstance(policy, str)
|
|
|
|
# If we are less than 1.2x the average rate since last update, wait for 1.1x the average rate
|
|
average_request_rate = limit.window / limit.max * 2
|
|
last_update = self.limits.get(policy, {}).get("last_update")
|
|
assert isinstance(last_update, datetime)
|
|
time_since_last_update = (datetime.now() - last_update).total_seconds()
|
|
if time_since_last_update <= average_request_rate * 1.5:
|
|
# Should almost always happen for limit with the longest average request rate
|
|
logger.debug(f"Waiting for {average_request_rate * 1.4} | {limit}, {state}")
|
|
self.wait(average_request_rate * 1.4)
|
|
|
|
# If we are close to exceeding this limit, wait 3x the average rate
|
|
if state.max >= math.floor(limit.max * 0.9):
|
|
logger.warning(
|
|
f"Close to exceeding limit, waiting for {average_request_rate * 3} | {limit}, {state}"
|
|
)
|
|
self.wait(average_request_rate * 3)
|
|
|
|
def wait_limit(self, policy: str, rules: str, limit: str, state: str):
|
|
assert isinstance(policy, str)
|
|
assert isinstance(rules, str)
|
|
assert isinstance(limit, str)
|
|
assert isinstance(state, str)
|
|
rate_limits = self.get_limits(limit, state)
|
|
self.limits[policy] = {
|
|
"policy": policy,
|
|
"rules": rules,
|
|
"limit": limit,
|
|
"state": state,
|
|
"rate_limits": rate_limits,
|
|
"last_update": datetime.now(),
|
|
"prev_state": self.limits.get(policy, {}).get("state"),
|
|
"prev_rate_limits": self.limits.get(policy, {}).get("rate_limits"),
|
|
}
|
|
rate_limits.reverse()
|
|
for limit, state in rate_limits:
|
|
self.wait_if_exceeded(limit, state)
|
|
self.wait_if_needed(limit, state, policy)
|
|
logger.debug(f"Current rate limits: {self.limits}")
|
|
|
|
def handle_headers(self, headers: dict[str, str], status_code: int):
|
|
"""Handles the headers returned from the server."""
|
|
assert isinstance(headers, CaseInsensitiveDict)
|
|
assert isinstance(status_code, int)
|
|
if status_code == 429:
|
|
logger.error("Rate limit exceeded.")
|
|
retry_after = headers.get("Retry-After")
|
|
if retry_after:
|
|
logger.error(f"Retry after: {retry_after}")
|
|
self.wait(int(retry_after))
|
|
else:
|
|
logger.error("No retry after header found.")
|
|
|
|
ac_headers = headers.get("access-control-expose-headers")
|
|
if isinstance(ac_headers, str):
|
|
ac_headers = ac_headers.lower()
|
|
if (
|
|
"x-rate-limit-policy" in ac_headers
|
|
and "x-rate-limit-rules" in ac_headers
|
|
and "x-rate-limit-ip" in ac_headers
|
|
and "x-rate-limit-ip-state" in ac_headers
|
|
):
|
|
self.wait_limit(
|
|
headers.get("x-rate-limit-policy"),
|
|
headers.get("x-rate-limit-rules"),
|
|
headers.get("x-rate-limit-ip"),
|
|
headers.get("x-rate-limit-ip-state"),
|
|
)
|
|
else:
|
|
logger.error("No access-control-expose-headers header found.")
|
|
|
|
def get(
|
|
self,
|
|
url: str,
|
|
payload: Optional[dict[str, str]] = None,
|
|
headers: Optional[dict[str, str]] = None,
|
|
) -> Response:
|
|
response = self.session.get(url, json=payload, headers=headers)
|
|
self.handle_headers(response.headers, response.status_code)
|
|
return response
|
|
|
|
def post(
|
|
self,
|
|
url: str,
|
|
payload: Optional[dict[str, str]] = None,
|
|
headers: Optional[dict[str, str]] = None,
|
|
) -> Response:
|
|
response = self.session.post(url, json=payload, headers=headers)
|
|
self.handle_headers(response.headers, response.status_code)
|
|
return response
|