greader 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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