From 184bb7f6329f27388538b3bbcda04a118054c974 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D0=BC=D0=B0=D0=BD=20=D0=91=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B8=D0=BD?= Date: Sat, 12 Mar 2022 22:33:20 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B0=D0=B4=D0=B0=D0=BF=D1=82=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BF=D0=BE=D0=B4=20Kodi=2019?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addon.xml | 2 +- lib/vk/__init__.py | 10 +- lib/vk/api.py | 197 ++++++++++++++++++++++++++---- lib/vk/exceptions.py | 3 +- lib/vk/logs.py | 27 +++++ lib/vk/mixins.py | 218 +++++++++++++++++++++++++++++++++ lib/vk/session.py | 281 ------------------------------------------- lib/vk/tests.py | 60 +++++++++ lib/vk/utils.py | 76 ++++++++---- 9 files changed, 541 insertions(+), 333 deletions(-) create mode 100644 lib/vk/logs.py create mode 100644 lib/vk/mixins.py delete mode 100644 lib/vk/session.py create mode 100644 lib/vk/tests.py diff --git a/addon.xml b/addon.xml index 42aaab2..d579742 100644 --- a/addon.xml +++ b/addon.xml @@ -1,7 +1,7 @@ - + diff --git a/lib/vk/__init__.py b/lib/vk/__init__.py index 42caa51..052fb16 100644 --- a/lib/vk/__init__.py +++ b/lib/vk/__init__.py @@ -1,5 +1,9 @@ -from .session import API, UserAPI, CommunityAPI -__version__ = '3.0-dev' +from vk.api import logger +from vk.api import Session, AuthSession, InteractiveSession, InteractiveAuthSession +from vk.api import VERSION +from vk.api import API -__all__ = (API, UserAPI, CommunityAPI) +__version__ = version = VERSION + +# API = OAuthAPI diff --git a/lib/vk/api.py b/lib/vk/api.py index 13bf503..043c443 100644 --- a/lib/vk/api.py +++ b/lib/vk/api.py @@ -1,38 +1,185 @@ +# coding=utf8 + import logging +import logging.config -from vk.utils import stringify_values +from vk.logs import LOGGING_CONFIG +from vk.utils import stringify_values, json_iter_parse, LoggingSession, str_type +from vk.exceptions import VkAuthError, VkAPIError +from vk.mixins import AuthMixin, InteractiveMixin + +VERSION = '2.0.2' + + +logging.config.dictConfig(LOGGING_CONFIG) logger = logging.getLogger('vk') -class APINamespace: - def __init__(self, api, method_common_params): +class Session(object): + API_URL = 'https://api.vk.com/method/' + + def __init__(self, access_token=None): + + logger.debug('API.__init__(access_token=%(access_token)r)', {'access_token': access_token}) + + self.access_token = access_token + self.access_token_is_needed = False + + self.requests_session = LoggingSession() + self.requests_session.headers['Accept'] = 'application/json' + self.requests_session.headers['Content-Type'] = 'application/x-www-form-urlencoded' + + @property + def access_token(self): + logger.debug('Check that we need new access token') + if self.access_token_is_needed: + logger.debug('We need new access token. Try to get it.') + self.access_token = self.get_access_token() + else: + logger.debug('Use old access token') + return self._access_token + + @access_token.setter + def access_token(self, value): + self._access_token = value + if isinstance(value, str_type) and len(value) >= 12: + self.censored_access_token = '{}***{}'.format(value[:4], value[-4:]) + else: + self.censored_access_token = value + logger.debug('access_token = %r', self.censored_access_token) + self.access_token_is_needed = not self._access_token + + def get_user_login(self): + logger.debug('Do nothing to get user login') + + def get_access_token(self): + """ + Dummy method + """ + logger.debug('API.get_access_token()') + return self._access_token + + def make_request(self, method_request, captcha_response=None): + + logger.debug('Prepare API Method request') + + response = self.send_api_request(method_request, captcha_response=captcha_response) + # todo Replace with something less exceptional + response.raise_for_status() + + # there are may be 2 dicts in one JSON + # for example: "{'error': ...}{'response': ...}" + for response_or_error in json_iter_parse(response.text): + if 'response' in response_or_error: + # todo Can we have error and response simultaneously + # for error in errors: + # logger.warning(str(error)) + + return response_or_error['response'] + + elif 'error' in response_or_error: + error_data = response_or_error['error'] + error = VkAPIError(error_data) + + if error.is_captcha_needed(): + captcha_key = self.get_captcha_key(error.captcha_img) + if not captcha_key: + raise error + + captcha_response = { + 'sid': error.captcha_sid, + 'key': captcha_key, + } + return self.make_request(method_request, captcha_response=captcha_response) + + elif error.is_access_token_incorrect(): + logger.info('Authorization failed. Access token will be dropped') + self.access_token = None + return self.make_request(method_request) + + else: + raise error + + def send_api_request(self, request, captcha_response=None): + url = self.API_URL + request._method_name + method_args = request._api._method_default_args.copy() + method_args.update(stringify_values(request._method_args)) + access_token = self.access_token + if access_token: + method_args['access_token'] = access_token + if captcha_response: + method_args['captcha_sid'] = captcha_response['sid'] + method_args['captcha_key'] = captcha_response['key'] + timeout = request._api._timeout + response = self.requests_session.post(url, method_args, timeout=timeout) + return response + + def get_captcha_key(self, captcha_image_url): + """ + Default behavior on CAPTCHA is to raise exception + Reload this in child + """ + return None + + def auth_code_is_needed(self, content, session): + """ + Default behavior on 2-AUTH CODE is to raise exception + Reload this in child + """ + raise VkAuthError('Authorization error (2-factor code is needed)') + + def auth_captcha_is_needed(self, content, session): + """ + Default behavior on CAPTCHA is to raise exception + Reload this in child + """ + raise VkAuthError('Authorization error (captcha)') + + def phone_number_is_needed(self, content, session): + """ + Default behavior on PHONE NUMBER is to raise exception + Reload this in child + """ + logger.error('Authorization error (phone number is needed)') + raise VkAuthError('Authorization error (phone number is needed)') + + +class API(object): + def __init__(self, session, timeout=10, **method_default_args): + self._session = session + self._timeout = timeout + self._method_default_args = method_default_args + + def __getattr__(self, method_name): + return Request(self, method_name) + + def __call__(self, method_name, **method_kwargs): + return getattr(self, method_name)(**method_kwargs) + + +class Request(object): + __slots__ = ('_api', '_method_name', '_method_args') + + def __init__(self, api, method_name): self._api = api - self._method_common_params = method_common_params + self._method_name = method_name - def __call__(self, method): - return APIMethod(self._api, method, self._method_common_params) + def __getattr__(self, method_name): + return Request(self._api, self._method_name + '.' + method_name) - __getattr__ = __call__ + def __call__(self, **method_args): + self._method_args = method_args + return self._api._session.make_request(self) -class APIMethod: - def __init__(self, api, method, method_common_params): - self._api = api - self._method = method - self._method_common_params = method_common_params - - def __getattr__(self, method): - return self.__class__(self._api, self._method + '.' + method, self._method_common_params) - - def __call__(self, **method_params): - request_method_params = self._method_common_params.copy() - request_method_params.update(stringify_values(method_params)) - - return self._api.send(APIRequest(self._method, request_method_params)) +class AuthSession(AuthMixin, Session): + pass -class APIRequest: - def __init__(self, method, method_params): - self.method = method - self.method_params = method_params +class InteractiveSession(InteractiveMixin, Session): + pass + + +class InteractiveAuthSession(InteractiveMixin, AuthSession): + pass diff --git a/lib/vk/exceptions.py b/lib/vk/exceptions.py index c5763dc..55369e6 100644 --- a/lib/vk/exceptions.py +++ b/lib/vk/exceptions.py @@ -4,7 +4,8 @@ AUTHORIZATION_FAILED = 5 # Invalid access token PERMISSION_IS_DENIED = 7 CAPTCHA_IS_NEEDED = 14 ACCESS_DENIED = 15 # No access to call this method -INVALID_USER_ID = 113 # User deactivated + # User deactivated +INVALID_USER_ID = 113 class VkException(Exception): diff --git a/lib/vk/logs.py b/lib/vk/logs.py new file mode 100644 index 0000000..2a8cc35 --- /dev/null +++ b/lib/vk/logs.py @@ -0,0 +1,27 @@ + +import sys + + +LOGGING_CONFIG = { + 'version': 1, + 'disable_existing_loggers': False, + 'loggers': { + 'vk': { + 'level': 'INFO', + 'handlers': ['vk-stdout'], + 'propagate': False, + }, + }, + 'handlers': { + 'vk-stdout': { + 'class': 'logging.StreamHandler', + 'stream': sys.stdout, + 'formatter': 'vk-verbose', + }, + }, + 'formatters': { + 'vk-verbose': { + 'format': '%(asctime)s %(name) -5s %(module)s:%(lineno)d %(levelname)s: %(message)s', + }, + }, +} diff --git a/lib/vk/mixins.py b/lib/vk/mixins.py new file mode 100644 index 0000000..f8c40a4 --- /dev/null +++ b/lib/vk/mixins.py @@ -0,0 +1,218 @@ +# coding=utf8 + +import logging + +from vk.exceptions import VkAuthError +from vk.utils import raw_input, get_url_query, LoggingSession, get_form_action + + +logger = logging.getLogger('vk') + + +class AuthMixin(object): + LOGIN_URL = 'https://m.vk.com' + # REDIRECT_URI = 'https://oauth.vk.com/blank.html' + AUTHORIZE_URL = 'https://oauth.vk.com/authorize' + CAPTCHA_URI = 'https://m.vk.com/captcha.php' + + def __init__(self, app_id=None, user_login='', user_password='', scope='offline', **kwargs): + logger.debug('AuthMixin.__init__(app_id=%(app_id)r, user_login=%(user_login)r, user_password=%(user_password)r, **kwargs=%(kwargs)s)', + dict(app_id=app_id, user_login=user_login, user_password=user_password, kwargs=kwargs)) + + super(AuthMixin, self).__init__(**kwargs) + + self.app_id = app_id + self.user_login = user_login + self.user_password = user_password + self.scope = scope + + # Some API methods get args (e.g. user id) from access token. + # If we define user login, we need get access token now. + if self.user_login: + self.access_token = self.get_access_token() + + @property + def user_login(self): + if not self._user_login: + self._user_login = self.get_user_login() + return self._user_login + + @user_login.setter + def user_login(self, value): + self._user_login = value + + def get_user_login(self): + return self._user_login + + @property + def user_password(self): + if not self._user_password: + self._user_password = self.get_user_password() + return self._user_password + + @user_password.setter + def user_password(self, value): + self._user_password = value + + def get_user_password(self): + return self._user_password + + def get_access_token(self): + """ + Get access token using app id and user login and password. + """ + logger.debug('AuthMixin.get_access_token()') + + auth_session = LoggingSession() + with auth_session as self.auth_session: + self.auth_session = auth_session + self.login() + auth_response_url_query = self.oauth2_authorization() + + if 'access_token' in auth_response_url_query: + return auth_response_url_query['access_token'] + else: + raise VkAuthError('OAuth2 authorization error') + + def login(self): + """ + Login + """ + + response = self.auth_session.get(self.LOGIN_URL) + login_form_action = get_form_action(response.text) + if not login_form_action: + raise VkAuthError('VK changed login flow') + + login_form_data = { + 'email': self.user_login, + 'pass': self.user_password, + } + response = self.auth_session.post(login_form_action, login_form_data) + logger.debug('Cookies: %s', self.auth_session.cookies) + + response_url_query = get_url_query(response.url) + + if 'remixsid' in self.auth_session.cookies or 'remixsid6' in self.auth_session.cookies: + return + + if 'sid' in response_url_query: + self.auth_captcha_is_needed(response, login_form_data) + elif response_url_query.get('act') == 'authcheck': + self.auth_check_is_needed(response.text) + elif 'security_check' in response_url_query: + self.phone_number_is_needed(response.text) + else: + message = 'Authorization error (incorrect password)' + logger.error(message) + raise VkAuthError(message) + + def oauth2_authorization(self): + """ + OAuth2 + """ + auth_data = { + 'client_id': self.app_id, + 'display': 'mobile', + 'response_type': 'token', + 'scope': self.scope, + 'v': '5.28', + } + response = self.auth_session.post(self.AUTHORIZE_URL, auth_data) + response_url_query = get_url_query(response.url) + if 'access_token' in response_url_query: + return response_url_query + + # Permissions is needed + logger.info('Getting permissions') + # form_action = re.findall(r'
', auth_response.text)[0] + form_action = get_form_action(response.text) + logger.debug('Response form action: %s', form_action) + if form_action: + response = self.auth_session.get(form_action) + response_url_query = get_url_query(response.url) + return response_url_query + + try: + response_json = response.json() + except ValueError: # not JSON in response + error_message = 'OAuth2 grant access error' + else: + error_message = 'VK error: [{}] {}'.format(response_json['error'], response_json['error_description']) + logger.error('Permissions obtained') + raise VkAuthError(error_message) + + def auth_check_is_needed(self, html): + logger.info('User enabled 2 factors authorization. Auth check code is needed') + auth_check_form_action = get_form_action(html) + auth_check_code = self.get_auth_check_code() + auth_check_data = { + 'code': auth_check_code, + '_ajax': '1', + 'remember': '1' + } + response = self.auth_session.post(auth_check_form_action, data=auth_check_data) + + def auth_captcha_is_needed(self, response, login_form_data): + logger.info('Captcha is needed') + + response_url_dict = get_url_query(response.url) + + # form_url = re.findall(r'', response.text) + captcha_form_action = get_form_action(response.text) + logger.debug('form_url %s', captcha_form_action) + if not captcha_form_action: + raise VkAuthError('Cannot find form url') + + # todo Are we sure that `response_url_dict` doesn't contain CAPTCHA image url? + captcha_url = '%s?s=%s&sid=%s' % (self.CAPTCHA_URI, response_url_dict['s'], response_url_dict['sid']) + # logger.debug('Captcha url %s', captcha_url) + + login_form_data['captcha_sid'] = response_url_dict['sid'] + login_form_data['captcha_key'] = self.get_captcha_key(captcha_url) + + response = self.auth_session.post(captcha_form_action, login_form_data) + + # logger.debug('Cookies %s', self.auth_session.cookies) + # if 'remixsid' not in self.auth_session.cookies and 'remixsid6' not in self.auth_session.cookies: + # raise VkAuthError('Authorization error (Bad password or captcha key)') + + def phone_number_is_needed(self, text): + raise VkAuthError('Phone number is needed') + + def get_auth_check_code(self): + raise VkAuthError('Auth check code is needed') + + +class InteractiveMixin(object): + def get_user_login(self): + user_login = raw_input('VK user login: ') + return user_login.strip() + + def get_user_password(self): + import getpass + + user_password = getpass.getpass('VK user password: ') + return user_password + + def get_access_token(self): + logger.debug('InteractiveMixin.get_access_token()') + access_token = super(InteractiveMixin, self).get_access_token() + if not access_token: + access_token = raw_input('VK API access token: ') + return access_token + + def get_captcha_key(self, captcha_image_url): + """ + Read CAPTCHA key from shell + """ + print('Open CAPTCHA image url: ', captcha_image_url) + captcha_key = raw_input('Enter CAPTCHA key: ') + return captcha_key + + def get_auth_check_code(self): + """ + Read Auth code from shell + """ + auth_check_code = raw_input('Auth check code: ') + return auth_check_code.strip() diff --git a/lib/vk/session.py b/lib/vk/session.py deleted file mode 100644 index 0d97915..0000000 --- a/lib/vk/session.py +++ /dev/null @@ -1,281 +0,0 @@ -import re -import urllib -import logging - -import requests - -from .exceptions import VkAuthError, VkAPIError -from .api import APINamespace -from .utils import json_iter_parse, stringify - -logger = logging.getLogger('vk') - - -class APIBase: - METHOD_COMMON_PARAMS = {'v', 'lang', 'https', 'test_mode'} - - API_URL = 'https://api.vk.com/method/' - CAPTCHA_URL = 'https://m.vk.com/captcha.php' - - def __new__(cls, *args, **kwargs): - method_common_params = {key: kwargs.pop(key) for key in tuple(kwargs) if key in cls.METHOD_COMMON_PARAMS} - - api = object.__new__(cls) - api.__init__(*args, **kwargs) - - return APINamespace(api, method_common_params) - - def __init__(self, timeout=10): - self.timeout = timeout - - self.session = requests.Session() - self.session.headers['Accept'] = 'application/json' - self.session.headers['Content-Type'] = 'application/x-www-form-urlencoded' - - def send(self, request): - - logger.debug('Prepare API Method request') - - self.prepare_request(request) - - method_url = self.API_URL + request.method - response = self.session.post(method_url, request.method_params, timeout=self.timeout) - - # todo Replace with something less exceptional - response.raise_for_status() - - # TODO: there are may be 2 dicts in one JSON - # for example: "{'error': ...}{'response': ...}" - for response_or_error in json_iter_parse(response.text): - request.response = response_or_error - - if 'response' in response_or_error: - # todo Can we have error and response simultaneously - # for error in errors: - # logger.warning(str(error)) - return response_or_error['response'] - - elif 'error' in response_or_error: - api_error = VkAPIError(request.response['error']) - request.api_error = api_error - return self.handle_api_error(request) - - def prepare_request(self, request): - request.method_params['access_token'] = self.access_token - - def get_access_token(self): - raise NotImplementedError - - def handle_api_error(self, request): - logger.error('Handle API error: %s', request.api_error) - - api_error_handler_name = 'on_api_error_' + str(request.api_error.code) - api_error_handler = getattr(self, api_error_handler_name, self.on_api_error) - - return api_error_handler(request) - - def on_api_error_14(self, request): - """ - 14. Captcha needed - """ - request.method_params['captcha_key'] = self.get_captcha_key(request) - request.method_params['captcha_sid'] = request.api_error.captcha_sid - - return self.send(request) - - def on_api_error_15(self, request): - """ - 15. Access denied - - due to scope - """ - logger.error('Authorization failed. Access token will be dropped') - self.access_token = self.get_access_token() - return self.send(request) - - def on_api_error(self, request): - logger.error('API error: %s', request.api_error) - raise request.api_error - - def get_captcha_key(self, request): - """ - Default behavior on CAPTCHA is to raise exception - Reload this in child - """ - # request.api_error.captcha_img - raise request.api_error - - -class API(APIBase): - def __init__(self, access_token, **kwargs): - super().__init__(**kwargs) - self.access_token = access_token - - -class UserAPI(APIBase): - LOGIN_URL = 'https://m.vk.com' - AUTHORIZE_URL = 'https://oauth.vk.com/authorize' - - def __init__(self, user_login='', user_password='', app_id=None, scope='offline', **kwargs): - super().__init__(**kwargs) - - self.user_login = user_login - self.user_password = user_password - self.app_id = app_id - self.scope = scope - - self.access_token = self.get_access_token() - - @staticmethod - def get_form_action(response): - form_action = re.findall(r'= 12: - return '{}***{}'.format(access_token[:4], access_token[-4:]) - elif access_token: - return '***' - else: - return access_token +def get_form_action(html): + form_action = re.findall(r'