lukemelia-twitter-stream 0.1.15
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/.gemtest +0 -0
- data/.gitignore +10 -0
- data/.rspec +2 -0
- data/Gemfile +2 -0
- data/LICENSE +20 -0
- data/README.markdown +42 -0
- data/Rakefile +12 -0
- data/VERSION +1 -0
- data/examples/reader.rb +40 -0
- data/fixtures/twitter/tweets.txt +4 -0
- data/lib/twitter/json_stream.rb +344 -0
- data/lukemelia-twitter-stream.gemspec +32 -0
- data/spec/spec_helper.rb +53 -0
- data/spec/twitter/json_stream_spec.rb +302 -0
- metadata +112 -0
data/.gemtest
ADDED
File without changes
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2012 Vladimir Kolesnikov, twitter-stream
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.markdown
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
# twitter-stream
|
2
|
+
|
3
|
+
Simple Ruby client library for [twitter streaming API](http://apiwiki.twitter.com/Streaming-API-Documentation).
|
4
|
+
Uses [EventMachine](http://rubyeventmachine.com/) for connection handling. Adheres to twitter's [reconnection guidline](https://dev.twitter.com/docs/streaming-api/concepts#connecting).
|
5
|
+
|
6
|
+
JSON format only.
|
7
|
+
|
8
|
+
## Install
|
9
|
+
|
10
|
+
sudo gem install twitter-stream -s http://gemcutter.org
|
11
|
+
|
12
|
+
## Usage
|
13
|
+
|
14
|
+
require 'rubygems'
|
15
|
+
require 'twitter/json_stream'
|
16
|
+
|
17
|
+
EventMachine::run {
|
18
|
+
stream = Twitter::JSONStream.connect(
|
19
|
+
:path => '/1/statuses/filter.json?track=football',
|
20
|
+
:auth => 'LOGIN:PASSWORD'
|
21
|
+
)
|
22
|
+
|
23
|
+
stream.each_item do |item|
|
24
|
+
# Do someting with unparsed JSON item.
|
25
|
+
end
|
26
|
+
|
27
|
+
stream.on_error do |message|
|
28
|
+
# No need to worry here. It might be an issue with Twitter.
|
29
|
+
# Log message for future reference. JSONStream will try to reconnect after a timeout.
|
30
|
+
end
|
31
|
+
|
32
|
+
stream.on_max_reconnects do |timeout, retries|
|
33
|
+
# Something is wrong on your side. Send yourself an email.
|
34
|
+
end
|
35
|
+
}
|
36
|
+
|
37
|
+
|
38
|
+
## Examples
|
39
|
+
|
40
|
+
Open examples/reader.rb. Replace LOGIN:PASSWORD with your real twitter login and password. And
|
41
|
+
ruby examples/reader.rb
|
42
|
+
|
data/Rakefile
ADDED
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
0.1.14
|
data/examples/reader.rb
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
lib_path = File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib'))
|
3
|
+
$LOAD_PATH.unshift lib_path unless $LOAD_PATH.include?(lib_path)
|
4
|
+
|
5
|
+
require 'twitter/json_stream'
|
6
|
+
|
7
|
+
EventMachine::run {
|
8
|
+
stream = Twitter::JSONStream.connect(
|
9
|
+
:path => '/1/statuses/filter.json',
|
10
|
+
:auth => 'LOGIN:PASSWORD',
|
11
|
+
:method => 'POST',
|
12
|
+
:content => 'track=basketball,football,baseball,footy,soccer'
|
13
|
+
)
|
14
|
+
|
15
|
+
stream.each_item do |item|
|
16
|
+
$stdout.print "item: #{item}\n"
|
17
|
+
$stdout.flush
|
18
|
+
end
|
19
|
+
|
20
|
+
stream.on_error do |message|
|
21
|
+
$stdout.print "error: #{message}\n"
|
22
|
+
$stdout.flush
|
23
|
+
end
|
24
|
+
|
25
|
+
stream.on_reconnect do |timeout, retries|
|
26
|
+
$stdout.print "reconnecting in: #{timeout} seconds\n"
|
27
|
+
$stdout.flush
|
28
|
+
end
|
29
|
+
|
30
|
+
stream.on_max_reconnects do |timeout, retries|
|
31
|
+
$stdout.print "Failed after #{retries} failed reconnects\n"
|
32
|
+
$stdout.flush
|
33
|
+
end
|
34
|
+
|
35
|
+
trap('TERM') {
|
36
|
+
stream.stop
|
37
|
+
EventMachine.stop if EventMachine.reactor_running?
|
38
|
+
}
|
39
|
+
}
|
40
|
+
puts "The event loop has ended"
|
@@ -0,0 +1,4 @@
|
|
1
|
+
{"text":"Just wanted the world to know what our theologically deep pastor @bprentice has for a desktop background. http://yfrog.com/1rl4ij","favorited":false,"in_reply_to_user_id":null,"in_reply_to_screen_name":null,"source":"<a href=\"http://www.atebits.com/\" rel=\"nofollow\">Tweetie</a>","truncated":false,"created_at":"Thu Oct 08 19:34:09 +0000 2009","geo":null,"user":{"geo_enabled":false,"profile_text_color":"5b5252","location":"Stillwater, Oklahoma","statuses_count":122,"followers_count":70,"profile_link_color":"220099","description":"Taking an unchanging Savior to a changing world! Eagle Heights in Stillwater, Oklahoma.","following":null,"friends_count":136,"profile_sidebar_fill_color":"f37a20","url":"http://www.eagleheights.com","profile_image_url":"http://a3.twimg.com/profile_images/249941843/online_logo_normal.jpg","verified":false,"notifications":null,"favourites_count":0,"profile_sidebar_border_color":"c5f109","protected":false,"screen_name":"eagleheights","profile_background_tile":false,"profile_background_image_url":"http://a1.twimg.com/profile_background_images/5935314/EHBC_LOGO.jpg","created_at":"Tue Mar 17 14:52:04 +0000 2009","name":"Eagle Heights","time_zone":"Central Time (US & Canada)","id":24892440,"utc_offset":-21600,"profile_background_color":"3d3d3d"},"id":4714982187,"in_reply_to_status_id":null}
|
2
|
+
{"text":"I finally took a good pic of our resident BobCat @ the JakeCruise Ranch: http://yfrog.com/769vij","favorited":false,"in_reply_to_user_id":null,"in_reply_to_screen_name":null,"source":"<a href=\"http://www.atebits.com/\" rel=\"nofollow\">Tweetie</a>","truncated":false,"created_at":"Thu Oct 08 19:34:12 +0000 2009","geo":null,"user":{"geo_enabled":false,"profile_text_color":"141318","location":"West Hollywood, CA","statuses_count":265,"followers_count":80,"profile_link_color":"DA4425","description":"Gay Programmer in SoCal","following":null,"friends_count":37,"profile_sidebar_fill_color":"5DD2F4","url":"http://www.kelvo.com/","profile_image_url":"http://a1.twimg.com/profile_images/197950488/kelvis_green_current_normal.jpeg","verified":false,"notifications":null,"favourites_count":1,"profile_sidebar_border_color":"1F6926","protected":false,"screen_name":"KelvisWeHo","profile_background_tile":false,"profile_background_image_url":"http://a1.twimg.com/profile_background_images/9898116/thaneeya3.jpg","created_at":"Fri Apr 17 18:44:57 +0000 2009","name":"Kelvis Del Rio","time_zone":"Pacific Time (US & Canada)","id":32517583,"utc_offset":-28800,"profile_background_color":"cccccc"},"id":4714983168,"in_reply_to_status_id":null}
|
3
|
+
{"text":"Thursdays are long. But at least we're allowed a cheat sheet for stats. http://yfrog.com/9gjc7j","favorited":false,"in_reply_to_user_id":null,"in_reply_to_screen_name":null,"source":"<a href=\"http://twitterrific.com\" rel=\"nofollow\">Twitterrific</a>","truncated":false,"created_at":"Thu Oct 08 19:34:21 +0000 2009","geo":null,"user":{"geo_enabled":false,"profile_text_color":"663B12","location":"Calgary ","statuses_count":68,"followers_count":10,"profile_link_color":"1F98C7","description":"pure. love. art. music. ","following":null,"friends_count":22,"profile_sidebar_fill_color":"DAECF4","url":null,"profile_image_url":"http://a3.twimg.com/profile_images/391882135/Photo_550_normal.jpg","verified":false,"notifications":null,"favourites_count":5,"profile_sidebar_border_color":"C6E2EE","protected":false,"screen_name":"KarinRudmik","profile_background_tile":false,"profile_background_image_url":"http://a3.twimg.com/profile_background_images/33520483/shimmering__by_AnBystrowska.jpg","created_at":"Wed Jul 29 02:37:13 +0000 2009","name":"Karin Rudmik","time_zone":"Mountain Time (US & Canada)","id":61091098,"utc_offset":-25200,"profile_background_color":"C6E2EE"},"id":4714986148,"in_reply_to_status_id":null}
|
4
|
+
{"text":"acabando de almorzar, les comparto mi ultima creacion: http://yfrog.com/5mi94j pollo a la maracuya, con verduras sofritas ^^","favorited":false,"in_reply_to_user_id":null,"in_reply_to_screen_name":null,"source":"<a href=\"http://www.seesmic.com/\" rel=\"nofollow\">Seesmic</a>","truncated":false,"created_at":"Thu Oct 08 19:34:28 +0000 2009","geo":null,"user":{"geo_enabled":false,"profile_text_color":"3C3940","location":"Cartagena de Indias","statuses_count":1016,"followers_count":190,"profile_link_color":"0099B9","description":"Cartagenero extremadamente consentido, flojo y amante del anime, el manga, la cocina y los mmorpgs. pdt: y de los postres por supuesto!!!","following":null,"friends_count":253,"profile_sidebar_fill_color":"95E8EC","url":"http://www.flickr.com/photos/lobitokun/","profile_image_url":"http://a1.twimg.com/profile_images/451679242/shippo_normal.jpg","verified":false,"notifications":null,"favourites_count":9,"profile_sidebar_border_color":"5ED4DC","protected":false,"screen_name":"lobitokun","profile_background_tile":false,"profile_background_image_url":"http://a3.twimg.com/profile_background_images/24664583/hideki1.jpg","created_at":"Fri Jul 17 15:32:14 +0000 2009","name":"emiro gomez beltran","time_zone":"Bogota","id":57672295,"utc_offset":-18000,"profile_background_color":"0099B9"},"id":4714988998,"in_reply_to_status_id":null}
|
@@ -0,0 +1,344 @@
|
|
1
|
+
require 'eventmachine'
|
2
|
+
require 'em/buftok'
|
3
|
+
require 'uri'
|
4
|
+
require 'simple_oauth'
|
5
|
+
require 'http/parser'
|
6
|
+
|
7
|
+
module Twitter
|
8
|
+
class JSONStream < EventMachine::Connection
|
9
|
+
MAX_LINE_LENGTH = 1024*1024
|
10
|
+
|
11
|
+
# network failure reconnections
|
12
|
+
NF_RECONNECT_START = 0.25
|
13
|
+
NF_RECONNECT_ADD = 0.25
|
14
|
+
NF_RECONNECT_MAX = 16
|
15
|
+
|
16
|
+
# app failure reconnections
|
17
|
+
AF_RECONNECT_START = 10
|
18
|
+
AF_RECONNECT_MUL = 2
|
19
|
+
|
20
|
+
RECONNECT_MAX = 320
|
21
|
+
RETRIES_MAX = 10
|
22
|
+
|
23
|
+
DEFAULT_OPTIONS = {
|
24
|
+
:method => 'GET',
|
25
|
+
:path => '/',
|
26
|
+
:content_type => "application/x-www-form-urlencoded",
|
27
|
+
:content => '',
|
28
|
+
:path => '/1/statuses/filter.json',
|
29
|
+
:host => 'stream.twitter.com',
|
30
|
+
:port => 443,
|
31
|
+
:ssl => true,
|
32
|
+
:user_agent => 'TwitterStream',
|
33
|
+
:timeout => 0,
|
34
|
+
:proxy => ENV['HTTP_PROXY'],
|
35
|
+
:auth => nil,
|
36
|
+
:oauth => {},
|
37
|
+
:filters => [],
|
38
|
+
:params => {},
|
39
|
+
:auto_reconnect => true
|
40
|
+
}
|
41
|
+
|
42
|
+
attr_accessor :code
|
43
|
+
attr_accessor :headers
|
44
|
+
attr_accessor :nf_last_reconnect
|
45
|
+
attr_accessor :af_last_reconnect
|
46
|
+
attr_accessor :reconnect_retries
|
47
|
+
attr_accessor :proxy
|
48
|
+
|
49
|
+
def self.connect options = {}
|
50
|
+
options[:port] = 443 if options[:ssl] && !options.has_key?(:port)
|
51
|
+
options = DEFAULT_OPTIONS.merge(options)
|
52
|
+
|
53
|
+
host = options[:host]
|
54
|
+
port = options[:port]
|
55
|
+
|
56
|
+
if options[:proxy]
|
57
|
+
proxy_uri = URI.parse(options[:proxy])
|
58
|
+
host = proxy_uri.host
|
59
|
+
port = proxy_uri.port
|
60
|
+
end
|
61
|
+
|
62
|
+
connection = EventMachine.connect host, port, self, options
|
63
|
+
connection
|
64
|
+
end
|
65
|
+
|
66
|
+
def initialize options = {}
|
67
|
+
@options = DEFAULT_OPTIONS.merge(options) # merge in case initialize called directly
|
68
|
+
@gracefully_closed = false
|
69
|
+
@nf_last_reconnect = nil
|
70
|
+
@af_last_reconnect = nil
|
71
|
+
@reconnect_retries = 0
|
72
|
+
@immediate_reconnect = false
|
73
|
+
@on_inited_callback = options.delete(:on_inited)
|
74
|
+
@proxy = URI.parse(options[:proxy]) if options[:proxy]
|
75
|
+
end
|
76
|
+
|
77
|
+
def each_item &block
|
78
|
+
@each_item_callback = block
|
79
|
+
end
|
80
|
+
|
81
|
+
def on_error &block
|
82
|
+
@error_callback = block
|
83
|
+
end
|
84
|
+
|
85
|
+
def on_reconnect &block
|
86
|
+
@reconnect_callback = block
|
87
|
+
end
|
88
|
+
|
89
|
+
def on_max_reconnects &block
|
90
|
+
@max_reconnects_callback = block
|
91
|
+
end
|
92
|
+
|
93
|
+
def on_close &block
|
94
|
+
@close_callback = block
|
95
|
+
end
|
96
|
+
|
97
|
+
def stop
|
98
|
+
@gracefully_closed = true
|
99
|
+
close_connection
|
100
|
+
end
|
101
|
+
|
102
|
+
def immediate_reconnect
|
103
|
+
@immediate_reconnect = true
|
104
|
+
@gracefully_closed = false
|
105
|
+
close_connection
|
106
|
+
end
|
107
|
+
|
108
|
+
def unbind
|
109
|
+
if @state == :stream && !@buffer.empty?
|
110
|
+
parse_stream_line(@buffer.flush)
|
111
|
+
end
|
112
|
+
schedule_reconnect if @options[:auto_reconnect] && !@gracefully_closed
|
113
|
+
@close_callback.call if @close_callback
|
114
|
+
|
115
|
+
end
|
116
|
+
|
117
|
+
# Receives raw data from the HTTP connection and pushes it into the
|
118
|
+
# HTTP parser which then drives subsequent callbacks.
|
119
|
+
def receive_data(data)
|
120
|
+
@parser << data
|
121
|
+
end
|
122
|
+
|
123
|
+
def connection_completed
|
124
|
+
start_tls if @options[:ssl]
|
125
|
+
send_request
|
126
|
+
end
|
127
|
+
|
128
|
+
def post_init
|
129
|
+
reset_state
|
130
|
+
@on_inited_callback.call if @on_inited_callback
|
131
|
+
end
|
132
|
+
|
133
|
+
protected
|
134
|
+
def schedule_reconnect
|
135
|
+
timeout = reconnect_timeout
|
136
|
+
@reconnect_retries += 1
|
137
|
+
if timeout <= RECONNECT_MAX && @reconnect_retries <= RETRIES_MAX
|
138
|
+
reconnect_after(timeout)
|
139
|
+
else
|
140
|
+
@max_reconnects_callback.call(timeout, @reconnect_retries) if @max_reconnects_callback
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
def reconnect_after timeout
|
145
|
+
@reconnect_callback.call(timeout, @reconnect_retries) if @reconnect_callback
|
146
|
+
|
147
|
+
if timeout == 0
|
148
|
+
reconnect @options[:host], @options[:port]
|
149
|
+
else
|
150
|
+
EventMachine.add_timer(timeout) do
|
151
|
+
reconnect @options[:host], @options[:port]
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
def reconnect_timeout
|
157
|
+
if @immediate_reconnect
|
158
|
+
@immediate_reconnect = false
|
159
|
+
return 0
|
160
|
+
end
|
161
|
+
|
162
|
+
if (@code == 0) # network failure
|
163
|
+
if @nf_last_reconnect
|
164
|
+
@nf_last_reconnect += NF_RECONNECT_ADD
|
165
|
+
else
|
166
|
+
@nf_last_reconnect = NF_RECONNECT_START
|
167
|
+
end
|
168
|
+
[@nf_last_reconnect,NF_RECONNECT_MAX].min
|
169
|
+
else
|
170
|
+
if @af_last_reconnect
|
171
|
+
@af_last_reconnect *= AF_RECONNECT_MUL
|
172
|
+
else
|
173
|
+
@af_last_reconnect = AF_RECONNECT_START
|
174
|
+
end
|
175
|
+
@af_last_reconnect
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
def reset_state
|
180
|
+
set_comm_inactivity_timeout @options[:timeout] if @options[:timeout] > 0
|
181
|
+
@code = 0
|
182
|
+
@headers = {}
|
183
|
+
@state = :init
|
184
|
+
@buffer = BufferedTokenizer.new("\r", MAX_LINE_LENGTH)
|
185
|
+
@stream = ''
|
186
|
+
|
187
|
+
@parser = Http::Parser.new
|
188
|
+
@parser.on_headers_complete = method(:handle_headers_complete)
|
189
|
+
@parser.on_body = method(:receive_stream_data)
|
190
|
+
end
|
191
|
+
|
192
|
+
# Called when the status line and all headers have been read from the
|
193
|
+
# stream.
|
194
|
+
def handle_headers_complete(headers)
|
195
|
+
@code = @parser.status_code.to_i
|
196
|
+
if @code != 200
|
197
|
+
receive_error("invalid status code: #{@code}.")
|
198
|
+
end
|
199
|
+
self.headers = headers
|
200
|
+
@state = :stream
|
201
|
+
end
|
202
|
+
|
203
|
+
# Called every time a chunk of data is read from the connection once it has
|
204
|
+
# been opened and after the headers have been processed.
|
205
|
+
def receive_stream_data(data)
|
206
|
+
begin
|
207
|
+
@buffer.extract(data).each do |line|
|
208
|
+
parse_stream_line(line)
|
209
|
+
end
|
210
|
+
@stream = ''
|
211
|
+
rescue Exception => e
|
212
|
+
receive_error("#{e.class}: " + [e.message, e.backtrace].flatten.join("\n\t"))
|
213
|
+
close_connection
|
214
|
+
return
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
def send_request
|
219
|
+
data = []
|
220
|
+
request_uri = @options[:path]
|
221
|
+
|
222
|
+
if @proxy
|
223
|
+
# proxies need the request to be for the full url
|
224
|
+
request_uri = "#{uri_base}:#{@options[:port]}#{request_uri}"
|
225
|
+
end
|
226
|
+
|
227
|
+
content = @options[:content]
|
228
|
+
|
229
|
+
unless (q = query).empty?
|
230
|
+
if @options[:method].to_s.upcase == 'GET'
|
231
|
+
request_uri << "?#{q}"
|
232
|
+
else
|
233
|
+
content = q
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
data << "#{@options[:method]} #{request_uri} HTTP/1.1"
|
238
|
+
data << "Host: #{@options[:host]}"
|
239
|
+
data << 'Accept: */*'
|
240
|
+
data << "User-Agent: #{@options[:user_agent]}" if @options[:user_agent]
|
241
|
+
|
242
|
+
if @options[:auth]
|
243
|
+
data << "Authorization: Basic #{[@options[:auth]].pack('m').delete("\r\n")}"
|
244
|
+
elsif @options[:oauth]
|
245
|
+
data << "Authorization: #{oauth_header}"
|
246
|
+
end
|
247
|
+
|
248
|
+
if @proxy && @proxy.user
|
249
|
+
data << "Proxy-Authorization: Basic " + ["#{@proxy.user}:#{@proxy.password}"].pack('m').delete("\r\n")
|
250
|
+
end
|
251
|
+
if ['POST', 'PUT'].include?(@options[:method])
|
252
|
+
data << "Content-type: #{@options[:content_type]}"
|
253
|
+
data << "Content-length: #{content.length}"
|
254
|
+
end
|
255
|
+
|
256
|
+
if @options[:headers]
|
257
|
+
@options[:headers].each do |name,value|
|
258
|
+
data << "#{name}: #{value}"
|
259
|
+
end
|
260
|
+
end
|
261
|
+
|
262
|
+
data << "\r\n"
|
263
|
+
|
264
|
+
send_data data.join("\r\n") << content
|
265
|
+
end
|
266
|
+
|
267
|
+
def receive_error e
|
268
|
+
@error_callback.call(e) if @error_callback
|
269
|
+
end
|
270
|
+
|
271
|
+
def parse_stream_line ln
|
272
|
+
ln.strip!
|
273
|
+
unless ln.empty?
|
274
|
+
if ln[0,1] == '{' || ln[ln.length-1,1] == '}'
|
275
|
+
@stream << ln
|
276
|
+
if @stream[0,1] == '{' && @stream[@stream.length-1,1] == '}'
|
277
|
+
@each_item_callback.call(@stream) if @each_item_callback
|
278
|
+
@stream = ''
|
279
|
+
end
|
280
|
+
end
|
281
|
+
end
|
282
|
+
end
|
283
|
+
|
284
|
+
def reset_timeouts
|
285
|
+
set_comm_inactivity_timeout @options[:timeout] if @options[:timeout] > 0
|
286
|
+
@nf_last_reconnect = @af_last_reconnect = nil
|
287
|
+
@reconnect_retries = 0
|
288
|
+
end
|
289
|
+
|
290
|
+
#
|
291
|
+
# URL and request components
|
292
|
+
#
|
293
|
+
|
294
|
+
# :filters => %w(miama lebron jesus)
|
295
|
+
# :oauth => {
|
296
|
+
# :consumer_key => [key],
|
297
|
+
# :consumer_secret => [token],
|
298
|
+
# :access_key => [access key],
|
299
|
+
# :access_secret => [access secret]
|
300
|
+
# }
|
301
|
+
def oauth_header
|
302
|
+
uri = uri_base + @options[:path].to_s
|
303
|
+
|
304
|
+
# The hash SimpleOAuth accepts is slightly different from that of
|
305
|
+
# ROAuth. To preserve backward compatability, fix the cache here
|
306
|
+
# so that the arguments passed in don't need to change.
|
307
|
+
oauth = {
|
308
|
+
:consumer_key => @options[:oauth][:consumer_key],
|
309
|
+
:consumer_secret => @options[:oauth][:consumer_secret],
|
310
|
+
:token => @options[:oauth][:access_key],
|
311
|
+
:token_secret => @options[:oauth][:access_secret]
|
312
|
+
}
|
313
|
+
|
314
|
+
data = ['POST', 'PUT'].include?(@options[:method]) ? params : {}
|
315
|
+
|
316
|
+
SimpleOAuth::Header.new(@options[:method], uri, data, oauth)
|
317
|
+
end
|
318
|
+
|
319
|
+
# Scheme (https if ssl, http otherwise) and host part of URL
|
320
|
+
def uri_base
|
321
|
+
"http#{'s' if @options[:ssl]}://#{@options[:host]}"
|
322
|
+
end
|
323
|
+
|
324
|
+
# Normalized query hash of escaped string keys and escaped string values
|
325
|
+
# nil values are skipped
|
326
|
+
def params
|
327
|
+
flat = {}
|
328
|
+
@options[:params].merge( :track => @options[:filters] ).each do |param, val|
|
329
|
+
next if val.to_s.empty? || (val.respond_to?(:empty?) && val.empty?)
|
330
|
+
val = val.join(",") if val.respond_to?(:join)
|
331
|
+
flat[param.to_s] = val.to_s
|
332
|
+
end
|
333
|
+
flat
|
334
|
+
end
|
335
|
+
|
336
|
+
def query
|
337
|
+
params.map{|param, value| [escape(param), escape(value)].join("=")}.sort.join("&")
|
338
|
+
end
|
339
|
+
|
340
|
+
def escape str
|
341
|
+
URI.escape(str.to_s, /[^a-zA-Z0-9\-\.\_\~]/)
|
342
|
+
end
|
343
|
+
end
|
344
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
|
3
|
+
Gem::Specification.new do |s|
|
4
|
+
s.name = %q{lukemelia-twitter-stream}
|
5
|
+
s.version = "0.1.15"
|
6
|
+
|
7
|
+
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
8
|
+
s.authors = ["Vladimir Kolesnikov"]
|
9
|
+
s.date = %q{2012-04-10}
|
10
|
+
s.description = %q{Simple Ruby client library for twitter streaming API. Uses EventMachine for connection handling. Adheres to twitter's reconnection guidline. JSON format only. (Fork to update eventmachine dependency)}
|
11
|
+
s.summary = %q{Twitter realtime API client}
|
12
|
+
s.homepage = %q{http://github.com/lukemelia/twitter-stream}
|
13
|
+
s.email = %q{voloko@gmail.com}
|
14
|
+
|
15
|
+
s.platform = Gem::Platform::RUBY
|
16
|
+
s.rubygems_version = %q{1.3.6}
|
17
|
+
s.required_rubygems_version = Gem::Requirement.new(">= 1.3.6") if s.respond_to? :required_rubygems_version=
|
18
|
+
|
19
|
+
s.rdoc_options = ["--charset=UTF-8"]
|
20
|
+
s.extra_rdoc_files = ["README.markdown", "LICENSE"]
|
21
|
+
|
22
|
+
s.add_runtime_dependency('eventmachine', ">= 1.0.0.beta.4")
|
23
|
+
s.add_runtime_dependency('simple_oauth', '~> 0.1.4')
|
24
|
+
s.add_runtime_dependency('http_parser.rb', '~> 0.5.1')
|
25
|
+
s.add_development_dependency('rspec', "~> 2.5.0")
|
26
|
+
|
27
|
+
s.files = `git ls-files`.split("\n")
|
28
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
29
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
30
|
+
s.require_paths = ["lib"]
|
31
|
+
end
|
32
|
+
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,53 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
|
3
|
+
gem 'rspec', '>= 2.5.0'
|
4
|
+
require 'rspec'
|
5
|
+
require 'rspec/mocks'
|
6
|
+
|
7
|
+
require 'twitter/json_stream'
|
8
|
+
|
9
|
+
def fixture_path(path)
|
10
|
+
File.join(File.dirname(__FILE__), '..', 'fixtures', path)
|
11
|
+
end
|
12
|
+
|
13
|
+
def read_fixture(path)
|
14
|
+
File.read(fixture_path(path))
|
15
|
+
end
|
16
|
+
|
17
|
+
def connect_stream(opts={}, &blk)
|
18
|
+
EM.run {
|
19
|
+
opts.merge!(:host => Host, :port => Port)
|
20
|
+
stop_in = opts.delete(:stop_in) || 0.5
|
21
|
+
unless opts[:start_server] == false
|
22
|
+
EM.start_server Host, Port, JSONServer
|
23
|
+
end
|
24
|
+
@stream = JSONStream.connect(opts)
|
25
|
+
blk.call if blk
|
26
|
+
EM.add_timer(stop_in){ EM.stop }
|
27
|
+
}
|
28
|
+
end
|
29
|
+
|
30
|
+
def http_response(status_code, status_text, headers, body)
|
31
|
+
res = "HTTP/1.1 #{status_code} #{status_text}\r\n"
|
32
|
+
headers = {
|
33
|
+
"Content-Type"=>"application/json",
|
34
|
+
"Transfer-Encoding"=>"chunked"
|
35
|
+
}.merge(headers)
|
36
|
+
headers.each do |key,value|
|
37
|
+
res << "#{key}: #{value}\r\n"
|
38
|
+
end
|
39
|
+
res << "\r\n"
|
40
|
+
if headers["Transfer-Encoding"] == "chunked" && body.kind_of?(Array)
|
41
|
+
body.each do |data|
|
42
|
+
res << http_chunk(data)
|
43
|
+
end
|
44
|
+
else
|
45
|
+
res << body
|
46
|
+
end
|
47
|
+
res
|
48
|
+
end
|
49
|
+
|
50
|
+
def http_chunk(data)
|
51
|
+
# See http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.6.1
|
52
|
+
"#{data.length.to_s(16)}\r\n#{data}\r\n"
|
53
|
+
end
|
@@ -0,0 +1,302 @@
|
|
1
|
+
require 'spec_helper.rb'
|
2
|
+
require 'twitter/json_stream'
|
3
|
+
|
4
|
+
include Twitter
|
5
|
+
|
6
|
+
Host = "127.0.0.1"
|
7
|
+
Port = 9550
|
8
|
+
|
9
|
+
class JSONServer < EM::Connection
|
10
|
+
attr_accessor :data
|
11
|
+
def receive_data data
|
12
|
+
$recieved_data = data
|
13
|
+
send_data $data_to_send
|
14
|
+
EventMachine.next_tick {
|
15
|
+
close_connection if $close_connection
|
16
|
+
}
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
|
21
|
+
|
22
|
+
describe JSONStream do
|
23
|
+
|
24
|
+
context "authentication" do
|
25
|
+
it "should connect with basic auth credentials" do
|
26
|
+
connect_stream :auth => "username:password", :ssl => false
|
27
|
+
$recieved_data.should include('Authorization: Basic')
|
28
|
+
end
|
29
|
+
|
30
|
+
it "should connect with oauth credentials" do
|
31
|
+
oauth = {
|
32
|
+
:consumer_key => '1234567890',
|
33
|
+
:consumer_secret => 'abcdefghijklmnopqrstuvwxyz',
|
34
|
+
:access_key => 'ohai',
|
35
|
+
:access_secret => 'ohno'
|
36
|
+
}
|
37
|
+
connect_stream :oauth => oauth, :ssl => false
|
38
|
+
$recieved_data.should include('Authorization: OAuth')
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
context "on create" do
|
43
|
+
|
44
|
+
it "should return stream" do
|
45
|
+
EM.should_receive(:connect).and_return('TEST INSTANCE')
|
46
|
+
stream = JSONStream.connect {}
|
47
|
+
stream.should == 'TEST INSTANCE'
|
48
|
+
end
|
49
|
+
|
50
|
+
it "should define default properties" do
|
51
|
+
EM.should_receive(:connect).with do |host, port, handler, opts|
|
52
|
+
host.should == 'stream.twitter.com'
|
53
|
+
port.should == 443
|
54
|
+
opts[:path].should == '/1/statuses/filter.json'
|
55
|
+
opts[:method].should == 'GET'
|
56
|
+
end
|
57
|
+
stream = JSONStream.connect {}
|
58
|
+
end
|
59
|
+
|
60
|
+
it "should connect to the proxy if provided" do
|
61
|
+
EM.should_receive(:connect).with do |host, port, handler, opts|
|
62
|
+
host.should == 'my-proxy'
|
63
|
+
port.should == 8080
|
64
|
+
opts[:host].should == 'stream.twitter.com'
|
65
|
+
opts[:port].should == 443
|
66
|
+
opts[:proxy].should == 'http://my-proxy:8080'
|
67
|
+
end
|
68
|
+
stream = JSONStream.connect(:proxy => "http://my-proxy:8080") {}
|
69
|
+
end
|
70
|
+
|
71
|
+
it "should not trigger SSL until connection is established" do
|
72
|
+
connection = stub('connection')
|
73
|
+
EM.should_receive(:connect).and_return(connection)
|
74
|
+
stream = JSONStream.connect(:ssl => true)
|
75
|
+
stream.should == connection
|
76
|
+
end
|
77
|
+
|
78
|
+
end
|
79
|
+
|
80
|
+
context "on valid stream" do
|
81
|
+
attr_reader :stream
|
82
|
+
before :each do
|
83
|
+
$body = File.readlines(fixture_path("twitter/tweets.txt"))
|
84
|
+
$body.each {|tweet| tweet.strip!; tweet << "\r" }
|
85
|
+
$data_to_send = http_response(200, "OK", {}, $body)
|
86
|
+
$recieved_data = ''
|
87
|
+
$close_connection = false
|
88
|
+
end
|
89
|
+
|
90
|
+
it "should add no params" do
|
91
|
+
connect_stream :ssl => false
|
92
|
+
$recieved_data.should include('/1/statuses/filter.json HTTP')
|
93
|
+
end
|
94
|
+
|
95
|
+
it "should add custom params" do
|
96
|
+
connect_stream :params => {:name => 'test'}, :ssl => false
|
97
|
+
$recieved_data.should include('?name=test')
|
98
|
+
end
|
99
|
+
|
100
|
+
it "should parse headers" do
|
101
|
+
connect_stream :ssl => false
|
102
|
+
stream.code.should == 200
|
103
|
+
stream.headers.keys.map{|k| k.downcase}.should include('content-type')
|
104
|
+
end
|
105
|
+
|
106
|
+
it "should parse headers even after connection close" do
|
107
|
+
connect_stream :ssl => false
|
108
|
+
stream.code.should == 200
|
109
|
+
stream.headers.keys.map{|k| k.downcase}.should include('content-type')
|
110
|
+
end
|
111
|
+
|
112
|
+
it "should extract records" do
|
113
|
+
connect_stream :user_agent => 'TEST_USER_AGENT', :ssl => false
|
114
|
+
$recieved_data.upcase.should include('USER-AGENT: TEST_USER_AGENT')
|
115
|
+
end
|
116
|
+
|
117
|
+
it 'should allow custom headers' do
|
118
|
+
connect_stream :headers => { 'From' => 'twitter-stream' }, :ssl => false
|
119
|
+
$recieved_data.upcase.should include('FROM: TWITTER-STREAM')
|
120
|
+
end
|
121
|
+
|
122
|
+
it "should deliver each item" do
|
123
|
+
items = []
|
124
|
+
connect_stream :ssl => false do
|
125
|
+
stream.each_item do |item|
|
126
|
+
items << item
|
127
|
+
end
|
128
|
+
end
|
129
|
+
# Extract only the tweets from the fixture
|
130
|
+
tweets = $body.map{|l| l.strip }.select{|l| l =~ /^\{/ }
|
131
|
+
items.size.should == tweets.size
|
132
|
+
tweets.each_with_index do |tweet,i|
|
133
|
+
items[i].should == tweet
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
it "should send correct user agent" do
|
138
|
+
connect_stream
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
shared_examples_for "network failure" do
|
143
|
+
it "should reconnect on network failure" do
|
144
|
+
connect_stream do
|
145
|
+
stream.should_receive(:reconnect)
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
it "should not reconnect on network failure when not configured to auto reconnect" do
|
150
|
+
connect_stream(:auto_reconnect => false) do
|
151
|
+
stream.should_receive(:reconnect).never
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
it "should reconnect with 0.25 at base" do
|
156
|
+
connect_stream do
|
157
|
+
stream.should_receive(:reconnect_after).with(0.25)
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
it "should reconnect with linear timeout" do
|
162
|
+
connect_stream do
|
163
|
+
stream.nf_last_reconnect = 1
|
164
|
+
stream.should_receive(:reconnect_after).with(1.25)
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
it "should stop reconnecting after 100 times" do
|
169
|
+
connect_stream do
|
170
|
+
stream.reconnect_retries = 100
|
171
|
+
stream.should_not_receive(:reconnect_after)
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
it "should notify after reconnect limit is reached" do
|
176
|
+
timeout, retries = nil, nil
|
177
|
+
connect_stream do
|
178
|
+
stream.on_max_reconnects do |t, r|
|
179
|
+
timeout, retries = t, r
|
180
|
+
end
|
181
|
+
stream.reconnect_retries = 100
|
182
|
+
end
|
183
|
+
timeout.should == 0.25
|
184
|
+
retries.should == 101
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
context "on network failure" do
|
189
|
+
attr_reader :stream
|
190
|
+
before :each do
|
191
|
+
$data_to_send = ''
|
192
|
+
$close_connection = true
|
193
|
+
end
|
194
|
+
|
195
|
+
it "should timeout on inactivity" do
|
196
|
+
connect_stream :stop_in => 1.5 do
|
197
|
+
stream.should_receive(:reconnect)
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
it "should not reconnect on inactivity when not configured to auto reconnect" do
|
202
|
+
connect_stream(:stop_in => 1.5, :auto_reconnect => false) do
|
203
|
+
stream.should_receive(:reconnect).never
|
204
|
+
end
|
205
|
+
end
|
206
|
+
|
207
|
+
it "should reconnect with SSL if enabled" do
|
208
|
+
connect_stream :ssl => true do
|
209
|
+
stream.should_receive(:start_tls).twice
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
213
|
+
it_should_behave_like "network failure"
|
214
|
+
end
|
215
|
+
|
216
|
+
context "on server unavailable" do
|
217
|
+
|
218
|
+
attr_reader :stream
|
219
|
+
|
220
|
+
# This is to make it so the network failure specs which call connect_stream
|
221
|
+
# can be reused. This way calls to connect_stream won't actually create a
|
222
|
+
# server to listen in.
|
223
|
+
def connect_stream_without_server(opts={},&block)
|
224
|
+
connect_stream_default(opts.merge(:start_server=>false),&block)
|
225
|
+
end
|
226
|
+
alias_method :connect_stream_default, :connect_stream
|
227
|
+
alias_method :connect_stream, :connect_stream_without_server
|
228
|
+
|
229
|
+
it_should_behave_like "network failure"
|
230
|
+
end
|
231
|
+
|
232
|
+
context "on application failure" do
|
233
|
+
attr_reader :stream
|
234
|
+
before :each do
|
235
|
+
$data_to_send = "HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"Firehose\"\r\n\r\n"
|
236
|
+
$close_connection = false
|
237
|
+
end
|
238
|
+
|
239
|
+
it "should reconnect on application failure 10 at base" do
|
240
|
+
connect_stream :ssl => false do
|
241
|
+
stream.should_receive(:reconnect_after).with(10)
|
242
|
+
end
|
243
|
+
end
|
244
|
+
|
245
|
+
it "should not reconnect on application failure 10 at base when not configured to auto reconnect" do
|
246
|
+
connect_stream :ssl => false, :auto_reconnect => false do
|
247
|
+
stream.should_receive(:reconnect_after).never
|
248
|
+
end
|
249
|
+
end
|
250
|
+
|
251
|
+
it "should reconnect with exponential timeout" do
|
252
|
+
connect_stream :ssl => false do
|
253
|
+
stream.af_last_reconnect = 160
|
254
|
+
stream.should_receive(:reconnect_after).with(320)
|
255
|
+
end
|
256
|
+
end
|
257
|
+
|
258
|
+
it "should not try to reconnect after limit is reached" do
|
259
|
+
connect_stream :ssl => false do
|
260
|
+
stream.af_last_reconnect = 320
|
261
|
+
stream.should_not_receive(:reconnect_after)
|
262
|
+
end
|
263
|
+
end
|
264
|
+
end
|
265
|
+
|
266
|
+
context "on stream with chunked transfer encoding" do
|
267
|
+
attr_reader :stream
|
268
|
+
before :each do
|
269
|
+
$recieved_data = ''
|
270
|
+
$close_connection = false
|
271
|
+
end
|
272
|
+
|
273
|
+
it "should ignore empty lines" do
|
274
|
+
body_chunks = ["{\"screen"+"_name\"",":\"user1\"}\r\r\r{","\"id\":9876}\r\r"]
|
275
|
+
$data_to_send = http_response(200,"OK",{},body_chunks)
|
276
|
+
items = []
|
277
|
+
connect_stream :ssl => false do
|
278
|
+
stream.each_item do |item|
|
279
|
+
items << item
|
280
|
+
end
|
281
|
+
end
|
282
|
+
items.size.should == 2
|
283
|
+
items[0].should == '{"screen_name":"user1"}'
|
284
|
+
items[1].should == '{"id":9876}'
|
285
|
+
end
|
286
|
+
|
287
|
+
it "should parse full entities even if split" do
|
288
|
+
body_chunks = ["{\"id\"",":1234}\r{","\"id\":9876}"]
|
289
|
+
$data_to_send = http_response(200,"OK",{},body_chunks)
|
290
|
+
items = []
|
291
|
+
connect_stream :ssl => false do
|
292
|
+
stream.each_item do |item|
|
293
|
+
items << item
|
294
|
+
end
|
295
|
+
end
|
296
|
+
items.size.should == 2
|
297
|
+
items[0].should == '{"id":1234}'
|
298
|
+
items[1].should == '{"id":9876}'
|
299
|
+
end
|
300
|
+
end
|
301
|
+
|
302
|
+
end
|
metadata
ADDED
@@ -0,0 +1,112 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: lukemelia-twitter-stream
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
prerelease:
|
5
|
+
version: 0.1.15
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Vladimir Kolesnikov
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
|
13
|
+
date: 2012-04-10 00:00:00 Z
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: eventmachine
|
17
|
+
prerelease: false
|
18
|
+
requirement: &id001 !ruby/object:Gem::Requirement
|
19
|
+
none: false
|
20
|
+
requirements:
|
21
|
+
- - ">="
|
22
|
+
- !ruby/object:Gem::Version
|
23
|
+
version: 1.0.0.beta.4
|
24
|
+
type: :runtime
|
25
|
+
version_requirements: *id001
|
26
|
+
- !ruby/object:Gem::Dependency
|
27
|
+
name: simple_oauth
|
28
|
+
prerelease: false
|
29
|
+
requirement: &id002 !ruby/object:Gem::Requirement
|
30
|
+
none: false
|
31
|
+
requirements:
|
32
|
+
- - ~>
|
33
|
+
- !ruby/object:Gem::Version
|
34
|
+
version: 0.1.4
|
35
|
+
type: :runtime
|
36
|
+
version_requirements: *id002
|
37
|
+
- !ruby/object:Gem::Dependency
|
38
|
+
name: http_parser.rb
|
39
|
+
prerelease: false
|
40
|
+
requirement: &id003 !ruby/object:Gem::Requirement
|
41
|
+
none: false
|
42
|
+
requirements:
|
43
|
+
- - ~>
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
version: 0.5.1
|
46
|
+
type: :runtime
|
47
|
+
version_requirements: *id003
|
48
|
+
- !ruby/object:Gem::Dependency
|
49
|
+
name: rspec
|
50
|
+
prerelease: false
|
51
|
+
requirement: &id004 !ruby/object:Gem::Requirement
|
52
|
+
none: false
|
53
|
+
requirements:
|
54
|
+
- - ~>
|
55
|
+
- !ruby/object:Gem::Version
|
56
|
+
version: 2.5.0
|
57
|
+
type: :development
|
58
|
+
version_requirements: *id004
|
59
|
+
description: Simple Ruby client library for twitter streaming API. Uses EventMachine for connection handling. Adheres to twitter's reconnection guidline. JSON format only. (Fork to update eventmachine dependency)
|
60
|
+
email: voloko@gmail.com
|
61
|
+
executables: []
|
62
|
+
|
63
|
+
extensions: []
|
64
|
+
|
65
|
+
extra_rdoc_files:
|
66
|
+
- README.markdown
|
67
|
+
- LICENSE
|
68
|
+
files:
|
69
|
+
- .gemtest
|
70
|
+
- .gitignore
|
71
|
+
- .rspec
|
72
|
+
- Gemfile
|
73
|
+
- LICENSE
|
74
|
+
- README.markdown
|
75
|
+
- Rakefile
|
76
|
+
- VERSION
|
77
|
+
- examples/reader.rb
|
78
|
+
- fixtures/twitter/tweets.txt
|
79
|
+
- lib/twitter/json_stream.rb
|
80
|
+
- lukemelia-twitter-stream.gemspec
|
81
|
+
- spec/spec_helper.rb
|
82
|
+
- spec/twitter/json_stream_spec.rb
|
83
|
+
homepage: http://github.com/lukemelia/twitter-stream
|
84
|
+
licenses: []
|
85
|
+
|
86
|
+
post_install_message:
|
87
|
+
rdoc_options:
|
88
|
+
- --charset=UTF-8
|
89
|
+
require_paths:
|
90
|
+
- lib
|
91
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
92
|
+
none: false
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: "0"
|
97
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
98
|
+
none: false
|
99
|
+
requirements:
|
100
|
+
- - ">="
|
101
|
+
- !ruby/object:Gem::Version
|
102
|
+
version: 1.3.6
|
103
|
+
requirements: []
|
104
|
+
|
105
|
+
rubyforge_project:
|
106
|
+
rubygems_version: 1.8.10
|
107
|
+
signing_key:
|
108
|
+
specification_version: 3
|
109
|
+
summary: Twitter realtime API client
|
110
|
+
test_files:
|
111
|
+
- spec/spec_helper.rb
|
112
|
+
- spec/twitter/json_stream_spec.rb
|