greader 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|