Limit the amount of connections made;
streamline GQL bulk campaigns fetching
This commit is contained in:
1 parent
29be8bd799
commit
0f538482c7
2 files changed
+56
-22
No files matched your search
@@ -13,7 +13,7 @@ from subprocess import CREATE_NO_WINDOW
|
|||||||
from collections import abc, deque, OrderedDict
|
from collections import abc, deque, OrderedDict
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from contextlib import suppress, asynccontextmanager
|
from contextlib import suppress, asynccontextmanager
|
||||||
from typing import Any, Literal, Final, NoReturn, cast, TYPE_CHECKING
|
from typing import Any, Literal, Final, NoReturn, overload, cast, TYPE_CHECKING
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from yarl import URL
|
from yarl import URL
|
||||||
@@ -41,6 +41,7 @@ from exceptions import (
|
|||||||
)
|
)
|
||||||
from utils import (
|
from utils import (
|
||||||
CHARS_HEX_LOWER,
|
CHARS_HEX_LOWER,
|
||||||
|
chunk,
|
||||||
timestamp,
|
timestamp,
|
||||||
create_nonce,
|
create_nonce,
|
||||||
task_wrapper,
|
task_wrapper,
|
||||||
@@ -564,8 +565,10 @@ class Twitch:
|
|||||||
cookie_jar = aiohttp.CookieJar()
|
cookie_jar = aiohttp.CookieJar()
|
||||||
if COOKIES_PATH.exists():
|
if COOKIES_PATH.exists():
|
||||||
cookie_jar.load(COOKIES_PATH)
|
cookie_jar.load(COOKIES_PATH)
|
||||||
# create session
|
# create session, limited to 50 connections at maximum
|
||||||
|
connector = aiohttp.TCPConnector(limit=50)
|
||||||
self._session = aiohttp.ClientSession(
|
self._session = aiohttp.ClientSession(
|
||||||
|
connector=connector,
|
||||||
cookie_jar=cookie_jar,
|
cookie_jar=cookie_jar,
|
||||||
headers={"User-Agent": chrome_agent},
|
headers={"User-Agent": chrome_agent},
|
||||||
timeout=aiohttp.ClientTimeout(connect=5, total=10),
|
timeout=aiohttp.ClientTimeout(connect=5, total=10),
|
||||||
@@ -1268,22 +1271,38 @@ class Twitch:
|
|||||||
with suppress(asyncio.TimeoutError):
|
with suppress(asyncio.TimeoutError):
|
||||||
await asyncio.wait_for(self.gui.wait_until_closed(), timeout=delay)
|
await asyncio.wait_for(self.gui.wait_until_closed(), timeout=delay)
|
||||||
|
|
||||||
async def gql_request(self, op: GQLOperation) -> JsonType:
|
@overload
|
||||||
gql_logger.debug(f"GQL Request: {op}")
|
async def gql_request(self, ops: GQLOperation) -> JsonType:
|
||||||
|
...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
async def gql_request(self, ops: list[GQLOperation]) -> list[JsonType]:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def gql_request(
|
||||||
|
self, ops: GQLOperation | list[GQLOperation]
|
||||||
|
) -> JsonType | list[JsonType]:
|
||||||
|
gql_logger.debug(f"GQL Request: {ops}")
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
auth_state = await self.get_auth()
|
auth_state = await self.get_auth()
|
||||||
async with self.request(
|
async with self.request(
|
||||||
"POST",
|
"POST",
|
||||||
"https://gql.twitch.tv/gql",
|
"https://gql.twitch.tv/gql",
|
||||||
json=op,
|
json=ops,
|
||||||
headers=auth_state.headers(gql=True),
|
headers=auth_state.headers(gql=True),
|
||||||
invalidate_after=getattr(auth_state, "integrity_expires", None),
|
invalidate_after=getattr(auth_state, "integrity_expires", None),
|
||||||
) as response:
|
) as response:
|
||||||
response_json: JsonType = await response.json()
|
response_json: JsonType | list[JsonType] = await response.json()
|
||||||
except RequestInvalid:
|
except RequestInvalid:
|
||||||
continue
|
continue
|
||||||
gql_logger.debug(f"GQL Response: {response_json}")
|
gql_logger.debug(f"GQL Response: {response_json}")
|
||||||
|
orig_response = response_json
|
||||||
|
if isinstance(response_json, list):
|
||||||
|
response_list = response_json
|
||||||
|
else:
|
||||||
|
response_list = [response_json]
|
||||||
|
for response_json in response_list:
|
||||||
if "errors" in response_json and response_json["errors"]:
|
if "errors" in response_json and response_json["errors"]:
|
||||||
if (
|
if (
|
||||||
"message" in response_json["errors"]
|
"message" in response_json["errors"]
|
||||||
@@ -1292,7 +1311,7 @@ class Twitch:
|
|||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
continue
|
continue
|
||||||
raise MinerException(f"GQL error: {response_json['errors']}")
|
raise MinerException(f"GQL error: {response_json['errors']}")
|
||||||
return response_json
|
return orig_response
|
||||||
|
|
||||||
def _merge_data(self, primary_data: JsonType, secondary_data: JsonType) -> JsonType:
|
def _merge_data(self, primary_data: JsonType, secondary_data: JsonType) -> JsonType:
|
||||||
merged = {}
|
merged = {}
|
||||||
@@ -1315,14 +1334,24 @@ class Twitch:
|
|||||||
merged[key] = secondary_data[key]
|
merged[key] = secondary_data[key]
|
||||||
return merged
|
return merged
|
||||||
|
|
||||||
async def fetch_campaign(self, campaign_id: str, available_data: JsonType) -> JsonType:
|
async def fetch_campaigns(
|
||||||
|
self, campaigns_chunk: list[tuple[str, JsonType]]
|
||||||
|
) -> dict[str, JsonType]:
|
||||||
|
campaign_ids: dict[str, JsonType] = dict(campaigns_chunk)
|
||||||
auth_state = await self.get_auth()
|
auth_state = await self.get_auth()
|
||||||
response = await self.gql_request(
|
response_list: list[JsonType] = await self.gql_request(
|
||||||
|
[
|
||||||
GQL_OPERATIONS["CampaignDetails"].with_variables(
|
GQL_OPERATIONS["CampaignDetails"].with_variables(
|
||||||
{"channelLogin": str(auth_state.user_id), "dropID": campaign_id}
|
{"channelLogin": str(auth_state.user_id), "dropID": cid}
|
||||||
)
|
)
|
||||||
|
for cid in campaign_ids
|
||||||
|
]
|
||||||
)
|
)
|
||||||
return self._merge_data(available_data, response["data"]["user"]["dropCampaign"])
|
fetched_data: dict[str, JsonType] = {
|
||||||
|
(campaign_data := response_json["data"]["user"]["dropCampaign"])["id"]: campaign_data
|
||||||
|
for response_json in response_list
|
||||||
|
}
|
||||||
|
return self._merge_data(campaign_ids, fetched_data)
|
||||||
|
|
||||||
async def fetch_inventory(self) -> None:
|
async def fetch_inventory(self) -> None:
|
||||||
status_update = self.gui.status.update
|
status_update = self.gui.status.update
|
||||||
@@ -1336,7 +1365,7 @@ class Twitch:
|
|||||||
b["id"]: timestamp(b["lastAwardedAt"]) for b in inventory["gameEventDrops"]
|
b["id"]: timestamp(b["lastAwardedAt"]) for b in inventory["gameEventDrops"]
|
||||||
}
|
}
|
||||||
inventory_data: dict[str, JsonType] = {c["id"]: c for c in ongoing_campaigns}
|
inventory_data: dict[str, JsonType] = {c["id"]: c for c in ongoing_campaigns}
|
||||||
# fetch all available campaigns data (campaigns)
|
# fetch general available campaigns data (campaigns)
|
||||||
response = await self.gql_request(GQL_OPERATIONS["Campaigns"])
|
response = await self.gql_request(GQL_OPERATIONS["Campaigns"])
|
||||||
available_list: list[JsonType] = response["data"]["currentUser"]["dropCampaigns"] or []
|
available_list: list[JsonType] = response["data"]["currentUser"]["dropCampaigns"] or []
|
||||||
applicable_statuses = ("ACTIVE", "UPCOMING")
|
applicable_statuses = ("ACTIVE", "UPCOMING")
|
||||||
@@ -1345,14 +1374,13 @@ class Twitch:
|
|||||||
for c in available_list
|
for c in available_list
|
||||||
if c["status"] in applicable_statuses # that are currently not expired
|
if c["status"] in applicable_statuses # that are currently not expired
|
||||||
}
|
}
|
||||||
# fetch additional data for each campaign that can be earned but is not in-progress yet
|
# fetch detailed data for each campaign, in chunks
|
||||||
fetched_campaigns: dict[str, JsonType] = {}
|
for i, chunk_coro in enumerate(
|
||||||
for i, coro in enumerate(
|
|
||||||
# specifically use an intermediate list per a Python bug
|
# specifically use an intermediate list per a Python bug
|
||||||
# https://github.com/python/cpython/issues/88342
|
# https://github.com/python/cpython/issues/88342
|
||||||
asyncio.as_completed([
|
asyncio.as_completed([
|
||||||
self.fetch_campaign(campaign_id, available_data)
|
self.fetch_campaigns(campaigns_chunk)
|
||||||
for campaign_id, available_data in available_campaigns.items()
|
for campaigns_chunk in chunk(available_campaigns.items(), 20)
|
||||||
]),
|
]),
|
||||||
start=1,
|
start=1,
|
||||||
):
|
):
|
||||||
@@ -1361,13 +1389,13 @@ class Twitch:
|
|||||||
counter=f"({i}/{len(available_campaigns)})"
|
counter=f"({i}/{len(available_campaigns)})"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
campaign_data = await coro
|
chunk_campaigns_data = await chunk_coro
|
||||||
fetched_campaigns[campaign_data["id"]] = campaign_data
|
# merge the inventory and campaigns datas together
|
||||||
# merge the inventory and campaigns datas together, then use it to create campaign objects
|
inventory_data = self._merge_data(inventory_data, chunk_campaigns_data)
|
||||||
merged_campaigns: dict[str, JsonType] = self._merge_data(inventory_data, fetched_campaigns)
|
# # use it to create campaign objects
|
||||||
campaigns: list[DropsCampaign] = [
|
campaigns: list[DropsCampaign] = [
|
||||||
DropsCampaign(self, campaign_data, claimed_benefits)
|
DropsCampaign(self, campaign_data, claimed_benefits)
|
||||||
for campaign_data in merged_campaigns.values()
|
for campaign_data in inventory_data.values()
|
||||||
]
|
]
|
||||||
campaigns.sort(key=lambda c: c.active, reverse=True)
|
campaigns.sort(key=lambda c: c.active, reverse=True)
|
||||||
campaigns.sort(key=lambda c: c.upcoming and c.starts_at or c.ends_at)
|
campaigns.sort(key=lambda c: c.upcoming and c.starts_at or c.ends_at)
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ async def first_to_complete(coros: abc.Iterable[abc.Coroutine[Any, Any, _T]]) ->
|
|||||||
return next(iter(done)).result()
|
return next(iter(done)).result()
|
||||||
|
|
||||||
|
|
||||||
|
def chunk(to_chunk: abc.Iterable[_T], chunk_length: int) -> abc.Generator[list[_T], None, None]:
|
||||||
|
list_to_chunk = list(to_chunk)
|
||||||
|
for i in range(0, len(list_to_chunk), chunk_length):
|
||||||
|
yield list_to_chunk[i:i + chunk_length]
|
||||||
|
|
||||||
|
|
||||||
def format_traceback(exc: BaseException, **kwargs: Any) -> str:
|
def format_traceback(exc: BaseException, **kwargs: Any) -> str:
|
||||||
"""
|
"""
|
||||||
Like `traceback.print_exc` but returns a string. Uses the passed-in exception.
|
Like `traceback.print_exc` but returns a string. Uses the passed-in exception.
|
||||||
|
|||||||
Reference in new issue
Block a user