greader 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/README.md +14 -0
- data/Rakefile +5 -0
- data/lib/greader.rb +103 -0
- data/lib/greader/client.rb +162 -0
- data/lib/greader/entries.rb +87 -0
- data/lib/greader/entry.rb +158 -0
- data/lib/greader/feed.rb +109 -0
- data/lib/greader/tag.rb +71 -0
- data/lib/greader/utilities.rb +21 -0
- data/test/client_test.rb +11 -0
- data/test/feed_test.rb +23 -0
- data/test/fixtures/auth.txt +4 -0
- data/test/fixtures/credentials.yml +3 -0
- data/test/fixtures/credentials.yml.example +3 -0
- data/test/fixtures/ruby-entries.json +57 -0
- data/test/fixtures/subscription-list.json +40 -0
- data/test/fixtures/tag-list.json +4 -0
- data/test/helper.rb +74 -0
- data/test/tag_test.rb +46 -0
- metadata +96 -0
data/README.md
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
# Google Reader API client
|
2
|
+
#### This is a Google Reader API client in Ruby.
|
3
|
+
|
4
|
+
`gem install greader`
|
5
|
+
|
6
|
+
Please read the {GReader} module documentation.
|
7
|
+
|
8
|
+
### External links
|
9
|
+
|
10
|
+
* [Documentation][doc]
|
11
|
+
* [Google Reader API reference][api]
|
12
|
+
|
13
|
+
[doc]: http://rubydoc.info/github/rstacruz/greader
|
14
|
+
[api]: http://code.google.com/p/pyrfeed/wiki/GoogleReaderAPI
|
data/Rakefile
ADDED
data/lib/greader.rb
ADDED
@@ -0,0 +1,103 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'rest_client' unless defined?(RestClient)
|
3
|
+
require 'nokogiri' unless defined?(Nokogiri)
|
4
|
+
|
5
|
+
# Google Reader API client.
|
6
|
+
#
|
7
|
+
# == Common usage
|
8
|
+
#
|
9
|
+
# First, log in (returns a {Client} or `nil`):
|
10
|
+
#
|
11
|
+
# @client = GReader.auth email: 'test@sinefunc.com', password: 'password'
|
12
|
+
#
|
13
|
+
# == Common {Client} usage
|
14
|
+
#
|
15
|
+
# A {Client} has many {Feed Feeds} and {Tag Tags}:
|
16
|
+
#
|
17
|
+
# @client.feeds #=> [#<Feed>, #<Feed>, ...]
|
18
|
+
# @client.tags #=> [#<Tag>, #<Tag>, ...]
|
19
|
+
#
|
20
|
+
# @client.feed('FEED_ID')
|
21
|
+
# @client.tag('TAG_ID')
|
22
|
+
#
|
23
|
+
# == Common {Feed} usage
|
24
|
+
#
|
25
|
+
# @client.feeds.each do |feed|
|
26
|
+
# p feed.id
|
27
|
+
# p feed.title
|
28
|
+
# p feed.url
|
29
|
+
#
|
30
|
+
# # A Feed has many entries
|
31
|
+
# feed.entries.each do |entry|
|
32
|
+
# p entry.title
|
33
|
+
# p entry.content
|
34
|
+
# end
|
35
|
+
# end
|
36
|
+
#
|
37
|
+
# == Common {Tag} usage
|
38
|
+
#
|
39
|
+
# A {Tag} also has many feeds:
|
40
|
+
#
|
41
|
+
# # Tag
|
42
|
+
# @client.tag('TAG_ID').feeds.each { |feed| }
|
43
|
+
#
|
44
|
+
# == Other
|
45
|
+
#
|
46
|
+
# GReader.version #=> "0.0.0"
|
47
|
+
#
|
48
|
+
# == See also
|
49
|
+
#
|
50
|
+
# {Feed}:: A website's feed.
|
51
|
+
# {Entry}:: An entry in a feed.
|
52
|
+
# {Tag}:: A feed's tag.
|
53
|
+
#
|
54
|
+
module GReader
|
55
|
+
PREFIX = File.expand_path('../greader/', __FILE__)
|
56
|
+
VERSION = "0.0.1"
|
57
|
+
|
58
|
+
autoload :Client, "#{PREFIX}/client"
|
59
|
+
autoload :Entry, "#{PREFIX}/entry"
|
60
|
+
autoload :Entries, "#{PREFIX}/entries"
|
61
|
+
autoload :Feed, "#{PREFIX}/feed"
|
62
|
+
autoload :Tag, "#{PREFIX}/tag"
|
63
|
+
autoload :Utilities, "#{PREFIX}/utilities"
|
64
|
+
|
65
|
+
def self.auth(options={})
|
66
|
+
client = GReader::Client.new options
|
67
|
+
client if client.logged_in?
|
68
|
+
end
|
69
|
+
|
70
|
+
# Returns a list of HTML pre-processors.
|
71
|
+
# HTML snippets will be passed through this.
|
72
|
+
#
|
73
|
+
# @example
|
74
|
+
# GReader.html_processors << lambda { |s| s.strip }
|
75
|
+
# GReader.process_html('<p> ') #=> '<p>'
|
76
|
+
#
|
77
|
+
def self.html_processors
|
78
|
+
@html_processors ||= Array.new
|
79
|
+
end
|
80
|
+
|
81
|
+
# Processes an HTML snippet through the given HTML
|
82
|
+
# processors.
|
83
|
+
def self.process_html(str)
|
84
|
+
html = str.dup
|
85
|
+
html_processors.each { |proc| html = proc.call(html) }
|
86
|
+
html
|
87
|
+
end
|
88
|
+
|
89
|
+
def self.version
|
90
|
+
VERSION
|
91
|
+
end
|
92
|
+
|
93
|
+
Error = Class.new(StandardError)
|
94
|
+
|
95
|
+
class ParseError < Error
|
96
|
+
attr_reader :node
|
97
|
+
|
98
|
+
def initialize(message, node)
|
99
|
+
super(message)
|
100
|
+
@node = node
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,162 @@
|
|
1
|
+
module GReader
|
2
|
+
# A client.
|
3
|
+
#
|
4
|
+
# == Common usage
|
5
|
+
#
|
6
|
+
# Greader.auth is the preferred way.
|
7
|
+
#
|
8
|
+
# @client = GReader.auth email: 'test@sinefunc.com', password: 'password'
|
9
|
+
# @client = GReader.auth email: 'test@sinefunc.com', access_token: <from oauth>
|
10
|
+
# @client.nil? # nil if logging in fails
|
11
|
+
#
|
12
|
+
# You can also use it like so:
|
13
|
+
#
|
14
|
+
# client = Client.new email: 'test@sinefunc.com', password: 'password'
|
15
|
+
# client.logged_in?
|
16
|
+
#
|
17
|
+
# See GReader for more common usage of the Client class.
|
18
|
+
#
|
19
|
+
# == Caching and expiration
|
20
|
+
#
|
21
|
+
# # Caching
|
22
|
+
# @client.tags
|
23
|
+
# @client.tags # Will be retrieved from cache
|
24
|
+
# @client.expire!
|
25
|
+
# @client.tags # Will be re-retrieved online
|
26
|
+
#
|
27
|
+
# == Internal low-level usage
|
28
|
+
#
|
29
|
+
# # Making calls
|
30
|
+
# @client.api_get 'subscription/list' # to /reader/api/0/...
|
31
|
+
# @client.get 'http://foo' # to arbitrary URL
|
32
|
+
#
|
33
|
+
class Client
|
34
|
+
include Utilities
|
35
|
+
|
36
|
+
AUTH_URL = "https://www.google.com/accounts/ClientLogin"
|
37
|
+
API_URL = "http://www.google.com/reader/api/0/"
|
38
|
+
|
39
|
+
attr_reader :auth
|
40
|
+
attr_reader :email
|
41
|
+
|
42
|
+
# Constructor.
|
43
|
+
#
|
44
|
+
# The constructor can be called without args, but you won't be able to
|
45
|
+
# do anything that requires authentication (which is pretty much
|
46
|
+
# everything).
|
47
|
+
#
|
48
|
+
def initialize(options={})
|
49
|
+
authenticate options if options[:password]
|
50
|
+
@oauth_token = options[:access_token] if options[:access_token]
|
51
|
+
end
|
52
|
+
|
53
|
+
# Authenticates to the Google Reader service.
|
54
|
+
# @return [true] on success
|
55
|
+
def authenticate(options={})
|
56
|
+
@email = options[:email]
|
57
|
+
|
58
|
+
response = RestClient.post AUTH_URL,
|
59
|
+
'service' => 'reader',
|
60
|
+
'continue' => 'http://www.google.com/',
|
61
|
+
'Email' => options[:email],
|
62
|
+
'Passwd' => options[:password],
|
63
|
+
'source' => client_name
|
64
|
+
|
65
|
+
m = /Auth=(.*)/i.match(response.to_s)
|
66
|
+
@auth = m ? m[1] : nil
|
67
|
+
|
68
|
+
true
|
69
|
+
rescue RestClient::Forbidden
|
70
|
+
false
|
71
|
+
end
|
72
|
+
|
73
|
+
def logged_in?
|
74
|
+
!@auth.nil? or !@oauth_token.nil?
|
75
|
+
end
|
76
|
+
|
77
|
+
def token
|
78
|
+
@token ||= api_get 'token'
|
79
|
+
end
|
80
|
+
|
81
|
+
# Expires the cache
|
82
|
+
def expire!
|
83
|
+
@feeds = nil
|
84
|
+
@tags = nil
|
85
|
+
end
|
86
|
+
|
87
|
+
def feeds
|
88
|
+
@feeds ||= begin
|
89
|
+
list = json_get('subscription/list')['subscriptions']
|
90
|
+
list.inject({}) { |h, item| feed = Feed.new self, item; h[feed.to_param] = feed; h }
|
91
|
+
end
|
92
|
+
@feeds.values.sort
|
93
|
+
end
|
94
|
+
|
95
|
+
def feed(what=nil)
|
96
|
+
feeds && @feeds[what.to_s.gsub('/', '_')]
|
97
|
+
end
|
98
|
+
|
99
|
+
def tags
|
100
|
+
@tags ||= begin
|
101
|
+
list = json_get('tag/list')['tags']
|
102
|
+
list.inject({}) { |h, item| tag = Tag.new self, item; h[tag.to_param] = tag; h }
|
103
|
+
end
|
104
|
+
@tags.values.sort
|
105
|
+
end
|
106
|
+
|
107
|
+
def tag(what=nil)
|
108
|
+
tags && @tags[what.gsub('/', '_')]
|
109
|
+
end
|
110
|
+
|
111
|
+
def unread_count
|
112
|
+
json_get 'unread-count', 'all' => 'all'
|
113
|
+
end
|
114
|
+
|
115
|
+
def client_name() "greader.rb/#{GReader.version}"; end
|
116
|
+
|
117
|
+
def get(url, options={})
|
118
|
+
request :get, url, params: options.merge('client' => client_name)
|
119
|
+
end
|
120
|
+
|
121
|
+
def post(url, options={})
|
122
|
+
request :post, url, options.merge('client' => client_name)
|
123
|
+
end
|
124
|
+
|
125
|
+
def json_get(url, options={})
|
126
|
+
JSON.parse get(url, options.merge('output' => 'json'))
|
127
|
+
end
|
128
|
+
|
129
|
+
def request(meth, url, options={})
|
130
|
+
url = API_URL + url
|
131
|
+
|
132
|
+
if @auth
|
133
|
+
auth_request meth, url, options
|
134
|
+
elsif @oauth_token
|
135
|
+
oauth_request meth, url, options
|
136
|
+
else
|
137
|
+
raise Error, "Not logged in"
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
def inspect
|
142
|
+
"#<#{self.class.name}#{email ? ' '+email.inspect : '' }>"
|
143
|
+
end
|
144
|
+
|
145
|
+
protected
|
146
|
+
|
147
|
+
# For those using #auth(email: x, password: x)
|
148
|
+
def auth_request(meth, url, options={})
|
149
|
+
options['Authorization'] = "GoogleLogin auth=#{self.auth}" if logged_in?
|
150
|
+
RestClient.send meth, url, options
|
151
|
+
end
|
152
|
+
|
153
|
+
# For those using #auth(access_token: x)
|
154
|
+
def oauth_request(meth, url, options={})
|
155
|
+
if meth == :get
|
156
|
+
@oauth_token.get url + '?' + kv_map(options[:params])
|
157
|
+
elsif meth == :post
|
158
|
+
@oauth_token.post url, options
|
159
|
+
end.body
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
module GReader
|
2
|
+
# Entries list
|
3
|
+
#
|
4
|
+
# == Common usage
|
5
|
+
#
|
6
|
+
# This is what's returned by methods like {Feed#entries} and
|
7
|
+
# {Tag#entries}.
|
8
|
+
#
|
9
|
+
# entries = tag.entries
|
10
|
+
#
|
11
|
+
# It's a simple array so just use it like so:
|
12
|
+
#
|
13
|
+
# entries.each { |entry| puts entry }
|
14
|
+
#
|
15
|
+
# entries.size #=> 20
|
16
|
+
#
|
17
|
+
# You can also lookup by id:
|
18
|
+
#
|
19
|
+
# feeds.entries['ENTRY_ID']
|
20
|
+
#
|
21
|
+
# But you can have it load more entries:
|
22
|
+
#
|
23
|
+
# entries.more
|
24
|
+
# entries.each { |entry| puts entry }
|
25
|
+
#
|
26
|
+
# entries.size #=> 40
|
27
|
+
#
|
28
|
+
# == Internal usage
|
29
|
+
#
|
30
|
+
# Pass it an Atom URL.
|
31
|
+
#
|
32
|
+
# Entries.fetch @client, @client.atom_url(xxx)
|
33
|
+
#
|
34
|
+
class Entries < Array
|
35
|
+
include Utilities
|
36
|
+
|
37
|
+
attr_reader :continuation # Continuation token (don't use)
|
38
|
+
attr_reader :client
|
39
|
+
|
40
|
+
# Fetch from atom
|
41
|
+
def self.fetch(client, url, options={})
|
42
|
+
doc = client.json_get(url, to_params(options))
|
43
|
+
contents = doc['items'].map do |node|
|
44
|
+
Entry.new client, Entry.parse_json(node)
|
45
|
+
end
|
46
|
+
|
47
|
+
new contents, client, options.merge(:url => url)
|
48
|
+
end
|
49
|
+
|
50
|
+
def initialize(contents, client, options={})
|
51
|
+
super contents
|
52
|
+
@continuation = options[:continuation]
|
53
|
+
@url = options[:url]
|
54
|
+
@client = client
|
55
|
+
end
|
56
|
+
|
57
|
+
def more
|
58
|
+
# self + fetch(@client, @url, :from => @continuation)
|
59
|
+
end
|
60
|
+
|
61
|
+
def +(other)
|
62
|
+
#
|
63
|
+
end
|
64
|
+
|
65
|
+
# Lookup
|
66
|
+
def [](id)
|
67
|
+
indices[slug(id)]
|
68
|
+
end
|
69
|
+
|
70
|
+
protected
|
71
|
+
def indices
|
72
|
+
@indices ||= entries.inject({}) { |h, entry| h[slug(entry.id)] = entry; h }
|
73
|
+
end
|
74
|
+
|
75
|
+
# Converts normal options to Google's equivalents.
|
76
|
+
# See {Feed#entries}.
|
77
|
+
def self.to_params(options={})
|
78
|
+
params = Hash.new
|
79
|
+
params[:n] = options[:limit] if options[:limit]
|
80
|
+
params[:c] = options[:from] if options[:from] # Continuation
|
81
|
+
params[:r] = 'o' if options[:order] == :asc
|
82
|
+
params[:ot] = options[:start_time].to_i if options[:start_time]
|
83
|
+
params
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
@@ -0,0 +1,158 @@
|
|
1
|
+
module GReader
|
2
|
+
# A feed entry.
|
3
|
+
#
|
4
|
+
# == Common usage
|
5
|
+
#
|
6
|
+
# Getting entries:
|
7
|
+
#
|
8
|
+
# feed.entries.each { |entry| }
|
9
|
+
# tag.entries.each { |entry| }
|
10
|
+
# feed.entries['ENTRY_ID'] # See Entry#id below
|
11
|
+
#
|
12
|
+
# Common metadata:
|
13
|
+
#
|
14
|
+
# entry.title #=> "On pride and prejudice" (or #to_s)
|
15
|
+
# entry.content #=> "<p>There was a time where..."
|
16
|
+
# entry.summary #=> "There was a time where..." (no HTML)
|
17
|
+
# entry.image_url # URL of the first image
|
18
|
+
#
|
19
|
+
# More metadata:
|
20
|
+
#
|
21
|
+
# entry.author #=> "Rico Sta. Cruz"
|
22
|
+
# entry.updated #=> #<Date>
|
23
|
+
# entry.published #=> #<Date>
|
24
|
+
# entry.url #=> "http://ricostacruz.com/on-pride-and-prejudice.html"
|
25
|
+
# entry.id #=> "reader_item_128cb290d31352d9"
|
26
|
+
#
|
27
|
+
# Relationships:
|
28
|
+
#
|
29
|
+
# entry.feed #=> #<Feed ...>
|
30
|
+
#
|
31
|
+
# States and actions:
|
32
|
+
#
|
33
|
+
# # Not implemented yet!
|
34
|
+
# entry.read? # Read or not?
|
35
|
+
# entry.starred?
|
36
|
+
#
|
37
|
+
# entry.read! # Mark as read
|
38
|
+
# entry.unread! # Mark as unread
|
39
|
+
#
|
40
|
+
class Entry
|
41
|
+
include Utilities
|
42
|
+
|
43
|
+
attr_reader :content
|
44
|
+
attr_reader :author
|
45
|
+
attr_reader :title
|
46
|
+
attr_reader :published
|
47
|
+
attr_reader :updated
|
48
|
+
attr_reader :url
|
49
|
+
attr_reader :id
|
50
|
+
|
51
|
+
attr_reader :feed
|
52
|
+
attr_reader :client
|
53
|
+
|
54
|
+
alias to_s title
|
55
|
+
|
56
|
+
# Constructor.
|
57
|
+
# Can be called with an options hash or a Nokogiri XML node.
|
58
|
+
def initialize(client=Client.new, options)
|
59
|
+
@client = client
|
60
|
+
|
61
|
+
@feed = client.feed(options[:feed])
|
62
|
+
@author = options[:author]
|
63
|
+
@content = GReader.process_html(options[:content])
|
64
|
+
@title = options[:title]
|
65
|
+
@published = options[:published]
|
66
|
+
@updated = options[:updated]
|
67
|
+
@url = options[:url]
|
68
|
+
@id = options[:id]
|
69
|
+
@read = options[:read]
|
70
|
+
@starred = options[:starred]
|
71
|
+
@options = options
|
72
|
+
end
|
73
|
+
|
74
|
+
def inspect
|
75
|
+
"#<#{self.class.name} \"#{title}\" (#{url})>"
|
76
|
+
end
|
77
|
+
|
78
|
+
def to_param
|
79
|
+
slug @id
|
80
|
+
end
|
81
|
+
|
82
|
+
# Returns a Nokogiri document.
|
83
|
+
def doc
|
84
|
+
@doc ||= Nokogiri.HTML(content)
|
85
|
+
end
|
86
|
+
|
87
|
+
# Returns a short summary
|
88
|
+
# @return [string]
|
89
|
+
# @return [nil]
|
90
|
+
def summary
|
91
|
+
doc = self.doc.dup
|
92
|
+
|
93
|
+
# Remove images and empty paragraphs
|
94
|
+
doc.xpath('*//img').each { |tag| tag.remove }
|
95
|
+
doc.xpath('*//*[normalize-space(.)=""]').each { |tag| tag.remove }
|
96
|
+
|
97
|
+
# The first block
|
98
|
+
el = doc.xpath('//body/p | //body/div').first || doc.xpath('//body').first
|
99
|
+
el and el.text
|
100
|
+
end
|
101
|
+
|
102
|
+
# Returns the {#content} without HTML tags
|
103
|
+
def bare_content
|
104
|
+
strip_tags(content)
|
105
|
+
end
|
106
|
+
|
107
|
+
# Returns the URL of the first image
|
108
|
+
# @return [string]
|
109
|
+
# @return [nil]
|
110
|
+
def image_url
|
111
|
+
url = raw_image_url and begin
|
112
|
+
return nil if url.include?('feedburner.com') # "Share on Facebook"
|
113
|
+
url
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
def raw_image_url
|
118
|
+
img = doc.xpath('*//img').first and img['src']
|
119
|
+
end
|
120
|
+
|
121
|
+
def image?
|
122
|
+
! image_url.nil?
|
123
|
+
end
|
124
|
+
|
125
|
+
def read?
|
126
|
+
@read
|
127
|
+
end
|
128
|
+
|
129
|
+
def starred?
|
130
|
+
@starred
|
131
|
+
end
|
132
|
+
|
133
|
+
# Converts a Noko XML node into a simpler Hash.
|
134
|
+
def self.parse_json(doc)
|
135
|
+
summary = (doc['content'] || doc['summary'])
|
136
|
+
summary = summary['content'] if summary.is_a?(Hash)
|
137
|
+
|
138
|
+
{ :url => doc['alternate'].first['href'],
|
139
|
+
:author => doc['author'],
|
140
|
+
:content => summary,
|
141
|
+
:title => doc['title'],
|
142
|
+
:published => Time.new(doc['published']),
|
143
|
+
:updated => Time.new(doc['updated']),
|
144
|
+
:feed => doc['origin']['streamId'],
|
145
|
+
:id => doc['id'],
|
146
|
+
:read => doc['categories'].any? { |s| s =~ /com\.google\/fresh$/ },
|
147
|
+
:starred => doc['categories'].any? { |s| s =~ /com\.google\/starred$/ }
|
148
|
+
# Also available: comments [], annotations [], enclosure
|
149
|
+
# [{href,type,length}]
|
150
|
+
}
|
151
|
+
rescue NoMethodError
|
152
|
+
raise ParseError.new("Malformed entries", doc)
|
153
|
+
end
|
154
|
+
|
155
|
+
# def read!
|
156
|
+
# def unread!
|
157
|
+
end
|
158
|
+
end
|