├── tests ├── __init__.py ├── local_config.py.default ├── config.py ├── test_special_feeds.py └── test_auth.py ├── MANIFEST.in ├── .gitignore ├── tox.ini ├── AUTHORS.md ├── libgreader ├── __init__.py ├── url.py ├── googlereader.py ├── items.py └── auth.py ├── LICENSE.txt ├── setup.py ├── HISTORY.md ├── README.md └── USAGE.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md HISTORY.md LICENSE.txt 2 | include tox.ini 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | *.swp 4 | bin 5 | build/ 6 | dist/ 7 | .tox/ 8 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py26,py27,py33 4 | 5 | [testenv] 6 | deps = 7 | requests 8 | commands = 9 | python setup.py test 10 | 11 | [testenv:py26] 12 | deps = 13 | requests 14 | unittest2 15 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | libgreader is written and maintained by Matthew Behrens and various contributors: 2 | 3 | Development Lead 4 | 5 | - Matthew Behrens 6 | 7 | Patches and Suggestions 8 | 9 | - Stephane Angel aka Twidi 10 | - Wu Yuntao 11 | - Valentin Alexeev 12 | -------------------------------------------------------------------------------- /tests/local_config.py.default: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | libG(oogle)Reader 6 | Copyright (C) 2010 Matt Behrens http://asktherelic.com 7 | 8 | Python library for working with the unofficial Google Reader API. 9 | 10 | """ 11 | 12 | #OAuth2 13 | # requires API access tokens from google 14 | # available at https://code.google.com/apis/console/ 15 | # -goto "API Access" and generate a new client id for web applications 16 | client_id = '' 17 | client_secret = '' 18 | redirect_url = '' 19 | -------------------------------------------------------------------------------- /libgreader/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # libgreader 4 | # Copyright (C) 2012 Matt Behrens 5 | # Python library for the Google Reader API 6 | 7 | __author__ = "Matt Behrens " 8 | __version__ = "0.8.0" 9 | __copyright__ = "Copyright (C) 2012 Matt Behrens" 10 | 11 | try: 12 | import requests 13 | except ImportError: 14 | # Will occur during setup.py install 15 | pass 16 | else: 17 | from .googlereader import GoogleReader 18 | from .auth import AuthenticationMethod, ClientAuthMethod, OAuthMethod, OAuth2Method 19 | from .items import * 20 | from .url import ReaderUrl 21 | -------------------------------------------------------------------------------- /tests/config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | libG(oogle)Reader 6 | Copyright (C) 2010 Matt Behrens http://asktherelic.com 7 | 8 | Python library for working with the unofficial Google Reader API. 9 | 10 | Unit tests for oauth and ClientAuthMethod in libgreader. 11 | """ 12 | 13 | #ClientAuthMethod 14 | #User account I created for testing 15 | # username = 'libgreadertest@gmail.com' 16 | # password = 'libgreadertestlibgreadertest' 17 | # firstname = 'Foo' 18 | 19 | #OAuth2 20 | # requires API access tokens from google 21 | # available at https://code.google.com/apis/console/ 22 | # -goto "API Access" and generate a new client id for web applications 23 | try: 24 | from .local_config import * 25 | except Exception: 26 | pass 27 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2013 Matt Behrens 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 18 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 19 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 20 | OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import setup, find_packages 3 | import libgreader 4 | 5 | setup( 6 | name = 'libgreader', 7 | version = libgreader.__version__, 8 | description = 'Library for working with the Google Reader API', 9 | long_description = open('README.md').read() + '\n\n' + open('HISTORY.md').read(), 10 | 11 | author = libgreader.__author__, 12 | author_email = 'askedrelic@gmail.com', 13 | url = 'https://github.com/askedrelic/libgreader', 14 | license = open("LICENSE.txt").read(), 15 | 16 | install_requires = ['requests>=1.0',], 17 | 18 | packages = ['libgreader'], 19 | test_suite = 'tests', 20 | 21 | classifiers = ( 22 | 'Intended Audience :: Developers', 23 | 'Natural Language :: English', 24 | 'Operating System :: OS Independent', 25 | 'Programming Language :: Python', 26 | 'Programming Language :: Python :: 2.6', 27 | 'Programming Language :: Python :: 2.7', 28 | 'Programming Language :: Python :: 3.3', 29 | 'Topic :: Internet :: WWW/HTTP', 30 | 'Topic :: Software Development :: Libraries :: Python Modules', 31 | ), 32 | ) 33 | -------------------------------------------------------------------------------- /libgreader/url.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | class ReaderUrl(object): 4 | READER_BASE_URL = 'https://www.google.com/reader/api' 5 | API_URL = READER_BASE_URL + '/0/' 6 | 7 | ACTION_TOKEN_URL = API_URL + 'token' 8 | USER_INFO_URL = API_URL + 'user-info' 9 | 10 | SUBSCRIPTION_LIST_URL = API_URL + 'subscription/list' 11 | SUBSCRIPTION_EDIT_URL = API_URL + 'subscription/edit' 12 | UNREAD_COUNT_URL = API_URL + 'unread-count' 13 | 14 | CONTENT_PART_URL = 'stream/contents/' 15 | CONTENT_BASE_URL = API_URL + CONTENT_PART_URL 16 | SPECIAL_FEEDS_PART_URL = 'user/-/state/com.google/' 17 | 18 | READING_LIST = 'reading-list' 19 | READ_LIST = 'read' 20 | KEPTUNREAD_LIST = 'kept-unread' 21 | STARRED_LIST = 'starred' 22 | SHARED_LIST = 'broadcast' 23 | NOTES_LIST = 'created' 24 | FRIENDS_LIST = 'broadcast-friends' 25 | SPECIAL_FEEDS = (READING_LIST, READ_LIST, KEPTUNREAD_LIST, 26 | STARRED_LIST, SHARED_LIST, FRIENDS_LIST, 27 | NOTES_LIST,) 28 | 29 | FEED_URL = CONTENT_BASE_URL 30 | CATEGORY_URL = CONTENT_BASE_URL + 'user/-/label/' 31 | 32 | EDIT_TAG_URL = API_URL + 'edit-tag' 33 | TAG_READ = 'user/-/state/com.google/read' 34 | TAG_STARRED = 'user/-/state/com.google/starred' 35 | TAG_SHARED = 'user/-/state/com.google/broadcast' 36 | 37 | MARK_ALL_READ_URL = API_URL + 'mark-all-as-read' 38 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | #History 2 | 3 | ##v0.8.0 - 4 | - Make API endpoint configurable 5 | 6 | ##v0.7.0 - 2013/03/18 7 | - Now requires Requests > 1.0 (Requests now used for all HTTP requests) 8 | - Python 3.3 Compatibility (Test suite passes for Python 2.6, 2.7, and 3.3) 9 | - Deprecate OAuth 1.0 auth method (Google deprecated it April 20, 2012 https://developers.google.com/accounts/docs/OAuth ) 10 | - RIP Google Reader :( 11 | 12 | ##v0.6.3 - 2013/02/20 13 | - Add support for add/remove tag transaction abi- lity, to mass edit tags on on an Item 14 | - Add since/until argument support for many Container calls 15 | - Add support for loadLimit argument with feed Containers loadItems() call 16 | 17 | ##v0.6.2 - 2012/10/11 18 | - Fix broken post() method with OAuth2 auth, https://github.com/askedrelic/libgreader/issues/11 19 | 20 | ##v0.6.1 - 2012/08/13 21 | - cleanup sdist package contents, to not include tests 22 | - Remove httplib2 as a require import unless you are using GAPDecoratorAuthMethod 23 | 24 | ##v0.6.0 - 2012/08/10 25 | * OAuth2 support 26 | * Deprecating OAuth support 27 | * Added auth support for Google App Engine with GAPDecoratorAuthMethod 28 | * Internal code re-organization 29 | 30 | ##v0.5 - 2010/12/29 31 | * Added project to PyPi, moved to real Python project structure 32 | * Style cleanup, more tests 33 | 34 | ##v0.4 - 2010/08/10 35 | Lot of improvements : 36 | 37 | * Manage special feeds (reading-list, shared, starred, friends...) 38 | * Manage categories (get all items, mark as read) 39 | * Manage feeds (get items, unread couts, mark as read, "fetch more") 40 | * Manage items (get and mark read, star, share) 41 | 42 | and: 43 | 44 | * oauth2 not required if you don't use it 45 | * replacing all xml calls by json ones 46 | 47 | ##v0.3 - 2010/03/07 48 | * All requests to Google use HTTPS 49 | * CLeaned up formatting, should mostly meet PEP8 50 | * Fixed random unicode issues 51 | * Added licensing 52 | 53 | ##v0.2 - 2009/10/27 54 | * Moved all get requests to private convenience method 55 | * Added a few more basic data calls 56 | 57 | ##v0.1 - 2009/10/27 58 | * Connects to GR and receives auth token correctly. 59 | * Pulls down subscription list. 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # libgreader readme 2 | libgreader is a Python library for authenticating and interacting with the unofficial Google Reader API. It currently supports all major user authentication methods (ClientLogin, OAuth2) and aims to simplify the many features that Google Reader offers. RSS ain't dead yet (but Google Reader may be)! 3 | 4 | Licensed under the MIT license: [http://www.opensource.org/licenses/mit-license.php]() 5 | 6 | ## Current Status 7 | As of March 2013, Google plans to shutdown down Google Reader on July 1st, 2013, which kind of makes this library not so useful. 8 | 9 | There are plans to recreate the Google Reader API in several open source projects, so perhaps this library could be extended to support multiple APIs. At present, the author is waiting to see how things turn out and what course of action would make the most sense. 10 | 11 | 12 | ## Features 13 | 14 | * Support for all Google recommended authentication methods, for easy integration with existing web or desktop applications 15 | * Explanation of most of the Google Reader API endpoints, which Google has never really opened up 16 | * Convenient functions and models for working with those endpoints 17 | * A modest integration test suite! 18 | 19 | ## Usage 20 | 21 | It's as simple as: 22 | 23 | 24 | >>> from libgreader import GoogleReader, ClientAuthMethod, Feed 25 | >>> auth = ClientAuthMethod('YOUR USERNAME','YOUR PASSWORD') 26 | >>> reader = GoogleReader(auth) 27 | >>> print reader.getUserInfo() 28 | {u'userName': u'Foo', u'userEmail': u'libgreadertest@gmail.com', u'userId': u'16058940398976999581', u'userProfileId': u'100275409503040726101', u'isBloggerUser': False, u'signupTimeSec': 0, u'isMultiLoginEnabled': False}` 29 | 30 | For more examples with all of the authentication methods, see the [USAGE file](https://github.com/askedrelic/libgreader/blob/master/USAGE.md). 31 | 32 | ## Installation 33 | 34 | libgreader is on pypi at [http://pypi.python.org/pypi/libgreader/](http://pypi.python.org/pypi/libgreader/) 35 | 36 | $ pip install libgreader 37 | 38 | or 39 | 40 | $ easy_install libgreader 41 | 42 | ## Testing and Contribution 43 | 44 | Want to test it out or contribute some changes? 45 | 46 | First, fork the repository on Github to make changes on your private branch. 47 | Then, create a dev environment using a virtualenv: 48 | 49 | $ pip install virtualenvwrapper 50 | $ mkvirtualenv venv-libgreader --no-site-packages 51 | 52 | Checkout your fork and then run the tests: 53 | 54 | $ python setup.py test 55 | 56 | Now hack away! Write tests which show that a bug was fixed or that the feature works as expected. Then send a pull request and bug me until it gets merged in and published. 57 | 58 | 59 | ## Thanks 60 | 61 | Originally created with help from: 62 | 63 | [http://blog.martindoms.com/2009/08/15/using-the-google-reader-api-part-1/]() 64 | 65 | [http://code.google.com/p/pyrfeed/wiki/GoogleReaderAPI]() 66 | 67 | [http://groups.google.com/group/fougrapi]() 68 | 69 | Since then, [many have contributed to the development of libgreader](https://github.com/askedrelic/libgreader/blob/master/AUTHORS.md). 70 | -------------------------------------------------------------------------------- /tests/test_special_feeds.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | libG(oogle)Reader 6 | Copyright (C) 2010 Matt Behrens http://asktherelic.com 7 | 8 | Python library for working with the unofficial Google Reader API. 9 | 10 | Unit tests for feeds. 11 | """ 12 | 13 | try: 14 | import unittest2 as unittest 15 | except: 16 | import unittest 17 | 18 | from libgreader import GoogleReader, OAuthMethod, ClientAuthMethod, Feed, ItemsContainer, Item, BaseFeed, SpecialFeed, ReaderUrl 19 | import re 20 | import time 21 | 22 | from .config import * 23 | 24 | class TestSpecialFeeds(unittest.TestCase): 25 | def test_reading_list_exists(self): 26 | ca = ClientAuthMethod(username,password) 27 | reader = GoogleReader(ca) 28 | reader.makeSpecialFeeds() 29 | feeds = reader.getFeedContent(reader.getSpecialFeed(ReaderUrl.READING_LIST)) 30 | 31 | self.assertEqual(dict, type(feeds)) 32 | 33 | list_match = re.search('reading list in Google Reader', feeds['title']) 34 | self.assertTrue(list_match) 35 | 36 | def test_marking_read(self): 37 | ca = ClientAuthMethod(username,password) 38 | reader = GoogleReader(ca) 39 | container = SpecialFeed(reader, ReaderUrl.READING_LIST) 40 | container.loadItems() 41 | 42 | feed_item = container.items[0] 43 | self.assertTrue(feed_item.markRead()) 44 | self.assertTrue(feed_item.isRead()) 45 | 46 | def test_loading_item_count(self): 47 | ca = ClientAuthMethod(username,password) 48 | reader = GoogleReader(ca) 49 | container = SpecialFeed(reader, ReaderUrl.READING_LIST) 50 | container.loadItems(loadLimit=5) 51 | 52 | self.assertEqual(5, len(container.items)) 53 | self.assertEqual(5, container.countItems()) 54 | 55 | def test_subscribe_unsubscribe(self): 56 | ca = ClientAuthMethod(username,password) 57 | reader = GoogleReader(ca) 58 | 59 | slashdot = 'feed/http://rss.slashdot.org/Slashdot/slashdot' 60 | 61 | #unsubscribe always return true; revert feedlist state 62 | self.assertTrue(reader.unsubscribe(slashdot)) 63 | 64 | # now subscribe 65 | self.assertTrue(reader.subscribe(slashdot)) 66 | 67 | # wait for server to update 68 | time.sleep(1) 69 | reader.buildSubscriptionList() 70 | 71 | # test subscribe successful 72 | self.assertIn(slashdot, [x.id for x in reader.getSubscriptionList()]) 73 | 74 | def test_add_remove_single_feed_tag(self): 75 | ca = ClientAuthMethod(username,password) 76 | reader = GoogleReader(ca) 77 | container = SpecialFeed(reader, ReaderUrl.READING_LIST) 78 | container.loadItems() 79 | 80 | tag_name = 'test-single-tag' 81 | feed_1 = container.items[0] 82 | 83 | # assert tag doesn't exist yet 84 | self.assertFalse(any([tag_name in x for x in feed_1.data['categories']])) 85 | 86 | # add tag 87 | reader.addItemTag(feed_1, 'user/-/label/' + tag_name) 88 | 89 | #reload now 90 | container.clearItems() 91 | container.loadItems() 92 | feed_2 = container.items[0] 93 | 94 | # assert tag is in new 95 | self.assertTrue(any([tag_name in x for x in feed_2.data['categories']])) 96 | 97 | # remove tag 98 | reader.removeItemTag(feed_2, 'user/-/label/' + tag_name) 99 | 100 | #reload now 101 | container.clearItems() 102 | container.loadItems() 103 | feed_3 = container.items[0] 104 | 105 | # assert tag is removed 106 | self.assertFalse(any([tag_name in x for x in feed_3.data['categories']])) 107 | 108 | def test_transaction_add_feed_tags(self): 109 | ca = ClientAuthMethod(username,password) 110 | reader = GoogleReader(ca) 111 | container = SpecialFeed(reader, ReaderUrl.READING_LIST) 112 | container.loadItems() 113 | 114 | tags = ['test-transaction%s' % x for x in range(5)] 115 | feed_1 = container.items[0] 116 | 117 | reader.beginAddItemTagTransaction() 118 | for tag in tags: 119 | reader.addItemTag(feed_1, 'user/-/label/' + tag) 120 | reader.commitAddItemTagTransaction() 121 | 122 | #reload now 123 | container.clearItems() 124 | container.loadItems() 125 | feed_2 = container.items[0] 126 | 127 | # figure out if all tags were returned 128 | tags_exist = [any(map(lambda tag: tag in x, tags)) for x in feed_2.data['categories']] 129 | tag_exist_count = sum([1 for x in tags_exist if x]) 130 | self.assertEqual(5, tag_exist_count) 131 | 132 | if __name__ == '__main__': 133 | unittest.main() 134 | -------------------------------------------------------------------------------- /USAGE.md: -------------------------------------------------------------------------------- 1 | #Usage 2 | The library is currently broken into 2 parts: The Authentication class and the GoogleReader class. 3 | 4 | The Authentication class authenticates itself with Google and then provides a GET/POST method for making authenticated calls. 5 | Currently, ClientLogin, OAuth are supported. 6 | 7 | The GoogleReader class keeps track of user data and provides wrapper methods around known Reader urls. 8 | 9 | ##ClientLogin 10 | To get started using the ClientLogin auth type, create a new ClientAuthMethod class: 11 | 12 | ```python 13 | from libgreader import GoogleReader, ClientAuthMethod, Feed 14 | auth = ClientAuthMethod('USERNAME','PASSWORD') 15 | ``` 16 | 17 | Then setup GoogleReader: 18 | 19 | ```python 20 | reader = GoogleReader(auth) 21 | ``` 22 | 23 | Then make whatever requests you want: 24 | 25 | ```python 26 | print reader.getUserInfo() 27 | ``` 28 | 29 | ##OAuth 30 | The OAuth method is a bit more complicated, depending on whether you want to use a callback or not, and because oauth is just complicated. 31 | 32 | ###No Callback 33 | Send user to authorize with Google in a new window or JS lightbox, tell them to close the window when done authenicating 34 | 35 | The oauth key and secret are setup with Google for your domain [https://www.google.com/accounts/ManageDomains]() 36 | 37 | ```python 38 | from libgreader import GoogleReader, OAuthMethod, Feed 39 | auth = OAuthMethod(oauth_key, oauth_secret) 40 | ``` 41 | 42 | We want to internally set the request token 43 | 44 | ```python 45 | auth.setRequestToken() 46 | ``` 47 | 48 | Get the authorization URL for that request token, which you can link the user to or popup in a new window 49 | 50 | ```python 51 | auth_url = auth.buildAuthUrl() 52 | ``` 53 | 54 | After they have authorized you, set the internal access token, and then you should have access to the user's data 55 | 56 | ```python 57 | auth.setAccessToken() 58 | reader = GoogleReader(auth) 59 | print reader.getUserInfo() 60 | ``` 61 | 62 | ###Callback 63 | User goes to Google, authenticates, then is automatically redirected to your callback url without using a new window, a much more seamless user experience 64 | 65 | Same opening bit, you still need an oauth key and secret from Google 66 | 67 | ```python 68 | from libgreader import GoogleReader, OAuthMethod, Feed 69 | auth = OAuthMethod(oauth_key, oauth_secret) 70 | ``` 71 | 72 | Set the callback... 73 | 74 | ```python 75 | auth.setCallback("http://www.asktherelic.com/theNextStep") 76 | ``` 77 | 78 | Now the interesting thing with using a callback is that you must split up the process of authenticating the user and store their token data while they leave your site. Whether you use internal sessions or cookies is up to you, but you need access to the token_secret when the user returns from Google. 79 | 80 | ```python 81 | token, token_secret = auth.setAndGetRequestToken() 82 | auth_url = auth.buildAuthUrl() 83 | ``` 84 | 85 | So assume the user goes, authenticates you, and now they are returning to http://www.asktherelic.com/theNextStep with two query string variables, the token and the verifier. You can now finish authenticating them and access their data. 86 | 87 | ```python 88 | #get the token verifier here 89 | token_verifier = "" 90 | auth.setAccessTokenFromCallback(token, token_secret, token_verifier) 91 | reader = GoogleReader(auth) 92 | print reader.getUserInfo() 93 | ``` 94 | 95 | ##Using libgreader on Google AppEngine 96 | If you want to use libgreader on Google AppEngine it is easier to use the Google's API for Python library which 97 | contains implementation of OAuth2 especially designed for AppEngine. 98 | 99 | Here is a minimal way to implement it: 100 | 101 | ```python 102 | from google.appengine.ext.webapp.util import login_required 103 | 104 | from oauth2client.appengine import CredentialsProperty 105 | from oauth2client.appengine import StorageByKeyName 106 | from oauth2client.appengine import OAuth2WebServerFlow 107 | 108 | from libgreader import GoogleReader 109 | from libgreader.auth import GAPDecoratorAuthMethod 110 | 111 | GOOGLE_URL = 'https://accounts.google.com' 112 | AUTHORIZATION_URL = GOOGLE_URL + '/o/oauth2/auth' 113 | ACCESS_TOKEN_URL = GOOGLE_URL + '/o/oauth2/token' 114 | REDIRECT_URI = '' 115 | 116 | FLOW = OAuth2WebServerFlow( 117 | client_id='', 118 | client_secret='', 119 | scope=[ 120 | 'https://www.googleapis.com/auth/userinfo.email', 121 | 'https://www.googleapis.com/auth/userinfo.profile', 122 | 'https://www.google.com/reader/api/', 123 | ], 124 | redirect_uri=REDIRECT_URI, 125 | user_agent='', 126 | auth_uri=AUTHORIZATION_URL, 127 | token_uri=ACCESS_TOKEN_URL) 128 | 129 | class Credentials(db.Model): 130 | credentials = CredentialsProperty() 131 | 132 | 133 | #... Checking and obtaining credentials if needed 134 | class MainHandler(webapp2.RequestHandler): 135 | @login_required 136 | def get(self): 137 | user = users.get_current_user() 138 | 139 | # get stored credentials for current user from the Datastore 140 | credentials = StorageByKeyName(Credentials, user.user_id(), 'credentials').get() 141 | 142 | if credentials is None or credentials.invalid == True: 143 | # we are not authorized (=no credentials) create an authorization URL 144 | authorize_url = FLOW.step1_get_authorize_url(REDIRECT_URI) 145 | template_values = { 146 | 'authurl': authorize_url 147 | } 148 | # a courtsey message to user to ask for authorization. we can just redirect here if we want 149 | path = os.path.join(os.path.dirname(__file__), 'templates/template_authorize.html') 150 | self.response.out.write(template.render(path, template_values)) 151 | 152 | #... Using credentials: 153 | class SubscriptionListHandler(webapp2.RequestHandler): 154 | @login_required 155 | def get(self): 156 | user = users.get_current_user() 157 | 158 | if user: 159 | storage = StorageByKeyName(Credentials, user.user_id(), 'credentials') 160 | credentials = storage.get() 161 | 162 | # Use the new AuthMethod to decorate all the requests with correct credentials 163 | auth = GAPDecoratorAuthMethod(credentials) 164 | reader = GoogleReader(auth) 165 | reader.buildSubscriptionList() 166 | ``` 167 | -------------------------------------------------------------------------------- /tests/test_auth.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | libG(oogle)Reader 6 | Copyright (C) 2010 Matt Behrens http://asktherelic.com 7 | 8 | Python library for working with the unofficial Google Reader API. 9 | 10 | Unit tests for oauth and ClientAuthMethod in libgreader. 11 | 12 | """ 13 | 14 | try: 15 | import unittest2 as unittest 16 | except: 17 | import unittest 18 | 19 | from libgreader import GoogleReader, OAuthMethod, OAuth2Method, ClientAuthMethod, Feed 20 | import requests 21 | import re 22 | 23 | from .config import * 24 | 25 | class TestClientAuthMethod(unittest.TestCase): 26 | def test_ClientAuthMethod_login(self): 27 | ca = ClientAuthMethod(username,password) 28 | self.assertNotEqual(ca, None) 29 | 30 | def test_reader(self): 31 | ca = ClientAuthMethod(username,password) 32 | reader = GoogleReader(ca) 33 | self.assertNotEqual(reader, None) 34 | 35 | def test_bad_user_details(self): 36 | self.assertRaises(IOError, ClientAuthMethod, 'asdsa', '') 37 | 38 | def test_reader_user_info(self): 39 | ca = ClientAuthMethod(username,password) 40 | reader = GoogleReader(ca) 41 | info = reader.getUserInfo() 42 | self.assertEqual(dict, type(info)) 43 | self.assertEqual(firstname, info['userName']) 44 | 45 | 46 | #automated approval of oauth url 47 | #returns mechanize Response of the last "You have accepted" page 48 | def automated_oauth_approval(url): 49 | #general process is: 50 | # 1. assume user isn't logged in, so get redirected to google accounts 51 | # login page. login using test account credentials 52 | # 2. redirected back to oauth approval page. br.submit() should choose the 53 | # first submit on that page, which is the "Accept" button 54 | br = mechanize.Browser() 55 | br.open(url) 56 | br.select_form(nr=0) 57 | br["Email"] = username 58 | br["Passwd"] = password 59 | response1 = br.submit() 60 | br.select_form(nr=0) 61 | req2 = br.click(type="submit", nr=0) 62 | response2 = br.open(req2) 63 | return response2 64 | 65 | @unittest.skip('deprecated') 66 | class TestOAuth(unittest.TestCase): 67 | def test_oauth_login(self): 68 | auth = OAuthMethod(oauth_key, oauth_secret) 69 | self.assertNotEqual(auth, None) 70 | 71 | def test_getting_request_token(self): 72 | auth = OAuthMethod(oauth_key, oauth_secret) 73 | token, token_secret = auth.setAndGetRequestToken() 74 | url = auth.buildAuthUrl() 75 | response = automated_oauth_approval(url) 76 | self.assertNotEqual(-1,response.get_data().find('You have successfully granted')) 77 | 78 | def test_full_auth_process_without_callback(self): 79 | auth = OAuthMethod(oauth_key, oauth_secret) 80 | auth.setRequestToken() 81 | auth_url = auth.buildAuthUrl() 82 | response = automated_oauth_approval(auth_url) 83 | auth.setAccessToken() 84 | reader = GoogleReader(auth) 85 | 86 | info = reader.getUserInfo() 87 | self.assertEqual(dict, type(info)) 88 | self.assertEqual(firstname, info['userName']) 89 | 90 | def test_full_auth_process_with_callback(self): 91 | auth = OAuthMethod(oauth_key, oauth_secret) 92 | #must be a working callback url for testing 93 | auth.setCallback("http://www.asktherelic.com") 94 | token, token_secret = auth.setAndGetRequestToken() 95 | auth_url = auth.buildAuthUrl() 96 | 97 | #callback section 98 | #get response, which is a redirect to the callback url 99 | response = automated_oauth_approval(auth_url) 100 | query_string = urlparse.urlparse(response.geturl()).query 101 | #grab the verifier token from the callback url query string 102 | token_verifier = urlparse.parse_qs(query_string)['oauth_verifier'][0] 103 | 104 | auth.setAccessTokenFromCallback(token, token_secret, token_verifier) 105 | reader = GoogleReader(auth) 106 | 107 | info = reader.getUserInfo() 108 | self.assertEqual(dict, type(info)) 109 | self.assertEqual(firstname, info['userName']) 110 | 111 | 112 | #automate getting the approval token 113 | def mechanize_oauth2_approval(url): 114 | """ 115 | general process is: 116 | 1. assume user isn't logged in, so get redirected to google accounts 117 | login page. login using account credentials 118 | But, if the user has already granted access, the user is auto redirected without 119 | having to confirm again. 120 | 2. redirected back to oauth approval page. br.submit() should choose the 121 | first submit on that page, which is the "Accept" button 122 | 3. mechanize follows the redirect, and should throw 40X exception and 123 | we return the token 124 | """ 125 | br = mechanize.Browser() 126 | br.open(url) 127 | br.select_form(nr=0) 128 | br["Email"] = username 129 | br["Passwd"] = password 130 | try: 131 | response1 = br.submit() 132 | br.select_form(nr=0) 133 | response2 = br.submit() 134 | except Exception as e: 135 | #watch for 40X exception on trying to load redirect page 136 | pass 137 | callback_url = br.geturl() 138 | # split off the token in hackish fashion 139 | return callback_url.split('code=')[1] 140 | 141 | def automated_oauth2_approval(url): 142 | """ 143 | general process is: 144 | 1. assume user isn't logged in, so get redirected to google accounts 145 | login page. login using account credentials 146 | 2. get redirected to oauth approval screen 147 | 3. authorize oauth app 148 | """ 149 | auth_url = url 150 | headers = {'Referer': auth_url} 151 | 152 | s = requests.Session() 153 | r1 = s.get(auth_url) 154 | post_data = dict((x[0],x[1]) for x in re.findall('name="(.*?)".*?value="(.*?)"', str(r1.content), re.MULTILINE)) 155 | post_data['Email'] = username 156 | post_data['Passwd'] = password 157 | post_data['timeStmp'] = '' 158 | post_data['secTok'] = '' 159 | post_data['signIn'] = 'Sign in' 160 | post_data['GALX'] = s.cookies['GALX'] 161 | 162 | r2 = s.post('https://accounts.google.com/ServiceLoginAuth', data=post_data, headers=headers, allow_redirects=False) 163 | 164 | #requests is fucking up the url encoding and double encoding ampersands 165 | scope_url = r2.headers['location'].replace('amp%3B','') 166 | 167 | # now get auth screen 168 | r3 = s.get(scope_url) 169 | 170 | # unless we have already authed! 171 | if 'asktherelic' in r3.url: 172 | code = r3.url.split('=')[1] 173 | return code 174 | 175 | post_data = dict((x[0],x[1]) for x in re.findall('name="(.*?)".*?value="(.*?)"', str(r3.content))) 176 | post_data['submit_access'] = 'true' 177 | post_data['_utf8'] = '☃' 178 | 179 | # again, fucked encoding for amp; 180 | action_url = re.findall('action="(.*?)"', str(r3.content))[0].replace('amp;','') 181 | 182 | r4 = s.post(action_url, data=post_data, headers=headers, allow_redirects=False) 183 | code = r4.headers['Location'].split('=')[1] 184 | 185 | s.close() 186 | 187 | return code 188 | 189 | @unittest.skipIf("client_id" not in globals(), 'OAuth2 config not setup') 190 | class TestOAuth2(unittest.TestCase): 191 | def test_full_auth_and_access_userdata(self): 192 | auth = OAuth2Method(client_id, client_secret) 193 | auth.setRedirectUri(redirect_url) 194 | url = auth.buildAuthUrl() 195 | token = automated_oauth2_approval(url) 196 | auth.code = token 197 | auth.setAccessToken() 198 | 199 | reader = GoogleReader(auth) 200 | info = reader.getUserInfo() 201 | self.assertEqual(dict, type(info)) 202 | self.assertEqual(firstname, info['userName']) 203 | 204 | def test_oauth_subscribe(self): 205 | auth = OAuth2Method(client_id, client_secret) 206 | auth.setRedirectUri(redirect_url) 207 | url = auth.buildAuthUrl() 208 | token = automated_oauth2_approval(url) 209 | auth.code = token 210 | auth.setAccessToken() 211 | auth.setActionToken() 212 | 213 | reader = GoogleReader(auth) 214 | 215 | slashdot = 'feed/http://rss.slashdot.org/Slashdot/slashdot' 216 | #unsubscribe always return true; revert feedlist state 217 | self.assertTrue(reader.unsubscribe(slashdot)) 218 | # now subscribe 219 | self.assertTrue(reader.subscribe(slashdot)) 220 | # wait for server to update 221 | import time 222 | time.sleep(1) 223 | reader.buildSubscriptionList() 224 | # test subscribe successful 225 | self.assertIn(slashdot, [x.id for x in reader.getSubscriptionList()]) 226 | 227 | if __name__ == '__main__': 228 | unittest.main() 229 | -------------------------------------------------------------------------------- /libgreader/googlereader.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import time 4 | 5 | try: 6 | import json 7 | except: 8 | import simplejson as json 9 | 10 | from .url import ReaderUrl 11 | from .items import SpecialFeed, Item, Category, Feed 12 | 13 | class GoogleReader(object): 14 | """ 15 | Class for using the unofficial Google Reader API and working with 16 | the data it returns. 17 | 18 | Requires valid google username and password. 19 | """ 20 | def __repr__(self): 21 | return "" % self.auth.username 22 | 23 | def __str__(self): 24 | return unicode(self).encode('utf-8') 25 | 26 | def __unicode__(self): 27 | return "" % self.auth.username 28 | 29 | def __init__(self, auth): 30 | self.auth = auth 31 | self.feeds = [] 32 | self.categories = [] 33 | self.feedsById = {} 34 | self.categoriesById = {} 35 | self.specialFeeds = {} 36 | self.orphanFeeds = [] 37 | self.userId = None 38 | self.addTagBacklog = {} 39 | self.inItemTagTransaction = False 40 | 41 | def toJSON(self): 42 | """ 43 | TODO: build a json object to return via ajax 44 | """ 45 | pass 46 | 47 | def getFeeds(self): 48 | """ 49 | @Deprecated, see getSubscriptionList 50 | """ 51 | return self.feeds 52 | 53 | def getSubscriptionList(self): 54 | """ 55 | Returns a list of Feed objects containing all of a users subscriptions 56 | or None if buildSubscriptionList has not been called, to get the Feeds 57 | """ 58 | return self.feeds 59 | 60 | def getCategories(self): 61 | """ 62 | Returns a list of all the categories or None if buildSubscriptionList 63 | has not been called, to get the Feeds 64 | """ 65 | return self.categories 66 | 67 | def makeSpecialFeeds(self): 68 | for type in ReaderUrl.SPECIAL_FEEDS: 69 | self.specialFeeds[type] = SpecialFeed(self, type) 70 | 71 | def getSpecialFeed(self, type): 72 | return self.specialFeeds[type] 73 | 74 | def buildSubscriptionList(self): 75 | """ 76 | Hits Google Reader for a users's alphabetically ordered list of feeds. 77 | 78 | Returns true if succesful. 79 | """ 80 | self._clearLists() 81 | unreadById = {} 82 | 83 | if not self.userId: 84 | self.getUserInfo() 85 | 86 | unreadJson = self.httpGet(ReaderUrl.UNREAD_COUNT_URL, { 'output': 'json', }) 87 | unreadCounts = json.loads(unreadJson, strict=False)['unreadcounts'] 88 | for unread in unreadCounts: 89 | unreadById[unread['id']] = unread['count'] 90 | 91 | feedsJson = self.httpGet(ReaderUrl.SUBSCRIPTION_LIST_URL, { 'output': 'json', }) 92 | subscriptions = json.loads(feedsJson, strict=False)['subscriptions'] 93 | 94 | for sub in subscriptions: 95 | categories = [] 96 | if 'categories' in sub: 97 | for hCategory in sub['categories']: 98 | cId = hCategory['id'] 99 | if not cId in self.categoriesById: 100 | category = Category(self, hCategory['label'], cId) 101 | self._addCategory(category) 102 | categories.append(self.categoriesById[cId]) 103 | 104 | try: 105 | feed = self.getFeed(sub['id']) 106 | if not feed: 107 | raise 108 | if not feed.title: 109 | feed.title = sub['title'] 110 | for category in categories: 111 | feed.addCategory(category) 112 | feed.unread = unreadById.get(sub['id'], 0) 113 | except: 114 | feed = Feed(self, 115 | sub['title'], 116 | sub['id'], 117 | sub.get('htmlUrl', None), 118 | unreadById.get(sub['id'], 0), 119 | categories) 120 | if not categories: 121 | self.orphanFeeds.append(feed) 122 | self._addFeed(feed) 123 | 124 | specialUnreads = [id for id in unreadById 125 | if id.find('user/%s/state/com.google/' % self.userId) != -1] 126 | for type in self.specialFeeds: 127 | feed = self.specialFeeds[type] 128 | feed.unread = 0 129 | for id in specialUnreads: 130 | if id.endswith('/%s' % type): 131 | feed.unread = unreadById.get(id, 0) 132 | break 133 | 134 | return True 135 | 136 | def _getFeedContent(self, url, excludeRead=False, continuation=None, loadLimit=20, since=None, until=None): 137 | """ 138 | A list of items (from a feed, a category or from URLs made with SPECIAL_ITEMS_URL) 139 | 140 | Returns a dict with 141 | :param id: (str, feed's id) 142 | :param continuation: (str, to be used to fetch more items) 143 | :param items: array of dits with : 144 | - update (update timestamp) 145 | - author (str, username) 146 | - title (str, page title) 147 | - id (str) 148 | - content (dict with content and direction) 149 | - categories (list of categories including states or ones provided by the feed owner) 150 | """ 151 | parameters = {} 152 | if excludeRead: 153 | parameters['xt'] = 'user/-/state/com.google/read' 154 | if continuation: 155 | parameters['c'] = continuation 156 | parameters['n'] = loadLimit 157 | if since: 158 | parameters['ot'] = since 159 | if until: 160 | parameters['nt'] = until 161 | contentJson = self.httpGet(url, parameters) 162 | return json.loads(contentJson, strict=False) 163 | 164 | def itemsToObjects(self, parent, items): 165 | objects = [] 166 | for item in items: 167 | objects.append(Item(self, item, parent)) 168 | return objects 169 | 170 | def getFeedContent(self, feed, excludeRead=False, continuation=None, loadLimit=20, since=None, until=None): 171 | """ 172 | Return items for a particular feed 173 | """ 174 | return self._getFeedContent(feed.fetchUrl, excludeRead, continuation, loadLimit, since, until) 175 | 176 | def getCategoryContent(self, category, excludeRead=False, continuation=None, loadLimit=20, since=None, until=None): 177 | """ 178 | Return items for a particular category 179 | """ 180 | return self._getFeedContent(category.fetchUrl, excludeRead, continuation, loadLimit, since, until) 181 | 182 | def _modifyItemTag(self, item_id, action, tag): 183 | """ wrapper around actual HTTP POST string for modify tags """ 184 | return self.httpPost(ReaderUrl.EDIT_TAG_URL, 185 | {'i': item_id, action: tag, 'ac': 'edit-tags'}) 186 | 187 | def removeItemTag(self, item, tag): 188 | """ 189 | Remove a tag to an individal item. 190 | 191 | tag string must be in form "user/-/label/[tag]" 192 | """ 193 | return self._modifyItemTag(item.id, 'r', tag) 194 | 195 | def beginAddItemTagTransaction(self): 196 | if self.inItemTagTransaction: 197 | raise Exception("Already in addItemTag transaction") 198 | self.addTagBacklog = {} 199 | self.inItemTagTransaction = True 200 | 201 | def addItemTag(self, item, tag): 202 | """ 203 | Add a tag to an individal item. 204 | 205 | tag string must be in form "user/-/label/[tag]" 206 | """ 207 | if self.inItemTagTransaction: 208 | # XXX: what if item's parent is not a feed? 209 | if not tag in self.addTagBacklog: 210 | self.addTagBacklog[tag] = [] 211 | self.addTagBacklog[tag].append({'i': item.id, 's': item.parent.id}) 212 | return "OK" 213 | else: 214 | return self._modifyItemTag(item.id, 'a', tag) 215 | 216 | 217 | def commitAddItemTagTransaction(self): 218 | if self.inItemTagTransaction: 219 | for tag in self.addTagBacklog: 220 | itemIds = [item['i'] for item in self.addTagBacklog[tag]] 221 | feedIds = [item['s'] for item in self.addTagBacklog[tag]] 222 | self.httpPost(ReaderUrl.EDIT_TAG_URL, 223 | {'i': itemIds, 'a': tag, 'ac': 'edit-tags', 's': feedIds}) 224 | self.addTagBacklog = {} 225 | self.inItemTagTransaction = False 226 | return True 227 | else: 228 | raise Exception("Not in addItemTag transaction") 229 | 230 | def markFeedAsRead(self, feed): 231 | return self.httpPost( 232 | ReaderUrl.MARK_ALL_READ_URL, 233 | {'s': feed.id, }) 234 | 235 | def subscribe(self, feedUrl): 236 | """ 237 | Adds a feed to the top-level subscription list 238 | 239 | Ubscribing seems idempotent, you can subscribe multiple times 240 | without error 241 | 242 | returns True or throws HTTPError 243 | """ 244 | response = self.httpPost( 245 | ReaderUrl.SUBSCRIPTION_EDIT_URL, 246 | {'ac':'subscribe', 's': feedUrl}) 247 | # FIXME - need better return API 248 | if response and 'OK' in response: 249 | return True 250 | else: 251 | return False 252 | 253 | def unsubscribe(self, feedUrl): 254 | """ 255 | Removes a feed url from the top-level subscription list 256 | 257 | Unsubscribing seems idempotent, you can unsubscribe multiple times 258 | without error 259 | 260 | returns True or throws HTTPError 261 | """ 262 | response = self.httpPost( 263 | ReaderUrl.SUBSCRIPTION_EDIT_URL, 264 | {'ac':'unsubscribe', 's': feedUrl}) 265 | # FIXME - need better return API 266 | if response and 'OK' in response: 267 | return True 268 | else: 269 | return False 270 | 271 | def getUserInfo(self): 272 | """ 273 | Returns a dictionary of user info that google stores. 274 | """ 275 | userJson = self.httpGet(ReaderUrl.USER_INFO_URL) 276 | result = json.loads(userJson, strict=False) 277 | self.userId = result['userId'] 278 | return result 279 | 280 | def getUserSignupDate(self): 281 | """ 282 | Returns the human readable date of when the user signed up for google reader. 283 | """ 284 | userinfo = self.getUserInfo() 285 | timestamp = int(float(userinfo["signupTimeSec"])) 286 | return time.strftime("%m/%d/%Y %H:%M", time.gmtime(timestamp)) 287 | 288 | def httpGet(self, url, parameters=None): 289 | """ 290 | Wrapper around AuthenticationMethod get() 291 | """ 292 | return self.auth.get(url, parameters) 293 | 294 | def httpPost(self, url, post_parameters=None): 295 | """ 296 | Wrapper around AuthenticationMethod post() 297 | """ 298 | return self.auth.post(url, post_parameters) 299 | 300 | def _addFeed(self, feed): 301 | if feed.id not in self.feedsById: 302 | self.feedsById[feed.id] = feed 303 | self.feeds.append(feed) 304 | 305 | def _addCategory (self, category): 306 | if category.id not in self.categoriesById: 307 | self.categoriesById[category.id] = category 308 | self.categories.append(category) 309 | 310 | def getFeed(self, id): 311 | return self.feedsById.get(id, None) 312 | 313 | def getCategory(self, id): 314 | return self.categoriesById.get(id, None) 315 | 316 | def _clearLists(self): 317 | """ 318 | Clear all list before sync : feeds and categories 319 | """ 320 | self.feedsById = {} 321 | self.feeds = [] 322 | self.categoriesById = {} 323 | self.categories = [] 324 | self.orphanFeeds = [] 325 | -------------------------------------------------------------------------------- /libgreader/items.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from requests.compat import quote 4 | 5 | from .url import ReaderUrl 6 | 7 | class ItemsContainer(object): 8 | """ 9 | A base class used for all classes aimed to have items (Categories and Feeds) 10 | """ 11 | def __init__(self): 12 | self.items = [] 13 | self.itemsById = {} 14 | self.lastLoadOk = False 15 | self.lastLoadLength = 0 16 | self.lastUpdated = None 17 | self.unread = 0 18 | self.continuation = None 19 | 20 | def _getContent(self, excludeRead=False, continuation=None, loadLimit=20, since=None, until=None): 21 | """ 22 | Get content from google reader with specified parameters. 23 | Must be overladed in inherited clases 24 | """ 25 | return None 26 | 27 | def loadItems(self, excludeRead=False, loadLimit=20, since=None, until=None): 28 | """ 29 | Load items and call itemsLoadedDone to transform data in objects 30 | """ 31 | self.clearItems() 32 | self.loadtLoadOk = False 33 | self.lastLoadLength = 0 34 | self._itemsLoadedDone(self._getContent(excludeRead, None, loadLimit, since, until)) 35 | 36 | def loadMoreItems(self, excludeRead=False, continuation=None, loadLimit=20, since=None, until=None): 37 | """ 38 | Load more items using the continuation parameters of previously loaded items. 39 | """ 40 | self.lastLoadOk = False 41 | self.lastLoadLength = 0 42 | if not continuation and not self.continuation: 43 | return 44 | self._itemsLoadedDone(self._getContent(excludeRead, continuation or self.continuation, loadLimit, since, until)) 45 | 46 | def _itemsLoadedDone(self, data): 47 | """ 48 | Called when all items are loaded 49 | """ 50 | if data is None: 51 | return 52 | self.continuation = data.get('continuation', None) 53 | self.lastUpdated = data.get('updated', None) 54 | self.lastLoadLength = len(data.get('items', [])) 55 | self.googleReader.itemsToObjects(self, data.get('items', [])) 56 | self.lastLoadOk = True 57 | 58 | def _addItem(self, item): 59 | self.items.append(item) 60 | self.itemsById[item.id] = item 61 | 62 | def getItem(self, id): 63 | return self.itemsById[id] 64 | 65 | def clearItems(self): 66 | self.items = [] 67 | self.itemsById = {} 68 | self.continuation = None 69 | 70 | def getItems(self): 71 | return self.items 72 | 73 | def countItems(self, excludeRead=False): 74 | if excludeRead: 75 | sum([1 for item in self.items if item.isUnread()]) 76 | else: 77 | return len(self.items) 78 | 79 | def markItemRead(self, item, read): 80 | if read and item.isUnread(): 81 | self.unread -= 1 82 | elif not read and item.isRead(): 83 | self.unread += 1 84 | 85 | def markAllRead(self): 86 | self.unread = 0 87 | for item in self.items: 88 | item.read = True 89 | item.canUnread = False 90 | result = self.googleReader.markFeedAsRead(self) 91 | return result.upper() == 'OK' 92 | 93 | def countUnread(self): 94 | self.unread = self.countItems(excludeRead=True) 95 | 96 | class Category(ItemsContainer): 97 | """ 98 | Class for representing a category 99 | """ 100 | def __str__(self): 101 | return unicode(self).encode('utf-8') 102 | 103 | def __unicode__(self): 104 | return "<%s (%d), %s>" % (self.label, self.unread, self.id) 105 | 106 | def __init__(self, googleReader, label, id): 107 | """ 108 | :param label: (str) 109 | :param id: (str) 110 | """ 111 | super(Category, self).__init__() 112 | self.googleReader = googleReader 113 | 114 | self.label = label 115 | self.id = id 116 | 117 | self.feeds = [] 118 | 119 | self.fetchUrl = ReaderUrl.CATEGORY_URL + Category.urlQuote(self.label) 120 | 121 | def _addFeed(self, feed): 122 | if not feed in self.feeds: 123 | self.feeds.append(feed) 124 | try: 125 | self.unread += feed.unread 126 | except: 127 | pass 128 | 129 | def getFeeds(self): 130 | return self.feeds 131 | 132 | def _getContent(self, excludeRead=False, continuation=None, loadLimit=20, since=None, until=None): 133 | return self.googleReader.getCategoryContent(self, excludeRead, continuation, loadLimit, since, until) 134 | 135 | def countUnread(self): 136 | self.unread = sum([feed.unread for feed in self.feeds]) 137 | 138 | def toArray(self): 139 | pass 140 | 141 | def toJSON(self): 142 | pass 143 | 144 | @staticmethod 145 | def urlQuote(string): 146 | """ Quote a string for being used in a HTTP URL """ 147 | return quote(string.encode("utf-8")) 148 | 149 | class BaseFeed(ItemsContainer): 150 | """ 151 | Class for representing a special feed. 152 | """ 153 | def __str__(self): 154 | return unicode(self).encode('utf-8') 155 | 156 | def __unicode__(self): 157 | return "<%s, %s>" % (self.title, self.id) 158 | 159 | def __init__(self, googleReader, title, id, unread, categories=[]): 160 | """ 161 | :param title: (str, name of the feed) 162 | :param id: (str, id for google reader) 163 | :param unread: (int, number of unread items, 0 by default) 164 | :param categories: (list) - list of all categories a feed belongs to, can be empty 165 | """ 166 | super(BaseFeed, self).__init__() 167 | 168 | self.googleReader = googleReader 169 | 170 | self.id = id 171 | self.title = title 172 | self.unread = unread 173 | 174 | self.categories = [] 175 | for category in categories: 176 | self.addCategory(category) 177 | 178 | self.continuation = None 179 | 180 | def addCategory(self, category): 181 | if not category in self.categories: 182 | self.categories.append(category) 183 | category._addFeed(self) 184 | 185 | def getCategories(self): 186 | return self.categories 187 | 188 | def _getContent(self, excludeRead=False, continuation=None, loadLimit=20, since=None, until=None): 189 | return self.googleReader.getFeedContent(self, excludeRead, continuation, loadLimit, since, until) 190 | 191 | def markItemRead(self, item, read): 192 | super(BaseFeed, self).markItemRead(item, read) 193 | for category in self.categories: 194 | category.countUnread() 195 | 196 | def markAllRead(self): 197 | self.unread = 0 198 | for category in self.categories: 199 | category.countUnread() 200 | return super(BaseFeed, self).markAllRead() 201 | 202 | def toArray(self): 203 | pass 204 | 205 | def toJSON(self): 206 | pass 207 | 208 | class SpecialFeed(BaseFeed): 209 | """ 210 | Class for representing specials feeds (starred, shared, friends...) 211 | """ 212 | def __init__(self, googleReader, type): 213 | """ 214 | type is one of ReaderUrl.SPECIAL_FEEDS 215 | """ 216 | super(SpecialFeed, self).__init__( 217 | googleReader, 218 | title = type, 219 | id = ReaderUrl.SPECIAL_FEEDS_PART_URL+type, 220 | unread = 0, 221 | categories = [], 222 | ) 223 | self.type = type 224 | 225 | self.fetchUrl = ReaderUrl.CONTENT_BASE_URL + Category.urlQuote(self.id) 226 | 227 | class Feed(BaseFeed): 228 | """ 229 | Class for representing a normal feed. 230 | """ 231 | 232 | def __init__(self, googleReader, title, id, siteUrl=None, unread=0, categories=[]): 233 | """ 234 | :param title: str name of the feed 235 | :param id: str, id for google reader 236 | :param siteUrl: str, can be empty 237 | :param unread: int, number of unread items, 0 by default 238 | :param categories: (list) - list of all categories a feed belongs to, can be empty 239 | """ 240 | super(Feed, self).__init__(googleReader, title, id, unread, categories) 241 | 242 | self.feedUrl = self.id.lstrip('feed/') 243 | self.siteUrl = siteUrl 244 | 245 | self.fetchUrl = ReaderUrl.FEED_URL + Category.urlQuote(self.id) 246 | 247 | class Item(object): 248 | """ 249 | Class for representing an individual item (an entry of a feed) 250 | """ 251 | def __str__(self): 252 | return unicode(self).encode('utf-8') 253 | 254 | def __unicode__(self): 255 | return '<"%s" by %s, %s>' % (self.title, self.author, self.id) 256 | 257 | def __init__(self, googleReader, item, parent): 258 | """ 259 | :param item: An item loaded from json 260 | :param parent: the object (Feed of Category) containing the Item 261 | """ 262 | self.googleReader = googleReader 263 | self.parent = parent 264 | 265 | self.data = item # save original data for accessing other fields 266 | self.id = item['id'] 267 | self.title = item.get('title', '(no title)') 268 | self.author = item.get('author', None) 269 | self.content = item.get('content', item.get('summary', {})).get('content', '') 270 | self.origin = { 'title': '', 'url': ''} 271 | if 'crawlTimeMsec' in item: 272 | self.time = int(item['crawlTimeMsec']) // 1000 273 | else: 274 | self.time = None 275 | 276 | # check original url 277 | self.url = None 278 | for alternate in item.get('alternate', []): 279 | if alternate.get('type', '') == 'text/html': 280 | self.url = alternate['href'] 281 | break 282 | 283 | # check status 284 | self.read = False 285 | self.starred = False 286 | self.shared = False 287 | for category in item.get('categories', []): 288 | if category.endswith('/state/com.google/read'): 289 | self.read = True 290 | elif category.endswith('/state/com.google/starred'): 291 | self.starred = True 292 | elif category in ('user/-/state/com.google/broadcast', 293 | 'user/%s/state/com.google/broadcast' % self.googleReader.userId): 294 | self.shared = True 295 | 296 | self.canUnread = item.get('isReadStateLocked', 'false') != 'true' 297 | 298 | # keep feed, can be used when item is fetched from a special feed, then it's the original one 299 | try: 300 | f = item['origin'] 301 | self.origin = { 302 | 'title': f.get('title', ''), 303 | 'url': f.get('htmlUrl', ''), 304 | } 305 | self.feed = self.googleReader.getFeed(f['streamId']) 306 | if not self.feed: 307 | raise 308 | if not self.feed.title and 'title' in f: 309 | self.feed.title = f['title'] 310 | except: 311 | try: 312 | self.feed = Feed(self, f.get('title', ''), f['streamId'], f.get('htmlUrl', None), 0, []) 313 | try: 314 | self.googleReader._addFeed(self.feed) 315 | except: 316 | pass 317 | except: 318 | self.feed = None 319 | 320 | self.parent._addItem(self) 321 | 322 | def isUnread(self): 323 | return not self.read 324 | 325 | def isRead(self): 326 | return self.read 327 | 328 | def markRead(self, read=True): 329 | self.parent.markItemRead(self, read) 330 | self.read = read 331 | if read: 332 | result = self.googleReader.addItemTag(self, ReaderUrl.TAG_READ) 333 | else: 334 | result = self.googleReader.removeItemTag(self, ReaderUrl.TAG_READ) 335 | return result.upper() == 'OK' 336 | 337 | def markUnread(self, unread=True): 338 | return self.markRead(not unread) 339 | 340 | def isShared(self): 341 | return self.shared 342 | 343 | def markShared(self, shared=True): 344 | self.shared = shared 345 | if shared: 346 | result = self.googleReader.addItemTag(self, ReaderUrl.TAG_SHARED) 347 | else: 348 | result = self.googleReader.removeItemTag(self, ReaderUrl.TAG_SHARED) 349 | return result.upper() == 'OK' 350 | 351 | def share(self): 352 | return self.markShared() 353 | 354 | def unShare(self): 355 | return self.markShared(False) 356 | 357 | def isStarred(self): 358 | return self.starred 359 | 360 | def markStarred(self, starred=True): 361 | self.starred = starred 362 | if starred: 363 | result = self.googleReader.addItemTag(self, ReaderUrl.TAG_STARRED) 364 | else: 365 | result = self.googleReader.removeItemTag(self, ReaderUrl.TAG_STARRED) 366 | return result.upper() == 'OK' 367 | 368 | def star(self): 369 | return self.markStarred() 370 | 371 | def unStar(self): 372 | return self.markStarred(False) 373 | -------------------------------------------------------------------------------- /libgreader/auth.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import requests 4 | from requests.compat import urlencode, urlparse 5 | 6 | # import urllib2 7 | 8 | import time 9 | 10 | try: 11 | import json 12 | except: 13 | # Python 2.6 support 14 | import simplejson as json 15 | 16 | try: 17 | import oauth2 as oauth 18 | has_oauth = True 19 | except: 20 | has_oauth = False 21 | 22 | try: 23 | import httplib2 24 | has_httplib2 = True 25 | except: 26 | has_httplib2 = False 27 | 28 | from .googlereader import GoogleReader 29 | from .url import ReaderUrl 30 | 31 | def toUnicode(obj, encoding='utf-8'): 32 | return obj 33 | # if isinstance(obj, basestring): 34 | # if not isinstance(obj, unicode): 35 | # obj = unicode(obj, encoding) 36 | # return obj 37 | 38 | class AuthenticationMethod(object): 39 | """ 40 | Defines an interface for authentication methods, must have a get method 41 | make this abstract? 42 | 1. auth on setup 43 | 2. need to have GET method 44 | """ 45 | def __init__(self): 46 | self.client = "libgreader" #@todo: is this needed? 47 | 48 | def getParameters(self, extraargs=None): 49 | parameters = {'ck':time.time(), 'client':self.client} 50 | if extraargs: 51 | parameters.update(extraargs) 52 | return urlencode(parameters) 53 | 54 | def postParameters(self, post=None): 55 | return post 56 | 57 | class ClientAuthMethod(AuthenticationMethod): 58 | """ 59 | Auth type which requires a valid Google Reader username and password 60 | """ 61 | CLIENT_URL = 'https://www.google.com/accounts/ClientLogin' 62 | 63 | def __init__(self, username, password): 64 | super(ClientAuthMethod, self).__init__() 65 | self.username = username 66 | self.password = password 67 | self.auth_token = self._getAuth() 68 | self.token = self._getToken() 69 | 70 | def postParameters(self, post=None): 71 | post.update({'T': self.token}) 72 | return super(ClientAuthMethod, self).postParameters(post) 73 | 74 | def get(self, url, parameters=None): 75 | """ 76 | Convenience method for requesting to google with proper cookies/params. 77 | """ 78 | getString = self.getParameters(parameters) 79 | headers = {'Authorization':'GoogleLogin auth=%s' % self.auth_token} 80 | req = requests.get(url + "?" + getString, headers=headers) 81 | return req.text 82 | 83 | def post(self, url, postParameters=None, urlParameters=None): 84 | """ 85 | Convenience method for requesting to google with proper cookies/params. 86 | """ 87 | if urlParameters: 88 | url = url + "?" + self.getParameters(urlParameters) 89 | headers = {'Authorization':'GoogleLogin auth=%s' % self.auth_token, 90 | 'Content-Type': 'application/x-www-form-urlencoded' 91 | } 92 | postString = self.postParameters(postParameters) 93 | req = requests.post(url, data=postString, headers=headers) 94 | return req.text 95 | 96 | def _getAuth(self): 97 | """ 98 | Main step in authorizing with Reader. 99 | Sends request to Google ClientAuthMethod URL which returns an Auth token. 100 | 101 | Returns Auth token or raises IOError on error. 102 | """ 103 | parameters = { 104 | 'service' : 'reader', 105 | 'Email' : self.username, 106 | 'Passwd' : self.password, 107 | 'accountType' : 'GOOGLE'} 108 | req = requests.post(ClientAuthMethod.CLIENT_URL, data=parameters) 109 | if req.status_code != 200: 110 | raise IOError("Error getting the Auth token, have you entered a" 111 | "correct username and password?") 112 | data = req.text 113 | #Strip newline and non token text. 114 | token_dict = dict(x.split('=') for x in data.split('\n') if x) 115 | return token_dict["Auth"] 116 | 117 | def _getToken(self): 118 | """ 119 | Second step in authorizing with Reader. 120 | Sends authorized request to Reader token URL and returns a token value. 121 | 122 | Returns token or raises IOError on error. 123 | """ 124 | headers = {'Authorization':'GoogleLogin auth=%s' % self.auth_token} 125 | req = requests.get(ReaderUrl.API_URL + 'token', headers=headers) 126 | if req.status_code != 200: 127 | raise IOError("Error getting the Reader token.") 128 | return req.content 129 | 130 | class OAuthMethod(AuthenticationMethod): 131 | """ 132 | Loose wrapper around OAuth2 lib. Kinda awkward. 133 | """ 134 | GOOGLE_URL = 'https://www.google.com/accounts/' 135 | REQUEST_TOKEN_URL = (GOOGLE_URL + 'OAuthGetRequestToken?scope=%s' % 136 | ReaderUrl.READER_BASE_URL) 137 | AUTHORIZE_URL = GOOGLE_URL + 'OAuthAuthorizeToken' 138 | ACCESS_TOKEN_URL = GOOGLE_URL + 'OAuthGetAccessToken' 139 | 140 | def __init__(self, consumer_key, consumer_secret): 141 | if not has_oauth: 142 | raise ImportError("No module named oauth2") 143 | super(OAuthMethod, self).__init__() 144 | self.oauth_key = consumer_key 145 | self.oauth_secret = consumer_secret 146 | self.consumer = oauth.Consumer(self.oauth_key, self.oauth_secret) 147 | self.authorized_client = None 148 | self.token_key = None 149 | self.token_secret = None 150 | self.callback = None 151 | self.username = "OAuth" 152 | 153 | def setCallback(self, callback_url): 154 | self.callback = '&oauth_callback=%s' % callback_url 155 | 156 | def setRequestToken(self): 157 | # Step 1: Get a request token. This is a temporary token that is used for 158 | # having the user authorize an access token and to sign the request to obtain 159 | # said access token. 160 | client = oauth.Client(self.consumer) 161 | if not self.callback: 162 | resp, content = client.request(OAuthMethod.REQUEST_TOKEN_URL) 163 | else: 164 | resp, content = client.request(OAuthMethod.REQUEST_TOKEN_URL + self.callback) 165 | if int(resp['status']) != 200: 166 | raise IOError("Error setting Request Token") 167 | token_dict = dict(urlparse.parse_qsl(content)) 168 | self.token_key = token_dict['oauth_token'] 169 | self.token_secret = token_dict['oauth_token_secret'] 170 | 171 | def setAndGetRequestToken(self): 172 | self.setRequestToken() 173 | return (self.token_key, self.token_secret) 174 | 175 | def buildAuthUrl(self, token_key=None): 176 | if not token_key: 177 | token_key = self.token_key 178 | #return auth url for user to click or redirect to 179 | return "%s?oauth_token=%s" % (OAuthMethod.AUTHORIZE_URL, token_key) 180 | 181 | def setAccessToken(self): 182 | self.setAccessTokenFromCallback(self.token_key, self.token_secret, None) 183 | 184 | def setAccessTokenFromCallback(self, token_key, token_secret, verifier): 185 | token = oauth.Token(token_key, token_secret) 186 | #step 2 depends on callback 187 | if verifier: 188 | token.set_verifier(verifier) 189 | client = oauth.Client(self.consumer, token) 190 | 191 | resp, content = client.request(OAuthMethod.ACCESS_TOKEN_URL, "POST") 192 | if int(resp['status']) != 200: 193 | raise IOError("Error setting Access Token") 194 | access_token = dict(urlparse.parse_qsl(content)) 195 | 196 | #created Authorized client using access tokens 197 | self.authFromAccessToken(access_token['oauth_token'], 198 | access_token['oauth_token_secret']) 199 | 200 | def authFromAccessToken(self, oauth_token, oauth_token_secret): 201 | self.token_key = oauth_token 202 | self.token_secret = oauth_token_secret 203 | token = oauth.Token(oauth_token,oauth_token_secret) 204 | self.authorized_client = oauth.Client(self.consumer, token) 205 | 206 | def getAccessToken(self): 207 | return (self.token_key, self.token_secret) 208 | 209 | def get(self, url, parameters=None): 210 | if self.authorized_client: 211 | getString = self.getParameters(parameters) 212 | #can't pass in urllib2 Request object here? 213 | resp, content = self.authorized_client.request(url + "?" + getString) 214 | return toUnicode(content) 215 | else: 216 | raise IOError("No authorized client available.") 217 | 218 | def post(self, url, postParameters=None, urlParameters=None): 219 | """ 220 | Convenience method for requesting to google with proper cookies/params. 221 | """ 222 | if self.authorized_client: 223 | if urlParameters: 224 | getString = self.getParameters(urlParameters) 225 | req = urllib2.Request(url + "?" + getString) 226 | else: 227 | req = urllib2.Request(url) 228 | postString = self.postParameters(postParameters) 229 | resp,content = self.authorized_client.request(req, method="POST", body=postString) 230 | return toUnicode(content) 231 | else: 232 | raise IOError("No authorized client available.") 233 | 234 | class OAuth2Method(AuthenticationMethod): 235 | ''' 236 | Google OAuth2 base method. 237 | ''' 238 | GOOGLE_URL = 'https://accounts.google.com' 239 | AUTHORIZATION_URL = GOOGLE_URL + '/o/oauth2/auth' 240 | ACCESS_TOKEN_URL = GOOGLE_URL + '/o/oauth2/token' 241 | SCOPE = [ 242 | 'https://www.googleapis.com/auth/userinfo.email', 243 | 'https://www.googleapis.com/auth/userinfo.profile', 244 | 'https://www.google.com/reader/api/', 245 | ] 246 | 247 | def __init__(self, client_id, client_secret): 248 | super(OAuth2Method, self).__init__() 249 | self.client_id = client_id 250 | self.client_secret = client_secret 251 | self.authorized_client = None 252 | self.code = None 253 | self.access_token = None 254 | self.action_token = None 255 | self.redirect_uri = None 256 | self.username = "OAuth2" 257 | 258 | def setRedirectUri(self, redirect_uri): 259 | self.redirect_uri = redirect_uri 260 | 261 | def buildAuthUrl(self): 262 | args = { 263 | 'client_id': self.client_id, 264 | 'redirect_uri': self.redirect_uri, 265 | 'scope': ' '.join(self.SCOPE), 266 | 'response_type': 'code', 267 | } 268 | return self.AUTHORIZATION_URL + '?' + urlencode(args) 269 | 270 | def setActionToken(self): 271 | ''' 272 | Get action to prevent XSRF attacks 273 | http://code.google.com/p/google-reader-api/wiki/ActionToken 274 | 275 | TODO: mask token expiring? handle regenerating? 276 | ''' 277 | self.action_token = self.get(ReaderUrl.ACTION_TOKEN_URL) 278 | 279 | def setAccessToken(self): 280 | params = { 281 | 'grant_type': 'authorization_code', # request auth code 282 | 'code': self.code, # server response code 283 | 'client_id': self.client_id, 284 | 'client_secret': self.client_secret, 285 | 'redirect_uri': self.redirect_uri 286 | } 287 | headers = {'Content-Type': 'application/x-www-form-urlencoded'} 288 | request = requests.post(self.ACCESS_TOKEN_URL, data=params, 289 | headers=headers) 290 | 291 | if request.status_code != 200: 292 | raise IOError('Error getting Access Token') 293 | 294 | response = request.json() 295 | if 'access_token' not in response: 296 | raise IOError('Error getting Access Token') 297 | else: 298 | self.authFromAccessToken(response['access_token']) 299 | 300 | def authFromAccessToken(self, access_token): 301 | self.access_token = access_token 302 | 303 | def get(self, url, parameters=None): 304 | """ 305 | Convenience method for requesting to google with proper cookies/params. 306 | """ 307 | if not self.access_token: 308 | raise IOError("No authorized client available.") 309 | if parameters is None: 310 | parameters = {} 311 | parameters.update({'access_token': self.access_token, 'alt': 'json'}) 312 | request = requests.get(url + '?' + self.getParameters(parameters)) 313 | if request.status_code != 200: 314 | return None 315 | else: 316 | return toUnicode(request.text) 317 | 318 | def post(self, url, postParameters=None, urlParameters=None): 319 | """ 320 | Convenience method for requesting to google with proper cookies/params. 321 | """ 322 | if not self.access_token: 323 | raise IOError("No authorized client available.") 324 | if not self.action_token: 325 | raise IOError("Need to generate action token.") 326 | if urlParameters is None: 327 | urlParameters = {} 328 | headers = {'Authorization': 'Bearer ' + self.access_token, 329 | 'Content-Type': 'application/x-www-form-urlencoded'} 330 | postParameters.update({'T':self.action_token}) 331 | request = requests.post(url + '?' + self.getParameters(urlParameters), 332 | data=postParameters, headers=headers) 333 | if request.status_code != 200: 334 | return None 335 | else: 336 | return toUnicode(request.text) 337 | 338 | class GAPDecoratorAuthMethod(AuthenticationMethod): 339 | """ 340 | An adapter to work with Google API for Python OAuth2 wrapper. 341 | Especially useful when deploying to Google AppEngine. 342 | """ 343 | def __init__(self, credentials): 344 | """ 345 | Initialize auth method with existing credentials. 346 | Args: 347 | credentials: OAuth2 credentials obtained via GAP OAuth2 library. 348 | """ 349 | if not has_httplib2: 350 | raise ImportError("No module named httplib2") 351 | super(GAPDecoratorAuthMethod, self).__init__() 352 | self._http = None 353 | self._credentials = credentials 354 | self._action_token = None 355 | 356 | def _setupHttp(self): 357 | """ 358 | Setup an HTTP session authorized by OAuth2. 359 | """ 360 | if self._http == None: 361 | http = httplib2.Http() 362 | self._http = self._credentials.authorize(http) 363 | 364 | def get(self, url, parameters=None): 365 | """ 366 | Implement libgreader's interface for authenticated GET request 367 | """ 368 | if self._http == None: 369 | self._setupHttp() 370 | uri = url + "?" + self.getParameters(parameters) 371 | response, content = self._http.request(uri, "GET") 372 | return content 373 | 374 | def post(self, url, postParameters=None, urlParameters=None): 375 | """ 376 | Implement libgreader's interface for authenticated POST request 377 | """ 378 | if self._action_token == None: 379 | self._action_token = self.get(ReaderUrl.ACTION_TOKEN_URL) 380 | 381 | if self._http == None: 382 | self._setupHttp() 383 | uri = url + "?" + self.getParameters(urlParameters) 384 | postParameters.update({'T':self._action_token}) 385 | body = self.postParameters(postParameters) 386 | response, content = self._http.request(uri, "POST", body=body) 387 | return content 388 | --------------------------------------------------------------------------------