This commit is contained in:
rdavydov
2022-12-17 18:28:34 +03:00
parent e7a107002c
commit 6369fbe002
7 changed files with 255 additions and 77 deletions

View File

@@ -43,7 +43,7 @@ logging.getLogger("chardet.charsetprober").setLevel(logging.ERROR)
logging.getLogger("requests").setLevel(logging.ERROR)
logging.getLogger("werkzeug").setLevel(logging.ERROR)
logging.getLogger("irc.client").setLevel(logging.ERROR)
#logging.getLogger("seleniumwire").setLevel(logging.ERROR)
logging.getLogger("seleniumwire").setLevel(logging.ERROR)
logging.getLogger("websocket").setLevel(logging.ERROR)
logger = logging.getLogger(__name__)
@@ -84,7 +84,8 @@ class TwitchChannelPointsMiner:
):
# Fixes TypeError: 'NoneType' object is not subscriptable
if not username or username == "your-twitch-username":
logger.error("Please edit your runner file (usually run.py) and try again.")
logger.error(
"Please edit your runner file (usually run.py) and try again.")
logger.error("No username, exiting...")
sys.exit(0)
@@ -92,7 +93,8 @@ class TwitchChannelPointsMiner:
Settings.enable_analytics = enable_analytics
if enable_analytics is True:
Settings.analytics_path = os.path.join(Path().absolute(), "analytics", username)
Settings.analytics_path = os.path.join(
Path().absolute(), "analytics", username)
Path(Settings.analytics_path).mkdir(parents=True, exist_ok=True)
self.username = username
@@ -105,7 +107,8 @@ class TwitchChannelPointsMiner:
streamer_settings.bet.default()
Settings.streamer_settings = streamer_settings
user_agent = get_user_agent("FIREFOX")
# user_agent = get_user_agent("FIREFOX")
user_agent = get_user_agent("CHROME")
self.twitch = Twitch(self.username, user_agent, password)
self.claim_drops_startup = claim_drops_startup
@@ -129,15 +132,18 @@ class TwitchChannelPointsMiner:
# Check for the latest version of the script
current_version, github_version = check_versions()
logger.info(f"Twitch Channel Points Miner v2-{current_version} (fork by rdavydov)")
logger.info("https://github.com/rdavydov/Twitch-Channel-Points-Miner-v2")
logger.info(
f"Twitch Channel Points Miner v2-{current_version} (fork by rdavydov)")
logger.info(
"https://github.com/rdavydov/Twitch-Channel-Points-Miner-v2")
if github_version == "0.0.0":
logger.error(
"Unable to detect if you have the latest version of this script"
)
elif current_version != github_version:
logger.info(f"You are running the version {current_version} of this script")
logger.info(
f"You are running the version {current_version} of this script")
logger.info(f"The latest version on GitHub is: {github_version}")
for sign in [signal.SIGINT, signal.SIGSEGV, signal.SIGTERM]:
@@ -153,7 +159,7 @@ class TwitchChannelPointsMiner:
# Analytics switch
if Settings.enable_analytics is True:
from TwitchChannelPointsMiner.classes.AnalyticsServer import AnalyticsServer
http_server = AnalyticsServer(
host=host, port=port, refresh=refresh, days_ago=days_ago
)
@@ -161,7 +167,8 @@ class TwitchChannelPointsMiner:
http_server.name = "Analytics Thread"
http_server.start()
else:
logger.error("Can't start analytics(), please set enable_analytics=True")
logger.error(
"Can't start analytics(), please set enable_analytics=True")
def mine(
self,
@@ -207,7 +214,8 @@ class TwitchChannelPointsMiner:
streamers_dict[username] = streamer
if followers is True:
followers_array = self.twitch.get_followers(order=followers_order)
followers_array = self.twitch.get_followers(
order=followers_order)
logger.info(
f"Load {len(followers_array)} followers from your profile!",
extra={"emoji": ":clipboard:"},
@@ -230,7 +238,8 @@ class TwitchChannelPointsMiner:
if isinstance(streamers_dict[username], Streamer) is True
else Streamer(username)
)
streamer.channel_id = self.twitch.get_channel_id(username)
streamer.channel_id = self.twitch.get_channel_id(
username)
streamer.settings = set_default_settings(
streamer.settings, Settings.streamer_settings
)
@@ -272,7 +281,8 @@ class TwitchChannelPointsMiner:
# If we have at least one streamer with settings = claim_drops True
# Spawn a thread for sync inventory and dashboard
if (
at_least_one_value_in_settings_is(self.streamers, "claim_drops", True)
at_least_one_value_in_settings_is(
self.streamers, "claim_drops", True)
is True
):
self.sync_campaigns_thread = threading.Thread(
@@ -298,8 +308,8 @@ class TwitchChannelPointsMiner:
# Subscribe to community-points-user. Get update for points spent or gains
user_id = self.twitch.twitch_login.get_user_id()
#print(f"!!!!!!!!!!!!!! USER_ID: {user_id}")
# print(f"!!!!!!!!!!!!!! USER_ID: {user_id}")
# Fixes 'ERR_BADAUTH'
if not user_id:
logger.error("No user_id, exiting...")
@@ -331,7 +341,8 @@ class TwitchChannelPointsMiner:
if streamer.settings.make_predictions is True:
self.ws_pool.submit(
PubsubTopic("predictions-channel-v1", streamer=streamer)
PubsubTopic("predictions-channel-v1",
streamer=streamer)
)
refresh_context = time.time()
@@ -348,7 +359,8 @@ class TwitchChannelPointsMiner:
logger.info(
f"#{index} - The last PING was sent more than 10 minutes ago. Reconnecting to the WebSocket..."
)
WebSocketsPool.handle_reconnection(self.ws_pool.ws[index])
WebSocketsPool.handle_reconnection(
self.ws_pool.ws[index])
if ((time.time() - refresh_context) // 60) >= 30:
refresh_context = time.time()
@@ -445,4 +457,4 @@ class TwitchChannelPointsMiner:
logger.info(
f"{self.streamers[streamer_index].print_history()}",
extra={"emoji": ":moneybag:"},
)
)

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
__version__ = "1.5.4"
__version__ = "1.6.0"
from .TwitchChannelPointsMiner import TwitchChannelPointsMiner
__all__ = [

View File

@@ -16,10 +16,17 @@ from pathlib import Path
from secrets import choice, token_hex
import json
import pickle
from base64 import urlsafe_b64decode
from hashlib import sha256
from base64 import urlsafe_b64decode
import requests
from undetected_chromedriver import ChromeOptions
import seleniumwire.undetected_chromedriver.v2 as uc
from TwitchChannelPointsMiner.classes.entities.Campaign import Campaign
from TwitchChannelPointsMiner.classes.entities.Drop import Drop
from TwitchChannelPointsMiner.classes.Exceptions import (
@@ -47,6 +54,71 @@ from TwitchChannelPointsMiner.utils import (
logger = logging.getLogger(__name__)
HEX_CHARS = '0123456789abcdef'
difficulty_1 = 10
subchallengeCount = 2
platformInputs = "tp-v2-input"
def get_time_now() -> int:
time_now = f'{time.time()}'
return int(time_now.replace('.', '')[:13])
def random_string(k=32):
return ''.join(random.choices(HEX_CHARS, k=k))
def string_to_sha256(string_to: str):
return sha256(string_to.encode()).hexdigest()
def get_hash_difficulty(hash_string):
return 4503599627370496 / (int(f'0x{hash_string[:13]}', 16) + 1)
def prof_work(config, _id, work_time):
f_list = []
difficulty = config['difficulty'] / config['subchallengeCount']
start_string = string_to_sha256(
platformInputs + ',\x20' + str(work_time) + ',\x20' + _id)
for _ in range(config['subchallengeCount']):
d = 1
while True:
start_string2 = string_to_sha256(str(d) + ',\x20' + start_string)
if (get_hash_difficulty(start_string2) >= difficulty):
f_list.append(d)
start_string = start_string2
break
d += 1
return {
'answers': f_list,
'finalHash': start_string
}
def get_kpsdk_cd():
config = {
"platformInputs": "tp-v2-input",
"difficulty": 10,
"subchallengeCount": 2
}
t0 = time.perf_counter()
_id = random_string()
work_time = get_time_now() - 527
prof_of_work = prof_work(config, _id, work_time)
t1 = time.perf_counter_ns()
duration = round(1000 * (t1 - t0)) / 1000
return {
'answers': prof_of_work['answers'],
'rst': time.perf_counter_ns(),
'st': time.perf_counter_ns(),
'd': duration,
'id': _id,
'workTime': work_time,
}
class Twitch(object):
__slots__ = [
@@ -127,22 +199,26 @@ class Twitch(object):
def get_spade_url(self, streamer):
try:
# fixes AttributeError: 'NoneType' object has no attribute 'group'
#headers = {"User-Agent": self.user_agent}
# headers = {"User-Agent": self.user_agent}
from TwitchChannelPointsMiner.constants import USER_AGENTS
headers = {"User-Agent": USER_AGENTS["Linux"]["FIREFOX"]}
# headers = {"User-Agent": USER_AGENTS["Linux"]["FIREFOX"]}
headers = {"User-Agent": USER_AGENTS["Windows"]["CHROME"]}
main_page_request = requests.get(streamer.streamer_url, headers=headers)
main_page_request = requests.get(
streamer.streamer_url, headers=headers)
response = main_page_request.text
#logger.info(response)
# logger.info(response)
regex_settings = "(https://static.twitchcdn.net/config/settings.*?js)"
settings_url = re.search(regex_settings, response).group(1)
settings_request = requests.get(settings_url, headers=headers)
response = settings_request.text
regex_spade = '"spade_url":"(.*?)"'
streamer.stream.spade_url = re.search(regex_spade, response).group(1)
streamer.stream.spade_url = re.search(
regex_spade, response).group(1)
except requests.exceptions.RequestException as e:
logger.error(f"Something went wrong during extraction of 'spade_url': {e}")
logger.error(
f"Something went wrong during extraction of 'spade_url': {e}")
def get_broadcast_id(self, streamer):
json_data = copy.deepcopy(GQLOperations.WithIsStreamLiveQuery)
@@ -156,7 +232,8 @@ class Twitch(object):
raise StreamerIsOfflineException
def get_stream_info(self, streamer):
json_data = copy.deepcopy(GQLOperations.VideoPlayerStreamInfoOverlayChannel)
json_data = copy.deepcopy(
GQLOperations.VideoPlayerStreamInfoOverlayChannel)
json_data["variables"] = {"channel": streamer.username}
response = self.post_gql_request(json_data)
if response != {}:
@@ -228,7 +305,8 @@ class Twitch(object):
logger.info(
f"Joining raid from {streamer} to {raid.target_login}!",
extra={"emoji": ":performing_arts:", "event": Events.JOIN_RAID},
extra={"emoji": ":performing_arts:",
"event": Events.JOIN_RAID},
)
def viewer_is_mod(self, streamer):
@@ -294,45 +372,109 @@ class Twitch(object):
):
return self.integrity
try:
response = requests.post(
# is_bad_bot becomes true when I use headless mode.
HEADLESS = False
options = uc.ChromeOptions()
if HEADLESS is True:
options.add_argument('--headless')
options.add_argument('--log-level=3')
options.add_argument('--disable-web-security')
options.add_argument('--allow-running-insecure-content')
options.add_argument('--lang=en')
options.add_argument('--no-sandbox')
options.add_argument('--disable-gpu')
# options.add_argument("--user-agent=\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36\"")
# options.add_argument("--window-size=1920,1080")
# options.set_capability("detach", True)
driver = uc.Chrome(
options=options, use_subprocess=True # , executable_path=EXECUTABLE_PATH
)
driver.minimize_window()
driver.get('https://www.twitch.tv/robots.txt')
cookies = pickle.load(open(self.cookies_file, "rb"))
for cookie in cookies:
driver.add_cookie(cookie)
driver.get('https://www.twitch.tv/settings/profile')
# Print request headers
# for request in driver.requests:
# logger.info(request.url) # <--------------- Request url
# logger.info(request.headers) # <----------- Request headers
# logger.info(request.response.headers) # <-- Response headers
# Set correct user agent
selenium_user_agent = driver.execute_script(
"return navigator.userAgent;")
self.user_agent = selenium_user_agent
session = requests.Session()
kpsdk_cd = json.dumps(get_kpsdk_cd())
for cookie in driver.get_cookies():
session.cookies.set(cookie['name'], cookie['value'],
domain=cookie['domain'])
if cookie["name"] == "KP_UIDz":
if cookie["value"] is not None:
kpsdk_ct = cookie["value"]
else:
logger.error("Can't extract kpsdk_ct")
headers = {
'Accept': '*/*',
'Accept-Language': 'en-US,en;q=0.9,pt;q=0.8',
"Authorization": f"OAuth {self.twitch_login.get_auth_token()}",
"Client-Id": CLIENT_ID,
"Client-Session-Id": self.client_session,
"Client-Version": self.update_client_version(),
'Origin': 'https://www.twitch.tv',
'Referer': 'https://www.twitch.tv/',
"User-Agent": self.user_agent,
# "User-Agent": selenium_user_agent,
"X-Device-Id": self.device_id,
'x-kpsdk-cd': kpsdk_cd,
'x-kpsdk-ct': kpsdk_ct
}
response = session.post(
GQLOperations.integrity_url,
json={},
headers={
"Authorization": f"OAuth {self.twitch_login.get_auth_token()}",
"Client-Id": CLIENT_ID,
"Client-Session-Id": self.client_session,
"Client-Version": self.update_client_version(),
"User-Agent": self.user_agent,
"X-Device-Id": self.device_id,
},
headers=headers
)
logger.debug(
f"Data: [], Status code: {response.status_code}, Content: {response.text}"
)
self.integrity = response.json().get("token", None)
#logger.info(f"integrity: {self.integrity}")
driver.close()
driver.quit()
integrity_json = response.json()
self.integrity = integrity_json.get("token", None)
# logger.info(f"integrity: {self.integrity}")
if self.isBadBot(self.integrity) is True:
logger.info("Uh-oh, Twitch has detected this miner as a \"Bad Bot\". Don't worry.")
self.integrity_expire = response.json().get("expiration", 0)
#logger.info(f"integrity_expire: {self.integrity_expire}")
logger.error(
"Uh-oh, Twitch has detected this miner as a \"Bad Bot\"")
self.integrity_expire = integrity_json.get("expiration", 0)
# logger.info(f"integrity_expire: {self.integrity_expire}")
return self.integrity
except requests.exceptions.RequestException as e:
logger.error(f"Error with post_integrity: {e}")
return self.integrity
# verify the integrity token's contents for the "is_bad_bot" flag
def isBadBot(self, integrity):
def isBadBot(self, integrity):
stripped_token: str = self.integrity.split('.')[2] + "=="
messy_json: str = urlsafe_b64decode(stripped_token.encode()).decode(errors="ignore")
messy_json: str = urlsafe_b64decode(
stripped_token.encode()).decode(errors="ignore")
match = re.search(r'(.+)(?<="}).+$', messy_json)
if match is None:
#raise MinerException("Unable to parse the integrity token")
# raise MinerException("Unable to parse the integrity token")
logger.info("Unable to parse the integrity token. Don't worry.")
return
decoded_header = json.loads(match.group(1))
#logger.info(f"decoded_header: {decoded_header}")
# logger.info(f"decoded_header: {decoded_header}")
if decoded_header.get("is_bad_bot", "false") != "false":
return True
else:
@@ -383,11 +525,13 @@ class Twitch(object):
streamers_watching += streamers_index[:2]
elif (
prior in [Priority.POINTS_ASCENDING, Priority.POINTS_DESCEDING]
prior in [Priority.POINTS_ASCENDING,
Priority.POINTS_DESCEDING]
and len(streamers_watching) < 2
):
items = [
{"points": streamers[index].channel_points, "index": index}
{"points": streamers[index].channel_points,
"index": index}
for index in streamers_index
]
items = sorted(
@@ -397,7 +541,8 @@ class Twitch(object):
True if prior == Priority.POINTS_DESCEDING else False
),
)
streamers_watching += [item["index"] for item in items][:2]
streamers_watching += [item["index"]
for item in items][:2]
elif prior == Priority.STREAK and len(streamers_watching) < 2:
"""
@@ -413,7 +558,8 @@ class Twitch(object):
and (
streamers[index].offline_at == 0
or (
(time.time() - streamers[index].offline_at)
(time.time() -
streamers[index].offline_at)
// 60
)
> 30
@@ -439,7 +585,8 @@ class Twitch(object):
]
streamers_with_multiplier = sorted(
streamers_with_multiplier,
key=lambda x: streamers[x].total_points_multiplier(),
key=lambda x: streamers[x].total_points_multiplier(
),
reverse=True,
)
streamers_watching += streamers_with_multiplier[:2]
@@ -508,10 +655,12 @@ class Twitch(object):
)
except requests.exceptions.ConnectionError as e:
logger.error(f"Error while trying to send minute watched: {e}")
logger.error(
f"Error while trying to send minute watched: {e}")
self.__check_connection_handler(chunk_size)
except requests.exceptions.Timeout as e:
logger.error(f"Error while trying to send minute watched: {e}")
logger.error(
f"Error while trying to send minute watched: {e}")
self.__chuncked_sleep(
next_iteration - time.time(), chunk_size=chunk_size
@@ -520,7 +669,8 @@ class Twitch(object):
if streamers_watching == []:
self.__chuncked_sleep(60, chunk_size=chunk_size)
except Exception:
logger.error("Exception raised in send minute watched", exc_info=True)
logger.error(
"Exception raised in send minute watched", exc_info=True)
# === CHANNEL POINTS / PREDICTION === #
# Load the amount of current points for a channel, check if a bonus is available
@@ -538,11 +688,12 @@ class Twitch(object):
streamer.activeMultipliers = community_points["activeMultipliers"]
if community_points["availableClaim"] is not None:
self.claim_bonus(streamer, community_points["availableClaim"]["id"])
self.claim_bonus(
streamer, community_points["availableClaim"]["id"])
def make_predictions(self, event):
decision = event.bet.calculate(event.streamer.channel_points)
#selector_index = 0 if decision["choice"] == "A" else 1
# selector_index = 0 if decision["choice"] == "A" else 1
logger.info(
f"Going to complete bet for {event}",
@@ -571,7 +722,7 @@ class Twitch(object):
else:
if decision["amount"] >= 10:
logger.info(
#f"Place {_millify(decision['amount'])} channel points on: {event.bet.get_outcome(selector_index)}",
# f"Place {_millify(decision['amount'])} channel points on: {event.bet.get_outcome(selector_index)}",
f"Place {_millify(decision['amount'])} channel points on: {event.bet.get_outcome(decision['choice'])}",
extra={
"emoji": ":four_leaf_clover:",
@@ -635,7 +786,8 @@ class Twitch(object):
# === CAMPAIGNS / DROPS / INVENTORY === #
def __get_campaign_ids_from_streamer(self, streamer):
json_data = copy.deepcopy(GQLOperations.DropsHighlightService_AvailableDrops)
json_data = copy.deepcopy(
GQLOperations.DropsHighlightService_AvailableDrops)
json_data["variables"] = {"channelID": streamer.channel_id}
response = self.post_gql_request(json_data)
try:
@@ -663,7 +815,8 @@ class Twitch(object):
response = self.post_gql_request(GQLOperations.ViewerDropsDashboard)
campaigns = response["data"]["currentUser"]["dropCampaigns"]
if status is not None:
campaigns = list(filter(lambda x: x["status"] == status.upper(), campaigns))
campaigns = list(
filter(lambda x: x["status"] == status.upper(), campaigns))
return campaigns
def __get_campaigns_details(self, campaigns):
@@ -672,7 +825,8 @@ class Twitch(object):
for chunk in chunks:
json_data = []
for campaign in chunk:
json_data.append(copy.deepcopy(GQLOperations.DropCampaignDetails))
json_data.append(copy.deepcopy(
GQLOperations.DropCampaignDetails))
json_data[-1]["variables"] = {
"dropID": campaign["id"],
"channelLogin": f"{self.twitch_login.get_user_id()}",
@@ -703,7 +857,8 @@ class Twitch(object):
campaigns[i].sync_drops(
progress["timeBasedDrops"], self.claim_drop
)
campaigns[i].clear_drops() # Remove all the claimed drops
# Remove all the claimed drops
campaigns[i].clear_drops()
break
return campaigns
@@ -713,7 +868,8 @@ class Twitch(object):
)
json_data = copy.deepcopy(GQLOperations.DropsPage_ClaimDropRewards)
json_data["variables"] = {"input": {"dropInstanceID": drop.drop_instance_id}}
json_data["variables"] = {
"input": {"dropInstanceID": drop.drop_instance_id}}
response = self.post_gql_request(json_data)
try:
# response["data"]["claimDropRewards"] can be null and respose["data"]["errors"] != []

View File

@@ -3,16 +3,15 @@ URL = "https://www.twitch.tv"
IRC = "irc.chat.twitch.tv"
IRC_PORT = 6667
WEBSOCKET = "wss://pubsub-edge.twitch.tv/v1"
#CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko" # Browser
CLIENT_ID = "kd1unb4b3q4t58fwlpcbzcbnm76a8fp" # Android App
#CLIENT_ID = "851cqzxpb9bqu9z6galo155du" # iOS App
CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko" # Browser
# CLIENT_ID = "kd1unb4b3q4t58fwlpcbzcbnm76a8fp" # Android App
# CLIENT_ID = "851cqzxpb9bqu9z6galo155du" # iOS App
DROP_ID = "c2542d6d-cd10-4532-919b-3d19f30a768b"
CLIENT_VERSION = "32d439b2-bd5b-4e35-b82a-fae10b04da70"
USER_AGENTS = {
"Windows": {
#"CHROME": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.104 Safari/537.36",
'CHROME': "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36",
'CHROME': "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36",
"FIREFOX": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:84.0) Gecko/20100101 Firefox/84.0",
},
"Linux": {
@@ -20,7 +19,7 @@ USER_AGENTS = {
"FIREFOX": "Mozilla/5.0 (X11; Linux x86_64; rv:85.0) Gecko/20100101 Firefox/85.0",
},
"Android": {
#"App": "Dalvik/2.1.0 (Linux; U; Android 7.1.2; SM-G975N Build/N2G48C) tv.twitch.android.app/13.4.1/1304010"
# "App": "Dalvik/2.1.0 (Linux; U; Android 7.1.2; SM-G975N Build/N2G48C) tv.twitch.android.app/13.4.1/1304010"
"App": "Dalvik/2.1.0 (Linux; U; Android 7.1.2; SM-G977N Build/LMY48Z) tv.twitch.android.app/14.3.2/1403020"
}
}

View File

@@ -32,7 +32,8 @@ def float_round(number, ndigits=2):
def server_time(message_data):
return (
datetime.fromtimestamp(message_data["server_time"], timezone.utc).isoformat()
datetime.fromtimestamp(
message_data["server_time"], timezone.utc).isoformat()
+ "Z"
if message_data is not None and "server_time" in message_data
else datetime.fromtimestamp(time.time(), timezone.utc).isoformat() + "Z"
@@ -54,12 +55,15 @@ def create_nonce(length=30) -> str:
return nonce
# for mobile-token
def get_user_agent(browser: str) -> str:
"""try:
try:
return USER_AGENTS[platform.system()][browser]
except KeyError:
return USER_AGENTS["Linux"]["FIREFOX"]"""
return USER_AGENTS["Android"]["App"]
# return USER_AGENTS["Linux"]["FIREFOX"]
return USER_AGENTS["Windows"]["CHROME"]
# return USER_AGENTS["Android"]["App"]
def remove_emoji(string: str) -> str:
@@ -141,6 +145,7 @@ def set_default_settings(settings, defaults):
'''def char_decision_as_index(char):
return 0 if char == "A" else 1'''
def internet_connection_available(host="8.8.8.8", port=53, timeout=3):
try:
socket.setdefaulttimeout(timeout)
@@ -155,7 +160,7 @@ def percentage(a, b):
def create_chunks(lst, n):
return [lst[i : (i + n)] for i in range(0, len(lst), n)] # noqa: E203
return [lst[i: (i + n)] for i in range(0, len(lst), n)] # noqa: E203
def download_file(name, fpath):

View File

@@ -10,3 +10,6 @@ flask
irc
pandas
browser_cookie3
selenium
selenium-wire
undetected_chromedriver

View File

@@ -37,7 +37,10 @@ setuptools.setup(
"flask",
"irc",
"pandas",
"browser_cookie3"
"browser_cookie3",
"selenium",
"selenium-wire",
"undetected_chromedriver"
],
long_description=read("README.md"),
long_description_content_type="text/markdown",