Reimplement standard login procedure, keep chrome as backup
This commit is contained in:
1 parent
5e3e7b6f4f
commit
504c6b06c0
4 files changed
+388
-197
No files matched your search
+3
-3
@@ -64,9 +64,9 @@ DEFAULT_LANG = "English"
|
|||||||
BASE_URL = URL("https://twitch.tv")
|
BASE_URL = URL("https://twitch.tv")
|
||||||
CLIENT_ID = "kd1unb4b3q4t58fwlpcbzcbnm76a8fp"
|
CLIENT_ID = "kd1unb4b3q4t58fwlpcbzcbnm76a8fp"
|
||||||
# CLIENT_ID = "uo6dggojyb8d6soh92zknwmi5ej1q2"
|
# CLIENT_ID = "uo6dggojyb8d6soh92zknwmi5ej1q2"
|
||||||
USER_AGENT = (
|
ANDROID_USER_AGENT = (
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) "
|
"Dalvik/2.1.0 (Linux; U; Android 7.1.2; SM-G975N Build/N2G48C) "
|
||||||
"Chrome/105.0.0.0 Safari/537.36"
|
"tv.twitch.android.app/13.4.1/1304010"
|
||||||
)
|
)
|
||||||
# Intervals and Delays
|
# Intervals and Delays
|
||||||
PING_INTERVAL = timedelta(minutes=3)
|
PING_INTERVAL = timedelta(minutes=3)
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
import json
|
|
||||||
import ctypes
|
import ctypes
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
@@ -10,7 +8,7 @@ import webbrowser
|
|||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from collections import abc
|
from collections import abc
|
||||||
from math import log10, ceil
|
from math import log10, ceil
|
||||||
from subprocess import CREATE_NO_WINDOW
|
from dataclasses import dataclass
|
||||||
from tkinter.font import Font, nametofont
|
from tkinter.font import Font, nametofont
|
||||||
from functools import partial, cached_property
|
from functools import partial, cached_property
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
@@ -23,31 +21,13 @@ import win32con
|
|||||||
import win32gui
|
import win32gui
|
||||||
from yarl import URL
|
from yarl import URL
|
||||||
from PIL import Image as Image_module
|
from PIL import Image as Image_module
|
||||||
try:
|
|
||||||
from seleniumwire.request import Request
|
|
||||||
from selenium.common.exceptions import WebDriverException
|
|
||||||
from seleniumwire.undetected_chromedriver import Chrome, ChromeOptions
|
|
||||||
except ImportError as exc:
|
|
||||||
raise ImportError(
|
|
||||||
"You need to install Visual C++ Redist (x86 and x64): "
|
|
||||||
"https://support.microsoft.com/en-gb/help/2977003/the-latest-supported-visual-c-downloads"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
from translate import _
|
from translate import _
|
||||||
from cache import ImageCache
|
from cache import ImageCache
|
||||||
|
from exceptions import ExitRequest
|
||||||
from utils import resource_path, Game, _T
|
from utils import resource_path, Game, _T
|
||||||
from registry import RegistryKey, ValueType
|
from registry import RegistryKey, ValueType
|
||||||
from exceptions import MinerException, ExitRequest, LoginException
|
from constants import SELF_PATH, FORMATTER, WS_TOPICS_LIMIT, MAX_WEBSOCKETS, WINDOW_TITLE, State
|
||||||
from constants import (
|
|
||||||
CLIENT_ID,
|
|
||||||
SELF_PATH,
|
|
||||||
# CACHE_PATH,
|
|
||||||
FORMATTER,
|
|
||||||
WS_TOPICS_LIMIT,
|
|
||||||
MAX_WEBSOCKETS,
|
|
||||||
WINDOW_TITLE,
|
|
||||||
State,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from twitch import Twitch
|
from twitch import Twitch
|
||||||
@@ -424,6 +404,13 @@ class WebsocketStatus:
|
|||||||
self._topics_var.set('\n'.join(topic_lines))
|
self._topics_var.set('\n'.join(topic_lines))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LoginData:
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
token: str
|
||||||
|
|
||||||
|
|
||||||
class LoginForm:
|
class LoginForm:
|
||||||
def __init__(self, manager: GUIManager, master: ttk.Widget):
|
def __init__(self, manager: GUIManager, master: ttk.Widget):
|
||||||
self._manager = manager
|
self._manager = manager
|
||||||
@@ -435,14 +422,14 @@ class LoginForm:
|
|||||||
frame.rowconfigure(4, weight=1)
|
frame.rowconfigure(4, weight=1)
|
||||||
ttk.Label(frame, text=_("gui", "login", "labels")).grid(column=0, row=0)
|
ttk.Label(frame, text=_("gui", "login", "labels")).grid(column=0, row=0)
|
||||||
ttk.Label(frame, textvariable=self._var, justify="center").grid(column=1, row=0)
|
ttk.Label(frame, textvariable=self._var, justify="center").grid(column=1, row=0)
|
||||||
# self._login_entry = PlaceholderEntry(frame, placeholder=_("gui", "login", "username"))
|
self._login_entry = PlaceholderEntry(frame, placeholder=_("gui", "login", "username"))
|
||||||
# self._login_entry.grid(column=0, row=1, columnspan=2)
|
self._login_entry.grid(column=0, row=1, columnspan=2)
|
||||||
# self._pass_entry = PlaceholderEntry(
|
self._pass_entry = PlaceholderEntry(
|
||||||
# frame, placeholder=_("gui", "login", "password"), show='•'
|
frame, placeholder=_("gui", "login", "password"), show='•'
|
||||||
# )
|
)
|
||||||
# self._pass_entry.grid(column=0, row=2, columnspan=2)
|
self._pass_entry.grid(column=0, row=2, columnspan=2)
|
||||||
# self._token_entry = PlaceholderEntry(frame, placeholder=_("gui", "login", "twofa_code"))
|
self._token_entry = PlaceholderEntry(frame, placeholder=_("gui", "login", "twofa_code"))
|
||||||
# self._token_entry.grid(column=0, row=3, columnspan=2)
|
self._token_entry.grid(column=0, row=3, columnspan=2)
|
||||||
self._confirm = asyncio.Event()
|
self._confirm = asyncio.Event()
|
||||||
self._button = ttk.Button(
|
self._button = ttk.Button(
|
||||||
frame, text=_("gui", "login", "button"), command=self._confirm.set, state="disabled"
|
frame, text=_("gui", "login", "button"), command=self._confirm.set, state="disabled"
|
||||||
@@ -450,150 +437,46 @@ class LoginForm:
|
|||||||
self._button.grid(column=0, row=4, columnspan=2)
|
self._button.grid(column=0, row=4, columnspan=2)
|
||||||
self.update(_("gui", "login", "logged_out"), None)
|
self.update(_("gui", "login", "logged_out"), None)
|
||||||
|
|
||||||
@staticmethod
|
def clear(self, login: bool = False, password: bool = False, token: bool = False):
|
||||||
def interceptor(request: Request) -> None:
|
clear_all = not login and not password and not token
|
||||||
if (
|
if login or clear_all:
|
||||||
request.method == "POST"
|
self._login_entry.clear()
|
||||||
and request.url == "https://passport.twitch.tv/protected_login"
|
if password or clear_all:
|
||||||
):
|
self._pass_entry.clear()
|
||||||
body = request.body.decode("utf-8")
|
if token or clear_all:
|
||||||
data = json.loads(body)
|
self._token_entry.clear()
|
||||||
data["client_id"] = CLIENT_ID
|
|
||||||
request.body = json.dumps(data).encode("utf-8")
|
|
||||||
del request.headers["Content-Length"]
|
|
||||||
request.headers["Content-Length"] = str(len(request.body))
|
|
||||||
|
|
||||||
async def ask_login(self) -> str:
|
async def wait_for_login_press(self) -> None:
|
||||||
self.update(_("gui", "login", "required"), None)
|
|
||||||
self._manager.print(_("gui", "login", "request"))
|
|
||||||
# ensure the window isn't hidden into tray when this runs
|
|
||||||
self._manager.tray.restore()
|
|
||||||
# the user needs to press on the login button to open the browser
|
|
||||||
self._confirm.clear()
|
self._confirm.clear()
|
||||||
try:
|
try:
|
||||||
self._button.config(state="normal")
|
self._button.config(state="normal")
|
||||||
await self._manager.coro_unless_closed(self._confirm.wait())
|
await self._manager.coro_unless_closed(self._confirm.wait())
|
||||||
finally:
|
finally:
|
||||||
self._button.config(state="disabled")
|
self._button.config(state="disabled")
|
||||||
self._confirm.clear()
|
|
||||||
self._manager.print("Opening Chrome...")
|
|
||||||
|
|
||||||
# open the chrome browser on the Twitch's login page
|
async def ask_login(self) -> LoginData:
|
||||||
# use a separate executor to void blocking the event loop
|
self.update(_("gui", "login", "required"), None)
|
||||||
loop = asyncio.get_running_loop()
|
# ensure the window isn't hidden into tray when this runs
|
||||||
driver = None
|
self._manager.tray.restore()
|
||||||
try:
|
while True:
|
||||||
version_main = None
|
self._manager.print(_("gui", "login", "request"))
|
||||||
for attempt in range(2):
|
await self.wait_for_login_press()
|
||||||
options = ChromeOptions()
|
login_data = LoginData(
|
||||||
options.add_argument("--log-level=3")
|
self._login_entry.get().strip(),
|
||||||
options.add_argument("--disable-web-security")
|
self._pass_entry.get(),
|
||||||
options.add_argument("--allow-running-insecure-content")
|
self._token_entry.get().strip(),
|
||||||
options.add_argument("--lang=en")
|
|
||||||
options.add_argument("--no-sandbox")
|
|
||||||
options.add_argument("--test-type")
|
|
||||||
options.add_argument("--disable-gpu")
|
|
||||||
options.set_capability("pageLoadStrategy", "eager")
|
|
||||||
try:
|
|
||||||
wire_options: dict[str, Any] = {"proxy": {}}
|
|
||||||
if self._manager._twitch.settings.proxy:
|
|
||||||
wire_options["proxy"]["http"] = str(self._manager._twitch.settings.proxy)
|
|
||||||
driver_coro = loop.run_in_executor(
|
|
||||||
None,
|
|
||||||
lambda: Chrome(
|
|
||||||
options=options,
|
|
||||||
suppress_welcome=True,
|
|
||||||
version_main=version_main,
|
|
||||||
seleniumwire_options=wire_options,
|
|
||||||
service_creationflags=CREATE_NO_WINDOW,
|
|
||||||
# user_data_dir=str(CACHE_PATH.joinpath("ChromeProfile")),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
driver = await self._manager.coro_unless_closed(driver_coro)
|
|
||||||
break
|
|
||||||
except WebDriverException as exc:
|
|
||||||
message = exc.msg
|
|
||||||
if (
|
|
||||||
message is not None
|
|
||||||
and (match := re.search(
|
|
||||||
r'Chrome version ([\d]+)\nCurrent browser version is ((\d+)\.[\d.]+)',
|
|
||||||
message,
|
|
||||||
)) is not None
|
|
||||||
):
|
|
||||||
if not attempt:
|
|
||||||
version_main = int(match.group(3))
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
raise MinerException(
|
|
||||||
"Your Chrome browser is out of date\n"
|
|
||||||
f"Required version: {match.group(1)}\n"
|
|
||||||
f"Current version: {match.group(2)}"
|
|
||||||
) from None
|
|
||||||
raise MinerException(
|
|
||||||
"An error occured while boostrapping the Chrome browser"
|
|
||||||
) from exc
|
|
||||||
assert driver is not None
|
|
||||||
driver.request_interceptor = self.interceptor
|
|
||||||
# driver.set_page_load_timeout(30)
|
|
||||||
page_coro = loop.run_in_executor(None, driver.get, "https://twitch.tv/login")
|
|
||||||
await self._manager.coro_unless_closed(page_coro)
|
|
||||||
# enable the login button once the page finishes opening
|
|
||||||
self._button.config(state="normal")
|
|
||||||
self._manager.print(
|
|
||||||
"Complete the login procedure manually by pressing the Login button again."
|
|
||||||
)
|
)
|
||||||
|
# basic input data validation
|
||||||
# auto login
|
if len(login_data.username) < 3:
|
||||||
# driver.find_element("id", "login-username").send_keys(username)
|
self.clear(login=True)
|
||||||
# driver.find_element("id", "password-input").send_keys(password)
|
continue
|
||||||
# driver.find_element(
|
if len(login_data.password) < 8:
|
||||||
# "css selector", '[data-a-target="passport-login-button"]'
|
self.clear(password=True)
|
||||||
# ).click()
|
continue
|
||||||
# token submit button css selectors
|
if login_data.token and len(login_data.token) < 6:
|
||||||
# Button: "screen="two_factor" target="submit_button"
|
self.clear(token=True)
|
||||||
# Input: <input type="text" autocomplete="one-time-code" data-a-target="tw-input"
|
continue
|
||||||
# inputmode="numeric" pattern="[0-9]*" value="">
|
return login_data
|
||||||
|
|
||||||
# wait for the user to navigate away from the URL, indicating successful login
|
|
||||||
# alternatively, they can press on the login button again
|
|
||||||
while (
|
|
||||||
driver.current_url != "https://www.twitch.tv/?no-reload=true"
|
|
||||||
and not self._confirm.is_set()
|
|
||||||
):
|
|
||||||
if self._manager.close_requested:
|
|
||||||
raise ExitRequest()
|
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
|
|
||||||
# cookies = [
|
|
||||||
# {
|
|
||||||
# "domain": ".twitch.tv",
|
|
||||||
# "expiry": 1700000000,
|
|
||||||
# "httpOnly": False,
|
|
||||||
# "name": "auth-token",
|
|
||||||
# "path": "/",
|
|
||||||
# "sameSite": "None",
|
|
||||||
# "secure": True,
|
|
||||||
# "value": "..."
|
|
||||||
# },
|
|
||||||
# ...,
|
|
||||||
# ]
|
|
||||||
cookies = driver.get_cookies()
|
|
||||||
for cookie in cookies:
|
|
||||||
if "twitch.tv" in cookie["domain"] and cookie["name"] == "auth-token":
|
|
||||||
auth_token = cookie["value"]
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
raise LoginException("Unable to extract authorization token")
|
|
||||||
except WebDriverException:
|
|
||||||
driver = None
|
|
||||||
raise LoginException(
|
|
||||||
"Chrome window was closed before the login procedure could complete."
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
self._button.config(state="disabled")
|
|
||||||
if driver is not None:
|
|
||||||
driver.quit()
|
|
||||||
return auth_token
|
|
||||||
|
|
||||||
def update(self, status: str, user_id: int | None):
|
def update(self, status: str, user_id: int | None):
|
||||||
if user_id is not None:
|
if user_id is not None:
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
# import json
|
import sys
|
||||||
|
import json
|
||||||
|
import random
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from math import ceil
|
from math import ceil
|
||||||
from time import time
|
from time import time
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
from functools import partial
|
from functools import partial
|
||||||
# from base64 import urlsafe_b64decode
|
from subprocess import CREATE_NO_WINDOW
|
||||||
from collections import abc, OrderedDict
|
from collections import abc, OrderedDict
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from contextlib import suppress, asynccontextmanager
|
from contextlib import suppress, asynccontextmanager
|
||||||
@@ -16,18 +18,34 @@ from typing import Any, Literal, Final, NoReturn, cast, TYPE_CHECKING
|
|||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from yarl import URL
|
from yarl import URL
|
||||||
|
try:
|
||||||
|
from seleniumwire.request import Request
|
||||||
|
from selenium.common.exceptions import WebDriverException
|
||||||
|
from seleniumwire.undetected_chromedriver import Chrome, ChromeOptions
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"You need to install Visual C++ Redist (x86 and x64): "
|
||||||
|
"https://support.microsoft.com/en-gb/help/2977003/the-latest-supported-visual-c-downloads"
|
||||||
|
) from exc
|
||||||
|
|
||||||
from translate import _
|
from translate import _
|
||||||
from gui import GUIManager
|
from gui import GUIManager
|
||||||
from channel import Channel
|
from channel import Channel
|
||||||
from websocket import WebsocketPool
|
from websocket import WebsocketPool
|
||||||
from inventory import DropsCampaign
|
from inventory import DropsCampaign
|
||||||
from exceptions import MinerException, ExitRequest, LoginException, ReloadRequest, RequestInvalid
|
from exceptions import (
|
||||||
|
MinerException,
|
||||||
|
ExitRequest,
|
||||||
|
LoginException,
|
||||||
|
ReloadRequest,
|
||||||
|
RequestInvalid,
|
||||||
|
)
|
||||||
from utils import (
|
from utils import (
|
||||||
CHARS_HEX_LOWER,
|
CHARS_HEX_LOWER,
|
||||||
timestamp,
|
timestamp,
|
||||||
create_nonce,
|
create_nonce,
|
||||||
task_wrapper,
|
task_wrapper,
|
||||||
|
first_to_complete,
|
||||||
OrderedSet,
|
OrderedSet,
|
||||||
AwaitableValue,
|
AwaitableValue,
|
||||||
ExponentialBackoff,
|
ExponentialBackoff,
|
||||||
@@ -35,13 +53,13 @@ from utils import (
|
|||||||
from constants import (
|
from constants import (
|
||||||
BASE_URL,
|
BASE_URL,
|
||||||
CLIENT_ID,
|
CLIENT_ID,
|
||||||
USER_AGENT,
|
|
||||||
COOKIES_PATH,
|
COOKIES_PATH,
|
||||||
GQL_OPERATIONS,
|
GQL_OPERATIONS,
|
||||||
MAX_WEBSOCKETS,
|
MAX_WEBSOCKETS,
|
||||||
WATCH_INTERVAL,
|
WATCH_INTERVAL,
|
||||||
WS_TOPICS_LIMIT,
|
WS_TOPICS_LIMIT,
|
||||||
DROPS_ENABLED_TAG,
|
DROPS_ENABLED_TAG,
|
||||||
|
ANDROID_USER_AGENT,
|
||||||
State,
|
State,
|
||||||
WebsocketTopic,
|
WebsocketTopic,
|
||||||
)
|
)
|
||||||
@@ -58,6 +76,16 @@ logger = logging.getLogger("TwitchDrops")
|
|||||||
gql_logger = logging.getLogger("TwitchDrops.gql")
|
gql_logger = logging.getLogger("TwitchDrops.gql")
|
||||||
|
|
||||||
|
|
||||||
|
class SkipExtraJsonDecoder(json.JSONDecoder):
|
||||||
|
def decode(self, s: str, *args):
|
||||||
|
# skip whitespace check
|
||||||
|
obj, end = self.raw_decode(s)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
SAFE_LOADS = lambda s: json.loads(s, cls=SkipExtraJsonDecoder)
|
||||||
|
|
||||||
|
|
||||||
class _AuthState:
|
class _AuthState:
|
||||||
def __init__(self, twitch: Twitch):
|
def __init__(self, twitch: Twitch):
|
||||||
self._twitch: Twitch = twitch
|
self._twitch: Twitch = twitch
|
||||||
@@ -98,37 +126,292 @@ class _AuthState:
|
|||||||
)
|
)
|
||||||
self._logged_in.clear()
|
self._logged_in.clear()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def interceptor(request: Request) -> None:
|
||||||
|
if (
|
||||||
|
request.method == "POST"
|
||||||
|
and request.url == "https://passport.twitch.tv/protected_login"
|
||||||
|
):
|
||||||
|
body = request.body.decode("utf-8")
|
||||||
|
data = json.loads(body)
|
||||||
|
data["client_id"] = CLIENT_ID
|
||||||
|
request.body = json.dumps(data).encode("utf-8")
|
||||||
|
del request.headers["Content-Length"]
|
||||||
|
request.headers["Content-Length"] = str(len(request.body))
|
||||||
|
|
||||||
|
async def _chrome_login(self) -> None:
|
||||||
|
gui_print = self._twitch.gui.print
|
||||||
|
login_form: LoginForm = self._twitch.gui.login
|
||||||
|
coro_unless_closed = self._twitch.gui.coro_unless_closed
|
||||||
|
|
||||||
|
# open the chrome browser on the Twitch's login page
|
||||||
|
# use a separate executor to void blocking the event loop
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
driver: Chrome | None = None
|
||||||
|
while True:
|
||||||
|
gui_print("Opening Chrome...")
|
||||||
|
try:
|
||||||
|
version_main = None
|
||||||
|
for attempt in range(2):
|
||||||
|
options = ChromeOptions()
|
||||||
|
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("--test-type")
|
||||||
|
options.add_argument("--disable-gpu")
|
||||||
|
options.set_capability("pageLoadStrategy", "eager")
|
||||||
|
try:
|
||||||
|
wire_options: dict[str, Any] = {"proxy": {}}
|
||||||
|
if self._twitch.settings.proxy:
|
||||||
|
wire_options["proxy"]["http"] = str(self._twitch.settings.proxy)
|
||||||
|
driver_coro = loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: Chrome(
|
||||||
|
options=options,
|
||||||
|
suppress_welcome=True,
|
||||||
|
version_main=version_main,
|
||||||
|
seleniumwire_options=wire_options,
|
||||||
|
service_creationflags=CREATE_NO_WINDOW,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
driver = await coro_unless_closed(driver_coro)
|
||||||
|
break
|
||||||
|
except WebDriverException as exc:
|
||||||
|
message = exc.msg
|
||||||
|
if (
|
||||||
|
message is not None
|
||||||
|
and (
|
||||||
|
match := re.search(
|
||||||
|
(
|
||||||
|
r'Chrome version ([\d]+)\n'
|
||||||
|
r'Current browser version is ((\d+)\.[\d.]+)'
|
||||||
|
),
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
) is not None
|
||||||
|
):
|
||||||
|
if not attempt:
|
||||||
|
version_main = int(match.group(3))
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
raise MinerException(
|
||||||
|
"Your Chrome browser is out of date\n"
|
||||||
|
f"Required version: {match.group(1)}\n"
|
||||||
|
f"Current version: {match.group(2)}"
|
||||||
|
) from None
|
||||||
|
raise MinerException(
|
||||||
|
"An error occured while boostrapping the Chrome browser"
|
||||||
|
) from exc
|
||||||
|
assert driver is not None
|
||||||
|
driver.request_interceptor = self.interceptor
|
||||||
|
# driver.set_page_load_timeout(30)
|
||||||
|
# page_coro = loop.run_in_executor(None, driver.get, "https://twitch.tv")
|
||||||
|
# await coro_unless_closed(page_coro)
|
||||||
|
page_coro = loop.run_in_executor(None, driver.get, "https://twitch.tv/login")
|
||||||
|
await coro_unless_closed(page_coro)
|
||||||
|
|
||||||
|
# auto login
|
||||||
|
# if login_data.username and login_data.password:
|
||||||
|
# driver.find_element("id", "login-username").send_keys(login_data.username)
|
||||||
|
# driver.find_element("id", "password-input").send_keys(login_data.password)
|
||||||
|
# driver.find_element(
|
||||||
|
# "css selector", '[data-a-target="passport-login-button"]'
|
||||||
|
# ).click()
|
||||||
|
# token submit button css selectors
|
||||||
|
# Button: "screen="two_factor" target="submit_button"
|
||||||
|
# Input: <input type="text" autocomplete="one-time-code" data-a-target="tw-input"
|
||||||
|
# inputmode="numeric" pattern="[0-9]*" value="">
|
||||||
|
|
||||||
|
# wait for the user to navigate away from the URL, indicating successful login
|
||||||
|
# alternatively, they can press on the login button again
|
||||||
|
async def url_waiter(driver=driver):
|
||||||
|
while driver.current_url != "https://www.twitch.tv/?no-reload=true":
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
gui_print(
|
||||||
|
"Complete the login procedure manually by pressing the Login button again."
|
||||||
|
)
|
||||||
|
await first_to_complete([
|
||||||
|
url_waiter(),
|
||||||
|
coro_unless_closed(login_form.wait_for_login_press()),
|
||||||
|
])
|
||||||
|
|
||||||
|
# cookies = [
|
||||||
|
# {
|
||||||
|
# "domain": ".twitch.tv",
|
||||||
|
# "expiry": 1700000000,
|
||||||
|
# "httpOnly": False,
|
||||||
|
# "name": "auth-token",
|
||||||
|
# "path": "/",
|
||||||
|
# "sameSite": "None",
|
||||||
|
# "secure": True,
|
||||||
|
# "value": "..."
|
||||||
|
# },
|
||||||
|
# ...,
|
||||||
|
# ]
|
||||||
|
cookies = driver.get_cookies()
|
||||||
|
for cookie in cookies:
|
||||||
|
if "twitch.tv" in cookie["domain"] and cookie["name"] == "auth-token":
|
||||||
|
self.access_token = cookie["value"]
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
gui_print("Unable to extract authorization token")
|
||||||
|
except WebDriverException:
|
||||||
|
gui_print(
|
||||||
|
"Chrome window was closed before the login procedure could complete."
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if driver is not None:
|
||||||
|
driver.quit()
|
||||||
|
driver = None
|
||||||
|
await coro_unless_closed(login_form.wait_for_login_press())
|
||||||
|
|
||||||
async def _login(self) -> str:
|
async def _login(self) -> str:
|
||||||
logger.debug("Login flow started")
|
logger.debug("Login flow started")
|
||||||
|
gui_print = self._twitch.gui.print
|
||||||
login_form: LoginForm = self._twitch.gui.login
|
login_form: LoginForm = self._twitch.gui.login
|
||||||
|
|
||||||
for attempt in range(1, 6): # 5 attempts
|
token_kind: str = ''
|
||||||
try:
|
use_chrome: bool = False
|
||||||
self.access_token = await login_form.ask_login()
|
payload: JsonType = {
|
||||||
break
|
"client_id": CLIENT_ID, # client ID to-be associated with the access token
|
||||||
except LoginException as exc:
|
"undelete_user": False, # purpose unknown
|
||||||
if attempt < 5:
|
"remember_me": True, # persist the session via the cookie
|
||||||
self._twitch.gui.print(exc.args[0])
|
# "authy_token": str, # 2FA token
|
||||||
continue
|
# "twitchguard_code": str, # email code
|
||||||
raise # reraise on and past the 5th attempt
|
# "captcha": str, # self-fed captcha
|
||||||
logger.debug("Access token granted")
|
# 'force_twitchguard': False, # force email code confirmation
|
||||||
return self.access_token
|
}
|
||||||
|
|
||||||
def gql_headers(self, *, integrity: bool) -> JsonType:
|
while True:
|
||||||
|
login_data = await login_form.ask_login()
|
||||||
|
payload["username"] = login_data.username
|
||||||
|
payload["password"] = login_data.password
|
||||||
|
# reinstate the 2FA token, if present
|
||||||
|
payload.pop("authy_token", None)
|
||||||
|
payload.pop("twitchguard_code", None)
|
||||||
|
if login_data.token:
|
||||||
|
# if there's no token kind set yet, and the user has entered a token,
|
||||||
|
# we can immediately assume it's an authenticator token and not an email one
|
||||||
|
if not token_kind:
|
||||||
|
token_kind = "authy"
|
||||||
|
if token_kind == "authy":
|
||||||
|
payload["authy_token"] = login_data.token
|
||||||
|
elif token_kind == "email":
|
||||||
|
payload["twitchguard_code"] = login_data.token
|
||||||
|
|
||||||
|
# use fancy headers to mimic the twitch android app
|
||||||
|
headers = {
|
||||||
|
"Accept": "application/vnd.twitchtv.v3+json",
|
||||||
|
"Accept-Encoding": "gzip",
|
||||||
|
"Accept-Language": "en-US",
|
||||||
|
"Client-Id": CLIENT_ID,
|
||||||
|
"Content-Type": "application/json; charset=UTF-8",
|
||||||
|
"Host": "passport.twitch.tv",
|
||||||
|
"User-Agent": ANDROID_USER_AGENT,
|
||||||
|
"X-Device-Id": self.device_id,
|
||||||
|
# "X-Device-Id": ''.join(random.choices('0123456789abcdef', k=32)),
|
||||||
|
}
|
||||||
|
async with self._twitch.request(
|
||||||
|
"POST", "https://passport.twitch.tv/login", headers=headers, json=payload
|
||||||
|
) as response:
|
||||||
|
login_response: JsonType = await response.json(loads=SAFE_LOADS)
|
||||||
|
|
||||||
|
# Feed this back in to avoid running into CAPTCHA if possible
|
||||||
|
if "captcha_proof" in login_response:
|
||||||
|
payload["captcha"] = {"proof": login_response["captcha_proof"]}
|
||||||
|
|
||||||
|
# Error handling
|
||||||
|
if "error_code" in login_response:
|
||||||
|
error_code: int = login_response["error_code"]
|
||||||
|
logger.debug(f"Login error code: {error_code}")
|
||||||
|
if error_code == 1000:
|
||||||
|
logger.debug("1000: CAPTCHA is required")
|
||||||
|
use_chrome = True
|
||||||
|
break
|
||||||
|
elif error_code == 3001:
|
||||||
|
logger.debug("3001: Login failed due to incorrect username or password")
|
||||||
|
gui_print(_("login", "incorrect_login_pass"))
|
||||||
|
login_form.clear(password=True)
|
||||||
|
continue
|
||||||
|
elif error_code in (
|
||||||
|
3012, # Invalid authy token
|
||||||
|
3023, # Invalid email code
|
||||||
|
):
|
||||||
|
logger.debug("3012/23: Login failed due to incorrect 2FA code")
|
||||||
|
if error_code == 3023:
|
||||||
|
token_kind = "email"
|
||||||
|
gui_print(_("login", "incorrect_email_code"))
|
||||||
|
else:
|
||||||
|
token_kind = "authy"
|
||||||
|
gui_print(_("login", "incorrect_twofa_code"))
|
||||||
|
login_form.clear(token=True)
|
||||||
|
continue
|
||||||
|
elif error_code in (
|
||||||
|
3011, # Authy token needed
|
||||||
|
3022, # Email code needed
|
||||||
|
):
|
||||||
|
# 2FA handling
|
||||||
|
logger.debug("3011/22: 2FA token required")
|
||||||
|
# user didn't provide a token, so ask them for it
|
||||||
|
if error_code == 3022:
|
||||||
|
token_kind = "email"
|
||||||
|
gui_print(_("login", "email_code_required"))
|
||||||
|
else:
|
||||||
|
token_kind = "authy"
|
||||||
|
gui_print(_("login", "twofa_code_required"))
|
||||||
|
continue
|
||||||
|
elif error_code == 5027:
|
||||||
|
# client blocked from this operation - use chrome
|
||||||
|
logger.debug("5027: Client blocked from this operation")
|
||||||
|
use_chrome = True
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
msg = str(login_response["error"])
|
||||||
|
logger.debug(msg)
|
||||||
|
raise LoginException(msg)
|
||||||
|
# Success handling
|
||||||
|
if "access_token" in login_response:
|
||||||
|
self.access_token = cast(str, login_response["access_token"])
|
||||||
|
logger.debug("Access token granted")
|
||||||
|
login_form.clear()
|
||||||
|
break
|
||||||
|
|
||||||
|
if use_chrome:
|
||||||
|
await self._chrome_login()
|
||||||
|
|
||||||
|
if hasattr(self, "access_token"):
|
||||||
|
logger.debug("Access token granted")
|
||||||
|
return self.access_token
|
||||||
|
raise MinerException("Login flow finished without setting the access token")
|
||||||
|
|
||||||
|
def headers(
|
||||||
|
self, *, user_agent: str = '', gql: bool = False, integrity: bool = False
|
||||||
|
) -> JsonType:
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"OAuth {self.access_token}",
|
|
||||||
"Accept": "*/*",
|
"Accept": "*/*",
|
||||||
|
"Accept-Encoding": "gzip",
|
||||||
|
"Accept-Language": "en-US",
|
||||||
"Pragma": "no-cache",
|
"Pragma": "no-cache",
|
||||||
"Cache-Control": "no-cache",
|
"Cache-Control": "no-cache",
|
||||||
"Origin": "https://www.twitch.tv",
|
|
||||||
"Referer": "https://www.twitch.tv/",
|
|
||||||
"Client-Id": CLIENT_ID,
|
"Client-Id": CLIENT_ID,
|
||||||
"Client-Session-Id": self.session_id,
|
|
||||||
"Client-Version": self.client_version,
|
|
||||||
"X-Device-Id": self.device_id,
|
|
||||||
}
|
}
|
||||||
# if integrity:
|
if user_agent:
|
||||||
# headers["Client-Integrity"] = self.integrity_token
|
headers["User-Agent"] = user_agent
|
||||||
|
if hasattr(self, "session_id"):
|
||||||
|
headers["Client-Session-Id"] = self.session_id
|
||||||
|
if hasattr(self, "client_version"):
|
||||||
|
headers["Client-Version"] = self.client_version
|
||||||
|
if hasattr(self, "device_id"):
|
||||||
|
headers["X-Device-Id"] = self.device_id
|
||||||
|
if gql:
|
||||||
|
headers["Origin"] = "https://www.twitch.tv"
|
||||||
|
headers["Referer"] = "https://www.twitch.tv/"
|
||||||
|
headers["Authorization"] = f"OAuth {self.access_token}"
|
||||||
|
if integrity:
|
||||||
|
headers["Client-Integrity"] = self.integrity_token
|
||||||
return headers
|
return headers
|
||||||
|
|
||||||
async def validate(self):
|
async def validate(self):
|
||||||
@@ -142,8 +425,11 @@ class _AuthState:
|
|||||||
session = await self._twitch.get_session()
|
session = await self._twitch.get_session()
|
||||||
jar = cast(aiohttp.CookieJar, session.cookie_jar)
|
jar = cast(aiohttp.CookieJar, session.cookie_jar)
|
||||||
if not self._hasattrs("client_version", "device_id"):
|
if not self._hasattrs("client_version", "device_id"):
|
||||||
async with self._twitch.request("GET", BASE_URL) as response:
|
async with self._twitch.request(
|
||||||
match = re.search(r'twilightBuildID="([-a-z0-9]+)"', await response.text("utf8"))
|
"GET", BASE_URL, headers=self.headers() # (user_agent=ANDROID_USER_AGENT)
|
||||||
|
) as response:
|
||||||
|
page_html = await response.text("utf8")
|
||||||
|
match = re.search(r'twilightBuildID="([-a-z0-9]+)"', page_html)
|
||||||
if match is None:
|
if match is None:
|
||||||
raise MinerException("Unable to extract client_version")
|
raise MinerException("Unable to extract client_version")
|
||||||
self.client_version = match.group(1)
|
self.client_version = match.group(1)
|
||||||
@@ -253,12 +539,23 @@ class Twitch:
|
|||||||
if session.closed:
|
if session.closed:
|
||||||
raise RuntimeError("Session is closed")
|
raise RuntimeError("Session is closed")
|
||||||
return session
|
return session
|
||||||
|
# obtain the latest Chrome user agent from a Github project
|
||||||
|
async with aiohttp.request(
|
||||||
|
"GET", "https://jnrbsn.github.io/user-agents/user-agents.json"
|
||||||
|
) as response:
|
||||||
|
agents = await response.json()
|
||||||
|
if sys.platform == "win32":
|
||||||
|
chrome_agent = random.choice(agents[:3])
|
||||||
|
elif sys.platform == "linux":
|
||||||
|
chrome_agent = random.choice(agents[7:11])
|
||||||
|
# load in cookies
|
||||||
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
|
||||||
self._session = aiohttp.ClientSession(
|
self._session = aiohttp.ClientSession(
|
||||||
cookie_jar=cookie_jar,
|
cookie_jar=cookie_jar,
|
||||||
headers={"User-Agent": USER_AGENT},
|
headers={"User-Agent": chrome_agent},
|
||||||
timeout=aiohttp.ClientTimeout(connect=5, total=10),
|
timeout=aiohttp.ClientTimeout(connect=5, total=10),
|
||||||
)
|
)
|
||||||
return self._session
|
return self._session
|
||||||
@@ -974,7 +1271,7 @@ class Twitch:
|
|||||||
"POST",
|
"POST",
|
||||||
"https://gql.twitch.tv/gql",
|
"https://gql.twitch.tv/gql",
|
||||||
json=op,
|
json=op,
|
||||||
headers=auth_state.gql_headers(integrity=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 = await response.json()
|
||||||
|
|||||||
@@ -38,6 +38,17 @@ _JSON_T = TypeVar("_JSON_T", bound=Mapping[Any, Any])
|
|||||||
logger = logging.getLogger("TwitchDrops")
|
logger = logging.getLogger("TwitchDrops")
|
||||||
|
|
||||||
|
|
||||||
|
async def first_to_complete(coros: abc.Iterable[abc.Coroutine[Any, Any, _T]]) -> _T:
|
||||||
|
# In Python 3.11, we need to explicitly wrap awaitables
|
||||||
|
tasks = [asyncio.ensure_future(coro) for coro in coros]
|
||||||
|
done: set[asyncio.Task[Any]]
|
||||||
|
pending: set[asyncio.Task[Any]]
|
||||||
|
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
return next(iter(done)).result()
|
||||||
|
|
||||||
|
|
||||||
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