Beginning of rewriting on Selenium
This commit is contained in:
parent
0f8b9940da
commit
0aac026caa
27 changed files with 86 additions and 4679 deletions
|
@ -1,11 +1 @@
|
|||
"""Init"""
|
||||
|
||||
from .atclient import Client
|
||||
from .atserver import AternosServer
|
||||
from .atserver import Edition
|
||||
from .atserver import Status
|
||||
from .atplayers import PlayersList
|
||||
from .atplayers import Lists
|
||||
from .atwss import Streams
|
||||
from .atjsparse import Js2PyInterpreter
|
||||
from .atjsparse import NodeInterpreter
|
||||
from .atclient import Client # noqa: F401
|
||||
|
|
|
@ -3,38 +3,30 @@ and allows to manage your account"""
|
|||
|
||||
import os
|
||||
import re
|
||||
from typing import Optional, Type
|
||||
from typing import Optional
|
||||
|
||||
from .atlog import log, is_debug, set_debug
|
||||
from .atmd5 import md5encode
|
||||
|
||||
from .ataccount import AternosAccount
|
||||
|
||||
from .atconnect import AternosConnect
|
||||
from .atselenium import SeleniumHelper, Remote
|
||||
from .atconnect import AJAX_URL
|
||||
|
||||
from .atlog import log, is_debug, set_debug
|
||||
from .aterrors import CredentialsError
|
||||
from .aterrors import TwoFactorAuthError
|
||||
|
||||
from . import atjsparse
|
||||
from .atjsparse import Interpreter
|
||||
from .atjsparse import Js2PyInterpreter
|
||||
|
||||
|
||||
class Client:
|
||||
"""Aternos API Client class, object
|
||||
of which contains user's auth data"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, driver: Remote) -> None:
|
||||
|
||||
self.se = SeleniumHelper(driver)
|
||||
|
||||
# Config
|
||||
self.sessions_dir = '~'
|
||||
self.js: Type[Interpreter] = Js2PyInterpreter
|
||||
# ###
|
||||
|
||||
self.saved_session = '~/.aternos' # will be rewritten by login()
|
||||
self.atconn = AternosConnect()
|
||||
self.account = AternosAccount(self)
|
||||
# self.atconn = AternosConnect()
|
||||
# self.account = AternosAccount(self)
|
||||
|
||||
def login(
|
||||
self,
|
||||
|
@ -50,73 +42,29 @@ class Client:
|
|||
code (Optional[int], optional): 2FA code
|
||||
"""
|
||||
|
||||
self.login_hashed(
|
||||
username,
|
||||
md5encode(password),
|
||||
code,
|
||||
)
|
||||
self.se.load_page('/go')
|
||||
|
||||
def login_hashed(
|
||||
self,
|
||||
username: str,
|
||||
md5: str,
|
||||
code: Optional[int] = None) -> None:
|
||||
"""Log in to your Aternos account
|
||||
with a username and a hashed password
|
||||
user_input = self.se.find_by_id('user')
|
||||
user_input.clear()
|
||||
user_input.send_keys(username)
|
||||
|
||||
Args:
|
||||
username (str): Username
|
||||
md5 (str): Password hashed with MD5
|
||||
code (int): 2FA code
|
||||
pswd_input = self.se.find_by_id('password')
|
||||
pswd_input.clear()
|
||||
pswd_input.send_keys(password)
|
||||
|
||||
Raises:
|
||||
TwoFactorAuthError: If the 2FA is enabled,
|
||||
but `code` argument was not passed or is incorrect
|
||||
CredentialsError: If the Aternos backend
|
||||
returned empty session cookie
|
||||
(usually because of incorrect credentials)
|
||||
ValueError: _description_
|
||||
"""
|
||||
err_msg = self.se.find_by_class('login-error')
|
||||
totp_input = self.se.find_by_id('twofactor-code')
|
||||
|
||||
filename = self.session_filename(
|
||||
username, self.sessions_dir
|
||||
)
|
||||
def logged_in_or_error(driver: Remote):
|
||||
return \
|
||||
driver.current_url.find('/servers') != -1 or \
|
||||
err_msg.is_displayed() or \
|
||||
totp_input.is_displayed()
|
||||
|
||||
try:
|
||||
self.restore_session(filename)
|
||||
except (OSError, CredentialsError):
|
||||
pass
|
||||
self.se.exec_js('login()')
|
||||
self.se.wait.until(logged_in_or_error)
|
||||
|
||||
atjsparse.get_interpreter(create=self.js)
|
||||
self.atconn.parse_token()
|
||||
self.atconn.generate_sec()
|
||||
|
||||
credentials = {
|
||||
'user': username,
|
||||
'password': md5,
|
||||
}
|
||||
|
||||
if code is not None:
|
||||
credentials['code'] = str(code)
|
||||
|
||||
loginreq = self.atconn.request_cloudflare(
|
||||
f'{AJAX_URL}/account/login',
|
||||
'POST', data=credentials, sendtoken=True,
|
||||
)
|
||||
|
||||
if b'"show2FA":true' in loginreq.content:
|
||||
raise TwoFactorAuthError('2FA code is required')
|
||||
|
||||
if 'ATERNOS_SESSION' not in loginreq.cookies:
|
||||
raise CredentialsError(
|
||||
'Check your username and password'
|
||||
)
|
||||
|
||||
self.saved_session = filename
|
||||
try:
|
||||
self.save_session(filename)
|
||||
except OSError:
|
||||
pass
|
||||
print(self.se.driver.get_cookie('ATERNOS_SESSION'))
|
||||
|
||||
def login_with_session(self, session: str) -> None:
|
||||
"""Log in using ATERNOS_SESSION cookie
|
||||
|
@ -125,9 +73,10 @@ class Client:
|
|||
session (str): Session cookie value
|
||||
"""
|
||||
|
||||
self.atconn.parse_token()
|
||||
self.atconn.generate_sec()
|
||||
self.atconn.session.cookies['ATERNOS_SESSION'] = session
|
||||
self.se.driver.add_cookie({
|
||||
'name': 'ATERNOS_SESSION',
|
||||
'value': session,
|
||||
})
|
||||
|
||||
def logout(self) -> None:
|
||||
"""Log out from the Aternos account"""
|
||||
|
|
|
@ -1,40 +1,15 @@
|
|||
"""Stores API session and sends requests"""
|
||||
|
||||
import re
|
||||
import time
|
||||
|
||||
import string
|
||||
import secrets
|
||||
|
||||
from functools import partial
|
||||
|
||||
from typing import Optional
|
||||
from typing import List, Dict, Any
|
||||
|
||||
import requests
|
||||
|
||||
from cloudscraper import CloudScraper
|
||||
|
||||
from .atlog import log, is_debug
|
||||
|
||||
from . import atjsparse
|
||||
from .aterrors import TokenError
|
||||
from .aterrors import CloudflareError
|
||||
from .aterrors import AternosPermissionError
|
||||
from typing import Dict, Any
|
||||
|
||||
|
||||
BASE_URL = 'https://aternos.org'
|
||||
AJAX_URL = f'{BASE_URL}/ajax'
|
||||
|
||||
REQUA = \
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ' \
|
||||
'(KHTML, like Gecko) Chrome/99.0.4844.84 Safari/537.36 OPR/85.0.4341.47'
|
||||
|
||||
ARROW_FN_REGEX = r'\(\(\).*?\)\(\);'
|
||||
SCRIPT_TAG_REGEX = (
|
||||
rb'<script type=([\'"]?)text/javascript\1>.+?</script>'
|
||||
)
|
||||
|
||||
SEC_ALPHABET = string.ascii_lowercase + string.digits
|
||||
|
||||
|
||||
|
@ -44,108 +19,15 @@ class AternosConnect:
|
|||
|
||||
def __init__(self) -> None:
|
||||
|
||||
self.session = CloudScraper()
|
||||
self.sec = ''
|
||||
self.token = ''
|
||||
self.atcookie = ''
|
||||
|
||||
def refresh_session(self) -> None:
|
||||
"""Creates a new CloudScraper
|
||||
session object and copies all cookies.
|
||||
Required for bypassing Cloudflare"""
|
||||
|
||||
old_cookies = self.session.cookies
|
||||
captcha_kwarg = self.session.captcha
|
||||
self.session = CloudScraper(captcha=captcha_kwarg)
|
||||
self.session.cookies.update(old_cookies)
|
||||
del old_cookies
|
||||
|
||||
def parse_token(self) -> str:
|
||||
"""Parses Aternos ajax token that
|
||||
is needed for most requests
|
||||
|
||||
Raises:
|
||||
TokenError: If the parser is unable
|
||||
to extract ajax token from HTML
|
||||
|
||||
Returns:
|
||||
Aternos ajax token
|
||||
"""
|
||||
|
||||
loginpage = self.request_cloudflare(
|
||||
f'{BASE_URL}/go/', 'GET'
|
||||
).content
|
||||
|
||||
# Using the standard string methods
|
||||
# instead of the expensive xml parsing
|
||||
head = b'<head>'
|
||||
headtag = loginpage.find(head)
|
||||
headend = loginpage.find(b'</head>', headtag + len(head))
|
||||
|
||||
# Some checks
|
||||
if headtag < 0 or headend < 0:
|
||||
pagehead = loginpage
|
||||
log.warning(
|
||||
'Unable to find <head> tag, parsing the whole page'
|
||||
)
|
||||
|
||||
else:
|
||||
# Extracting <head> content
|
||||
headtag = headtag + len(head)
|
||||
pagehead = loginpage[headtag:headend]
|
||||
|
||||
js_code: Optional[List[Any]] = None
|
||||
|
||||
try:
|
||||
text = pagehead.decode('utf-8', 'replace')
|
||||
js_code = re.findall(ARROW_FN_REGEX, text)
|
||||
|
||||
token_func = js_code[0]
|
||||
if len(js_code) > 1:
|
||||
token_func = js_code[1]
|
||||
|
||||
js = atjsparse.get_interpreter()
|
||||
js.exec_js(token_func)
|
||||
self.token = js['AJAX_TOKEN']
|
||||
|
||||
except (IndexError, TypeError) as err:
|
||||
|
||||
log.warning('---')
|
||||
log.warning('Unable to parse AJAX_TOKEN!')
|
||||
log.warning('Please, insert the info below')
|
||||
log.warning('to the GitHub issue description:')
|
||||
log.warning('---')
|
||||
|
||||
log.warning('JavaScript: %s', js_code)
|
||||
log.warning(
|
||||
'All script tags: %s',
|
||||
re.findall(SCRIPT_TAG_REGEX, pagehead)
|
||||
)
|
||||
log.warning('---')
|
||||
|
||||
raise TokenError(
|
||||
'Unable to parse TOKEN from the page'
|
||||
) from err
|
||||
|
||||
return self.token
|
||||
return ''
|
||||
|
||||
def generate_sec(self) -> str:
|
||||
"""Generates Aternos SEC token which
|
||||
is also needed for most API requests
|
||||
|
||||
Returns:
|
||||
Random SEC `key:value` string
|
||||
"""
|
||||
|
||||
randkey = self.generate_sec_part()
|
||||
randval = self.generate_sec_part()
|
||||
self.sec = f'{randkey}:{randval}'
|
||||
self.session.cookies.set(
|
||||
f'ATERNOS_SEC_{randkey}', randval,
|
||||
domain='aternos.org'
|
||||
)
|
||||
|
||||
return self.sec
|
||||
return 'a:b'
|
||||
|
||||
def generate_sec_part(self) -> str:
|
||||
"""Generates a part for SEC token"""
|
||||
|
@ -163,124 +45,8 @@ class AternosConnect:
|
|||
reqcookies: Optional[Dict[Any, Any]] = None,
|
||||
sendtoken: bool = False,
|
||||
retries: int = 5,
|
||||
timeout: int = 4) -> requests.Response:
|
||||
"""Sends a request to Aternos API bypass Cloudflare
|
||||
|
||||
Args:
|
||||
url (str): Request URL
|
||||
method (str): Request method, must be GET or POST
|
||||
params (Optional[Dict[Any, Any]], optional): URL parameters
|
||||
data (Optional[Dict[Any, Any]], optional): POST request data,
|
||||
if the method is GET, this dict will be combined with params
|
||||
headers (Optional[Dict[Any, Any]], optional): Custom headers
|
||||
reqcookies (Optional[Dict[Any, Any]], optional):
|
||||
Cookies only for this request
|
||||
sendtoken (bool, optional): If the ajax and SEC token
|
||||
should be sent
|
||||
retries (int, optional): How many times parser must retry
|
||||
connection to API bypass Cloudflare
|
||||
timeout (int, optional): Request timeout in seconds
|
||||
|
||||
Raises:
|
||||
CloudflareError: When the parser has exceeded retries count
|
||||
NotImplementedError: When the specified method is not GET or POST
|
||||
|
||||
Returns:
|
||||
API response
|
||||
"""
|
||||
|
||||
if retries <= 0:
|
||||
raise CloudflareError('Unable to bypass Cloudflare protection')
|
||||
|
||||
try:
|
||||
self.atcookie = self.session.cookies['ATERNOS_SESSION']
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
self.refresh_session()
|
||||
|
||||
params = params or {}
|
||||
data = data or {}
|
||||
headers = headers or {}
|
||||
reqcookies = reqcookies or {}
|
||||
|
||||
method = method or 'GET'
|
||||
method = method.upper().strip()
|
||||
if method not in ('GET', 'POST'):
|
||||
raise NotImplementedError('Only GET and POST are available')
|
||||
|
||||
if sendtoken:
|
||||
params['TOKEN'] = self.token
|
||||
params['SEC'] = self.sec
|
||||
headers['X-Requested-With'] = 'XMLHttpRequest'
|
||||
|
||||
# requests.cookies.CookieConflictError bugfix
|
||||
reqcookies['ATERNOS_SESSION'] = self.atcookie
|
||||
del self.session.cookies['ATERNOS_SESSION']
|
||||
|
||||
if is_debug():
|
||||
|
||||
reqcookies_dbg = {
|
||||
k: str(v or '')[:3]
|
||||
for k, v in reqcookies.items()
|
||||
}
|
||||
|
||||
session_cookies_dbg = {
|
||||
k: str(v or '')[:3]
|
||||
for k, v in self.session.cookies.items()
|
||||
}
|
||||
|
||||
log.debug('Requesting(%s)%s', method, url)
|
||||
log.debug('headers=%s', headers)
|
||||
log.debug('params=%s', params)
|
||||
log.debug('data=%s', data)
|
||||
log.debug('req-cookies=%s', reqcookies_dbg)
|
||||
log.debug('session-cookies=%s', session_cookies_dbg)
|
||||
|
||||
if method == 'POST':
|
||||
sendreq = partial(
|
||||
self.session.post,
|
||||
params=params,
|
||||
data=data,
|
||||
)
|
||||
else:
|
||||
sendreq = partial(
|
||||
self.session.get,
|
||||
params={**params, **data},
|
||||
)
|
||||
|
||||
req = sendreq(
|
||||
url,
|
||||
headers=headers,
|
||||
cookies=reqcookies,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
resp_type = req.headers.get('content-type', '')
|
||||
html_type = resp_type.find('text/html') != -1
|
||||
cloudflare = req.status_code == 403
|
||||
|
||||
if html_type and cloudflare:
|
||||
log.info('Retrying to bypass Cloudflare')
|
||||
time.sleep(0.3)
|
||||
return self.request_cloudflare(
|
||||
url, method,
|
||||
params, data,
|
||||
headers, reqcookies,
|
||||
sendtoken, retries - 1
|
||||
)
|
||||
|
||||
log.debug('AternosConnect received: %s', req.text[:65])
|
||||
log.info(
|
||||
'%s completed with %s status',
|
||||
method, req.status_code
|
||||
)
|
||||
|
||||
if req.status_code == 402:
|
||||
raise AternosPermissionError
|
||||
|
||||
req.raise_for_status()
|
||||
return req
|
||||
timeout: int = 4) -> Any:
|
||||
return None
|
||||
|
||||
@property
|
||||
def atsession(self) -> str:
|
||||
|
|
40
python_aternos/atselenium.py
Normal file
40
python_aternos/atselenium.py
Normal file
|
@ -0,0 +1,40 @@
|
|||
from selenium.webdriver import Remote
|
||||
from selenium.webdriver.remote.webelement import WebElement
|
||||
from selenium.webdriver.support.wait import WebDriverWait
|
||||
from selenium.webdriver.common.by import By
|
||||
|
||||
|
||||
BASE_URL = 'https://aternos.org'
|
||||
|
||||
RM_SCRIPTS = '''
|
||||
const lst = document.querySelectorAll("script")
|
||||
for (let js of lst) {
|
||||
if (
|
||||
js.src.includes('googletagmanager.com') ||
|
||||
js.src.includes('cloudflareinsights.com') ||
|
||||
js.innerText.includes('LANGUAGE_VARIABLES')
|
||||
) {
|
||||
js.remove()
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class SeleniumHelper:
|
||||
|
||||
def __init__(self, driver: Remote) -> None:
|
||||
self.driver = driver
|
||||
self.wait = WebDriverWait(driver, 2.0)
|
||||
|
||||
def load_page(self, path: str) -> None:
|
||||
self.driver.get(f'{BASE_URL}{path}')
|
||||
self.driver.execute_script(RM_SCRIPTS)
|
||||
|
||||
def find_by_id(self, value: str) -> WebElement:
|
||||
return self.driver.find_element(By.ID, value)
|
||||
|
||||
def find_by_class(self, value: str) -> WebElement:
|
||||
return self.driver.find_element(By.CLASS_NAME, value)
|
||||
|
||||
def exec_js(self, script: str) -> None:
|
||||
self.driver.execute_script(script)
|
67
python_aternos/data/package-lock.json
generated
67
python_aternos/data/package-lock.json
generated
|
@ -1,67 +0,0 @@
|
|||
{
|
||||
"name": "data",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"vm2": "^3.9.13"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.8.1",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.1.tgz",
|
||||
"integrity": "sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA==",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-walk": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz",
|
||||
"integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vm2": {
|
||||
"version": "3.9.19",
|
||||
"resolved": "https://registry.npmjs.org/vm2/-/vm2-3.9.19.tgz",
|
||||
"integrity": "sha512-J637XF0DHDMV57R6JyVsTak7nIL8gy5KH4r1HiwWLf/4GBbb5MKL5y7LpmF4A8E2nR6XmzpmMFQ7V7ppPTmUQg==",
|
||||
"dependencies": {
|
||||
"acorn": "^8.7.0",
|
||||
"acorn-walk": "^8.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"vm2": "bin/vm2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"acorn": {
|
||||
"version": "8.8.1",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.1.tgz",
|
||||
"integrity": "sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA=="
|
||||
},
|
||||
"acorn-walk": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz",
|
||||
"integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA=="
|
||||
},
|
||||
"vm2": {
|
||||
"version": "3.9.19",
|
||||
"resolved": "https://registry.npmjs.org/vm2/-/vm2-3.9.19.tgz",
|
||||
"integrity": "sha512-J637XF0DHDMV57R6JyVsTak7nIL8gy5KH4r1HiwWLf/4GBbb5MKL5y7LpmF4A8E2nR6XmzpmMFQ7V7ppPTmUQg==",
|
||||
"requires": {
|
||||
"acorn": "^8.7.0",
|
||||
"acorn-walk": "^8.2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"vm2": "^3.9.13"
|
||||
}
|
||||
}
|
|
@ -1,49 +0,0 @@
|
|||
const http = require('http')
|
||||
const process = require('process')
|
||||
|
||||
const { VM } = require('vm2')
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const port = args[0] || 8000
|
||||
const host = args[1] || 'localhost'
|
||||
|
||||
const stubFunc = (_i) => {}
|
||||
|
||||
const vm = new VM({
|
||||
timeout: 2000,
|
||||
allowAsync: false,
|
||||
sandbox: {
|
||||
atob: atob,
|
||||
setTimeout: stubFunc,
|
||||
setInterval: stubFunc,
|
||||
document: {
|
||||
getElementById: stubFunc,
|
||||
prepend: stubFunc,
|
||||
append: stubFunc,
|
||||
appendChild: stubFunc,
|
||||
doctype: {},
|
||||
currentScript: {},
|
||||
},
|
||||
},
|
||||
})
|
||||
vm.run('var window = global')
|
||||
|
||||
const listener = (req, res) => {
|
||||
|
||||
if (req.method != 'POST')
|
||||
res.writeHead(405) & res.end()
|
||||
|
||||
let body = ''
|
||||
req.on('data', chunk => (body += chunk))
|
||||
|
||||
req.on('end', () => {
|
||||
let resp
|
||||
try { resp = JSON.stringify(vm.run(body)) }
|
||||
catch (ex) { resp = ex.message }
|
||||
res.writeHead(200)
|
||||
res.end(resp)
|
||||
})
|
||||
}
|
||||
|
||||
const server = http.createServer(listener)
|
||||
server.listen(port, host, () => console.log('OK'))
|
Reference in a new issue