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.
@@ -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
@@ -0,0 +1,5 @@
1
+ $:.push File.expand_path('../test', __FILE__)
2
+
3
+ task :test do
4
+ Dir['test/**/*_test.rb'].each { |f| load f }
5
+ end
@@ -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