knu-friendfeed 0.1.1 → 0.1.2

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/bin/tw2ff ADDED
@@ -0,0 +1,332 @@
1
+ #!/usr/bin/env ruby
2
+ # -*- mode: ruby -*-
3
+
4
+ $KCODE = 'u'
5
+
6
+ require 'pathname'
7
+
8
+ $LOAD_PATH.unshift(Pathname($0).dirname.parent.join('lib'))
9
+
10
+ require 'rubygems'
11
+ require 'digest'
12
+ require 'friendfeed'
13
+ require 'friendfeed/unofficial'
14
+ require 'main'
15
+ require 'mechanize'
16
+ require 'uri'
17
+ require 'tempfile'
18
+ require 'twitter'
19
+ require 'yaml'
20
+ require 'yaml/store'
21
+
22
+ MYNAME = File.basename($0)
23
+
24
+ TWITTER_URI = URI.parse('http://twitter.com/')
25
+
26
+ def ConfigDir()
27
+ $config_dir ||=
28
+ begin
29
+ config_dir = File.expand_path('~/.%s' % MYNAME)
30
+ if !File.directory?(config_dir)
31
+ Dir.mkdir(config_dir, 0700)
32
+ end
33
+ config_dir
34
+ end
35
+ end
36
+
37
+ def ConfigFile()
38
+ $config_file ||= File.join(ConfigDir(), 'config.yml')
39
+ end
40
+
41
+ def Config(keypath, default = :omitted)
42
+ $config ||= YAML.load_file(ConfigFile())
43
+
44
+ keypath.split('.').inject($config) { |hash, key|
45
+ hash.is_a?(Hash) or raise TypeError
46
+ hash.fetch(key)
47
+ }
48
+ rescue => e
49
+ return default if default != :omitted
50
+
51
+ STDERR.print <<EOM
52
+ The key "#{keypath}" is missing in #{ConfigFile()}.
53
+ Please edit the file to look like the following:
54
+
55
+ ---
56
+ friendfeed:
57
+ username: "username"
58
+ password: "password"
59
+ twitter:
60
+ username: "username"
61
+ password: "password"
62
+ EOM
63
+ exit 1
64
+ end
65
+
66
+ def puterror(message)
67
+ STDERR.puts MYNAME + ': ' + e.to_s
68
+ end
69
+
70
+ def putinfo(fmt, *args)
71
+ STDERR.puts sprintf(fmt, *args)
72
+ end
73
+
74
+ def Status(key)
75
+ $status ||= YAML::Store.new(File.join(ConfigDir(), 'status.yml'))
76
+ if block_given?
77
+ $status.transaction(false) {
78
+ return $status[key] = yield
79
+ }
80
+ else
81
+ $status.transaction(true) {
82
+ return $status[key]
83
+ }
84
+ end
85
+ end
86
+
87
+ def friendfeed_client
88
+ $ff_client ||=
89
+ begin
90
+ username = Config('friendfeed.username')
91
+ password = Config('friendfeed.password')
92
+ putinfo 'Logging in to FriendFeed as %s', username
93
+ FriendFeed::Client.new.login(username, password)
94
+ end
95
+ end
96
+
97
+ class Twitter::Base
98
+ def all_friends
99
+ list = []
100
+ (1..100).each { |i|
101
+ slice = friends(:page => i)
102
+ list.concat(slice)
103
+ break if slice.size < 100
104
+ }
105
+ list
106
+ end
107
+ end
108
+
109
+ def twitter_client
110
+ $tw_client ||=
111
+ begin
112
+ username = Config('twitter.username')
113
+ password = Config('twitter.password')
114
+ putinfo 'Logging in to Twitter as %s', username
115
+ Twitter::Base.new(Twitter::HTTPAuth.new(username, password))
116
+ end
117
+ end
118
+
119
+ Main {
120
+ description 'Twitter to FriendFeed migration helper'
121
+
122
+ def run
123
+ print usage.to_s
124
+ end
125
+
126
+ mode 'sync' do
127
+ description 'Add imaginary friends for Twitter-only friends'
128
+
129
+ def run
130
+ require 'set'
131
+
132
+ ffcli = friendfeed_client()
133
+
134
+ subscribed_real = Set[]
135
+ subscribed_imag = Set[]
136
+
137
+ putinfo "Checking real friends in FriendFeed..."
138
+ ffcli.get_real_friends.each { |profile|
139
+ profile['services'].each { |service|
140
+ url = service['profileUrl'] or next
141
+ if (name = TWITTER_URI.route_to(url).to_s).match(/\A[A-Za-z0-9_]+\z/)
142
+ putinfo 'Found a Twitter friend %s in FriendFeed', name
143
+ subscribed_real << name
144
+ end
145
+ }
146
+ }
147
+
148
+ putinfo "Checking imaginary friends in FriendFeed..."
149
+ ffcli.get_imaginary_friends.each { |profile|
150
+ profile['services'].each { |service|
151
+ url = service['profileUrl'] or next
152
+ if (name = TWITTER_URI.route_to(url).to_s).match(/\A[A-Za-z0-9_]+\z/)
153
+ putinfo 'Found a Twitter friend %s in FriendFeed (imaginary)', name
154
+ subscribed_imag << name
155
+ end
156
+ }
157
+ }
158
+
159
+ putinfo "Checking groups in FriendFeed..."
160
+ ffcli.get_profile['rooms'].each { |room|
161
+ ffcli.get_services(room['nickname']).each { |service|
162
+ url = service['profileUrl'] or next
163
+ if (name = TWITTER_URI.route_to(url).to_s).match(/\A[A-Za-z0-9_]+\z/)
164
+ putinfo 'Found a Twitter friend %s in FriendFeed (group)', name
165
+ subscribed_imag << name
166
+ end
167
+ }
168
+ }
169
+
170
+ Status('friends_subscribed_real') { subscribed_real.sort }
171
+ Status('friends_subscribed_imag') { subscribed_imag.sort }
172
+
173
+ (subscribed_real & subscribed_imag).each { |name|
174
+ putinfo 'Duplicated subscription: %s', name
175
+ }
176
+
177
+ subscribed = subscribed_real + subscribed_imag
178
+
179
+ friends = Set[]
180
+ to_subscribe = Set[]
181
+ to_watch = Set[]
182
+ picture_urls = {}
183
+
184
+ twitter_client().all_friends.each { |friend|
185
+ name = friend.screen_name
186
+ friends << name
187
+ next if subscribed.include?(name)
188
+
189
+ if friend.protected
190
+ to_watch << name
191
+ else
192
+ to_subscribe << name
193
+ picture_urls[name] = friend.profile_image_url
194
+ end
195
+ }
196
+ friends << Config('twitter.username')
197
+
198
+ Status('friends') { friends.sort }
199
+ Status('friends_to_watch') { to_watch.sort }
200
+
201
+ to_watch.each { |name|
202
+ putinfo 'Skipping a protected user %s', name
203
+ }
204
+
205
+ agent = WWW::Mechanize.new
206
+
207
+ to_subscribe.each { |name|
208
+ putinfo 'Creating an imaginary friend for %s', name
209
+ id = ffcli.create_imaginary_friend('(%s)' % name)
210
+ ffcli.add_twitter(id, name)
211
+ if picture_urls.key?(name)
212
+ putinfo 'Setting the picture of %s', name
213
+ t = Tempfile.open("picture")
214
+ t.write agent.get_file(picture_urls[name])
215
+ t.close
216
+ File.open(t.path) { |f|
217
+ ffcli.change_picture(id, f)
218
+ }
219
+ end
220
+ }
221
+ end
222
+ end
223
+
224
+ mode 'replies' do
225
+ description 'Produce an RSS feed for Twitter replies from non-friends'
226
+
227
+ argument('filename') {
228
+ description 'Specifies a flie to write RSS to'
229
+ }
230
+
231
+ def run
232
+ require 'nokogiri'
233
+ require 'rss'
234
+ require 'set'
235
+ require 'time'
236
+
237
+ filename = params['filename'].value
238
+
239
+ File.open(filename, 'w') { |w|
240
+ feed = RSS::Maker.make("2.0") { |rss|
241
+ rss.channel.title = 'Twitter replies from non-friends'
242
+ rss.channel.link = 'http://twitter.com/replies'
243
+ rss.channel.description = 'Twitter replies from non-friends'
244
+
245
+ friends = Status('friends').to_set
246
+
247
+ twitter_client().replies.each { |reply|
248
+ user = reply.user
249
+ next if user.protected
250
+ name = user.screen_name
251
+ next if friends.include?(name)
252
+ text = '%s: %s' % [name, reply.text]
253
+ url = 'http://twitter.com/%s/statuses/%d' % [name, reply.id]
254
+ timestamp = Time.parse(reply.created_at)
255
+ rss.items.new_item { |item|
256
+ item.title = Nokogiri.HTML(text).inner_text
257
+ item.link = url
258
+ item.description = text
259
+ item.date = timestamp
260
+ }
261
+ }
262
+ }
263
+ w.print feed.to_s
264
+ }
265
+ end
266
+ end
267
+
268
+ mode 'protected' do
269
+ description 'Produce an RSS feed for Twitter entries from protected friends'
270
+
271
+ argument('filename') {
272
+ description 'Specifies a flie to write RSS to'
273
+ }
274
+
275
+ def run
276
+ require 'nokogiri'
277
+ require 'rss'
278
+ require 'set'
279
+ require 'time'
280
+
281
+ filename = params['filename'].value
282
+
283
+ friends = Status('friends').to_set
284
+ friends_subscribed_real = Status('friends_subscribed_real').to_set
285
+
286
+ items = []
287
+
288
+ twitter_client().replies.each { |reply|
289
+ user = reply.user
290
+ next if !user.protected
291
+ name = user.screen_name
292
+ next if friends.include?(name)
293
+
294
+ text = '[%s]: %s' % [name, reply.text]
295
+ url = 'http://twitter.com/%s/statuses/%d' % [name, reply.id]
296
+ timestamp = Time.parse(reply.created_at)
297
+ items << [timestamp, text, url]
298
+ }
299
+
300
+ twitter_client().friends_timeline.each { |status|
301
+ user = status.user
302
+ next if !user.protected
303
+ name = user.screen_name
304
+ next if friends_subscribed_real.include?(name)
305
+ text = '[%s]: %s' % [name, status.text]
306
+ url = 'http://twitter.com/%s/statuses/%d' % [name, status.id]
307
+ timestamp = Time.parse(status.created_at)
308
+ items << [timestamp, text, url]
309
+ }
310
+
311
+ File.open(filename, 'w') { |w|
312
+ feed = RSS::Maker.make("2.0") { |rss|
313
+ rss.channel.title = 'Twitter entries from protected friends'
314
+ rss.channel.link = 'http://twitter.com/home'
315
+ rss.channel.description = 'Twitter entries from protected friends'
316
+
317
+ items.sort { |a, b|
318
+ b.first <=> a.first
319
+ }.each { |timestamp, text, url|
320
+ rss.items.new_item { |item|
321
+ item.title = Nokogiri.HTML(text).inner_text
322
+ item.link = url
323
+ item.description = text
324
+ item.date = timestamp
325
+ }
326
+ }
327
+ }
328
+ w.print feed.to_s
329
+ }
330
+ end
331
+ end
332
+ }
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env ruby
2
+ #--
3
+ # friendfeed/compat.rb - defines compatibility methods for older Ruby
4
+ #++
5
+ # Copyright (c) 2009 Akinori MUSHA <knu@iDaemons.org>
6
+ #
7
+ # All rights reserved. You can redistribute and/or modify it under the same
8
+ # terms as Ruby.
9
+ #
10
+
11
+ class Hash
12
+ def self.try_convert(obj)
13
+ return obj if obj.instance_of?(self)
14
+ return nil if !obj.respond_to?(:to_hash)
15
+ nobj = obj.to_hash
16
+ return nobj if nobj.instance_of?(self)
17
+ raise TypeError, format("can't convert %s to %s (%s#to_hash gives %s)", obj.class, self.class, obj.class, nobj.class)
18
+ end unless self.respond_to?(:try_convert)
19
+ end
20
+
21
+ class IO
22
+ def self.try_convert(obj)
23
+ return obj if obj.is_a?(self)
24
+ return nil if !obj.respond_to?(:to_io)
25
+ nobj = obj.to_io
26
+ return nobj if nobj.instance_of?(self)
27
+ raise TypeError, format("can't convert %s to %s (%s#to_io gives %s)", obj.class, self.class, obj.class, nobj.class)
28
+ end unless self.respond_to?(:try_convert)
29
+ end
30
+
31
+ class String
32
+ def self.try_convert(obj)
33
+ return obj if obj.instance_of?(self)
34
+ return nil if !obj.respond_to?(:to_str)
35
+ nobj = obj.to_str
36
+ return nobj if nobj.instance_of?(self)
37
+ raise TypeError, format("can't convert %s to %s (%s#to_str gives %s)", obj.class, self.class, obj.class, nobj.class)
38
+ end unless self.respond_to?(:try_convert)
39
+ end
40
+
41
+ class Array
42
+ def self.try_convert(obj)
43
+ return obj if obj.instance_of?(self)
44
+ return nil if !obj.respond_to?(:to_ary)
45
+ nobj = obj.to_ary
46
+ return nobj if nobj.instance_of?(self)
47
+ raise TypeError, format("can't convert %s to %s (%s#to_ary gives %s)", obj.class, self.class, obj.class, nobj.class)
48
+ end unless self.respond_to?(:try_convert)
49
+ end
@@ -0,0 +1,314 @@
1
+ #!/usr/bin/env ruby
2
+ #--
3
+ # friendfeed/unofficial.rb - provides access to FriendFeed unofficial API's
4
+ #++
5
+ # Copyright (c) 2009 Akinori MUSHA <knu@iDaemons.org>
6
+ #
7
+ # All rights reserved. You can redistribute and/or modify it under the same
8
+ # terms as Ruby.
9
+ #
10
+
11
+ require 'friendfeed'
12
+ require 'rubygems'
13
+ require 'json'
14
+ require 'mechanize'
15
+ require 'uri'
16
+
17
+ module FriendFeed
18
+ class Client
19
+ #
20
+ # Unofficial API
21
+ #
22
+
23
+ LOGIN_URI = ROOT_URI + "/account/login"
24
+
25
+ private
26
+
27
+ def get_login_agent
28
+ @login_agent or raise 'login() must be called first to use this feature'
29
+ end
30
+
31
+ public
32
+
33
+ # Performs a login with a +nickname+ and +password+ and returns
34
+ # self. This enables call of any API, including both official API
35
+ # and unofficial API.
36
+ def login(nickname, password)
37
+ @nickname = nickname
38
+ @password = password
39
+ @login_agent = WWW::Mechanize.new
40
+
41
+ page = @login_agent.get(LOGIN_URI)
42
+
43
+ login_form = page.forms.find { |form|
44
+ LOGIN_URI + form.action == LOGIN_URI
45
+ } or raise 'Cannot locate a login form'
46
+
47
+ login_form.set_fields(:email => @nickname, :password => @password)
48
+
49
+ page = login_form.submit
50
+
51
+ login_form = page.forms.find { |form|
52
+ LOGIN_URI + form.action == LOGIN_URI
53
+ } and raise 'Login failed'
54
+
55
+ page = @login_agent.get(ROOT_URI + "/account/api")
56
+ remote_key = page.parser.xpath("//td[text()='FriendFeed remote key:']/following-sibling::td[1]/text()").to_s
57
+
58
+ api_login(nickname, remote_key)
59
+ end
60
+
61
+ # Posts a request to an internal API of FriendFeed and returns
62
+ # either a parser object for an HTML response or an object parsed
63
+ # from a JSON response). [unofficial]
64
+ def post(uri, query = {})
65
+ agent = get_login_agent()
66
+
67
+ page = agent.post(uri, {
68
+ 'at' => agent.cookies.find { |cookie|
69
+ cookie.domain == 'friendfeed.com' && cookie.name == 'AT'
70
+ }.value
71
+ }.update(query))
72
+ if page.respond_to?(:parser)
73
+ parser = page.parser
74
+ messages = parser.xpath("//div[@id='errormessage']/text()")
75
+ messages.empty? or
76
+ raise messages.map { |message| message.to_s }.join(" ")
77
+ parser
78
+ else
79
+ json = JSON.parse(page.body)
80
+ message = json['error'] and
81
+ raise message
82
+ if html_frag = json['html']
83
+ html_body = '<html><body>' << html_frag << '</body></html>'
84
+ json['html_parser'] = WWW::Mechanize.html_parser.parse(html_body)
85
+ end
86
+ json
87
+ end
88
+ end
89
+
90
+ # Gets a list of services of a user or a room of a given
91
+ # +nickname+, defaulted to the authenticated user.
92
+ def get_services(nickname = @nickname)
93
+ agent = get_login_agent()
94
+
95
+ services_uri = ROOT_URI + ("/%s/services" % URI.encode(nickname))
96
+ parser = agent.get(services_uri).parser
97
+
98
+ active_servicelist = parser.xpath("//*[@class='active']//ul[@class='servicelist']")
99
+
100
+ if !active_servicelist.empty?
101
+ services = active_servicelist.xpath("./li/a").map { |a|
102
+ {
103
+ 'service' => a['class'].split.find { |a_class|
104
+ a_class != 'l_editservice' && a_class != 'service'
105
+ },
106
+ 'serviceid' => a['serviceid'].to_s,
107
+ }
108
+ }
109
+ profile_uri = ROOT_URI + ("/%s" % URI.encode(nickname))
110
+ agent.get(profile_uri).parser.xpath("//div[@class='servicespreview']/a").each_with_index { |a, i|
111
+ href = (profile_uri + a['href'].to_s).to_s
112
+ break if profile_uri.route_to(href).relative?
113
+ services[i]['profileUrl'] = href
114
+ }
115
+ else
116
+ services = parser.xpath("//ul[@class='servicelist']/li/a").map { |a|
117
+ {
118
+ 'service' => a['class'].split.find { |a_class|
119
+ a_class != 'service'
120
+ },
121
+ 'profileUrl' => a['href'].to_s,
122
+ }
123
+ }
124
+ end
125
+ services
126
+ end
127
+
128
+ # Creates a new feed of a given (unique) +nickname+ and display
129
+ # +name+, and returns a unique ID string on success. The +type+
130
+ # can be one of "group", "microblog" and "public". Like other
131
+ # methods in general, an exception is raised on
132
+ # failure. [unofficial]
133
+ def create_group(nickname, name, type = 'group')
134
+ post(ROOT_URI + '/a/createfeed', 'nickname' => nickname, 'name' => name, 'type' => type).xpath("(//a[@class='l_feedinvite'])[1]/@sid").to_s
135
+ end
136
+
137
+ EDIT_GROUP_URI = ROOT_URI + '/a/editprofile'
138
+
139
+ # Gets profile information of a group specified by a unique
140
+ # ID. [unofficial]
141
+ def get_group(id)
142
+ parser = post(ROOT_URI + '/a/profiledialog', 'stream' => id)['html_parser']
143
+ form = parser.xpath("//form[1]")
144
+ hash = { 'stream' => id }
145
+ form.xpath(".//input").each { |input|
146
+ case input['type'].downcase
147
+ when 'text'
148
+ hash[input['name']] = input['value']
149
+ when 'radio', 'checkbox'
150
+ if input['checked']
151
+ value = input['value']
152
+ if value && !value.empty?
153
+ hash[input['name']] = value
154
+ end
155
+ end
156
+ end
157
+ }
158
+ form.xpath(".//textarea").each { |input|
159
+ hash[input['name']] = input.text
160
+ }
161
+ hash
162
+ end
163
+
164
+ # Edits profile information of a group specified by a unique ID.
165
+ # Supported fields are 'nickname', 'name', 'description', 'access'
166
+ # ('private', 'semipublic' or 'public'), and 'anyoneinvite' (none
167
+ # or '1'). [unofficial]
168
+ def edit_group(id, hash)
169
+ param_hash = get_group(id)
170
+ param_hash.update(hash)
171
+ post(EDIT_GROUP_URI, param_hash)
172
+ end
173
+
174
+ # Adds a feed to the authenticated user, a group or an imaginary
175
+ # friend specified by a unique ID. Specify 'isstatus' => 'on' to
176
+ # display entries as messages (no link), and 'importcomment' =>
177
+ # 'on' to include entry description as a comment. [unofficial]
178
+ def add_service(id, service, options = nil)
179
+ params = {
180
+ 'stream' => id,
181
+ 'service' => service,
182
+ }
183
+ params.update(options) if options
184
+ post(ROOT_URI + '/a/configureservice', params)
185
+ end
186
+
187
+ # Edits a service of the authenticated user, a group or an
188
+ # imaginary friend specified by a unique ID. [unofficial]
189
+ def edit_service(id, serviceid, service, options = nil)
190
+ params = {
191
+ 'stream' => id,
192
+ 'service' => service,
193
+ 'serviceid' => serviceid,
194
+ }
195
+ params.update(options) if options
196
+ post(ROOT_URI + '/a/configureservice', params)
197
+ end
198
+
199
+ # Removes a service of the authenticated user, a group or an
200
+ # imaginary friend specified by a unique ID. Specify
201
+ # 'deleteentries' => 'on' to delete entries also. [unofficial]
202
+ def remove_service(id, serviceid, service, options = nil)
203
+ params = {
204
+ 'stream' => id,
205
+ 'service' => service,
206
+ 'serviceid' => serviceid,
207
+ }
208
+ params.update(options) if options
209
+ post(ROOT_URI + '/a/removeservice', params)
210
+ end
211
+
212
+ # Refreshes a feed of the authenticated user, a group or an
213
+ # imaginary friend specified by a unique ID. [unofficial]
214
+ def refresh_service(id, serviceid, service, options = nil)
215
+ params = {
216
+ 'refresh' => 1,
217
+ }
218
+ params.update(options) if options
219
+ edit_service(id, serviceid, service, params)
220
+ end
221
+
222
+ # Adds a feed to the authenticated user, a group or an imaginary
223
+ # friend specified by a unique ID. Specify 'isstatus' => 'on' to
224
+ # display entries as messages (no link), and 'importcomment' =>
225
+ # 'on' to include entry description as a comment. [unofficial]
226
+ def add_feed(id, url, options = nil)
227
+ params = { 'url' => url }
228
+ params.update(options) if options
229
+ add_service(id, 'feed', options)
230
+ end
231
+
232
+ # Adds a Twitter service to the authenticated user, a group or an
233
+ # imaginary friend specified by a unique ID. [unofficial]
234
+ def add_twitter(id, twitter_name)
235
+ add_service(id, 'twitter', 'username' => twitter_name)
236
+ end
237
+
238
+ # Edits a feed of the authenticated user, a group or an imaginary
239
+ # friend specified by a unique ID. Specify 'isstatus' => 'on' to
240
+ # display entries as messages (no link), and 'importcomment' =>
241
+ # 'on' to include entry description as a comment. [unofficial]
242
+ def edit_feed(id, serviceid, url, options = nil)
243
+ params = { 'url' => url }
244
+ params.update(options) if options
245
+ add_service(id, 'feed', options)
246
+ end
247
+
248
+ # Edits a Twitter service of the authenticated user, a group or an
249
+ # imaginary friend specified by a unique ID. Specify 'isstatus'
250
+ # => 'on' to display entries as messages (no link), and
251
+ # 'importcomment' => 'on' to include entry description as a
252
+ # comment. [unofficial]
253
+ def edit_twitter(id, serviceid, twitter_name)
254
+ edit_service(id, serviceid, 'twitter', 'username' => twitter_name)
255
+ end
256
+
257
+ # Removes a feed from the authenticated user, a group or an
258
+ # imaginary friend specified by a unique ID. Specify
259
+ # 'deleteentries' => 'on' to delete entries also. [unofficial]
260
+ def remove_feed(id, serviceid, url, options = nil)
261
+ params = { 'url' => url }
262
+ params.update(options) if options
263
+ remove_service(id, serviceid, 'feed', options = nil)
264
+ end
265
+
266
+ # Removes a Twitter service from the authenticated user, a group
267
+ # or an imaginary friend specified by a unique ID. Specify
268
+ # 'deleteentries' => 'on' to delete entries also. [unofficial]
269
+ def remove_twitter(id, serviceid, twitter_name, options = nil)
270
+ params = { 'username' => twitter_name }
271
+ params.update(options) if options
272
+ remove_service(id, serviceid, 'twitter', options = nil)
273
+ end
274
+
275
+ # Changes the picture of the authenticated user, a group or an
276
+ # imaginary friend. [unofficial]
277
+ def change_picture(id, io)
278
+ post(ROOT_URI + '/a/changepicture', 'stream' => id,
279
+ 'picture' => io)
280
+ end
281
+
282
+ # Unsubscribe from a friend, a group or an imaginary friend
283
+ # specified by a unique ID. [unofficial]
284
+ def unsubscribe_from(id)
285
+ post(ROOT_URI + '/a/unsubscribe', 'stream' => id)
286
+ end
287
+
288
+ # Creates an imaginary friend of a given +nickname+ and returns a
289
+ # unique ID string on success. Like other methods in general, an
290
+ # exception is raised on failure. [unofficial]
291
+ def create_imaginary_friend(nickname)
292
+ post(ROOT_URI + '/a/createimaginary', 'name' => nickname).xpath("//*[@id='serviceseditor']/@streamid").to_s
293
+ end
294
+
295
+ # Renames an imaginary friend specified by a unique ID to a given
296
+ # +nickname+. [unofficial]
297
+ def rename_imaginary_friend(id, nickname)
298
+ parser = post(ROOT_URI + '/a/profiledialog', 'stream' => id)['html_parser']
299
+ form = parser.xpath("//form[1]")
300
+ hash = { 'stream' => id }
301
+ form.xpath(".//input").each { |input|
302
+ case input['type'].downcase
303
+ when 'text'
304
+ hash[input['name']] = input['value']
305
+ end
306
+ }
307
+ form.xpath(".//textarea").each { |input|
308
+ hash[input['name']] = input.text
309
+ }
310
+ hash['name'] = nickname
311
+ post(ROOT_URI + '/a/editprofile', hash)
312
+ end
313
+ end
314
+ end
data/lib/friendfeed.rb ADDED
@@ -0,0 +1,478 @@
1
+ #!/usr/bin/env ruby
2
+ #--
3
+ # friendfeed.rb - provides access to FriendFeed API's
4
+ #++
5
+ # Copyright (c) 2009 Akinori MUSHA <knu@iDaemons.org>
6
+ #
7
+ # All rights reserved. You can redistribute and/or modify it under the same
8
+ # terms as Ruby.
9
+ #
10
+
11
+ require 'rubygems'
12
+ require 'json'
13
+ require 'mechanize'
14
+ require 'uri'
15
+ require 'friendfeed/compat'
16
+
17
+ module FriendFeed
18
+ ROOT_URI = URI.parse("https://friendfeed.com/")
19
+
20
+ # Client library for FriendFeed API.
21
+ class Client
22
+ attr_reader :nickname
23
+
24
+ #
25
+ # Official API
26
+ #
27
+
28
+ API_URI = ROOT_URI + "/api/"
29
+
30
+ private
31
+
32
+ def get_api_agent
33
+ @api_agent ||= WWW::Mechanize.new
34
+ end
35
+
36
+ def validate
37
+ call_api('validate')
38
+ end
39
+
40
+ def require_api_login
41
+ @nickname or raise 'not logged in'
42
+ end
43
+
44
+ def call_subscription_api(path)
45
+ require_api_login
46
+
47
+ uri = API_URI + path
48
+
49
+ agent = WWW::Mechanize.new
50
+ agent.auth('username', @nickname)
51
+ JSON.parse(agent.post(uri, { 'apikey' => @remote_key }).body)
52
+ end
53
+
54
+ public
55
+
56
+ attr_reader :nickname, :remote_key
57
+
58
+ # Performs a login with a +nickname+ and +remote key+ and returns
59
+ # self. This enables call of any official API that requires
60
+ # authentication. It is not needed to call this method if you
61
+ # have called login(), which internally obtains a remote key and
62
+ # calls this method. An exception is raised if authentication
63
+ # fails.
64
+ def api_login(nickname, remote_key)
65
+ @nickname = nickname
66
+ @remote_key = remote_key
67
+ @api_agent = get_api_agent()
68
+ @api_agent.auth(@nickname, @remote_key)
69
+ validate
70
+
71
+ self
72
+ end
73
+
74
+ # Calls an official API specified by a +path+ with optional
75
+ # +get_parameters+ and +post_parameters+, and returns an object
76
+ # parsed from a JSON response. If +post_parameters+ is given, a
77
+ # POST request is issued. A GET request is issued otherwise.
78
+ def call_api(path, get_parameters = nil, post_parameters = nil, raw = false)
79
+ api_agent = get_api_agent()
80
+
81
+ uri = API_URI + path
82
+ if get_parameters
83
+ uri.query = get_parameters.map { |key, value|
84
+ if array = Array.try_convert(value)
85
+ value = array.join(',')
86
+ end
87
+ URI.encode(key) + "=" + URI.encode(value)
88
+ }.join('&')
89
+ end
90
+
91
+ if post_parameters
92
+ body = api_agent.post(uri, post_parameters).body
93
+ else
94
+ body = api_agent.get_file(uri)
95
+ end
96
+
97
+ if raw
98
+ body
99
+ else
100
+ JSON.parse(body)
101
+ end
102
+ end
103
+
104
+ # Gets profile information of a user of a given +nickname+,
105
+ # defaulted to the authenticated user, in hash.
106
+ def get_profile(nickname = @nickname)
107
+ nickname or require_api_login
108
+ call_api('user/%s/profile' % URI.encode(nickname))
109
+ end
110
+
111
+ # Edits profile information of the authenticated user. The fields
112
+ # "name" and "picture" are supported.
113
+ def edit_profile(hash)
114
+ nickname or require_api_login
115
+ call_api('user/%s/profile' % URI.encode(nickname), nil, hash)
116
+ end
117
+
118
+ # Gets an array of profile information of users of given
119
+ # +nicknames+.
120
+ def get_profiles(nicknames)
121
+ call_api('profiles', 'nickname' => nicknames)['profiles']
122
+ end
123
+
124
+ # Gets an array of profile information of friends of a user of a
125
+ # given +nickname+ (defaulted to the authenticated user) is
126
+ # subscribing to.
127
+ def get_real_friends(nickname = @nickname)
128
+ nickname or require_api_login
129
+ nicknames = []
130
+ get_profile(@nickname)['subscriptions'].each { |subscription|
131
+ if nickname = subscription['nickname']
132
+ nicknames << nickname
133
+ end
134
+ }
135
+ get_profiles(nicknames)
136
+ end
137
+
138
+ # Gets an array of profile information of the authenticated user's
139
+ # imaginary friends.
140
+ def get_imaginary_friends
141
+ nickname or require_api_login
142
+ profiles = []
143
+ get_profile(@nickname)['subscriptions'].each { |subscription|
144
+ if subscription['nickname'].nil?
145
+ profiles << get_profile(subscription['id'])
146
+ end
147
+ }
148
+ profiles
149
+ end
150
+
151
+ # Gets profile information of one of the authenticated user's
152
+ # imaginary friends.
153
+ def get_imaginary_friend(id)
154
+ get_profile(id)
155
+ end
156
+
157
+ # Gets an array of the most recent public entries.
158
+ def get_public_entries()
159
+ call_api('feed/public')['entries']
160
+ end
161
+
162
+ # Gets an array of the entries the authenticated user would see on
163
+ # their home page.
164
+ def get_home_entries()
165
+ require_api_login
166
+ call_api('feed/home')['entries']
167
+ end
168
+
169
+ # Gets an array of the entries for the authenticated user's list
170
+ # of a given +nickname+
171
+ def get_list_entries(nickname)
172
+ require_api_login
173
+ call_api('feed/list/%s' % URI.encode(nickname))['entries']
174
+ end
175
+
176
+ # Gets an array of the most recent entries from a user of a given
177
+ # +nickname+ (defaulted to the authenticated user).
178
+ def get_user_entries(nickname = @nickname)
179
+ nickname or require_api_login
180
+ call_api('feed/user/%s' % URI.encode(nickname))['entries']
181
+ end
182
+
183
+ # Gets an array of the most recent entries from users of given
184
+ # +nicknames+.
185
+ def get_multi_user_entries(nicknames)
186
+ call_api('feed/user', 'nickname' => nicknames)['entries']
187
+ end
188
+
189
+ # Gets an array of the most recent entries a user of a given
190
+ # +nickname+ (defaulted to the authenticated user) has commented
191
+ # on.
192
+ def get_user_commented_entries(nickname = @nickname)
193
+ nickname or require_api_login
194
+ call_api('feed/user/%s/comments' % URI.encode(nickname))['entries']
195
+ end
196
+
197
+ # Gets an array of the most recent entries a user of a given
198
+ # +nickname+ (defaulted to the authenticated user) has like'd.
199
+ def get_user_liked_entries(nickname = @nickname)
200
+ nickname or require_api_login
201
+ call_api('feed/user/%s/likes' % URI.encode(nickname))['entries']
202
+ end
203
+
204
+ # Gets an array of the most recent entries a user of a given
205
+ # +nickname+ (defaulted to the authenticated user) has commented
206
+ # on or like'd.
207
+ def get_user_discussed_entries(nickname = @nickname)
208
+ nickname or require_api_login
209
+ call_api('feed/user/%s/discussion' % URI.encode(nickname))['entries']
210
+ end
211
+
212
+ # Gets an array of the most recent entries from friends of a user
213
+ # of a given +nickname+ (defaulted to the authenticated user).
214
+ def get_user_friend_entries(nickname = @nickname)
215
+ nickname or require_api_login
216
+ call_api('feed/user/%s/friends' % URI.encode(nickname))['entries']
217
+ end
218
+
219
+ # Gets an array of the most recent entries in a room of a given
220
+ # +nickname+.
221
+ def get_room_entries(nickname)
222
+ call_api('feed/room/%s' % URI.encode(nickname))['entries']
223
+ end
224
+
225
+ # Gets an array of the entries the authenticated user would see on
226
+ # their rooms page.
227
+ def get_rooms_entries()
228
+ call_api('feed/rooms')['entries']
229
+ end
230
+
231
+ # Gets an entry of a given +entryid+. An exception is raised when
232
+ # it fails.
233
+ def get_entry(entryid)
234
+ call_api('feed/entry/%s' % URI.encode(entryid))['entries'].first
235
+ end
236
+
237
+ # Gets an array of entries of given +entryids+. An exception is
238
+ # raised when it fails.
239
+ def get_entries(entryids)
240
+ call_api('feed/entry', 'entry_id' => entryids)['entries']
241
+ end
242
+
243
+ # Gets an array of entries that match a given +query+.
244
+ def search(query)
245
+ call_api('feed/search', 'q' => query)['entries']
246
+ end
247
+
248
+ # Gets an array of entries that link to a given +url+.
249
+ def search_for_url(url, options = nil)
250
+ new_options = { 'url' => url }
251
+ new_options.merge!(options) if options
252
+ call_api('feed/url', new_options)['entries']
253
+ end
254
+
255
+ # Gets an array of entries that link to a given +domain+.
256
+ def search_for_domain(url, options = nil)
257
+ new_options = { 'url' => url }
258
+ new_options.merge!(options) if options
259
+ call_api('feed/domain', new_options)['entries']
260
+ end
261
+
262
+ # Publishes (shares) a given entry.
263
+ def add_entry(title, options = nil)
264
+ require_api_login
265
+ new_options = { 'title' => title }
266
+ if options
267
+ options.each { |key, value|
268
+ case key = key.to_s
269
+ when 'title', 'link', 'comment', 'room'
270
+ new_options[key] = value
271
+ when 'images'
272
+ value.each_with_index { |value, i|
273
+ if url = String.try_convert(value)
274
+ link = nil
275
+ else
276
+ if array = Array.try_convert(value)
277
+ value1, value2 = *array
278
+ elsif hash = Hash.try_convert(value)
279
+ value1, value2 = *hash.values_at('url', 'link')
280
+ else
281
+ raise TypeError, "Each image must be specified by <image URL>, [<image URL>, <link URL>], or {'url' => <image URL>, 'link' => <link URL>}."
282
+ end
283
+ url = String.try_convert(value1) or
284
+ raise TypeError, "can't convert #{value1.class} into String"
285
+ link = String.try_convert(value2) or
286
+ raise TypeError, "can't convert #{value2.class} into String"
287
+ end
288
+ new_options['image%d_url' % i] = url
289
+ new_options['image%d_link' % i] = link if link
290
+ }
291
+ when 'audios'
292
+ value.each_with_index { |value, i|
293
+ if url = String.try_convert(value)
294
+ link = nil
295
+ else
296
+ if array = Array.try_convert(value)
297
+ value1, value2 = *array
298
+ elsif hash = Hash.try_convert(value)
299
+ value1, value2 = *hash.values_at('url', 'link')
300
+ else
301
+ raise TypeError, "Each audio must be specified by <audio URL>, [<audio URL>, <link URL>], or {'url' => <audio URL>, 'link' => <link URL>}."
302
+ end
303
+ url = String.try_convert(value1) or
304
+ raise TypeError, "can't convert #{value1.class} into String"
305
+ link = String.try_convert(value2) or
306
+ raise TypeError, "can't convert #{value2.class} into String"
307
+ end
308
+ new_options['audio%d_url' % i] = url
309
+ new_options['audio%d_link' % i] = link if link
310
+ }
311
+ when 'files'
312
+ value.each_with_index { |value, i|
313
+ if file = IO.try_convert(value)
314
+ link = nil
315
+ else
316
+ if array = Array.try_convert(value)
317
+ value1, value2 = *array
318
+ elsif hash = Hash.try_convert(value)
319
+ value1, value2 = *hash.values_at('file', 'link')
320
+ else
321
+ raise TypeError, "Each file must be specified by <file IO>, [<file IO>, <link URL>], or {'file' => <file IO>, 'link' => <link URL>}."
322
+ end
323
+ file = IO.try_convert(value1) or
324
+ raise TypeError, "can't convert #{value1.class} into IO"
325
+ link = String.try_convert(value2) or
326
+ raise TypeError, "can't convert #{value2.class} into String"
327
+ end
328
+ new_options['file%d' % i] = file
329
+ new_options['file%d_link' % i] = link if link
330
+ }
331
+ end
332
+ }
333
+ end
334
+ call_api('share', nil, new_options)['entries'].first
335
+ end
336
+
337
+ alias publish add_entry
338
+ alias share add_entry
339
+
340
+ # Adds a comment to a given entry.
341
+ def add_comment(entryid, body)
342
+ require_api_login
343
+ call_api('comment', nil, {
344
+ 'entry' => entryid,
345
+ 'body' => body,
346
+ })
347
+ end
348
+
349
+ # Edits a given comment.
350
+ def edit_comment(entryid, commentid, body)
351
+ require_api_login
352
+ call_api('comment', nil, {
353
+ 'entry' => entryid,
354
+ 'comment' => commentid,
355
+ 'body' => body,
356
+ })
357
+ end
358
+
359
+ # Deletes a given comment.
360
+ def delete_comment(entryid, commentid)
361
+ require_api_login
362
+ call_api('comment/delete', nil, {
363
+ 'entry' => entryid,
364
+ 'comment' => commentid,
365
+ })
366
+ end
367
+
368
+ # Undeletes a given comment that is already deleted.
369
+ def undelete_comment(entryid, commentid)
370
+ require_api_login
371
+ call_api('comment/delete', nil, {
372
+ 'entry' => entryid,
373
+ 'comment' => commentid,
374
+ 'undelete' => 'on',
375
+ })
376
+ end
377
+
378
+ # Adds a "like" to a given entry.
379
+ def add_like(entryid)
380
+ require_api_login
381
+ call_api('like', nil, {
382
+ 'entry' => entryid,
383
+ })
384
+ end
385
+
386
+ # Deletes an existing "like" from a given entry.
387
+ def delete_like(entryid)
388
+ require_api_login
389
+ call_api('like/delete', nil, {
390
+ 'entry' => entryid,
391
+ })
392
+ end
393
+
394
+ # Deletes an existing entry of a given +entryid+.
395
+ def delete_entry(entryid)
396
+ require_api_login
397
+ call_api('entry/delete', nil, {
398
+ 'entry' => entryid,
399
+ })
400
+ end
401
+
402
+ # Undeletes a given entry that is already deleted.
403
+ def undelete_entry(entryid)
404
+ require_api_login
405
+ call_api('entry/delete', nil, {
406
+ 'entry' => entryid,
407
+ 'undelete' => 'on',
408
+ })
409
+ end
410
+
411
+ # Hides an existing entry of a given +entryid+.
412
+ def hide_entry(entryid)
413
+ require_api_login
414
+ call_api('entry/hide', nil, {
415
+ 'entry' => entryid,
416
+ })
417
+ end
418
+
419
+ # Unhides a given entry that is already hidden.
420
+ def unhide_entry(entryid)
421
+ require_api_login
422
+ call_api('entry/hide', nil, {
423
+ 'entry' => entryid,
424
+ 'unhide' => 'on',
425
+ })
426
+ end
427
+
428
+ # Gets a picture of a user of a given +nickname+ (defaulted to the
429
+ # authenticated user) in blob. Size can be 'small' (default),
430
+ # 'medium' or 'large',
431
+ def get_picture(nickname = @nickname, size = 'small')
432
+ nickname or require_api_login
433
+ call_api('/%s/picture' % URI.escape(nickname), { 'size' => size }, nil, true)
434
+ end
435
+
436
+ # Gets a picture of a room of a given +nickname+ in blob. Size
437
+ # can be 'small' (default), 'medium' or 'large',
438
+ def get_room_picture(nickname, size = 'small')
439
+ call_api('/rooms/%s/picture' % URI.escape(nickname), { 'size' => size }, nil, true)
440
+ end
441
+
442
+ # Gets profile information of a room of a given +nickname+ in
443
+ # hash.
444
+ def get_room_profile(nickname)
445
+ call_api('room/%s/profile' % URI.encode(nickname))
446
+ end
447
+
448
+ # Gets profile information of the authenticated user's list of a
449
+ # given +nickname+ in hash.
450
+ def get_list_profile(nickname)
451
+ call_api('list/%s/profile' % URI.encode(nickname))
452
+ end
453
+
454
+ # Subscribes to a user of a given +nickname+ and returns a status
455
+ # string.
456
+ def subscribe_to_user(nickname)
457
+ call_subscription_api('user/%s/subscribe' % URI.encode(nickname))['status']
458
+ end
459
+
460
+ # Unsubscribes from a user of a given +nickname+ and returns a
461
+ # status string.
462
+ def unsubscribe_from_user(nickname)
463
+ call_subscription_api('user/%s/subscribe?unsubscribe=1' % URI.encode(nickname))['status']
464
+ end
465
+
466
+ # Subscribes to a room of a given +nickname+ and returns a status
467
+ # string.
468
+ def subscribe_to_room(nickname)
469
+ call_subscription_api('room/%s/subscribe' % URI.encode(nickname))['status']
470
+ end
471
+
472
+ # Unsubscribes from a room of a given +nickname+ and returns a
473
+ # status string.
474
+ def unsubscribe_from_room(nickname)
475
+ call_subscription_api('room/%s/subscribe?unsubscribe=1' % URI.encode(nickname))['status']
476
+ end
477
+ end
478
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: knu-friendfeed
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Akinori MUSHA
@@ -15,14 +15,17 @@ dependencies: []
15
15
 
16
16
  description: A Ruby module that provides access to FriendFeed API's.
17
17
  email: knu@idaemons.org
18
- executables: []
19
-
18
+ executables:
19
+ - tw2ff
20
20
  extensions: []
21
21
 
22
22
  extra_rdoc_files: []
23
23
 
24
- files: []
25
-
24
+ files:
25
+ - lib/friendfeed.rb
26
+ - lib/friendfeed/compat.rb
27
+ - lib/friendfeed/unofficial.rb
28
+ - bin/tw2ff
26
29
  has_rdoc: true
27
30
  homepage: http://github.com/knu/ruby-friendfeed
28
31
  post_install_message: