├── wykop ├── api │ ├── __init__.py │ ├── models │ │ ├── __init__.py │ │ ├── models.py │ │ ├── wykop_connect.py │ │ └── model_utils.py │ ├── exceptions │ │ ├── __init__.py │ │ ├── client_exceptions.py │ │ ├── resolvers.py │ │ └── base.py │ ├── api_const.py │ ├── api_values.py │ ├── multi_key_client.py │ └── client.py ├── core │ ├── __init__.py │ ├── requesters │ │ ├── __init__.py │ │ ├── base.py │ │ ├── requests.py │ │ └── urllib.py │ ├── parsers │ │ ├── __init__.py │ │ ├── base.py │ │ └── json.py │ ├── credentials.py │ └── requestor.py ├── __init__.py └── utils.py ├── requirements.txt ├── requirements_test.txt ├── .gitignore ├── tests ├── functional │ ├── conftest.py │ └── test_api.py ├── unit │ ├── core │ │ ├── test_params_validation.py │ │ ├── test_requestor.py │ │ └── test_multi_key_client.py │ └── test_utils.py └── integration │ └── test_api_integration.py ├── .github └── workflows │ └── python-publish.yml ├── LICENSE.txt ├── setup.py ├── README.md └── CHANGES.md /wykop/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /wykop/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | six==1.15.0 -------------------------------------------------------------------------------- /wykop/api/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | mock==4.0.2 2 | pytest==6.1.2 3 | pytest-cov==2.10.1 4 | pytest-pep8==1.0.6 5 | requests==2.25.0 6 | responses==0.12.1 -------------------------------------------------------------------------------- /wykop/api/models/models.py: -------------------------------------------------------------------------------- 1 | """Wykop API models module..""" 2 | class WykopAPIResponse(dict): 3 | __getattr__ = dict.__getitem__ 4 | __setattr__ = dict.__setitem__ 5 | -------------------------------------------------------------------------------- /wykop/api/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | from wykop.api.exceptions.base import * 2 | from wykop.api.exceptions.base import __all_exceptions__ 3 | from wykop.api.exceptions.resolvers import ExceptionResolver 4 | 5 | default_exception_resolver = ExceptionResolver(__all_exceptions__) 6 | -------------------------------------------------------------------------------- /wykop/api/api_const.py: -------------------------------------------------------------------------------- 1 | PAGE_NAMED_ARG = 'page' 2 | BODY_NAMED_ARG = 'body' 3 | FILE_POST_NAME = 'embed' 4 | 5 | PROTOCOL = 'https' 6 | DOMAIN = 'a2.wykop.pl' 7 | 8 | CLIENT_NAME = 'wykop-sdk-reborn' 9 | 10 | ANDROID_APPKEY = 'aNd401dAPp' 11 | ANDROID_MIKROBLOG_PLUS_APPKEY = 'd99b6pFK8f' 12 | -------------------------------------------------------------------------------- /wykop/core/requesters/__init__.py: -------------------------------------------------------------------------------- 1 | # try requests module 2 | try: 3 | import requests 4 | 5 | from wykop.core.requesters.requests import RequestsRequester as Requester 6 | except ImportError: 7 | from wykop.core.requesters.urllib import UrllibRequester as Requester 8 | 9 | default_requester = Requester() 10 | -------------------------------------------------------------------------------- /wykop/core/parsers/__init__.py: -------------------------------------------------------------------------------- 1 | """Wykop API parsers module.""" 2 | from wykop.api.exceptions import default_exception_resolver 3 | from wykop.core.parsers.json import JSONParser 4 | from wykop.api.models.models import WykopAPIResponse 5 | 6 | default_parser = JSONParser( 7 | default_exception_resolver, 8 | object_hook=WykopAPIResponse, 9 | ) 10 | -------------------------------------------------------------------------------- /wykop/core/requesters/base.py: -------------------------------------------------------------------------------- 1 | """Wykop API base requester module.""" 2 | class BaseRequester(object): 3 | """Base Wykop API reqeuster""" 4 | 5 | def make_request(self, url, data=None, headers=None, files=None): 6 | raise NotImplementedError( 7 | "%s: `make_request` method must be implemented" % 8 | self.__class__.__name__) 9 | -------------------------------------------------------------------------------- /wykop/api/exceptions/client_exceptions.py: -------------------------------------------------------------------------------- 1 | class WykopAPIClientError(Exception): 2 | """Base Wykop client API exception.""" 3 | pass 4 | 5 | 6 | class NamedParameterNone(WykopAPIClientError): 7 | pass 8 | 9 | 10 | class ApiParameterNone(WykopAPIClientError): 11 | pass 12 | 13 | 14 | class InvalidWykopConnectSign(WykopAPIClientError): 15 | pass 16 | 17 | -------------------------------------------------------------------------------- /wykop/__init__.py: -------------------------------------------------------------------------------- 1 | """Python library for the Wykop API.""" 2 | from wykop.api.client import WykopAPI 3 | from wykop.api import api_values 4 | from wykop.api.api_values import PROFILE_SETTINGS 5 | from wykop.api.multi_key_client import MultiKeyWykopAPI 6 | from wykop.api.exceptions import WykopAPIError 7 | from wykop.utils import get_version 8 | from wykop.api.models import * 9 | 10 | __version__ = get_version() 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | .cache 10 | dist 11 | build 12 | eggs 13 | parts 14 | bin 15 | var 16 | sdist 17 | develop-eggs 18 | .installed.cfg 19 | lib 20 | lib64 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | htmlcov 30 | 31 | # Translations 32 | *.mo 33 | 34 | # Mr Developer 35 | .mr.developer.cfg 36 | .project 37 | .pydevproject 38 | 39 | deploy.sh 40 | clean.sh -------------------------------------------------------------------------------- /wykop/api/models/wykop_connect.py: -------------------------------------------------------------------------------- 1 | from wykop.api.models.model_utils import auto_str, auto_repr 2 | 3 | 4 | @auto_str 5 | @auto_repr 6 | class WykopConnectLoginInfo: 7 | 8 | def __init__(self, appkey, login, token, sign): 9 | self.app_key = appkey 10 | self.login = login 11 | self.token = token 12 | self.sign = sign 13 | 14 | def __iter__(self): 15 | return iter( 16 | (self.app_key, 17 | self.login, 18 | self.token, 19 | self.sign)) 20 | 21 | -------------------------------------------------------------------------------- /wykop/api/models/model_utils.py: -------------------------------------------------------------------------------- 1 | def auto_str(cls): 2 | def __str__(self): 3 | return '%s(%s)' % ( 4 | type(self).__name__, 5 | ', '.join('%s=%s' % item for item in vars(self).items()) 6 | ) 7 | 8 | cls.__str__ = __str__ 9 | return cls 10 | 11 | 12 | def auto_repr(cls): 13 | def __repr__(self): 14 | return '%s(%s)' % ( 15 | type(self).__name__, 16 | ', '.join('%s=%s' % item for item in vars(self).items()) 17 | ) 18 | 19 | cls.__repr__ = __repr__ 20 | return cls 21 | -------------------------------------------------------------------------------- /tests/functional/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from wykop.api.client import WykopAPI 4 | from wykop.core.requesters.requests import RequestsRequester 5 | from wykop.core.requesters.urllib import UrllibRequester 6 | 7 | 8 | @pytest.fixture 9 | def requests_requester(): 10 | return RequestsRequester() 11 | 12 | 13 | @pytest.fixture 14 | def urllib_requester(): 15 | return UrllibRequester() 16 | 17 | 18 | @pytest.fixture 19 | def wykop_api(): 20 | appkey = '123456app' 21 | secretkey = '654321secret' 22 | api = WykopAPI(appkey, secretkey) 23 | api._domain = 'api.test.com' 24 | return api 25 | -------------------------------------------------------------------------------- /wykop/api/api_values.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class PROFILE_SETTINGS: 5 | REAL_NAME = 'realname' 6 | HOME_SITE = 'homesite' 7 | CITY = 'city' 8 | EMAIL = 'email' 9 | SKYPE = 'skype' 10 | ABOUT = 'about' 11 | FACEBOOK = 'facebook' 12 | TWITTER = 'twitter' 13 | INSTAGRAM = 'instagram' 14 | 15 | 16 | class DirectNotificationType(Enum): 17 | ENTRY_MENTIONED = 'entry_comment_directed' 18 | DIRECT_MESSAGE = 'pm' 19 | 20 | 21 | class NotificationType(Enum): 22 | TAG_NOTIFICATION = 'entry_directed' 23 | ENTRY_MENTIONED = 'entry_comment_directed' 24 | DIRECT_MESSAGE = 'pm' 25 | -------------------------------------------------------------------------------- /wykop/api/exceptions/resolvers.py: -------------------------------------------------------------------------------- 1 | """Wykop API exceptions resolver module.""" 2 | import sys 3 | 4 | from wykop.utils import force_bytes 5 | 6 | 7 | class ExceptionResolver(object): 8 | """Wykop API exception resolver.""" 9 | 10 | def __init__(self, exceptions): 11 | self.exceptions = exceptions 12 | 13 | def get_class(self, code, default): 14 | return self.exceptions.get(code, default) 15 | 16 | def get_message(self, message): 17 | encoding = getattr(sys.stdout, 'encoding', 'utf-8') 18 | return force_bytes(message, encoding) 19 | 20 | def resolve(self, code, msg, default_class): 21 | klass = self.get_class(code, default_class) 22 | message = self.get_message(msg) 23 | return klass(message) 24 | -------------------------------------------------------------------------------- /tests/unit/core/test_params_validation.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from wykop.api.exceptions.client_exceptions import NamedParameterNone, ApiParameterNone 4 | from wykop.core.credentials import EMPTY_CREDENTIALS 5 | from wykop.core.requestor import Requestor 6 | 7 | 8 | class TestParamsValidation(object): 9 | 10 | def test_should_raise_exception_if_named_parameter_is_none(self): 11 | requestor = Requestor(EMPTY_CREDENTIALS) 12 | named_params = { 13 | 'key': None 14 | } 15 | 16 | with pytest.raises(NamedParameterNone): 17 | assert requestor.request('test', named_params=named_params) 18 | 19 | def test_should_raise_exception_if_api_parameter_is_none(self): 20 | requestor = Requestor(EMPTY_CREDENTIALS) 21 | api_params = ['value', None] 22 | 23 | with pytest.raises(ApiParameterNone): 24 | assert requestor.request('test', api_params=api_params) 25 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Jan Danecki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /wykop/core/parsers/base.py: -------------------------------------------------------------------------------- 1 | """Wykop API base praser module.""" 2 | from collections import namedtuple 3 | 4 | from wykop.api.exceptions import WykopAPIError 5 | 6 | Error = namedtuple('Error', ['code', 'message']) 7 | 8 | 9 | class BaseParser(object): 10 | 11 | def __init__(self, exception_resolver): 12 | self.exception_resolver = exception_resolver 13 | 14 | def _resolve_exception(self, code, message, default_class): 15 | return self.exception_resolver.resolve(code, message, default_class) 16 | 17 | def parse_response(self, data): 18 | response = self._get_response(data) 19 | error = self._get_error(response) 20 | 21 | if error: 22 | raise self._resolve_exception( 23 | error.code, error.message, WykopAPIError) 24 | 25 | return response['data'] 26 | 27 | def _get_response(self, data): 28 | raise NotImplementedError( 29 | "%s: `_get_response` method must be implemented" % 30 | self.__class__.__name__) 31 | 32 | def _get_error(self, response): 33 | raise NotImplementedError( 34 | "%s: `_get_error` method must be implemented" % 35 | self.__class__.__name__) 36 | -------------------------------------------------------------------------------- /wykop/core/credentials.py: -------------------------------------------------------------------------------- 1 | from ..api.api_const import ANDROID_APPKEY, ANDROID_MIKROBLOG_PLUS_APPKEY 2 | 3 | 4 | class Credentials: 5 | 6 | def __init__(self, appkey, secretkey, accountkey=None, login=None, password=None): 7 | self.appkey = appkey 8 | self.secretkey = secretkey 9 | self.account_key = accountkey 10 | self.login = login 11 | self.password = password 12 | 13 | def __eq__(self, other: object) -> bool: 14 | if isinstance(other, Credentials): 15 | return self.appkey == other.appkey \ 16 | and self.secretkey == other.secretkey \ 17 | and self.account_key == other.account_key \ 18 | and self.login == other.login \ 19 | and self.password == other.password 20 | return False 21 | 22 | def appkey_type(self): 23 | # welcome to the wykop zone 24 | if self.appkey == ANDROID_APPKEY: 25 | return {'official': True, '2fa_required': False} 26 | if self.appkey == ANDROID_MIKROBLOG_PLUS_APPKEY: 27 | return {'official': True, '2fa_required': True} 28 | # 2fa requirement depends on whether you log in with accountkey or via login/connect 29 | return {'official': False, '2fa_required': None} 30 | 31 | 32 | EMPTY_CREDENTIALS = Credentials('', '') 33 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Created on 18-12-2012 4 | 5 | @author: maciag.artur 6 | @author: jan.danecki 7 | """ 8 | 9 | from setuptools import setup, find_packages 10 | 11 | 12 | with open('requirements.txt') as f: 13 | requirements = f.read().splitlines() 14 | with open('requirements_test.txt') as f: 15 | tests_requires = f.read().splitlines() 16 | 17 | version = '0.9.4' 18 | 19 | setup( 20 | name='wykop-sdk-reborn', 21 | version=version, 22 | packages=find_packages(), 23 | # PyPI metadata 24 | author='Jan Danecki', 25 | author_email='janek@projmen.pl', 26 | description='Client library for Wykop API v2', 27 | long_description=open("README.md").read(), 28 | long_description_content_type="text/markdown", 29 | url='https://github.com/krasnoludkolo/wykop-sdk-reborn', 30 | install_requires=requirements, 31 | tests_require=requirements + tests_requires, 32 | license='MIT', 33 | classifiers=[ 34 | 'Programming Language :: Python :: 2.7', 35 | 'Programming Language :: Python :: 3.3', 36 | 'Programming Language :: Python :: 3.4', 37 | 'Programming Language :: Python :: 3.5', 38 | 'Programming Language :: Python :: 3.6', 39 | 'License :: OSI Approved :: MIT License', 40 | 'Programming Language :: Python', 41 | 'Topic :: Software Development :: Libraries :: Python Modules', 42 | ], 43 | ) 44 | -------------------------------------------------------------------------------- /wykop/core/requesters/requests.py: -------------------------------------------------------------------------------- 1 | """Wykop API requests requester module.""" 2 | from __future__ import absolute_import 3 | import logging 4 | 5 | from requests import request 6 | from requests.exceptions import RequestException 7 | 8 | from wykop.api.exceptions import WykopAPIError 9 | from wykop.core.requesters.base import BaseRequester 10 | from wykop.utils import dictmap, mimetype, force_text 11 | 12 | log = logging.getLogger(__name__) 13 | 14 | 15 | class RequestsRequester(BaseRequester): 16 | """ 17 | Requests Wykop API requester. Uses requests module. 18 | """ 19 | 20 | METHOD_GET = 'GET' 21 | METHOD_POST = 'POST' 22 | 23 | def make_request(self, url, data=None, headers=None, files=None): 24 | log.debug( 25 | " Fetching url: `%s` (data: %s, headers: `%s`)", 26 | str(url), str(data), str(headers), 27 | ) 28 | try: 29 | files = self._get_files(files) 30 | method = self._get_method(data, files) 31 | resp = request(method, url, data=data, headers=headers, files=files) 32 | resp.raise_for_status() 33 | return force_text(resp.content) 34 | except RequestException as ex: 35 | raise WykopAPIError(0, str(ex)) 36 | 37 | def _get_files(self, files): 38 | return dictmap(lambda x: (x.name, x, mimetype(x.name)), files) 39 | 40 | def _get_method(self, data, files): 41 | return self.METHOD_POST if data or files else self.METHOD_GET 42 | -------------------------------------------------------------------------------- /wykop/core/parsers/json.py: -------------------------------------------------------------------------------- 1 | """Wykop API JSON praser module.""" 2 | from __future__ import absolute_import 3 | 4 | import base64 5 | 6 | from wykop.api.models.wykop_connect import WykopConnectLoginInfo 7 | from wykop.utils import force_bytes 8 | 9 | try: 10 | import simplejson as json 11 | except ImportError: 12 | import json 13 | 14 | from wykop.core.parsers.base import BaseParser, Error 15 | 16 | 17 | class JSONParser(BaseParser): 18 | 19 | def __init__(self, exception_resolver, **json_kwargs): 20 | super(JSONParser, self).__init__(exception_resolver) 21 | self.json_kwargs = json_kwargs 22 | 23 | def _get_response(self, data): 24 | return json.loads(data, **self.json_kwargs) 25 | 26 | def _get_error(self, response): 27 | if not isinstance(response, dict): 28 | return 29 | 30 | error_data = response.get('error') 31 | 32 | if error_data is None: 33 | return 34 | 35 | code = error_data.get('code') 36 | message = error_data.get('message') 37 | 38 | if message is None: 39 | # try english message 40 | message = error_data.get('message_en') 41 | 42 | return Error(code, message) 43 | 44 | def parse_wykop_connect_response(self, response) -> WykopConnectLoginInfo: 45 | decoded_response = base64.b64decode(force_bytes(response)).decode('utf-8') 46 | field_dict = json.loads(decoded_response) 47 | return WykopConnectLoginInfo(**field_dict) 48 | -------------------------------------------------------------------------------- /tests/unit/core/test_requestor.py: -------------------------------------------------------------------------------- 1 | from wykop.core.credentials import Credentials 2 | from wykop.core.requestor import Requestor 3 | 4 | 5 | class TestRequestor(object): 6 | 7 | def test_should_named_parameters_be_string(self): 8 | api = self.create_requestor() 9 | 10 | params = api.named_params(named_params={}) 11 | 12 | for key, value in params.items(): 13 | assert isinstance(key, str) 14 | assert isinstance(value, str) 15 | 16 | def test_should_take_only_named_parameters_with_value(self): 17 | api = self.create_requestor() 18 | params_with_no_value = 'params_with_no_value' 19 | params_with_value = 'params_with_value' 20 | named_params = { 21 | params_with_no_value: None, 22 | params_with_value: "value" 23 | } 24 | 25 | params = api.named_params(named_params=named_params) 26 | 27 | assert params_with_no_value not in params 28 | assert params_with_value in params 29 | 30 | def test_should_not_take_named_parameters_with_empty_value(self): 31 | api = self.create_requestor() 32 | params_with_empty_value = 'params_with_value' 33 | named_params = { 34 | params_with_empty_value: "" 35 | } 36 | 37 | params = api.named_params(named_params=named_params) 38 | 39 | assert params_with_empty_value not in params 40 | 41 | def create_requestor(self): 42 | return Requestor(Credentials("appkey", "secretkey")) 43 | -------------------------------------------------------------------------------- /wykop/core/requesters/urllib.py: -------------------------------------------------------------------------------- 1 | """wykop API urllib requester module.""" 2 | import contextlib 3 | import logging 4 | 5 | from six.moves.urllib.error import HTTPError, URLError 6 | from six.moves.urllib.parse import urlencode 7 | from six.moves.urllib.request import Request, urlopen 8 | 9 | from wykop.api.exceptions import WykopAPIError 10 | from wykop.core.requesters.base import BaseRequester 11 | from wykop.utils import force_bytes, force_text 12 | 13 | log = logging.getLogger(__name__) 14 | 15 | 16 | class UrllibRequester(BaseRequester): 17 | """ 18 | Urllib Wykop API requester. Uses urllib module. 19 | """ 20 | 21 | def make_request(self, url, data=None, headers=None, files=None): 22 | log.debug( 23 | " Fetching url: `%s` (data: %s, headers: `%s`)", 24 | str(url), str(data), str(headers), 25 | ) 26 | 27 | if files: 28 | raise NotImplementedError( 29 | "Install requests package to send files.") 30 | 31 | if headers is None: 32 | headers = {} 33 | 34 | data_bytes = force_bytes(urlencode(data)) if data else None 35 | req = Request(url, data=data_bytes, headers=headers) 36 | 37 | try: 38 | with contextlib.closing(urlopen(req)) as resp: 39 | return force_text(resp.read()) 40 | except HTTPError as ex: 41 | raise WykopAPIError(0, str(ex.code)) 42 | except URLError as ex: 43 | raise WykopAPIError(0, str(ex.reason)) 44 | -------------------------------------------------------------------------------- /tests/integration/test_api_integration.py: -------------------------------------------------------------------------------- 1 | import os 2 | from wykop import WykopAPI, MultiKeyWykopAPI 3 | from wykop.api.api_const import ANDROID_APPKEY 4 | 5 | 6 | def create_base_client(): 7 | key = os.environ.get('WYKOP_TAKTYK_KEY') 8 | secret = os.environ.get('WYKOP_TAKTYK_SECRET') 9 | account_key = os.environ.get('WYKOP_TAKTYK_ACCOUNTKEY') 10 | return WykopAPI(key, secret, output='clear', account_key=account_key) 11 | 12 | 13 | def create_multi_key_client(): 14 | key = os.environ.get('WYKOP_TAKTYK_KEY') 15 | secret = os.environ.get('WYKOP_TAKTYK_SECRET') 16 | account_key = os.environ.get('WYKOP_TAKTYK_ACCOUNTKEY') 17 | return MultiKeyWykopAPI([(key, secret, account_key)], output='clear') 18 | 19 | 20 | def all_api_clients(): 21 | return [create_base_client(), create_multi_key_client()] 22 | 23 | 24 | class TestApiIntegration(object): 25 | 26 | def test_all_api_clients_connection(self): 27 | for api in all_api_clients(): 28 | entries = api.entries_hot() 29 | 30 | assert isinstance(entries, list) 31 | 32 | def test_all_api_clients_connection_with_method_related_to_account(self): 33 | for api in all_api_clients(): 34 | api.authenticate() 35 | conversations = api.conversations_list() 36 | 37 | assert isinstance(conversations, list) 38 | 39 | def test_connection_with_login_and_password(self): 40 | login = os.environ.get('WYKOP_TAKTYK_BOT_LOGIN') 41 | password = os.environ.get('WYKOP_TAKTYK_BOT_PASSWORD') 42 | api = WykopAPI(ANDROID_APPKEY) 43 | 44 | api.authenticate(login=login, password=password) 45 | conversations = api.conversations_list() 46 | 47 | assert isinstance(conversations, list) 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Wykop API v2 Python SDK 2 | [![PyPI version](https://badge.fury.io/py/wykop-sdk-reborn.svg)](https://badge.fury.io/py/wykop-sdk-reborn) 3 | 4 | Biblioteka ta jest implementacją [Wykop API v2](https://www.wykop.pl/dla-programistow/apiv2docs/wstep/) w Python. 5 | 6 | 7 | Fork [wykop-sdk](https://github.com/p1c2u/wykop-sdk) w którym staram się poprawiać sdk wraz z (nie)udokumentowanymi zmianami w api 8 | wykopu. 9 | 10 | ## Instalacja 11 | 12 | `pip install wykop-sdk-reborn` 13 | 14 | ## Uwierzytelnienie 15 | Aby móc wykonywać działania jako zalogowany użytkownik należy się wcześniej uwierzytenić. 16 | Potrzebne do tego będą klucze aplikacji, oraz klucz "połączenie" które można wygenerować [tutaj](https://www.wykop.pl/dla-programistow/apiv2/) 17 | 18 | ```python 19 | import wykop 20 | 21 | api = wykop.WykopAPI(klucz_aplikacji, sekret_aplikacji) 22 | api.authenticate(klucz_polaczenia) 23 | api.conversations_list() 24 | 25 | # lub 26 | 27 | api = wykop.WykopAPI(key, secret, account_key=account_key) 28 | api.authenticate() 29 | api.conversations_list() 30 | ``` 31 | 32 | ## Jak pomóc? 33 | 34 | * Masz pomysł albo chcesz zgłosić błąd? 35 | 36 | Zgłoś w zakładce [issues](https://github.com/krasnoludkolo/wykop-sdk-reborn/issues) 37 | 38 | * Chcesz pomóc w rozwoju? 39 | 40 | Wybierz jakieś zadanie z [issues](https://github.com/krasnoludkolo/wykop-sdk-reborn/issues), 41 | napisz komentarz ze chcesz się nim zając i mnie oznacz. Zrób forka repo, opracuj rozwiązanie i wystaw RPa 42 | 43 | ## Zgłaszanie błędów 44 | 45 | [issues](https://github.com/krasnoludkolo/wykop-sdk-reborn/issues) albo napisz mi PW na wykopie [@krasnoludkolo](https://www.wykop.pl/ludzie/krasnoludkolo/) 46 | 47 | ## Dokumentacja metod 48 | To, jakim metodom api odpowiają jakie metody klienta można sprawdzić na [wiki](https://github.com/krasnoludkolo/wykop-sdk-reborn/wiki/Stan-implementacji-metod). -------------------------------------------------------------------------------- /tests/unit/core/test_multi_key_client.py: -------------------------------------------------------------------------------- 1 | from wykop import MultiKeyWykopAPI 2 | from wykop.api.exceptions import DailyRequestLimitError 3 | from wykop.core.credentials import Credentials 4 | from wykop.core.requestor import Requestor 5 | import pytest 6 | 7 | RESULT = 'result' 8 | 9 | 10 | class TestKeyLoader(object): 11 | 12 | def test_reload_current_credentials_if_used(self): 13 | used_key = 'used_key' 14 | used_secret = 'used_secret' 15 | new_key = 'new_key' 16 | new_secret = 'new_secret' 17 | 18 | api = MultiKeyWykopAPI([(used_key, used_secret), (new_key, new_secret)]) 19 | api.__requestor = FakeRequestor(allowed_keys=([(new_key, new_secret)]), appkey=used_key, secretkey=used_secret) 20 | 21 | assert api.tag('test') == RESULT 22 | 23 | def test_reuse_used_credentials_if_available_again(self): 24 | used_key = 'used_key' 25 | used_secret = 'used_secret' 26 | new_key = 'new_key' 27 | new_secret = 'new_secret' 28 | fake_requestor = FakeRequestor(allowed_keys=[(new_key, new_secret)], appkey=used_key, secretkey=used_secret) 29 | 30 | api = MultiKeyWykopAPI([(used_key, used_secret), (new_key, new_secret)]) 31 | api.__requestor = fake_requestor 32 | 33 | assert api.tag('test') == RESULT 34 | 35 | fake_requestor.allowed_keys = [(used_key, used_secret)] 36 | 37 | assert api.tag('test') == RESULT 38 | 39 | def test_raise_error_if_all_are_used(self): 40 | used_key = 'used_key' 41 | used_secret = 'used_secret' 42 | 43 | api = MultiKeyWykopAPI([(used_key, used_secret)]) 44 | api.__requestor = FakeRequestor(allowed_keys=(), appkey=used_key, secretkey=used_secret) 45 | 46 | with pytest.raises(DailyRequestLimitError): 47 | api.tag('test') 48 | 49 | 50 | class FakeRequestor(Requestor): 51 | 52 | def __init__(self, allowed_keys, appkey, secretkey): 53 | super().__init__(Credentials(appkey, secretkey)) 54 | self.allowed_keys = allowed_keys 55 | 56 | def request(self, rtype, rmethod=None, 57 | named_params=None, api_params=None, post_params=None, file_params=None): 58 | if (self.credentials.appkey, self.credentials.secretkey) in self.allowed_keys: 59 | return RESULT 60 | else: 61 | raise DailyRequestLimitError 62 | -------------------------------------------------------------------------------- /tests/unit/test_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import datetime 3 | from pkg_resources import DistributionNotFound 4 | import mock 5 | import pytest 6 | 7 | from six import b, u 8 | 9 | from wykop.utils import force_text, force_bytes, get_version 10 | 11 | 12 | class TestForceText(object): 13 | 14 | def test_text(self): 15 | result = force_text('test') 16 | 17 | assert result == 'test' 18 | 19 | def test_decoding(self): 20 | msg = b('cze\xc5\x9b\xc4\x87') 21 | 22 | result = force_text(msg) 23 | 24 | assert result == u('cze\u015b\u0107') 25 | 26 | def test_decoding_ignore(self): 27 | msg = b('cze\xc5\x9b\xc4\x87') 28 | 29 | result = force_text(msg, encoding='ascii', errors='ignore') 30 | 31 | assert result == u('cze') 32 | 33 | 34 | class TestForceBytes(object): 35 | 36 | def test_exception(self): 37 | error_msg = u('cze\u015b\u0107') 38 | exc = ValueError(error_msg) 39 | 40 | result = force_bytes(exc) 41 | 42 | assert result == error_msg.encode('utf8') 43 | 44 | def test_date(self): 45 | date = datetime.date(2017, 9, 5) 46 | 47 | result = force_bytes(date) 48 | 49 | assert result == b'2017-09-05' 50 | 51 | def test_encoding(self): 52 | msg = u('cze\u015b\u0107') 53 | 54 | result = force_bytes(msg) 55 | 56 | assert result == b('cze\xc5\x9b\xc4\x87') 57 | 58 | def test_encoding_ignore(self): 59 | msg = u('cze\u015b\u0107') 60 | 61 | result = force_bytes(msg, encoding='ascii', errors='ignore') 62 | 63 | assert result == b('cze') 64 | 65 | 66 | class TestGetVersion(object): 67 | 68 | @mock.patch('wykop.utils.get_distribution') 69 | def test_no_distribution(self, m_get_distribution): 70 | m_get_distribution.side_effect = DistributionNotFound 71 | 72 | result = get_version() 73 | 74 | assert result == 'dev' 75 | 76 | @mock.patch('wykop.utils.get_distribution') 77 | def test_version(self, m_get_distribution): 78 | class Distribution(): 79 | def __init__(self, version): 80 | self.version = version 81 | 82 | version = mock.sentinel.version 83 | m_get_distribution.return_value = Distribution(version) 84 | 85 | result = get_version() 86 | 87 | assert result == version 88 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | ## 0.9.4 2 | 3 | * dodanie metod z grupy `profiles` (`EntriesComments`) 4 | 5 | ## 0.9.3 6 | 7 | * dodanie metod z grupy `mywykop` (`entries`) 8 | 9 | ## 0.9.2 10 | 11 | * dodanie metody z grupy `links` (`link`) 12 | 13 | ## 0.9.1 14 | 15 | * dodanie metod z grupy `profile` (`profile_comments`, `profile_entries`) 16 | 17 | ## 0.9.0 18 | 19 | * dodanie pierwszych metod z grupy `profile` (`profile_added`, `profile_buried`, `profile_digged`) 20 | 21 | ## 0.8.2 22 | 23 | * obsługa błędu api, które zwraca status 503 jeśli nie istnieje konwersacja [#68](https://github.com/krasnoludkolo/wykop-sdk-reborn/issues/68) 24 | 25 | 26 | ## 0.8.0 27 | 28 | * dodanie `wykop connect` [#63](https://github.com/krasnoludkolo/wykop-sdk-reborn/issues/63) 29 | 30 | ## 0.7.0 31 | 32 | * dodanie metod `settings` [#16](https://github.com/krasnoludkolo/wykop-sdk-reborn/issues/16) 33 | * dodanie dedykowanego wyjątek dla sytuacji, gdy zostanie wysłana wiadomość do osoby, która ma zablokowane pw [#43](https://github.com/krasnoludkolo/wykop-sdk-reborn/issues/43) 34 | * dodanie możliwości filtrowania wyniku pobieranie notyfikacji według typu [#37](https://github.com/krasnoludkolo/wykop-sdk-reborn/issues/37) 35 | * wewnętrzny refactoring `requestor`a tak, aby nie wysyłał jeśli parametr jest ustawiony na None [#54](https://github.com/krasnoludkolo/wykop-sdk-reborn/issues/54) 36 | 37 | ## 0.6.1 38 | 39 | * `entries_stream` oraz `entries_hot` nie ignorują parametru page [#53](https://github.com/krasnoludkolo/wykop-sdk-reborn/issues/53) 40 | 41 | 42 | ## 0.6.0 43 | 44 | * ujednolicenie wszystkich metod do formatu `zasób_metoda`[#42](https://github.com/krasnoludkolo/wykop-sdk-reborn/issues/42) 45 | 46 | ## 0.5.0 47 | 48 | * dodanie możliwości logowania za pomocą loginu i hasła [#47](https://github.com/krasnoludkolo/wykop-sdk-reborn/issues/47) 49 | 50 | ## 0.4.0 51 | 52 | * usunięcie parametru `password` 53 | * **[BETA]** dodanie nowego klienta będącego rozszerzeniem bazowego klienta o możliwość używania kilku kluczy api 54 | [#25](https://github.com/krasnoludkolo/wykop-sdk-reborn/issues/25) 55 | 56 | ## 0.3.0 57 | 58 | * metoda `search_entries`, `search_profiles`, `search_links` [#15](https://github.com/krasnoludkolo/wykop-sdk-reborn/issues/15) 59 | * przedrostek `entry_` dla wszystkich metod związanych z komentarzami do wpisów na mirkoblogu 60 | * zmiana nazewnictwa metod związanych z powiadomieniami, tak aby wszystkie miały przedrostek `notification_`/`notifications_` 61 | 62 | ## 0.2.3 63 | 64 | * naprawa błedu pobierania konkretnego wpisu na mikroblogu 65 | 66 | ## 0.2.2 67 | 68 | * naprawa błędu logowania 69 | 70 | ## 0.2.1 71 | 72 | * dodawanie obrazków do nowych wpisów i komentarzy [#28](https://github.com/krasnoludkolo/wykop-sdk-reborn/issues/28) 73 | * dodawanie obrazków do edytowanych wpisów i komentarzy [#28](https://github.com/krasnoludkolo/wykop-sdk-reborn/issues/28) 74 | * poprawny request zwraca pole `data` [#27](https://github.com/krasnoludkolo/wykop-sdk-reborn/issues/27) 75 | 76 | ## 0.2.0 77 | 78 | * usunięcie przedrostka `get_` 79 | * obsługa wiadomości prywatnych 80 | * obsługa mikrobloga (bez wstawiania obrazków oraz ankiet) 81 | -------------------------------------------------------------------------------- /wykop/utils.py: -------------------------------------------------------------------------------- 1 | """Wykop utils module.""" 2 | import mimetypes 3 | from collections import OrderedDict 4 | from typing import Dict, Any, List 5 | 6 | from pkg_resources import get_distribution, DistributionNotFound 7 | 8 | from six import b, u, PY3, text_type, string_types 9 | from six.moves.urllib.request import pathname2url 10 | 11 | from wykop.api.exceptions.client_exceptions import NamedParameterNone, ApiParameterNone 12 | 13 | 14 | def paramsencode(d): 15 | return ','.join(['%s,%s' % (k, d[k]) for k in sorted(d)]) 16 | 17 | 18 | def dictmap(f, d): 19 | return dict([(k_v[0], f(k_v[1])) for k_v in iter(d.items())]) 20 | 21 | 22 | def mimetype(filename): 23 | return mimetypes.guess_type(pathname2url(filename))[0] 24 | 25 | 26 | def force_bytes(s, encoding='utf-8', errors='strict'): 27 | if isinstance(s, bytes): 28 | if encoding == 'utf-8': 29 | return s 30 | else: 31 | return s.decode('utf-8', errors).encode(encoding, errors) 32 | if not isinstance(s, string_types): 33 | try: 34 | if PY3: 35 | return text_type(s).encode(encoding) 36 | else: 37 | return bytes(s) 38 | except UnicodeEncodeError: 39 | if isinstance(s, Exception): 40 | return b(' ').join(force_bytes(arg, encoding, errors) 41 | for arg in s) 42 | return text_type(s).encode(encoding, errors) 43 | else: 44 | return s.encode(encoding, errors) 45 | 46 | 47 | def force_text(s, encoding='utf-8', errors='strict'): 48 | if issubclass(type(s), text_type): 49 | return s 50 | try: 51 | if not issubclass(type(s), string_types): 52 | if PY3: 53 | if isinstance(s, bytes): 54 | s = text_type(s, encoding, errors) 55 | else: 56 | s = text_type(s) 57 | elif hasattr(s, '__unicode__'): 58 | s = text_type(s) 59 | else: 60 | s = text_type(bytes(s), encoding, errors) 61 | else: 62 | s = s.decode(encoding, errors) 63 | except UnicodeDecodeError: 64 | s = u(' ').join(force_text(arg, encoding, errors) for arg in s) 65 | return s 66 | 67 | 68 | def get_version(): 69 | try: 70 | return get_distribution('wykop').version 71 | except DistributionNotFound: 72 | return 'dev' 73 | 74 | 75 | def sort_and_remove_none_values(post_params: Dict[str, str]) -> Dict[str, str]: 76 | return OrderedDict({k: v for k, v in sorted( 77 | post_params.items()) if v} if post_params else {}) 78 | 79 | 80 | def validate_named_parameters(named_params: Dict[str, str]) -> Dict[str, str]: 81 | if not named_params: 82 | return {} 83 | for key, value in named_params.items(): 84 | if key is None or value is None: 85 | raise NamedParameterNone(key, value) 86 | return named_params 87 | 88 | 89 | def validate_api_parameters(api_params: List[Any]) -> List[Any]: 90 | if not api_params: 91 | return [] 92 | for value in api_params: 93 | if value is None: 94 | raise ApiParameterNone(value) 95 | return api_params 96 | -------------------------------------------------------------------------------- /wykop/api/multi_key_client.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from wykop import WykopAPI 4 | from wykop.api.exceptions import DailyRequestLimitError 5 | from wykop.core.credentials import Credentials, EMPTY_CREDENTIALS 6 | 7 | logging = logging.getLogger(__name__) 8 | 9 | 10 | def create_credentials(keys) -> Credentials: 11 | if len(keys) == 2: 12 | return Credentials(keys[0], keys[1]) 13 | else: 14 | return Credentials(keys[0], keys[1], keys[2]) 15 | 16 | 17 | class MultiKeyWykopAPI(WykopAPI): 18 | 19 | def __init__(self, credentials_list, output='', response_format='json'): 20 | super().__init__('', '', None, output, response_format) 21 | self.output = output, 22 | self.response_format = response_format 23 | 24 | self.credentials = [create_credentials(c) for c in credentials_list] 25 | logging.debug(f'Loaded {len(self.credentials)} credentials') 26 | self.available_credentials = [] 27 | 28 | self.reset_available_credentials() 29 | 30 | self.__requestor.credentials = self.next_credentials() 31 | self.authenticate_if_needed() 32 | self.has_credentials_with_exceeded_limit = False 33 | 34 | def request(self, rtype, rmethod=None, named_params=None, api_params=None, post_params=None, file_params=None): 35 | try: 36 | response = super(MultiKeyWykopAPI, self).request(rtype, rmethod=rmethod, named_params=named_params, 37 | api_params=api_params, 38 | post_params=post_params, file_params=file_params) 39 | if self.has_credentials_with_exceeded_limit: 40 | logging.debug('Successful request with current credentials. Request reset available credentials') 41 | self.reset_available_credentials() 42 | return response 43 | 44 | except DailyRequestLimitError: 45 | logging.debug('Daily request limit for current used credentials') 46 | self.load_next_credentials() 47 | return self.request(rtype, rmethod, named_params, api_params, post_params, file_params) 48 | 49 | def load_next_credentials(self): 50 | logging.debug('Loading next credentials') 51 | self.__requestor.credentials = self.next_credentials() 52 | self.authenticate_if_needed() 53 | self.has_credentials_with_exceeded_limit = True 54 | 55 | def reset_available_credentials(self): 56 | logging.debug('Resetting available credentials') 57 | self.available_credentials = list(self.credentials) 58 | self.available_credentials.reverse() 59 | if self.__requestor.credentials != EMPTY_CREDENTIALS: 60 | self.available_credentials.remove(self.__requestor.credentials) 61 | self.has_credentials_with_exceeded_limit = False 62 | 63 | def authenticate_if_needed(self): 64 | if self.__requestor.credentials.account_key: 65 | logging.debug('Authenticating with new credentials') 66 | self.authenticate() 67 | 68 | def next_credentials(self) -> Credentials: 69 | logging.debug('New credentials requested') 70 | 71 | if not self.available_credentials: 72 | logging.debug('No more keys') 73 | raise DailyRequestLimitError 74 | 75 | return self.available_credentials.pop() 76 | -------------------------------------------------------------------------------- /wykop/api/exceptions/base.py: -------------------------------------------------------------------------------- 1 | """Wykop API base exceptions module.""" 2 | __all__ = [ 3 | 'InvalidAPIKeyError', 'InvalidParamsError', 'NotEnoughParamsError', 4 | 'AppWritePermissionsError', 'DailyRequestLimitError', 5 | 'InvalidAPISignError', 'AppPermissionsError', 'SessionAppPermissionError', 6 | 'InvalidUserKeyError', 'InvalidSessionKeyError', 'UserDoesNotExistError', 7 | 'InvalidCredentialsError', 'CredentialsMissingError', 'IPBannedError', 8 | 'UserBannedError', 'OwnVoteError', 'InvalidLinkIDError', 'OwnObserveError', 9 | 'CommentEditError', 'EntryEditError', 'RemovedLinkError', 10 | 'PrivateLinkError', 'EntryDoesNotExistError', 'EntryLimitExceededError', 11 | 'QueryTooShortError', 'CommentDoesNotExistError', 'NiceTryError', 12 | 'UnreachableAPIError', 'NoIndexError', 'WykopAPIError', 'ReceiverHasBlockedDMError', 13 | 'ReceiverProbablyDoesNotExist' 14 | ] 15 | 16 | 17 | class WykopAPIError(Exception): 18 | """Base Wykop API exception.""" 19 | pass 20 | 21 | 22 | class InvalidAPIKeyError(WykopAPIError): 23 | pass 24 | 25 | 26 | class InvalidParamsError(WykopAPIError): 27 | pass 28 | 29 | 30 | class NotEnoughParamsError(WykopAPIError): 31 | pass 32 | 33 | 34 | class AppWritePermissionsError(WykopAPIError): 35 | pass 36 | 37 | 38 | class DailyRequestLimitError(WykopAPIError): 39 | pass 40 | 41 | 42 | class InvalidAPISignError(WykopAPIError): 43 | pass 44 | 45 | 46 | class AppPermissionsError(WykopAPIError): 47 | pass 48 | 49 | 50 | class SessionAppPermissionError(WykopAPIError): 51 | pass 52 | 53 | 54 | class NotSupportedAPIKeyError(WykopAPIError): 55 | pass 56 | 57 | 58 | class InvalidUserKeyError(WykopAPIError): 59 | pass 60 | 61 | 62 | class InvalidSessionKeyError(WykopAPIError): 63 | pass 64 | 65 | 66 | class UserDoesNotExistError(WykopAPIError): 67 | pass 68 | 69 | 70 | class InvalidCredentialsError(WykopAPIError): 71 | pass 72 | 73 | 74 | class CredentialsMissingError(WykopAPIError): 75 | pass 76 | 77 | 78 | class IPBannedError(WykopAPIError): 79 | pass 80 | 81 | 82 | class UserBannedError(WykopAPIError): 83 | pass 84 | 85 | 86 | class OwnVoteError(WykopAPIError): 87 | pass 88 | 89 | 90 | class InvalidLinkIDError(WykopAPIError): 91 | pass 92 | 93 | 94 | class OwnObserveError(WykopAPIError): 95 | pass 96 | 97 | 98 | class CommentEditError(WykopAPIError): 99 | pass 100 | 101 | 102 | class EntryEditError(WykopAPIError): 103 | pass 104 | 105 | 106 | class RemovedLinkError(WykopAPIError): 107 | pass 108 | 109 | 110 | class PrivateLinkError(WykopAPIError): 111 | pass 112 | 113 | 114 | class EntryDoesNotExistError(WykopAPIError): 115 | pass 116 | 117 | 118 | class EntryLimitExceededError(WykopAPIError): 119 | pass 120 | 121 | 122 | class QueryTooShortError(WykopAPIError): 123 | pass 124 | 125 | 126 | class CommentDoesNotExistError(WykopAPIError): 127 | pass 128 | 129 | 130 | class ReceiverHasBlockedDMError(WykopAPIError): 131 | pass 132 | 133 | 134 | class NiceTryError(WykopAPIError): 135 | pass 136 | 137 | 138 | class UnreachableAPIError(WykopAPIError): 139 | pass 140 | 141 | 142 | class NoIndexError(WykopAPIError): 143 | pass 144 | 145 | 146 | class No2FAError(WykopAPIError): 147 | pass 148 | 149 | 150 | class Invalid2FACodeError(WykopAPIError): 151 | pass 152 | 153 | 154 | class ReceiverProbablyDoesNotExist(WykopAPIError): 155 | pass 156 | 157 | 158 | __all_exceptions__ = { 159 | 1: InvalidAPIKeyError, 160 | 2: InvalidParamsError, 161 | 3: NotEnoughParamsError, 162 | 4: AppWritePermissionsError, 163 | 5: DailyRequestLimitError, 164 | 6: InvalidAPISignError, 165 | 7: AppPermissionsError, 166 | 8: SessionAppPermissionError, 167 | 9: NotSupportedAPIKeyError, 168 | 11: InvalidUserKeyError, 169 | 12: InvalidSessionKeyError, 170 | 13: UserDoesNotExistError, 171 | 14: InvalidCredentialsError, 172 | 15: CredentialsMissingError, 173 | 17: IPBannedError, 174 | 18: UserBannedError, 175 | 31: OwnVoteError, 176 | 32: InvalidLinkIDError, 177 | 33: OwnObserveError, 178 | 34: CommentEditError, 179 | 35: EntryEditError, 180 | 41: RemovedLinkError, 181 | 42: PrivateLinkError, 182 | 61: EntryDoesNotExistError, 183 | 62: EntryLimitExceededError, 184 | 71: QueryTooShortError, 185 | 81: CommentDoesNotExistError, 186 | 102: ReceiverHasBlockedDMError, 187 | 999: NiceTryError, 188 | 1001: UnreachableAPIError, 189 | 1002: NoIndexError, 190 | 1101: No2FAError, 191 | 1102: Invalid2FACodeError, 192 | } 193 | -------------------------------------------------------------------------------- /tests/functional/test_api.py: -------------------------------------------------------------------------------- 1 | import json 2 | import mock 3 | import responses 4 | 5 | from wykop.api.api_const import PROTOCOL, DOMAIN 6 | 7 | 8 | class FileMock(object): 9 | 10 | def __init__(self, name): 11 | self.name = name 12 | 13 | def read(self): 14 | return self.name 15 | 16 | def close(self): 17 | return 18 | 19 | 20 | class TestWykopAPI(object): 21 | 22 | @responses.activate 23 | def test_simple(self, wykop_api): 24 | rtype = 'rtype' 25 | body_dict = { 26 | 'data': 'data' 27 | } 28 | body = json.dumps(body_dict) 29 | 30 | named_params = 'appkey/123456app/format/json' 31 | url = '{protocol}://{domain}/{rtype}/{named_params}'.format( 32 | protocol=PROTOCOL, 33 | domain=DOMAIN, 34 | rtype=rtype, 35 | named_params=named_params, 36 | ) 37 | responses.add( 38 | responses.GET, 39 | url, 40 | body=body, 41 | status=200, 42 | content_type='application/json', 43 | ) 44 | 45 | response = wykop_api.request(rtype) 46 | 47 | assert response == body_dict['data'] 48 | 49 | @responses.activate 50 | def test_rmethod(self, wykop_api): 51 | rtype = 'rtype' 52 | rmethod = 'rmethod' 53 | body_dict = { 54 | 'data': 'data' 55 | } 56 | body = json.dumps(body_dict) 57 | 58 | named_params = 'appkey/123456app/format/json' 59 | url = '{protocol}://{domain}/{rtype}/{rmethod}/{named_params}'.format( 60 | protocol=PROTOCOL, 61 | domain=DOMAIN, 62 | rtype=rtype, 63 | rmethod=rmethod, 64 | named_params=named_params, 65 | ) 66 | responses.add( 67 | responses.GET, 68 | url, 69 | body=body, 70 | status=200, 71 | content_type='application/json', 72 | ) 73 | 74 | response = wykop_api.request(rtype, rmethod) 75 | 76 | assert response == body_dict['data'] 77 | 78 | @responses.activate 79 | def test_named_params(self, wykop_api): 80 | rtype = 'rtype' 81 | rmethod = 'rmethod' 82 | named_params_dict = { 83 | 'page': '2', 84 | } 85 | body_dict = { 86 | 'data': 'data' 87 | } 88 | body = json.dumps(body_dict) 89 | 90 | named_params = 'appkey/123456app/format/json/page/2' 91 | url = '{protocol}://{domain}/{rtype}/{rmethod}/{named_params}'.format( 92 | protocol=PROTOCOL, 93 | domain=DOMAIN, 94 | rtype=rtype, 95 | rmethod=rmethod, 96 | named_params=named_params, 97 | ) 98 | responses.add( 99 | responses.GET, 100 | url, 101 | body=body, 102 | status=200, 103 | content_type='application/json', 104 | ) 105 | 106 | response = wykop_api.request( 107 | rtype, rmethod, named_params=named_params_dict) 108 | 109 | assert response == body_dict['data'] 110 | 111 | @responses.activate 112 | def test_post_params(self, wykop_api): 113 | rtype = 'rtype' 114 | rmethod = 'rmethod' 115 | post_param_name = 'post_param_name' 116 | post_param_value = 'post_param_value' 117 | post_params = { 118 | post_param_name: post_param_value, 119 | } 120 | body_dict = { 121 | 'data': 'data' 122 | } 123 | body = json.dumps(body_dict) 124 | 125 | named_params = 'appkey/123456app/format/json' 126 | url = '{protocol}://{domain}/{rtype}/{rmethod}/{named_params}'.format( 127 | protocol=PROTOCOL, 128 | domain=DOMAIN, 129 | rtype=rtype, 130 | rmethod=rmethod, 131 | named_params=named_params, 132 | ) 133 | responses.add( 134 | responses.POST, 135 | url, 136 | body=body, 137 | status=200, 138 | content_type='application/json', 139 | ) 140 | 141 | response = wykop_api.request( 142 | rtype, rmethod, post_params=post_params) 143 | 144 | assert response == body_dict['data'] 145 | 146 | @responses.activate 147 | def test_file_params(self, wykop_api): 148 | rtype = 'rtype' 149 | rmethod = 'rmethod' 150 | file_param_name = 'file_param_name' 151 | file_params = { 152 | file_param_name: FileMock(file_param_name), 153 | } 154 | body_dict = { 155 | 'data': 'data' 156 | } 157 | body = json.dumps(body_dict) 158 | 159 | named_params = 'appkey/123456app/format/json' 160 | url = '{protocol}://{domain}/{rtype}/{rmethod}/{named_params}'.format( 161 | protocol=PROTOCOL, 162 | domain=DOMAIN, 163 | rtype=rtype, 164 | rmethod=rmethod, 165 | named_params=named_params, 166 | ) 167 | responses.add( 168 | responses.POST, 169 | url, 170 | body=body, 171 | status=200, 172 | content_type='application/json', 173 | ) 174 | 175 | response = wykop_api.request( 176 | rtype, rmethod, file_params=file_params) 177 | 178 | assert response == body_dict['data'] 179 | 180 | -------------------------------------------------------------------------------- /wykop/core/requestor.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hashlib 3 | import itertools 4 | import logging 5 | from collections import OrderedDict 6 | 7 | from typing import Dict 8 | from urllib.parse import quote_plus, urlunparse 9 | 10 | from wykop.api.exceptions import WykopAPIError 11 | from wykop.api.api_const import CLIENT_NAME, DOMAIN, PROTOCOL 12 | from wykop.api.models.wykop_connect import WykopConnectLoginInfo 13 | from wykop.core.credentials import Credentials 14 | from wykop.core.parsers import default_parser 15 | from wykop.core.requesters import default_requester 16 | from wykop.utils import force_bytes, force_text, get_version, dictmap, sort_and_remove_none_values, \ 17 | validate_named_parameters, validate_api_parameters 18 | 19 | log = logging.getLogger(__name__) 20 | 21 | 22 | class Requestor: 23 | 24 | def __init__(self, credentials: Credentials, parser, output='', response_format='json'): 25 | self.credentials = credentials 26 | self.output = output 27 | self.format = response_format 28 | self.userkey = '' 29 | self.requester = default_requester 30 | self.parser = parser 31 | 32 | def request(self, rtype, rmethod=None, 33 | named_params=None, api_params=None, post_params=None, file_params=None): 34 | log.debug('Making request') 35 | 36 | post_params = sort_and_remove_none_values(post_params) 37 | 38 | file_params = file_params or {} 39 | 40 | rtype = force_text(rtype) 41 | rmethod = rmethod and force_text(rmethod) 42 | post_params = dictmap(force_bytes, post_params) 43 | 44 | url = self.construct_url( 45 | rtype=rtype, rmethod=rmethod, api_params=api_params, named_params=named_params) 46 | headers = self.headers(url, **post_params) 47 | 48 | response = self.requester.make_request( 49 | url, post_params, headers, file_params) 50 | 51 | if self.parser is None: 52 | return response 53 | 54 | return self.parser.parse_response(response) 55 | 56 | def authenticate(self, account_key=None, login=None, password=None): 57 | self.credentials.account_key = account_key or self.credentials.account_key 58 | self.credentials.login = login or self.credentials.login 59 | self.credentials.password = password or self.credentials.password 60 | 61 | if self.credentials.account_key: 62 | res = self.user_login_with_accountkey(self.credentials.account_key) 63 | elif self.credentials.login and self.credentials.password: 64 | appkey_type = self.credentials.appkey_type() 65 | if appkey_type['official']: 66 | res = self.user_login_with_password(self.credentials.login, self.credentials.password) 67 | else: 68 | # TODO: implement login/connect polyfill 69 | raise WykopAPIError( 70 | 0, 'login and password provided on unofficial appkey') 71 | else: 72 | raise WykopAPIError( 73 | 0, 'neither accountkey nor login and password are set') 74 | 75 | self.userkey = res['userkey'] 76 | 77 | def user_login_with_accountkey(self, account_key): 78 | post_params = {'accountkey': account_key} 79 | return self.request('login', post_params=post_params) 80 | 81 | def user_login_with_password(self, login, password): 82 | post_params = {'login': login, 'password': password} 83 | return self.request('login', post_params=post_params) 84 | 85 | def user_login_2fa(self, tfa_token): 86 | post_params = {'code': tfa_token} 87 | return self.request('login', '2fa', post_params=post_params) 88 | 89 | def wykop_connect_url(self, redirect_url: str = None): 90 | named_params = self.connect_named_params(redirect_url) 91 | return self.construct_url(rtype='Login', rmethod='Connect', named_params=named_params) 92 | 93 | def default_named_params(self): 94 | """ 95 | Gets default named parameters. 96 | """ 97 | return { 98 | 'appkey': self.credentials.appkey, 99 | 'format': self.format, 100 | 'output': self.output, 101 | 'userkey': self.userkey, 102 | } 103 | 104 | def api_sign(self, url, **post_params): 105 | """ 106 | Gets request api sign. 107 | """ 108 | post_params_values = self.post_params_values(**post_params) 109 | post_params_values_str = ",".join(post_params_values) 110 | post_params_values_bytes = force_bytes(post_params_values_str) 111 | url_bytes = force_bytes(url) 112 | secretkey_bytes = force_bytes(self.credentials.secretkey) 113 | return hashlib.md5( 114 | secretkey_bytes + url_bytes + post_params_values_bytes).hexdigest() 115 | 116 | def post_params_values(self, **post_params): 117 | """ 118 | Gets post parameters values list. Required to api sign. 119 | """ 120 | return [force_text(post_params[key]) 121 | for key in sorted(post_params.keys())] 122 | 123 | def user_agent(self): 124 | """ 125 | Gets User-Agent header. 126 | """ 127 | client_version = get_version() 128 | return '/'.join([CLIENT_NAME, client_version]) 129 | 130 | def headers(self, url, **post_params): 131 | """ 132 | Gets request headers. 133 | """ 134 | apisign = self.api_sign(url, **post_params) 135 | user_agent = self.user_agent() 136 | 137 | return { 138 | 'apisign': apisign, 139 | 'User-Agent': user_agent, 140 | } 141 | 142 | def connect_named_params(self, redirect_url=None): 143 | """ 144 | Gets request api parameters for wykop connect. 145 | """ 146 | apisign = self.api_sign(redirect_url) 147 | 148 | named_params = { 149 | 'secure': apisign, 150 | } 151 | 152 | if redirect_url is not None: 153 | redirect_url_bytes = force_bytes(redirect_url) 154 | redirect_url_encoded = quote_plus( 155 | base64.b64encode(redirect_url_bytes)) 156 | named_params.update({ 157 | 'redirect': redirect_url_encoded, 158 | }) 159 | 160 | return named_params 161 | 162 | def construct_url(self, rtype, rmethod=None, api_params=None, named_params=None): 163 | """ 164 | Constructs request url. 165 | """ 166 | named_params = dictmap(force_text, validate_named_parameters(named_params)) 167 | api_params = validate_api_parameters(api_params) 168 | 169 | path = self.path(rtype, api_params=api_params, 170 | rmethod=rmethod, named_params=named_params) 171 | 172 | urlparts = (PROTOCOL, DOMAIN, path, '', '', '') 173 | return str(urlunparse(urlparts)) 174 | 175 | def path(self, rtype, api_params, rmethod=None, **named_params): 176 | """ 177 | Gets request path. 178 | """ 179 | pathparts = [rtype] 180 | 181 | if rmethod is not None: 182 | pathparts += [rmethod] 183 | 184 | if api_params is not None: 185 | pathparts += tuple(api_params) 186 | 187 | named_params = self.named_params(**named_params) 188 | 189 | if named_params: 190 | pathparts += list(itertools.chain(*named_params.items())) 191 | 192 | return '/'.join(pathparts) 193 | 194 | def named_params(self, named_params) -> Dict[str, str]: 195 | """ 196 | Gets request method parameters. 197 | """ 198 | params = self.default_named_params() 199 | params.update(named_params) 200 | return { 201 | str(key): str(value) 202 | for key, value in params.items() 203 | if value 204 | } 205 | 206 | def valid_sign(self, connect_response: WykopConnectLoginInfo) -> bool: 207 | secret_bytes = force_bytes(self.credentials.secretkey) 208 | app_key_bytes = force_bytes(connect_response.app_key) 209 | login_bytes = force_bytes(connect_response.login) 210 | token_bytes = force_bytes(connect_response.token) 211 | to_hash = secret_bytes + app_key_bytes + login_bytes + token_bytes 212 | return hashlib.md5(to_hash).hexdigest() == connect_response.sign 213 | -------------------------------------------------------------------------------- /wykop/api/client.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from typing import Dict, List, Any 4 | 5 | from wykop.api.api_const import PAGE_NAMED_ARG, BODY_NAMED_ARG, FILE_POST_NAME 6 | from wykop.api.api_values import NotificationType, DirectNotificationType 7 | from wykop.api.exceptions.base import ReceiverProbablyDoesNotExist, WykopAPIError 8 | from wykop.api.exceptions.client_exceptions import InvalidWykopConnectSign 9 | from wykop.api.models.wykop_connect import WykopConnectLoginInfo 10 | from wykop.core.credentials import Credentials 11 | from wykop.core.parsers import default_parser 12 | from wykop.core.requestor import Requestor 13 | 14 | log = logging.getLogger(__name__) 15 | 16 | 17 | class WykopAPI: 18 | """Wykop API version 2.""" 19 | 20 | def __init__(self, appkey, secretkey=None, account_key=None, output='', response_format='json'): 21 | self.__parser = default_parser 22 | self.__requestor = Requestor( 23 | credentials=Credentials(appkey, secretkey, account_key), 24 | parser=self.__parser, 25 | output=output, 26 | response_format=response_format 27 | ) 28 | 29 | def request(self, rtype, rmethod=None, 30 | named_params=None, api_params=None, post_params=None, file_params=None): 31 | return self.__requestor.request(rtype, rmethod=rmethod, 32 | named_params=named_params, 33 | api_params=api_params, 34 | post_params=post_params, 35 | file_params=file_params) 36 | 37 | def authenticate(self, account_key=None, login=None, password=None): 38 | self.__requestor.authenticate(account_key=account_key, login=login, password=password) 39 | 40 | def authenticate_2fa(self, tfa_code): 41 | self.__requestor.user_login_2fa(tfa_code) 42 | 43 | def wykop_connect_url(self, redirect_url: str = None): 44 | return self.__requestor.wykop_connect_url(redirect_url) 45 | 46 | def parse_wykop_connect_response(self, response: str) -> WykopConnectLoginInfo: 47 | connect_response = self.__parser.parse_wykop_connect_response(response) 48 | if not self.__requestor.valid_sign(connect_response): 49 | raise InvalidWykopConnectSign 50 | return connect_response 51 | 52 | # entries 53 | 54 | def entries_stream(self, page=1, first_id=None): 55 | named_params = self \ 56 | .__with_page(page) 57 | if first_id: 58 | named_params.update(dict(firstId=first_id)) 59 | return self.request('Entries', 'Stream', named_params=named_params) 60 | 61 | def entries_hot(self, page=1, period=12): 62 | assert period in [6, 12, 24] 63 | named_params = self \ 64 | .__with_page(page) 65 | named_params.update(dict(period=period)) 66 | return self.request('Entries', 'Hot', 67 | named_params=named_params) 68 | 69 | def entries_active(self, page=1): 70 | return self.request('Entries', 'Active', 71 | named_params=self.__with_page(page)) 72 | 73 | def entries_observed(self, page=1): 74 | return self.request('Entries', 'Observed', 75 | named_params=self.__with_page(page)) 76 | 77 | def entry(self, entry_id): 78 | return self.request('Entries', 'Entry', 79 | api_params=self.__api_param(entry_id)) 80 | 81 | def entry_add(self, body: str, file=None, file_url: str = None, is_adult_media: bool = False): 82 | return self.request('Entries', 'Add', 83 | post_params=self.content_post_params( 84 | body, file_url, is_adult_media), 85 | file_params=self.__with_file(file)) 86 | 87 | def entry_edit(self, entry_id: str, body: str, file=None, file_url: str = None, is_adult_media: bool = False): 88 | return self.request('Entries', 'Edit', 89 | post_params=self.content_post_params( 90 | body, file_url, is_adult_media), 91 | api_params=self.__api_param(entry_id), 92 | file_params=self.__with_file(file)) 93 | 94 | def entry_vote_up(self, entry_id: str): 95 | return self.request('Entries', 'VoteUp', 96 | api_params=self.__api_param(entry_id)) 97 | 98 | def entry_vote_remove(self, entry_id: str): 99 | return self.request('Entries', 'VoteRemove', 100 | api_params=self.__api_param(entry_id)) 101 | 102 | def entry_upvoters(self, entry_id: str): 103 | return self.request('Entries', 'Upvoters', 104 | api_params=self.__api_param(entry_id)) 105 | 106 | def entry_delete(self, entry_id: str): 107 | return self.request('Entries', 'Delete', 108 | api_params=self.__api_param(entry_id)) 109 | 110 | def entry_favorite_toggle(self, entry_id: str): 111 | return self.request('Entries', 'Favorite', 112 | api_params=self.__api_param(entry_id)) 113 | 114 | def entry_survey_vote(self, entry_id: str, answer_id: str): 115 | return self.request('Entries', 'SurveyVote', 116 | api_params=[entry_id, answer_id]) 117 | 118 | # comments 119 | 120 | def entry_comment(self, comment_id: str): 121 | return self.request('Entries', 'Comment', 122 | api_params=self.__api_param(comment_id)) 123 | 124 | def entry_comment_add(self, entry_id: str, body: str, file=None, file_url: str = None, 125 | is_adult_media: bool = False): 126 | return self.request('Entries', 'CommentAdd', 127 | post_params=self.content_post_params( 128 | body, file_url, is_adult_media), 129 | api_params=self.__api_param(entry_id), 130 | file_params=self.__with_file(file)) 131 | 132 | def entry_comment_edit(self, comment_id: str, body: str, file=None, file_url: str = None, 133 | is_adult_media: bool = False): 134 | return self.request('Entries', 'CommentEdit', 135 | post_params=self.content_post_params( 136 | body, file_url, is_adult_media), 137 | api_params=self.__api_param(comment_id), 138 | file_params=self.__with_file(file)) 139 | 140 | def entry_comment_delete(self, comment_id: str): 141 | return self.request('Entries', 'CommentDelete', 142 | api_params=self.__api_param(comment_id)) 143 | 144 | def entry_comment_vote_up(self, comment_id: str): 145 | return self.request('Entries', 'CommentVoteUp', 146 | api_params=self.__api_param(comment_id)) 147 | 148 | def entry_comment_vote_remote(self, comment_id: str): 149 | return self.request('Entries', 'CommentVoteRemove', 150 | api_params=self.__api_param(comment_id)) 151 | 152 | def entry_comment_observed(self, page: int = 1): 153 | return self.request('Entries', 'ObservedComments', 154 | named_params=self.__with_page(page)) 155 | 156 | def entry_comment_favorite_toggle(self, entry_id: str): 157 | return self.request('Entries', 'CommentFavorite', 158 | api_params=self.__api_param(entry_id)) 159 | 160 | # links 161 | 162 | def links_promoted(self, page=1): 163 | return self.request('links', 'promoted', 164 | named_params=self.__with_page(page)) 165 | 166 | def link(self, link_id: str): 167 | return self.request('links', 'link', 168 | api_params=self.__api_param(link_id)) 169 | 170 | # mywykop 171 | 172 | def mywykop_entries(self, page: int = 1) -> list: 173 | """ 174 | Get entries from observed users and tags. 175 | """ 176 | return self.request('mywykop', 'entries', 177 | named_params=self.__with_page(page)) 178 | 179 | # profiles 180 | 181 | def profile(self, login): 182 | return self.request('profiles', 'index', api_params=self.__api_param(login)) 183 | 184 | def profile_observe(self, login): 185 | named_params = { 186 | 'observe': login, 187 | } 188 | return self.request('profiles', named_params=named_params) 189 | 190 | def profile_unobserve(self, login): 191 | named_params = { 192 | 'unobserve': login, 193 | } 194 | return self.request('profiles', named_params=named_params) 195 | 196 | def profile_block(self, login): 197 | named_params = { 198 | 'block': login, 199 | } 200 | return self.request('profiles', named_params=named_params) 201 | 202 | def profile_unblock(self, login): 203 | named_params = { 204 | 'unblock': login, 205 | } 206 | return self.request('profiles', named_params=named_params) 207 | 208 | def profile_added(self, login, page=1): 209 | return self.request('profiles', 'Added', 210 | named_params=self.__with_page(page), 211 | api_params=self.__api_params([login]) 212 | ) 213 | 214 | def profile_digged(self, login, page=1): 215 | return self.request('profiles', 'Digged', 216 | named_params=self.__with_page(page), 217 | api_params=self.__api_params([login]) 218 | ) 219 | 220 | def profile_buried(self, login, page=1): 221 | return self.request('profiles', 'Buried', 222 | named_params=self.__with_page(page), 223 | api_params=self.__api_params([login]) 224 | ) 225 | 226 | def profile_comments(self, login, page=1): 227 | return self.request('profiles', 'Comments', 228 | named_params=self.__with_page(page), 229 | api_params=self.__api_params([login]) 230 | ) 231 | 232 | def profile_entries_comments(self, login, page=1): 233 | return self.request('profiles', 'EntriesComments', 234 | named_params=self.__with_page(page), 235 | api_params=self.__api_params([login]) 236 | ) 237 | 238 | def profile_entries(self, login, page=1): 239 | return self.request('profiles', 'Entries', 240 | named_params=self.__with_page(page), 241 | api_params=self.__api_params([login]) 242 | ) 243 | 244 | # hits 245 | 246 | def hits_popular(self): 247 | return self.request('hits', 'popular') 248 | 249 | def hits_day(self): 250 | return self.request('hits', 'day') 251 | 252 | def hits_week(self): 253 | return self.request('hits', 'week') 254 | 255 | def hits_month(self, month: int = None, year: int = None): 256 | return self.request('hits', 'month', 257 | api_params=self.__api_params([year, month])) 258 | 259 | def hits_year(self, year: int = None): 260 | return self.request('hits', 'year', 261 | api_params=self.__api_param(str(year))) 262 | 263 | # pm 264 | 265 | def conversations_list(self): 266 | return self.request('pm', 'conversationsList') 267 | 268 | def conversation(self, receiver: str): 269 | try: 270 | return self.request('pm', 'Conversation', api_params=self.__api_param(receiver)) 271 | except WykopAPIError as ex: 272 | if '503' in ex.args[1]: 273 | raise ReceiverProbablyDoesNotExist() 274 | raise ex 275 | 276 | def message_send(self, receiver: str, message: str): 277 | return self.request('pm', 'SendMessage', 278 | post_params=self.__with_body(message), 279 | api_params=self.__api_param(receiver)) 280 | 281 | def conversation_delete(self, receiver: str): 282 | return self.request('pm', 'DeleteConversation', 283 | api_params=self.__api_param(receiver)) 284 | 285 | # notifications 286 | 287 | def notifications_direct(self, page=1, notification_type: DirectNotificationType = None): 288 | notifications = self.request('notifications', named_params=self.__with_page(page)) 289 | if notification_type: 290 | return [n for n in notifications if n['type'] == notification_type.value] 291 | return notifications 292 | 293 | def notifications_direct_count(self): 294 | return self.request('notifications', 'Count') 295 | 296 | def notifications_hashtags_notifications(self, page=1): 297 | return self.request('notifications', 'hashtags', 298 | named_params=self.__with_page(page)) 299 | 300 | def notifications_hashtags_count(self): 301 | return self.request('notifications', 'hashtagscount') 302 | 303 | def notifications_all(self, page=1, notification_type: NotificationType = None): 304 | notifications = self.request('notifications', 'total', named_params=self.__with_page(page)) 305 | if notification_type: 306 | return [n for n in notifications if n['type'] == notification_type.value] 307 | return notifications 308 | 309 | def notifications_all_count(self): 310 | return self.request('notifications', 'totalcount') 311 | 312 | def notification_mark_all_as_read(self): 313 | return self.request('Notifications', 'ReadAllNotifications') 314 | 315 | def notifications_mark_all_direct_as_read(self): 316 | return self.request('Notifications', 'ReadDirectedNotifications') 317 | 318 | def notifications_mark_all_hashtag_as_read(self): 319 | return self.request('Notifications', 'ReadHashTagsNotifications') 320 | 321 | def notification_mark_as_read(self, notification_id): 322 | return self.request('Notifications', 'MarkAsRead', 323 | api_params=self.__api_param(notification_id)) 324 | 325 | # search 326 | 327 | def search_links(self, page=1, query=None, when=None, votes=None, from_date=None, to_date=None, what=None, 328 | sort=None): 329 | assert len(query) > 2 if query else True 330 | assert when.lower() in ["all", "today", "yesterday", "week", "month", "range"] if when else True 331 | assert what.lower() in ["all", "promoted", "archived", "duplicates"] if when else True 332 | assert sort.lower() in ["best", "diggs", "comments", "new"] if when else True 333 | assert (from_date and to_date) or (not from_date and not to_date) 334 | post_params = { 335 | 'q': query, 336 | 'when': 'range' if from_date and to_date else when, 337 | 'votes': votes, 338 | 'from': from_date, 339 | 'to': to_date, 340 | 'what': what, 341 | 'sort': sort 342 | } 343 | return self.request('Search', 'Links', 344 | post_params=post_params, 345 | named_params=self.__with_page(page)) 346 | 347 | def search_entries(self, page=1, query=None, when='all', votes=None, from_date=None, to_date=None): 348 | assert len(query) > 2 if query else True 349 | assert when.lower() in ["all", "today", "yesterday", "week", "month", "range"] if when else True 350 | assert (from_date and to_date) or (not from_date and not to_date) 351 | post_params = { 352 | 'q': query, 353 | 'when': 'range' if from_date and to_date else when, 354 | 'votes': votes, 355 | 'from': from_date, 356 | 'to': to_date 357 | } 358 | return self.request('Search', 'Entries', 359 | post_params=post_params, 360 | named_params=self.__with_page(page)) 361 | 362 | def search_profiles(self, query): 363 | assert len(query) > 2 if query else True 364 | post_params = { 365 | 'q': query, 366 | } 367 | return self.request('Search', 'Profiles', 368 | post_params=post_params) 369 | 370 | # tags 371 | 372 | def tag(self, tag, page=1): 373 | return self.request('Tags', 'Index', 374 | named_params=dict(page=page), 375 | api_params=self.__api_param(tag)) 376 | 377 | def tag_links(self, tag, page=1): 378 | return self.request('Tags', 'Links', 379 | named_params=self.__with_page(page), 380 | api_params=self.__api_param(tag)) 381 | 382 | def tag_entries(self, tag, page=1): 383 | return self.request('Tags', 'Entries', 384 | named_params=self.__with_page(page), 385 | api_params=self.__api_param(tag)) 386 | 387 | def tag_observe(self, tag): 388 | return self.request('Tags', 'Observe', 389 | api_params=self.__api_param(tag)) 390 | 391 | def tag_unobserve(self, tag): 392 | return self.request('Tags', 'Unobserve', 393 | api_params=self.__api_param(tag)) 394 | 395 | def tag_enable_notifications(self, tag): 396 | return self.request('Tags', 'Notify', 397 | api_params=self.__api_param(tag)) 398 | 399 | def tag_disable_notifications(self, tag): 400 | return self.request('Tags', 'Dontnotify', 401 | api_params=self.__api_param(tag)) 402 | 403 | def tag_block(self, tag): 404 | return self.request('Tags', 'Block', 405 | api_params=self.__api_param(tag)) 406 | 407 | def tag_unblock(self, tag): 408 | return self.request('Tags', 'Unblock', 409 | api_params=self.__api_param(tag)) 410 | 411 | # settings 412 | 413 | def settings_profile_update(self, profile_settings: Dict[str, str]): 414 | return self.request('Settings', 'Profile', 415 | post_params=profile_settings) 416 | 417 | def settings_avatar_update(self, avatar_file): 418 | return self.request('Settings', 'Avatar', 419 | file_params=self.__with_file(avatar_file)) 420 | 421 | def settings_background_update(self, background_file): 422 | return self.request('Settings', 'Background', 423 | file_params=self.__with_file(background_file)) 424 | 425 | def settings_password_update(self, old_password: str, new_password: str): 426 | post_params = { 427 | 'old_password': old_password, 428 | 'password': new_password 429 | } 430 | return self.request('Settings', 'Password', 431 | post_params=post_params) 432 | 433 | def settings_password_reset(self, email: str): 434 | post_params = { 435 | 'email': email 436 | } 437 | return self.request('Settings', 'ResetPassword', 438 | post_params=post_params) 439 | 440 | @staticmethod 441 | def __api_param(param: str) -> List[str]: 442 | return [str(param)] if param else None 443 | 444 | @staticmethod 445 | def __api_params(params: List[Any]) -> List[str]: 446 | api_params = [str(p) for p in params if p] 447 | return api_params if api_params else None 448 | 449 | @staticmethod 450 | def __with_page(page: int) -> Dict[str, int]: 451 | return {PAGE_NAMED_ARG: page} if page else {} 452 | 453 | @staticmethod 454 | def __with_body(body: str) -> Dict[str, str]: 455 | return {BODY_NAMED_ARG: body} if body else {} 456 | 457 | @staticmethod 458 | def __with_file(file: str) -> Dict[str, str]: 459 | return {FILE_POST_NAME: file} if file else {} 460 | 461 | @staticmethod 462 | def content_post_params(body: str, file_url: str, is_adult_media: bool): 463 | post_params = { 464 | 'adultmedia': is_adult_media, 465 | 'body': body, 466 | 'embed': file_url 467 | } 468 | return post_params 469 | --------------------------------------------------------------------------------