knu-friendfeed 0.1.1 → 0.1.2

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