From c250e5a33a882a54811465c45e67c059e7e87070 Mon Sep 17 00:00:00 2001 From: Linusp Date: Sun, 17 Nov 2019 18:24:54 +0800 Subject: [PATCH 01/16] implemented `InoreaderConfigManager` --- inoreader/config.py | 68 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 inoreader/config.py diff --git a/inoreader/config.py b/inoreader/config.py new file mode 100644 index 0000000..e1ebfee --- /dev/null +++ b/inoreader/config.py @@ -0,0 +1,68 @@ +# coding: utf-8 +from __future__ import print_function, unicode_literals + +import os +from configparser import ConfigParser + +import codecs + + +class InoreaderConfigManager(): + + def __init__(self, config_file): + self.config_file = config_file + self.data = {} + if os.path.exists(config_file): + self.load() + + def load(self): + config_parser = ConfigParser() + config_parser.read(self.config_file) + for section_name in config_parser.sections(): + self.data[section_name] = dict(config_parser[section_name]) + + def save(self): + with codecs.open(self.config_file, mode='w', encoding='utf-8') as f: + config_parser = ConfigParser() + config_parser.update(self.data) + config_parser.write(f) + + @property + def app_id(self): + return self.data.get('auth', {}).get('appid') + + @app_id.setter + def app_id(self, value): + self.data.setdefault('auth', {})['appid'] = value + + @property + def app_key(self): + return self.data.get('auth', {}).get('appkey') + + @app_key.setter + def app_key(self, value): + self.data.setdefault('auth', {})['appkey'] = value + + @property + def access_token(self): + return self.data.get('auth', {}).get('access_token') + + @access_token.setter + def access_token(self, value): + self.data.setdefault('auth', {})['access_token'] = value + + @property + def refresh_token(self): + return self.data.get('auth', {}).get('refresh_token') + + @refresh_token.setter + def refresh_token(self, value): + self.data.setdefault('auth', {})['refresh_token'] = value + + @property + def expires_at(self): + return self.data.get('auth', {}).get('expires_at') + + @expires_at.setter + def expires_at(self, value): + self.data.setdefault('auth', {})['expires_at'] = value From 276362999418b5d409c55c82f221641f308eb889 Mon Sep 17 00:00:00 2001 From: Linusp Date: Sun, 17 Nov 2019 18:47:37 +0800 Subject: [PATCH 02/16] use OAuth 2.0 in `InoreaderClient` --- inoreader/client.py | 86 ++++++++++++++++++++++++++++----------------- 1 file changed, 53 insertions(+), 33 deletions(-) diff --git a/inoreader/client.py b/inoreader/client.py index 94aadbb..3bf6ffd 100644 --- a/inoreader/client.py +++ b/inoreader/client.py @@ -1,7 +1,9 @@ # coding: utf-8 from __future__ import print_function, unicode_literals +import logging from uuid import uuid4 +from datetime import datetime from operator import itemgetter try: # python2 from urlparse import urljoin @@ -11,32 +13,67 @@ import requests -from .consts import BASE_URL, LOGIN_URL +from .consts import BASE_URL from .exception import NotLoginError, APIError from .article import Article from .subscription import Subscription +LOGGER = logging.getLogger(__name__) + + class InoreaderClient(object): - def __init__(self, app_id, app_key, userid=None, auth_token=None): + # paths + TOKEN_PATH = '/oauth2/token' + + def __init__(self, app_id, app_key, access_token, refresh_token, + expires_at, userid=None, config_manager=None): self.app_id = app_id self.app_key = app_key - self.auth_token = auth_token + self.access_token = access_token + self.refresh_token = refresh_token + self.expires_at = float(expires_at) self.session = requests.Session() self.session.headers.update({ 'AppId': self.app_id, 'AppKey': self.app_key, - 'Authorization': 'GoogleLogin auth={}'.format(self.auth_token) + 'Authorization': 'Bearer {}'.format(self.access_token) }) - if userid: - self.userid = userid - else: - self.userid = None if not self.auth_token else self.userinfo()['userId'] + self.userid = userid or self.userinfo()['userId'] + self.config_manager = config_manager + if self.userid and self.config_manager and not self.config_manager.user_id: + self.config_manager.user_id = self.userid + self.config_manager.save() + + def check_token(self): + now = datetime.now().timestamp() + if now >= self.expires_at: + self.refresh_access_token() + + def refresh_access_token(self): + url = urljoin(BASE_URL, self.TOKEN_PATH) + payload = { + 'client_id': self.app_id, + 'client_secret': self.app_key, + 'grant_type': 'refresh_token', + 'refresh_token': self.refresh_token, + } + resp = requests.post(url, json=payload) + response = resp.json() + self.access_token = response['access_token'] + self.refresh_token = response['refresh_token'] + self.expires_at = datetime.now().timestamp() + response['expires_in'] + self.session.headers['Authorization'] = 'Bear {}'.format(self.access_token) + + if self.config_manager: + self.config_manager.access_token = self.access_token + self.config_manager.refresh_token = self.refresh_token + self.config_manager.expires_at = self.expires_at + self.config_manager.save() def userinfo(self): - if not self.auth_token: - raise NotLoginError + self.check_token() url = urljoin(BASE_URL, 'user-info') resp = self.session.post(url) @@ -45,20 +82,8 @@ def userinfo(self): return resp.json() - def login(self, username, password): - resp = self.session.get(LOGIN_URL, params={'Email': username, 'Passwd': password}) - if resp.status_code != 200: - return False - - for line in resp.text.split('\n'): - if line.startswith('Auth'): - self.auth_token = line.replace('Auth=', '').strip() - - return bool(self.auth_token) - def get_folders(self): - if not self.auth_token: - raise NotLoginError + self.check_token() url = urljoin(BASE_URL, 'tag/list') params = {'types': 1, 'counts': 1} @@ -78,8 +103,7 @@ def get_folders(self): return folders def get_tags(self): - if not self.auth_token: - raise NotLoginError + self.check_token() url = urljoin(BASE_URL, 'tag/list') params = {'types': 1, 'counts': 1} @@ -99,8 +123,7 @@ def get_tags(self): return tags def get_subscription_list(self): - if not self.auth_token: - raise NotLoginError + self.check_token() url = urljoin(BASE_URL, 'subscription/list') resp = self.session.get(url) @@ -119,8 +142,7 @@ def get_stream_contents(self, stream_id, c=''): break def __get_stream_contents(self, stream_id, continuation=''): - if not self.auth_token: - raise NotLoginError + self.check_token() url = urljoin(BASE_URL, 'stream/contents/' + quote_plus(stream_id)) params = { @@ -139,8 +161,7 @@ def __get_stream_contents(self, stream_id, continuation=''): return resp.json()['items'], None def fetch_unread(self, folder=None, tags=None): - if not self.auth_token: - raise NotLoginError + self.check_token() url = urljoin(BASE_URL, 'stream/contents/') if folder: @@ -183,8 +204,7 @@ def fetch_unread(self, folder=None, tags=None): continuation = resp.json().get('continuation') def add_general_label(self, articles, label): - if not self.auth_token: - raise NotLoginError + self.check_token() url = urljoin(BASE_URL, 'edit-tag') for start in range(0, len(articles), 10): From 6baeef2bca00398b52e550131e02fea0e83ff09c Mon Sep 17 00:00:00 2001 From: Linusp Date: Sun, 17 Nov 2019 18:48:41 +0800 Subject: [PATCH 03/16] support OAuth 2.0 in command `login` --- inoreader/consts.py | 7 +++ inoreader/main.py | 133 ++++++++++++++++++++++++-------------------- 2 files changed, 81 insertions(+), 59 deletions(-) diff --git a/inoreader/consts.py b/inoreader/consts.py index 04ea1a7..e28100e 100644 --- a/inoreader/consts.py +++ b/inoreader/consts.py @@ -1,3 +1,10 @@ # coding: utf-8 +import os + BASE_URL = 'https://www.inoreader.com/reader/api/0/' LOGIN_URL = 'https://www.inoreader.com/accounts/ClientLogin' + +DEFAULT_APPID = '1000000337' +DEFAULT_APPKEY = 'Bp1UxqT8KbbhNe5lmJUS6bpJ0EKow9Ze' + +CONFIG_FILE = os.path.join(os.environ.get('HOME'), '.inoreader') diff --git a/inoreader/main.py b/inoreader/main.py index bdcf353..177d093 100644 --- a/inoreader/main.py +++ b/inoreader/main.py @@ -7,15 +7,24 @@ import json import codecs import logging +import threading +from queue import Queue +from uuid import uuid4 +from functools import partial from logging.config import dictConfig from collections import defaultdict, Counter -from configparser import ConfigParser import yaml import click +from flask import Flask, request +from requests_oauthlib import OAuth2Session + from inoreader import InoreaderClient from inoreader.filter import get_filter from inoreader.sim import sim_of, InvIndex +from inoreader.exception import NotLoginError +from inoreader.config import InoreaderConfigManager +from inoreader.consts import DEFAULT_APPID, DEFAULT_APPKEY APPID_ENV_NAME = 'INOREADER_APP_ID' @@ -57,46 +66,16 @@ }) -def read_config(): - config = ConfigParser() - if os.path.exists(CONFIG_FILE): - config.read(CONFIG_FILE) - - return config - - -def get_appid_key(config): - # 先尝试从配置文件中读取 appid 和 appkey - appid = config.get('auth', 'appid') if config.has_section('auth') else None - appkey = config.get('auth', 'appkey') if config.has_section('auth') else None - if not appid: - appid = os.environ.get(APPID_ENV_NAME) - if not appkey: - appkey = os.environ.get(APPKEY_ENV_NAME) - - return appid, appkey - - def get_client(): - config = read_config() - appid, appkey = get_appid_key(config) - if not appid or not appkey: - LOGGER.error("'appid' or 'appkey' is missing") - sys.exit(1) - - token = None - if config.has_section('auth'): - token = config.get('auth', 'token') - token = token or os.environ.get(TOKEN_ENV_NAME) - if not token: + config = InoreaderConfigManager(CONFIG_FILE) + if not config.data: LOGGER.error("Please login first") sys.exit(1) - userid = None - if config.has_section('user'): - userid = config.get('user', 'id') - - client = InoreaderClient(appid, appkey, userid=userid, auth_token=token) + client = InoreaderClient( + config.app_id, config.app_key, config.access_token, config.refresh_token, + config.expires_at, userid=config.user_id, config_manager=config + ) return client @@ -107,29 +86,65 @@ def main(): @main.command() def login(): - """Login to your inoreader account""" - client = InoreaderClient(None, None) - - username = input("EMAIL: ").strip() - password = input("PASSWORD: ").strip() - status = client.login(username, password) - if status: - LOGGER.info("Login as '%s'", username) - auth_token = client.auth_token - config = read_config() - if 'auth' in config: - config['auth']['token'] = auth_token - else: - config['auth'] = {'token': auth_token} - - appid, appkey = get_appid_key(config) - client = InoreaderClient(appid, appkey, auth_token=auth_token) - config['user'] = {'email': username, 'id': client.userinfo()['userId']} - with codecs.open(CONFIG_FILE, mode='w', encoding='utf-8') as fconfig: - config.write(fconfig) - LOGGER.info("save token in config file '%s'", CONFIG_FILE) + """Login to your inoreader account with OAuth 2.0""" + # run simple daemon http server to handle callback + app = Flask(__name__) + + # disable flask output + app.logger.disabled = True + logger = logging.getLogger('werkzeug') + logger.setLevel(logging.ERROR) + logger.disabled = True + sys.modules['flask.cli'].show_server_banner = lambda *x: None + + # use queue to pass data between threads + queue = Queue() + + config = InoreaderConfigManager(CONFIG_FILE) + app_id = config.app_id or DEFAULT_APPID + app_key = config.app_key or DEFAULT_APPKEY + state = str(uuid4()) + oauth = OAuth2Session(app_id, + redirect_uri='http://localhost:8080/oauth/redirect', + scope='read write', + state=state) + + @app.route('/oauth/redirect') + def redirect(): + token = oauth.fetch_token('https://www.inoreader.com/oauth2/token', + authorization_response=request.url, + client_secret=app_key) + queue.put(token) + queue.task_done() + return 'Done.' + + func = partial(app.run, port=8080, debug=False) + threading.Thread(target=func, daemon=True).start() + + os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1' + authorization_url, ret_state = oauth.authorization_url('https://www.inoreader.com/oauth2/auth') + if state != ret_state: + LOGGER.error("Server return bad state") + sys.exit(1) + + token = None + print('Open the link to authorize access:', authorization_url) + while True: + token = queue.get() + if token: + break + + queue.join() + if token: + config.app_id = app_id + config.app_key = app_key + config.access_token = token['access_token'] + config.refresh_token = token['refresh_token'] + config.expires_at = token['expires_at'] + config.save() + print("Login successfully, tokens are saved in config file %s" % config.config_file) else: - LOGGER.info("Login failed: Wrong username or password") + print("Login failed, please check your environment or try again later.") sys.exit(1) From 199aadcabe3fc696d0254d8f6436be6665c08035 Mon Sep 17 00:00:00 2001 From: Linusp Date: Sun, 17 Nov 2019 19:38:00 +0800 Subject: [PATCH 04/16] add method `parse_response` to handle errors --- inoreader/client.py | 69 ++++++++++++++++++--------------------------- 1 file changed, 28 insertions(+), 41 deletions(-) diff --git a/inoreader/client.py b/inoreader/client.py index 3bf6ffd..da3233f 100644 --- a/inoreader/client.py +++ b/inoreader/client.py @@ -51,6 +51,15 @@ def check_token(self): if now >= self.expires_at: self.refresh_access_token() + @staticmethod + def parse_response(response, json_data=True): + if response.status_code == 401: + raise NotLoginError + elif response.status_code != 200: + raise APIError(response.text) + + return response.json() if json_data else response.text + def refresh_access_token(self): url = urljoin(BASE_URL, self.TOKEN_PATH) payload = { @@ -59,8 +68,7 @@ def refresh_access_token(self): 'grant_type': 'refresh_token', 'refresh_token': self.refresh_token, } - resp = requests.post(url, json=payload) - response = resp.json() + response = self.parse_response(requests.post(url, json=payload)) self.access_token = response['access_token'] self.refresh_token = response['refresh_token'] self.expires_at = datetime.now().timestamp() + response['expires_in'] @@ -76,23 +84,17 @@ def userinfo(self): self.check_token() url = urljoin(BASE_URL, 'user-info') - resp = self.session.post(url) - if resp.status_code != 200: - raise APIError(resp.text) - - return resp.json() + return self.parse_response(self.session.post(url)) def get_folders(self): self.check_token() url = urljoin(BASE_URL, 'tag/list') params = {'types': 1, 'counts': 1} - resp = self.session.post(url, params=params) - if resp.status_code != 200: - raise APIError(resp.text) + response = self.parse_response(self.session.post(url, params=params)) folders = [] - for item in resp.json()['tags']: + for item in response['tags']: if item.get('type') != 'folder': continue @@ -107,12 +109,10 @@ def get_tags(self): url = urljoin(BASE_URL, 'tag/list') params = {'types': 1, 'counts': 1} - resp = self.session.post(url, params=params) - if resp.status_code != 200: - raise APIError(resp.text) + response = self.parse_response(self.session.post(url, params=params)) tags = [] - for item in resp.json()['tags']: + for item in response['tags']: if item.get('type') != 'tag': continue @@ -126,11 +126,8 @@ def get_subscription_list(self): self.check_token() url = urljoin(BASE_URL, 'subscription/list') - resp = self.session.get(url) - if resp.status_code != 200: - raise APIError(resp.text) - - for item in resp.json()['subscriptions']: + response = self.parse_response(self.session.get(url)) + for item in response['subscriptions']: yield Subscription.from_json(item) def get_stream_contents(self, stream_id, c=''): @@ -151,14 +148,11 @@ def __get_stream_contents(self, stream_id, continuation=''): 'c': continuation, 'output': 'json' } - resp = self.session.post(url, params=params) - if resp.status_code != 200: - raise APIError(resp.text) - - if 'continuation' in resp.json(): - return resp.json()['items'], resp.json()['continuation'] + response = self.parse_response(self.session.post(url, params=params)) + if 'continuation' in response(): + return response['items'], response['continuation'] else: - return resp.json()['items'], None + return response['items'], None def fetch_unread(self, folder=None, tags=None): self.check_token() @@ -174,11 +168,8 @@ def fetch_unread(self, folder=None, tags=None): 'c': str(uuid4()) } - resp = self.session.post(url, params=params) - if resp.status_code != 200: - raise APIError(resp.text) - - for data in resp.json()['items']: + response = self.parse_response(self.session.post(url, params=params)) + for data in response['items']: categories = set([ category.split('/')[-1] for category in data.get('categories', []) if category.find('label') > 0 @@ -187,13 +178,11 @@ def fetch_unread(self, folder=None, tags=None): continue yield Article.from_json(data) - continuation = resp.json().get('continuation') + continuation = response.get('continuation') while continuation: params['c'] = continuation - resp = self.session.post(url, params=params) - if resp.status_code != 200: - raise APIError(resp.text) - for data in resp.json()['items']: + response = self.parse_response(self.session.post(url, params=params)) + for data in response['items']: categories = set([ category.split('/')[-1] for category in data.get('categories', []) if category.find('label') > 0 @@ -201,7 +190,7 @@ def fetch_unread(self, folder=None, tags=None): if tags and not categories.issuperset(set(tags)): continue yield Article.from_json(data) - continuation = resp.json().get('continuation') + continuation = response.get('continuation') def add_general_label(self, articles, label): self.check_token() @@ -213,9 +202,7 @@ def add_general_label(self, articles, label): 'a': label, 'i': [articles[idx].id for idx in range(start, end)] } - resp = self.session.post(url, params=params) - if resp.status_code != 200: - raise APIError(resp.text) + self.parse_response(self.session.post(url, params=params), json_data=False) def add_tag(self, articles, tag): self.add_general_label(articles, 'user/-/label/{}'.format(tag)) From 86ffe53b90e5181ad8925f8337d6c5ecf162ca61 Mon Sep 17 00:00:00 2001 From: Linusp Date: Sun, 17 Nov 2019 20:23:43 +0800 Subject: [PATCH 05/16] define url paths and tag names as class variables --- inoreader/client.py | 43 ++++++++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/inoreader/client.py b/inoreader/client.py index da3233f..78045a0 100644 --- a/inoreader/client.py +++ b/inoreader/client.py @@ -26,6 +26,18 @@ class InoreaderClient(object): # paths TOKEN_PATH = '/oauth2/token' + USER_INFO_PATH = 'user-info' + TAG_LIST_PATH = 'tag/list' + SUBSCRIPTION_LIST_PATH = 'subscription/list' + STREAM_CONTENTS_PATH = 'stream/contents/' + EDIT_TAG_PATH = 'edit-tag' + + # tags + GENERAL_TAG_TEMPLATE = 'user/-/label/{}' + READ_TAG = 'user/-/state/com.google/read' + STARRED_TAG = 'user/-/state/com.google/starred' + LIKED_TAG = 'user/-/state/com.google/like' + BROADCAST_TAG = 'user/-/state/com.google/broadcast' def __init__(self, app_id, app_key, access_token, refresh_token, expires_at, userid=None, config_manager=None): @@ -83,13 +95,13 @@ def refresh_access_token(self): def userinfo(self): self.check_token() - url = urljoin(BASE_URL, 'user-info') + url = urljoin(BASE_URL, self.USER_INFO_PATH) return self.parse_response(self.session.post(url)) def get_folders(self): self.check_token() - url = urljoin(BASE_URL, 'tag/list') + url = urljoin(BASE_URL, self.TAG_LIST_PATH) params = {'types': 1, 'counts': 1} response = self.parse_response(self.session.post(url, params=params)) @@ -107,7 +119,7 @@ def get_folders(self): def get_tags(self): self.check_token() - url = urljoin(BASE_URL, 'tag/list') + url = urljoin(BASE_URL, self.TAG_LIST_PATH) params = {'types': 1, 'counts': 1} response = self.parse_response(self.session.post(url, params=params)) @@ -125,7 +137,7 @@ def get_tags(self): def get_subscription_list(self): self.check_token() - url = urljoin(BASE_URL, 'subscription/list') + url = urljoin(BASE_URL, self.SUBSCRIPTION_LIST_PATH) response = self.parse_response(self.session.get(url)) for item in response['subscriptions']: yield Subscription.from_json(item) @@ -141,7 +153,7 @@ def get_stream_contents(self, stream_id, c=''): def __get_stream_contents(self, stream_id, continuation=''): self.check_token() - url = urljoin(BASE_URL, 'stream/contents/' + quote_plus(stream_id)) + url = urljoin(BASE_URL, self.STREAM_CONTENTS_PATH + quote_plus(stream_id)) params = { 'n': 50, # default 20, max 1000 'r': '', @@ -157,16 +169,13 @@ def __get_stream_contents(self, stream_id, continuation=''): def fetch_unread(self, folder=None, tags=None): self.check_token() - url = urljoin(BASE_URL, 'stream/contents/') + url = urljoin(BASE_URL, self.STREAM_CONTENTS_PATH) if folder: url = urljoin( url, - quote_plus('user/{}/label/{}'.format(self.userid, folder)) + quote_plus(self.GENERAL_TAG_TEMPLATE.format(folder)) ) - params = { - 'xt': 'user/{}/state/com.google/read'.format(self.userid), - 'c': str(uuid4()) - } + params = {'xt': self.READ_TAG, 'c': str(uuid4())} response = self.parse_response(self.session.post(url, params=params)) for data in response['items']: @@ -195,7 +204,7 @@ def fetch_unread(self, folder=None, tags=None): def add_general_label(self, articles, label): self.check_token() - url = urljoin(BASE_URL, 'edit-tag') + url = urljoin(BASE_URL, self.EDIT_TAG_PATH) for start in range(0, len(articles), 10): end = min(start + 10, len(articles)) params = { @@ -205,16 +214,16 @@ def add_general_label(self, articles, label): self.parse_response(self.session.post(url, params=params), json_data=False) def add_tag(self, articles, tag): - self.add_general_label(articles, 'user/-/label/{}'.format(tag)) + self.add_general_label(articles, self.GENERAL_TAG_TEMPLATE.format(tag)) def mark_as_read(self, articles): - self.add_general_label(articles, 'user/-/state/com.google/read') + self.add_general_label(articles, self.READ_TAG) def mark_as_starred(self, articles): - self.add_general_label(articles, 'user/-/state/com.google/starred') + self.add_general_label(articles, self.STARRED_TAG) def mark_as_liked(self, articles): - self.add_general_label(articles, 'user/-/state/com.google/like') + self.add_general_label(articles, self.LIKED_TAG) def broadcast(self, articles): - self.add_general_label(articles, 'user/-/state/com.google/broadcast') + self.add_general_label(articles, self.BROADCAST_TAG) From 122457624c0e68a2ae717ea51a5a5a9dda72db6c Mon Sep 17 00:00:00 2001 From: Linusp Date: Sun, 17 Nov 2019 20:27:29 +0800 Subject: [PATCH 06/16] remove user id from client --- inoreader/client.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/inoreader/client.py b/inoreader/client.py index 78045a0..849f1b5 100644 --- a/inoreader/client.py +++ b/inoreader/client.py @@ -40,7 +40,7 @@ class InoreaderClient(object): BROADCAST_TAG = 'user/-/state/com.google/broadcast' def __init__(self, app_id, app_key, access_token, refresh_token, - expires_at, userid=None, config_manager=None): + expires_at, config_manager=None): self.app_id = app_id self.app_key = app_key self.access_token = access_token @@ -52,11 +52,7 @@ def __init__(self, app_id, app_key, access_token, refresh_token, 'AppKey': self.app_key, 'Authorization': 'Bearer {}'.format(self.access_token) }) - self.userid = userid or self.userinfo()['userId'] self.config_manager = config_manager - if self.userid and self.config_manager and not self.config_manager.user_id: - self.config_manager.user_id = self.userid - self.config_manager.save() def check_token(self): now = datetime.now().timestamp() From 335e17e846a87e07d2ff5f2a1e00991b4e437441 Mon Sep 17 00:00:00 2001 From: Linusp Date: Sun, 17 Nov 2019 20:27:42 +0800 Subject: [PATCH 07/16] remove user id from cli --- inoreader/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inoreader/main.py b/inoreader/main.py index 177d093..ea94c20 100644 --- a/inoreader/main.py +++ b/inoreader/main.py @@ -74,7 +74,7 @@ def get_client(): client = InoreaderClient( config.app_id, config.app_key, config.access_token, config.refresh_token, - config.expires_at, userid=config.user_id, config_manager=config + config.expires_at, config_manager=config ) return client From ca0aa741f2a4438dbff8990097af7233cacf3243 Mon Sep 17 00:00:00 2001 From: Linusp Date: Sun, 17 Nov 2019 20:28:27 +0800 Subject: [PATCH 08/16] update cli logging config --- inoreader/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inoreader/main.py b/inoreader/main.py index ea94c20..edc47a9 100644 --- a/inoreader/main.py +++ b/inoreader/main.py @@ -40,7 +40,7 @@ 'version': 1, 'formatters': { 'simple': { - 'format': '%(asctime)s - %(filename)s:%(lineno)s: %(message)s', + 'format': '%(asctime)s - %(message)s', } }, 'handlers': { From f7d91964f5adec178b84571b4b3340914ab8208a Mon Sep 17 00:00:00 2001 From: Linusp Date: Sun, 17 Nov 2019 20:37:46 +0800 Subject: [PATCH 09/16] use logging instead of print in login command --- inoreader/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/inoreader/main.py b/inoreader/main.py index edc47a9..96ff633 100644 --- a/inoreader/main.py +++ b/inoreader/main.py @@ -142,9 +142,9 @@ def redirect(): config.refresh_token = token['refresh_token'] config.expires_at = token['expires_at'] config.save() - print("Login successfully, tokens are saved in config file %s" % config.config_file) + LOGGER.info("Login successfully, tokens are saved in config file %s", config.config_file) else: - print("Login failed, please check your environment or try again later.") + LOGGER.warning("Login failed, please check your environment or try again later.") sys.exit(1) From 7f80b0aabd77c1882fcb24c3fc1a0236025b8a94 Mon Sep 17 00:00:00 2001 From: Linusp Date: Sun, 17 Nov 2019 20:43:45 +0800 Subject: [PATCH 10/16] pretty print results of command `list-folders` and `list-tags` --- inoreader/main.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/inoreader/main.py b/inoreader/main.py index 96ff633..a4cacc6 100644 --- a/inoreader/main.py +++ b/inoreader/main.py @@ -16,6 +16,7 @@ import yaml import click +from tabulate import tabulate from flask import Flask, request from requests_oauthlib import OAuth2Session @@ -153,9 +154,12 @@ def list_folders(): """List all folders""" client = get_client() res = client.get_folders() - print("unread\tfolder") + + output_info = [["Folder", "Unread Count"]] for item in res: - print("{}\t{}".format(item['unread_count'], item['name'])) + output_info.append([item['name'], item['unread_count']]) + + print(tabulate(output_info, headers='firstrow', tablefmt="github")) @main.command("list-tags") @@ -163,8 +167,12 @@ def list_tags(): """List all tags""" client = get_client() res = client.get_tags() + + output_info = [["Tag", "Unread Count"]] for item in res: - print("{}\t{}".format(item['unread_count'], item['name'])) + output_info.append([item['name'], item['unread_count']]) + + print(tabulate(output_info, headers='firstrow', tablefmt="github")) @main.command("fetch-unread") From 65212587ee538711172fe39765a965ae275ca614 Mon Sep 17 00:00:00 2001 From: Linusp Date: Sun, 17 Nov 2019 20:50:05 +0800 Subject: [PATCH 11/16] optimize command `fetch-unread`: add link in results --- inoreader/main.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/inoreader/main.py b/inoreader/main.py index a4cacc6..1843714 100644 --- a/inoreader/main.py +++ b/inoreader/main.py @@ -195,19 +195,30 @@ def fetch_unread(folder, tags, outfile, out_format): LOGGER.info("fetched %d articles", idx) title = article.title text = article.text + link = article.link if out_format == 'json': - print(json.dumps({'title': title, 'content': text}, ensure_ascii=False), file=fout) + print(json.dumps({'title': title, 'content': text, 'url': link}, ensure_ascii=False), + file=fout) elif out_format == 'csv': - writer.writerow([title, text]) + writer.writerow([link, title, text]) elif out_format == 'plain': print('TITLE: {}'.format(title), file=fout) + print("LINK: {}".format(link), file=fout) print("CONTENT: {}".format(text), file=fout) print(file=fout) elif out_format == 'markdown': - print('# {}\n'.format(title), file=fout) + if link: + print('# [{}]({})\n'.format(title, link), file=fout) + else: + print('# {}\n'.format(title), file=fout) print(text + '\n', file=fout) elif out_format == 'org-mode': - print('* {}\n'.format(title), file=fout) + if link: + title = title.replace('[', '_').replace(']', '_') + print('* [[{}][{}]]\n'.format(link, title), + file=fout) + else: + print('* {}\n'.format(title), file=fout) print(text + '\n', file=fout) LOGGER.info("fetched %d articles and saved them in %s", idx + 1, outfile) From a2db5a4f58afa1d87c74a71c04327bcdb4e248ff Mon Sep 17 00:00:00 2001 From: Linusp Date: Sun, 17 Nov 2019 21:11:23 +0800 Subject: [PATCH 12/16] add error handling for commands --- inoreader/main.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/inoreader/main.py b/inoreader/main.py index 1843714..82131be 100644 --- a/inoreader/main.py +++ b/inoreader/main.py @@ -10,7 +10,7 @@ import threading from queue import Queue from uuid import uuid4 -from functools import partial +from functools import partial, wraps from logging.config import dictConfig from collections import defaultdict, Counter @@ -23,7 +23,7 @@ from inoreader import InoreaderClient from inoreader.filter import get_filter from inoreader.sim import sim_of, InvIndex -from inoreader.exception import NotLoginError +from inoreader.exception import NotLoginError, APIError from inoreader.config import InoreaderConfigManager from inoreader.consts import DEFAULT_APPID, DEFAULT_APPKEY @@ -80,6 +80,22 @@ def get_client(): return client +def catch_error(func): + + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except NotLoginError: + print('Error: Please login first!') + sys.exit(1) + except APIError as exception: + print("Error:", str(exception)) + sys.exit(1) + + return wrapper + + @click.group(context_settings=dict(help_option_names=['-h', '--help'])) def main(): pass @@ -150,6 +166,7 @@ def redirect(): @main.command("list-folders") +@catch_error def list_folders(): """List all folders""" client = get_client() @@ -163,6 +180,7 @@ def list_folders(): @main.command("list-tags") +@catch_error def list_tags(): """List all tags""" client = get_client() @@ -183,6 +201,7 @@ def list_tags(): type=click.Choice(['json', 'csv', 'plain', 'markdown', 'org-mode']), default='json', help='Format of output file, default: json') +@catch_error def fetch_unread(folder, tags, outfile, out_format): """Fetch unread articles""" client = get_client() @@ -253,6 +272,7 @@ def apply_action(articles, client, action, tags): @main.command("filter") @click.option("-r", "--rules-file", required=True, help='YAML file with your rules') +@catch_error def filter_articles(rules_file): """Select articles and do something""" client = get_client() @@ -329,6 +349,7 @@ def filter_articles(rules_file): @click.option("--out-format", type=click.Choice(["json", "csv"]), default="csv", help="Format of output, default: csv") +@catch_error def get_subscriptions(outfile, folder, out_format): """Get your subscriptions""" client = get_client() @@ -366,6 +387,7 @@ def get_subscriptions(outfile, folder, out_format): type=click.Choice(["json", "csv", 'plain', 'markdown', 'org-mode']), default="json", help="Format of output, default: json") +@catch_error def fetch_articles(outfile, stream_id, out_format): """Fetch articles by stream id""" client = get_client() @@ -406,6 +428,7 @@ def fetch_articles(outfile, stream_id, out_format): @click.option("-f", "--folder", help="Folder you want to deduplicate") @click.option("-t", "--thresh", type=float, default=0.8, help="Minimum similarity score") +@catch_error def dedupe(folder, thresh): """Deduplicate articles""" client = get_client() From 18664f06c5b359c2ec7e16fee0c0159f391abdc3 Mon Sep 17 00:00:00 2001 From: Linusp Date: Sun, 17 Nov 2019 21:11:46 +0800 Subject: [PATCH 13/16] add deps: `requests-oauthlib`, `flask` and `tabulate` --- requirements.txt | 3 +++ setup.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/requirements.txt b/requirements.txt index 79b0615..5fd7401 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,5 +2,8 @@ lxml requests PyYAML click +flask +requests-oauthlib +tabulate flake8 pytest diff --git a/setup.py b/setup.py index f5f51b3..d78c668 100644 --- a/setup.py +++ b/setup.py @@ -10,6 +10,9 @@ 'requests', 'PyYAML', 'click', + 'requests-oauthlib', + 'flask', + 'tabulate', ] From 2121d59ec95c371058dfeeec27a74d1579ac6620 Mon Sep 17 00:00:00 2001 From: Linusp Date: Sun, 17 Nov 2019 21:19:43 +0800 Subject: [PATCH 14/16] bump version --- CHANGELOG.md | 13 +++++++++++++ setup.py | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d435e85..c548819 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # CHANGELOG + +## v0.4.0 + +Added + +- New Class: `InoreaderConfigManager` for config management + +Changed + +- Use OAuth2.0 authentication instead of user authentication with password +- Optimized code of `InoreaderClient` +- Optimized results of commands + ## v0.3.0 Added diff --git a/setup.py b/setup.py index d78c668..580509e 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import setup, find_packages -VERSION = '0.3.0' +VERSION = '0.4.0' REQS = [ 'lxml', 'requests', From 55b15536bfa1145bc5d224d9ecffb9db4a9f3b8b Mon Sep 17 00:00:00 2001 From: Linusp Date: Sun, 17 Nov 2019 21:19:49 +0800 Subject: [PATCH 15/16] fix typo --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c548819..72944bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,7 +48,7 @@ Added - `InoreaderClient.mark_as_read` - `InoreaderClient.mark_as_starred` - `InoreaderClient.mark_as_liked` - - `InoreaderClient.boradcast` + - `InoreaderClient.broadcast` - New command `filter` From d6a8de775c2100ce7ca3057cfef28b1438c85116 Mon Sep 17 00:00:00 2001 From: Linusp Date: Sun, 17 Nov 2019 21:25:43 +0800 Subject: [PATCH 16/16] update README --- README.md | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 4455a7d..8a2ceb0 100644 --- a/README.md +++ b/README.md @@ -21,26 +21,10 @@ pip install git+https://github.com/Linusp/python-inoreader.git ## Usage -1. [Register your application](https://www.inoreader.com/developers/register-app) - -2. Set `appid` and `appkey` in your system, you can set them with environment variables like - - ```shell - export INOREADER_APP_ID = 'your-app-id' - export INOREADER_APP_KEY = 'your-app-key' - ``` - - or write them in `$HOME/.inoreader`, e.g.: - ```shell - [auth] - appid = your-app-id - appkey = your-app-key - ``` - -3. Login to your Inoreader account +1. Login to your Inoreader account ```shell inoreader login ``` -3. Use the command line tool `inoreader` to do something, run `inoreader --help` for details +2. Use the command line tool `inoreader` to do something, run `inoreader --help` for details