├── .rspec
├── spec
├── fixtures
│ ├── limit.json
│ ├── delete.json
│ ├── user_withheld.json
│ ├── status_withheld.json
│ ├── scrub_geo.json
│ ├── stall_warning.json
│ ├── info.json
│ ├── ids.json
│ ├── direct_messages.json
│ ├── favorite.json
│ └── statuses.json
├── tweetstream
│ ├── daemon_spec.rb
│ ├── client_authentication_spec.rb
│ ├── client_userstream_spec.rb
│ ├── client_site_stream_spec.rb
│ ├── site_stream_client_spec.rb
│ └── client_spec.rb
├── helper.rb
└── tweetstream_spec.rb
├── lib
├── tweetstream
│ ├── version.rb
│ ├── arguments.rb
│ ├── daemon.rb
│ ├── configuration.rb
│ ├── site_stream_client.rb
│ └── client.rb
└── tweetstream.rb
├── .yardopts
├── .gitignore
├── .travis.yml
├── Guardfile
├── Gemfile
├── CONTRIBUTING.md
├── Rakefile
├── examples
├── oauth.rb
├── growl_daemon.rb
├── userstream.rb
└── sitestream.rb
├── LICENSE.md
├── .rubocop.yml
├── tweetstream.gemspec
├── CHANGELOG.md
└── README.md
/.rspec:
--------------------------------------------------------------------------------
1 | --color
2 | --order random
3 |
--------------------------------------------------------------------------------
/spec/fixtures/limit.json:
--------------------------------------------------------------------------------
1 | {
2 | "limit": {
3 | "track": 123
4 | }
5 | }
--------------------------------------------------------------------------------
/lib/tweetstream/version.rb:
--------------------------------------------------------------------------------
1 | module TweetStream
2 | VERSION = '2.6.1'
3 | end
4 |
--------------------------------------------------------------------------------
/.yardopts:
--------------------------------------------------------------------------------
1 | --markup markdown
2 | -
3 | CHANGELOG.md
4 | CONTRIBUTING.md
5 | LICENSE.md
6 | README.md
7 |
--------------------------------------------------------------------------------
/spec/fixtures/delete.json:
--------------------------------------------------------------------------------
1 | {
2 | "delete": {
3 | "status": {
4 | "id": 123,
5 | "user_id": 3
6 | }
7 | }
8 | }
--------------------------------------------------------------------------------
/spec/fixtures/user_withheld.json:
--------------------------------------------------------------------------------
1 | {
2 | "user_withheld":{
3 | "id":123,
4 | "withheld_in_countries":["de","ar"]
5 | }
6 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.sw?
2 | .DS_Store
3 | coverage
4 | rdoc
5 | pkg
6 | Gemfile.lock
7 | coverage/*
8 | .yardoc/*
9 | doc/*
10 | .bundle
11 | .swp
12 | .idea
13 | .rvmrc
14 |
--------------------------------------------------------------------------------
/spec/fixtures/status_withheld.json:
--------------------------------------------------------------------------------
1 | {
2 | "status_withheld":{
3 | "id":1234567890,
4 | "user_id":123,
5 | "withheld_in_countries":["de", "ar"]
6 | }
7 | }
--------------------------------------------------------------------------------
/spec/fixtures/scrub_geo.json:
--------------------------------------------------------------------------------
1 | {
2 | "scrub_geo": {
3 | "user_id": 123,
4 | "user_id_str": "123",
5 | "up_to_status_id":987,
6 | "up_to_status_id_string": "987"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | bundler_args: --without development
2 | language: ruby
3 | rvm:
4 | - 1.9.3
5 | - 2.0.0
6 | - 2.1
7 | - rbx-2
8 | - ruby-head
9 | matrix:
10 | allow_failures:
11 | - rvm: ruby-head
12 |
--------------------------------------------------------------------------------
/lib/tweetstream/arguments.rb:
--------------------------------------------------------------------------------
1 | module TweetStream
2 | class Arguments < Array
3 | attr_reader :options
4 |
5 | def initialize(args)
6 | @options = args.last.is_a?(::Hash) ? args.pop : {}
7 | super(args)
8 | end
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/spec/fixtures/stall_warning.json:
--------------------------------------------------------------------------------
1 | {
2 | "warning":{
3 | "code":"FALLING_BEHIND",
4 | "message":"Your connection is falling behind and messages are being queued for delivery to you. Your queue is now over 60% full. You will be disconnected when the queue is full.",
5 | "percent_full": 60
6 | }
7 | }
--------------------------------------------------------------------------------
/Guardfile:
--------------------------------------------------------------------------------
1 | # A sample Guardfile
2 | # More info at https://github.com/guard/guard#readme
3 |
4 | guard 'rspec', :version => 2 do
5 | watch(/^spec\/.+_spec\.rb$/)
6 | watch(/^lib\/(.+)\.rb$/) { |m| "spec/#{m[1]}_spec.rb" }
7 | watch('spec/helper.rb') { 'spec/' }
8 | watch('spec/data/.+') { 'spec/' }
9 | end
10 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org'
2 |
3 | gem 'rake'
4 | gem 'yard'
5 |
6 | group :development do
7 | gem 'kramdown'
8 | gem 'pry'
9 | end
10 |
11 | group :test do
12 | gem 'coveralls'
13 | gem 'rspec', '>= 3'
14 | gem 'rubocop', '>= 0.27'
15 | gem 'simplecov', '>= 0.9'
16 | gem 'webmock'
17 | end
18 |
19 | gemspec
20 |
--------------------------------------------------------------------------------
/spec/fixtures/info.json:
--------------------------------------------------------------------------------
1 | {
2 | "info":
3 | {
4 | "users":
5 | [
6 | {
7 | "id":119476949,
8 | "name":"oauth_dancer",
9 | "dm":false
10 | }
11 | ],
12 | "delimited":"none",
13 | "include_followings_activity":false,
14 | "include_user_changes":false,
15 | "replies":"none",
16 | "with":"user"
17 | }
18 | }
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | ## Contributing
2 | * Fork the project.
3 | * Make your feature addition or bug fix.
4 | * Add tests for it. This is important so I don't break it in a future version
5 | unintentionally.
6 | * Commit, do not mess with rakefile, version, or history. (if you want to have
7 | your own version, that is fine but bump version in a commit by itself I can
8 | ignore when I pull)
9 | * Send me a pull request. Bonus points for topic branches.
10 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require 'bundler'
2 | Bundler::GemHelper.install_tasks
3 |
4 | require 'rspec/core/rake_task'
5 | RSpec::Core::RakeTask.new(:spec)
6 |
7 | task :test => :spec
8 |
9 | require 'yard'
10 | YARD::Rake::YardocTask.new
11 |
12 | begin
13 | require 'rubocop/rake_task'
14 | RuboCop::RakeTask.new
15 | rescue LoadError
16 | task :rubocop do
17 | $stderr.puts 'RuboCop is disabled'
18 | end
19 | end
20 |
21 | task :default => [:spec, :rubocop]
22 |
--------------------------------------------------------------------------------
/examples/oauth.rb:
--------------------------------------------------------------------------------
1 | require 'tweetstream'
2 |
3 | TweetStream.configure do |config|
4 | config.consumer_key = 'abcdefghijklmnopqrstuvwxyz'
5 | config.consumer_secret = '0123456789'
6 | config.oauth_token = 'abcdefghijklmnopqrstuvwxyz'
7 | config.oauth_token_secret = '0123456789'
8 | config.auth_method = :oauth
9 | end
10 |
11 | client = TweetStream::Client.new
12 |
13 | client.on_error do |message|
14 | puts message
15 | end
16 |
17 | client.track('yankees') do |status|
18 | puts "#{status.text}"
19 | end
20 |
--------------------------------------------------------------------------------
/examples/growl_daemon.rb:
--------------------------------------------------------------------------------
1 | require 'rubygems'
2 | require 'tweetstream'
3 | require 'growl'
4 |
5 | tracks = 'yankees'
6 | puts "Starting a TweetStream Daemon to track: #{tracks}"
7 |
8 | TweetStream.configure do |config|
9 | config.consumer_key = 'abcdefghijklmnopqrstuvwxyz'
10 | config.consumer_secret = '0123456789'
11 | config.oauth_token = 'abcdefghijklmnopqrstuvwxyz'
12 | config.oauth_token_secret = '0123456789'
13 | config.auth_method = :oauth
14 | end
15 |
16 | TweetStream::Daemon.new('tracker').track(tracks) do |status|
17 | Growl.notify status.text, :title => status.user.screen_name
18 | end
19 |
--------------------------------------------------------------------------------
/examples/userstream.rb:
--------------------------------------------------------------------------------
1 | require 'tweetstream'
2 |
3 | TweetStream.configure do |config|
4 | config.consumer_key = 'abcdefghijklmnopqrstuvwxyz'
5 | config.consumer_secret = '0123456789'
6 | config.oauth_token = 'abcdefghijklmnopqrstuvwxyz'
7 | config.oauth_token_secret = '0123456789'
8 | config.auth_method = :oauth
9 | end
10 |
11 | client = TweetStream::Client.new
12 |
13 | client.on_error do |message|
14 | puts message
15 | end
16 |
17 | client.on_direct_message do |direct_message|
18 | puts direct_message.text
19 | end
20 |
21 | client.on_timeline_status do |status|
22 | puts status.text
23 | end
24 |
25 | client.userstream
26 |
--------------------------------------------------------------------------------
/spec/fixtures/ids.json:
--------------------------------------------------------------------------------
1 | {
2 | "follow":
3 | {
4 | "user":
5 | {
6 | "id":119476949,
7 | "name":"oauth_dancer",
8 | "dm":false
9 | },
10 | "friends":
11 | [
12 | 795649,
13 | 819797,
14 | 1401881,
15 | 3191321,
16 | 6253282,
17 | 8285392,
18 | 9160152,
19 | 13058772,
20 | 15147442,
21 | 15266205,
22 | 15822993,
23 | 27831060,
24 | 101058399,
25 | 289788076
26 | ],
27 | "previous_cursor":0,
28 | "next_cursor":0
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/spec/tweetstream/daemon_spec.rb:
--------------------------------------------------------------------------------
1 | require 'helper'
2 |
3 | describe TweetStream::Daemon do
4 | describe '.new' do
5 | it 'initializes with no arguments' do
6 | client = TweetStream::Daemon.new
7 | expect(client).to be_kind_of(TweetStream::Client)
8 | end
9 |
10 | it 'initializes with defaults' do
11 | client = TweetStream::Daemon.new
12 | expect(client.app_name).to eq(TweetStream::Daemon::DEFAULT_NAME)
13 | expect(client.daemon_options).to eq(TweetStream::Daemon::DEFAULT_OPTIONS)
14 | end
15 |
16 | it 'initializes with an app_name' do
17 | client = TweetStream::Daemon.new('tweet_tracker')
18 | expect(client.app_name).to eq('tweet_tracker')
19 | end
20 | end
21 |
22 | describe '#start' do
23 | it 'starts the daemon' do
24 | client = TweetStream::Daemon.new
25 | expect(Daemons).to receive(:run_proc).once
26 | client.track('intridea')
27 | end
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/examples/sitestream.rb:
--------------------------------------------------------------------------------
1 | require 'tweetstream'
2 |
3 | TweetStream.configure do |config|
4 | config.consumer_key = 'abcdefghijklmnopqrstuvwxyz'
5 | config.consumer_secret = '0123456789'
6 | config.oauth_token = 'abcdefghijklmnopqrstuvwxyz'
7 | config.oauth_token_secret = '0123456789'
8 | config.auth_method = :oauth
9 | end
10 |
11 | EM.run do
12 | client = TweetStream::Client.new
13 |
14 | client.on_error do |error|
15 | puts error
16 | end
17 |
18 | client.sitestream([user_id], :followings => true) do |status|
19 | puts status.inspect
20 | end
21 |
22 | EM::Timer.new(60) do
23 | client.control.add_user(user_id_to_add)
24 | client.control.info { |i| puts i.inspect }
25 | end
26 |
27 | EM::Timer.new(75) do
28 | client.control.friends_ids(user_id) do |friends|
29 | puts friends.inspect
30 | end
31 | end
32 |
33 | EM::Timer.new(90) do
34 | client.control.remove_user(user_id_to_remove)
35 | client.control.info { |i| puts i.inspect }
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/lib/tweetstream.rb:
--------------------------------------------------------------------------------
1 | require 'tweetstream/configuration'
2 | require 'tweetstream/client'
3 | require 'tweetstream/daemon'
4 |
5 | module TweetStream
6 | extend Configuration
7 |
8 | class ReconnectError < StandardError
9 | attr_accessor :timeout, :retries
10 | def initialize(timeout, retries)
11 | self.timeout = timeout
12 | self.retries = retries
13 | super("Failed to reconnect after #{retries} tries.")
14 | end
15 | end
16 |
17 | class << self
18 | # Alias for TweetStream::Client.new
19 | #
20 | # @return [TweetStream::Client]
21 | def new(options = {})
22 | TweetStream::Client.new(options)
23 | end
24 |
25 | # Delegate to TweetStream::Client
26 | def method_missing(method, *args, &block)
27 | return super unless new.respond_to?(method)
28 | new.send(method, *args, &block)
29 | end
30 |
31 | # Delegate to TweetStream::Client
32 | def respond_to?(method, include_private = false)
33 | new.respond_to?(method, include_private) || super(method, include_private)
34 | end
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright (c) 2012-2013 Intridea, Inc.
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.
21 |
--------------------------------------------------------------------------------
/.rubocop.yml:
--------------------------------------------------------------------------------
1 | Metrics/BlockNesting:
2 | Max: 2
3 |
4 | Metrics/LineLength:
5 | AllowURI: true
6 | Enabled: false
7 |
8 | Metrics/MethodLength:
9 | CountComments: false
10 | Max: 35 # TODO: Lower to 15
11 |
12 | Metrics/ParameterLists:
13 | Max: 4
14 | CountKeywordArgs: true
15 |
16 | Metrics/AbcSize:
17 | Enabled: false
18 |
19 | Style/AccessModifierIndentation:
20 | EnforcedStyle: outdent
21 |
22 | Style/CollectionMethods:
23 | PreferredMethods:
24 | map: 'collect'
25 | reduce: 'inject'
26 | find: 'detect'
27 | find_all: 'select'
28 |
29 | Style/Documentation:
30 | Enabled: false
31 |
32 | Style/DotPosition:
33 | EnforcedStyle: trailing
34 |
35 | Style/DoubleNegation:
36 | Enabled: false
37 |
38 | Style/EachWithObject:
39 | Enabled: false
40 |
41 | Style/Encoding:
42 | Enabled: false
43 |
44 | Style/HashSyntax:
45 | EnforcedStyle: hash_rockets
46 |
47 | Style/Lambda:
48 | Enabled: false
49 |
50 | Style/RaiseArgs:
51 | EnforcedStyle: compact
52 |
53 | Style/SpaceInsideHashLiteralBraces:
54 | EnforcedStyle: no_space
55 |
56 | Style/TrailingComma:
57 | EnforcedStyleForMultiline: 'comma'
58 |
--------------------------------------------------------------------------------
/tweetstream.gemspec:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | lib = File.expand_path('../lib', __FILE__)
3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4 | require 'tweetstream/version'
5 |
6 | Gem::Specification.new do |spec|
7 | spec.name = 'tweetstream'
8 | spec.version = TweetStream::VERSION
9 |
10 | spec.authors = ['Michael Bleigh', 'Steve Agalloco']
11 | spec.email = ['michael@intridea.com', 'steve.agalloco@gmail.com']
12 | spec.description = 'TweetStream is a simple wrapper for consuming the Twitter Streaming API.'
13 | spec.summary = spec.description
14 | spec.homepage = 'https://github.com/tweetstream/tweetstream'
15 | spec.licenses = ['MIT']
16 |
17 | spec.add_dependency 'daemons', '~> 1.1'
18 | spec.add_dependency 'em-http-request', '>= 1.1.1'
19 | spec.add_dependency 'em-twitter', '~> 0.3'
20 | spec.add_dependency 'twitter', '~> 5.12'
21 | spec.add_dependency 'multi_json', '~> 1.3'
22 | spec.add_development_dependency 'bundler', '~> 1.0'
23 |
24 | spec.files = %w(.yardopts CHANGELOG.md CONTRIBUTING.md LICENSE.md README.md tweetstream.gemspec) + Dir['lib/**/*.rb']
25 |
26 | spec.require_paths = ['lib']
27 | spec.required_ruby_version = '>= 1.9.3'
28 | end
29 |
--------------------------------------------------------------------------------
/spec/helper.rb:
--------------------------------------------------------------------------------
1 | require 'simplecov'
2 | require 'coveralls'
3 |
4 | SimpleCov.formatters = [SimpleCov::Formatter::HTMLFormatter, Coveralls::SimpleCov::Formatter]
5 |
6 | SimpleCov.start do
7 | add_filter '/vendor/bundle'
8 | add_filter '/spec'
9 | minimum_coverage(97.85)
10 | end
11 |
12 | require 'tweetstream'
13 | require 'tweetstream/site_stream_client'
14 | require 'json'
15 | require 'rspec'
16 | require 'webmock/rspec'
17 |
18 | WebMock.disable_net_connect!(:allow => 'coveralls.io')
19 |
20 | RSpec.configure do |config|
21 | config.expect_with :rspec do |c|
22 | c.syntax = :expect
23 | end
24 |
25 | config.after(:each) do
26 | TweetStream.reset
27 | end
28 | end
29 |
30 | def samples(fixture)
31 | samples = []
32 | fixture(fixture).each_line do |line|
33 | samples << JSON.parse(line, :symbolize_names => true)
34 | end
35 | samples
36 | end
37 |
38 | def sample_tweets
39 | return @tweets if @tweets
40 | @tweets = samples('statuses.json')
41 | end
42 |
43 | def sample_direct_messages
44 | return @direct_messages if @direct_messages
45 | @direct_messages = samples('direct_messages.json')
46 | end
47 |
48 | def fixture_path
49 | File.expand_path('../fixtures', __FILE__)
50 | end
51 |
52 | def fixture(file)
53 | File.new(fixture_path + '/' + file)
54 | end
55 |
56 | FakeHttp = Class.new do
57 | def callback
58 | end
59 |
60 | def errback
61 | end
62 | end
63 |
--------------------------------------------------------------------------------
/lib/tweetstream/daemon.rb:
--------------------------------------------------------------------------------
1 | require 'daemons'
2 |
3 | # A daemonized TweetStream client that will allow you to
4 | # create backgroundable scripts for application specific
5 | # processes. For instance, if you create a script called
6 | # tracker.rb and fill it with this:
7 | #
8 | # require 'rubygems'
9 | # require 'tweetstream'
10 | #
11 | # TweetStream.configure do |config|
12 | # config.consumer_key = 'abcdefghijklmnopqrstuvwxyz'
13 | # config.consumer_secret = '0123456789'
14 | # config.oauth_token = 'abcdefghijklmnopqrstuvwxyz'
15 | # config.oauth_token_secret = '0123456789'
16 | # config.auth_method = :oauth
17 | # end
18 | #
19 | # TweetStream::Daemon.new('tracker').track('intridea') do |status|
20 | # # do something here
21 | # end
22 | #
23 | # And then you call this from the shell:
24 | #
25 | # ruby tracker.rb start
26 | #
27 | # A daemon process will spawn that will automatically
28 | # run the code in the passed block whenever a new tweet
29 | # matching your search term ('intridea' in this case)
30 | # is posted.
31 | #
32 | module TweetStream
33 | class Daemon < TweetStream::Client
34 | DEFAULT_NAME = 'tweetstream'.freeze
35 | DEFAULT_OPTIONS = {:multiple => true}
36 |
37 | attr_accessor :app_name, :daemon_options
38 |
39 | # The daemon has an optional process name for use when querying
40 | # running processes. You can also pass daemon options.
41 | def initialize(name = DEFAULT_NAME, options = DEFAULT_OPTIONS)
42 | @app_name = name
43 | @daemon_options = options
44 | super({})
45 | end
46 |
47 | def start(path, query_parameters = {}, &block) #:nodoc:
48 | Daemons.run_proc(@app_name, @daemon_options) do
49 | super(path, query_parameters, &block)
50 | end
51 | end
52 | end
53 | end
54 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | Version 2.6.0
2 | =============
3 | * Re-implemented multi_json for response parsing
4 | * added on_control callback method for Site Streams
5 | * fixed issue with Site Stream add/remove when passed a single user id (philgyford)
6 | * raised em-twitter dependency to 0.3.0 to resolve issues with ruby 2.0.0
7 |
8 | Version 2.5.0
9 | =============
10 | * added proxy support
11 |
12 | Version 2.4.0
13 | =============
14 | * Revert "use extract_options! from the Twitter gem"
15 | * Reorganize development and test dependencies
16 |
17 | Version 2.3.0
18 | =============
19 | * Added support for Site Stream friends list
20 | * Update paths for API 1.1
21 | * Added stall warning handling
22 |
23 | Version 2.2.0
24 | =============
25 | * Change method to request_method
26 |
27 | Version 2.1.0
28 | =============
29 | * Disable identity map to reduce memory usage
30 | * Added options support to UserStreams
31 |
32 | Version 2.0.1
33 | =============
34 | * Fixed Twitter gem objects
35 | * Added on_unauthorized callback method (koenpunt)
36 |
37 | Version 2.0.0
38 | =============
39 | * Added Site Stream support
40 | * Switched to [em-twitter](https://github.com/spagalloco/em-twitter) for underlying streaming lib
41 | * Switched to Twitter gem objects instead of custom hashes, see [47e5cd3d21a9562b3d959bc231009af460b37567](https://github.com/intridea/tweetstream/commit/47e5cd3d21a9562b3d959bc231009af460b37567) for details (sferik)
42 | * Made OAuth the default authentication method
43 | * Removed on_interval callback method
44 | * Removed parser configuration option
45 |
46 | Version 1.1.5
47 | =============
48 | * Added support for the scrub_geo response (augustj)
49 | * Update multi_json and twitter-stream version dependencies
50 |
51 | Version 1.1.4
52 | =============
53 | * Added Client#connect to start streaming inside an EM reactor (pelle)
54 | * Added shutdown_stream to cleanly stop the stream (lud)
55 | * Loosened multi_json dependency for Rails 3.2 compatibiltiy
56 |
57 | Version 1.1.3
58 | =============
59 | * Added on_reconnect callback method
60 |
61 | Version 1.1.2
62 | =============
63 | * Added support for statuses/links
64 | * Client now checks that specified json_parser can be loaded during initialization
65 |
66 | Version 1.1.1
67 | =============
68 | * Fix for 1.8.6 compatibility
69 |
70 | Version 1.1.0
71 | =============
72 | * OAuth authentication
73 | * User Stream support
74 | * Removed swappable JSON backend support for MultiJson
75 | * Added EventMachine epoll and kqueue support
76 | * Added on_interval callback
77 | * Added on_inited callback
78 |
79 | Version 1.0.5
80 | =============
81 | * Force SSL to comply with
82 |
83 | Version 1.0.0
84 | =============
85 | * Swappable JSON backend support
86 | * Switches to use EventMachine instead of Yajl for the HTTP Stream
87 | * Support reconnect and on_error
88 |
--------------------------------------------------------------------------------
/spec/tweetstream/client_authentication_spec.rb:
--------------------------------------------------------------------------------
1 | require 'helper'
2 |
3 | describe TweetStream::Client do
4 | before do
5 | @stream = double('EM::Twitter::Client',
6 | :connect => true,
7 | :unbind => true,
8 | :each => true,
9 | :on_error => true,
10 | :on_max_reconnects => true,
11 | :on_reconnect => true,
12 | :connection_completed => true,
13 | :on_no_data_received => true,
14 | :on_unauthorized => true,
15 | :on_enhance_your_calm => true,
16 | )
17 | allow(EM).to receive(:run).and_yield
18 | allow(EM::Twitter::Client).to receive(:connect).and_return(@stream)
19 | end
20 |
21 | describe 'basic auth' do
22 | before do
23 | TweetStream.configure do |config|
24 | config.username = 'tweetstream'
25 | config.password = 'rubygem'
26 | config.auth_method = :basic
27 | end
28 |
29 | @client = TweetStream::Client.new
30 | end
31 |
32 | it 'tries to connect via a JSON stream with basic auth' do
33 | expect(EM::Twitter::Client).to receive(:connect).with(
34 | :path => '/1.1/statuses/filter.json',
35 | :method => 'POST',
36 | :user_agent => TweetStream::Configuration::DEFAULT_USER_AGENT,
37 | :on_inited => nil,
38 | :params => {:track => 'monday'},
39 | :basic => {
40 | :username => 'tweetstream',
41 | :password => 'rubygem',
42 | },
43 | :proxy => nil,
44 | ).and_return(@stream)
45 |
46 | @client.track('monday')
47 | end
48 | end
49 |
50 | describe 'oauth' do
51 | before do
52 | TweetStream.configure do |config|
53 | config.consumer_key = '123456789'
54 | config.consumer_secret = 'abcdefghijklmnopqrstuvwxyz'
55 | config.oauth_token = '123456789'
56 | config.oauth_token_secret = 'abcdefghijklmnopqrstuvwxyz'
57 | config.auth_method = :oauth
58 | end
59 |
60 | @client = TweetStream::Client.new
61 | end
62 |
63 | it 'tries to connect via a JSON stream with oauth' do
64 | expect(EM::Twitter::Client).to receive(:connect).with(
65 | :path => '/1.1/statuses/filter.json',
66 | :method => 'POST',
67 | :user_agent => TweetStream::Configuration::DEFAULT_USER_AGENT,
68 | :on_inited => nil,
69 | :params => {:track => 'monday'},
70 | :oauth => {
71 | :consumer_key => '123456789',
72 | :consumer_secret => 'abcdefghijklmnopqrstuvwxyz',
73 | :token => '123456789',
74 | :token_secret => 'abcdefghijklmnopqrstuvwxyz',
75 | },
76 | :proxy => nil,
77 | ).and_return(@stream)
78 |
79 | @client.track('monday')
80 | end
81 | end
82 | end
83 |
--------------------------------------------------------------------------------
/spec/fixtures/direct_messages.json:
--------------------------------------------------------------------------------
1 | {"direct_message":{"created_at":"Sat Sep 24 18:59:38 +0000 2011", "id_str":"4227325281", "sender_screen_name":"coreyhaines", "sender":{"name":"Corey Haines", "profile_sidebar_fill_color":"DAECF4", "profile_sidebar_border_color":"C6E2EE", "profile_background_tile":false, "profile_image_url":"http://a0.twimg.com/profile_images/1508969901/Photo_on_2011-08-22_at_19.15__3_normal.jpg", "created_at":"Sun Dec 23 18:11:29 +0000 2007", "location":"Chicago, IL", "follow_request_sent":false, "id_str":"11458102", "is_translator":false, "profile_link_color":"1F98C7", "default_profile":false, "favourites_count":122, "contributors_enabled":false, "url":"http://www.coreyhaines.com", "id":11458102, "profile_image_url_https":"https://si0.twimg.com/profile_images/1508969901/Photo_on_2011-08-22_at_19.15__3_normal.jpg", "utc_offset":-21600, "profile_use_background_image":true, "listed_count":593, "lang":"en", "followers_count":5764, "protected":false, "profile_text_color":"663B12", "notifications":false, "description":"Software Journeyman, Coderetreat Facilitator, Cofounder of MercuryApp.com, Awesome....\r\nI make magic!", "verified":false, "profile_background_color":"C6E2EE", "geo_enabled":false, "profile_background_image_url_https":"https://si0.twimg.com/images/themes/theme2/bg.gif", "time_zone":"Central Time (US & Canada)", "profile_background_image_url":"http://a1.twimg.com/images/themes/theme2/bg.gif", "default_profile_image":false, "friends_count":423, "statuses_count":35950, "following":false, "screen_name":"coreyhaines", "show_all_inline_media":false}, "recipient_screen_name":"coreyhainestest", "text":"waddup gain", "id":4227325281, "recipient":{"name":"Corey's Test Account", "profile_sidebar_fill_color":"DDEEF6", "profile_sidebar_border_color":"C0DEED", "profile_background_tile":false, "profile_image_url":"http://a2.twimg.com/sticky/default_profile_images/default_profile_3_normal.png", "created_at":"Sat Sep 24 13:04:56 +0000 2011", "location":null, "follow_request_sent":false, "id_str":"379145826", "is_translator":false, "profile_link_color":"0084B4", "default_profile":true, "favourites_count":0, "contributors_enabled":false, "url":null, "id":379145826, "profile_image_url_https":"https://si0.twimg.com/sticky/default_profile_images/default_profile_3_normal.png", "utc_offset":null, "profile_use_background_image":true, "listed_count":0, "lang":"en", "followers_count":1, "protected":false, "profile_text_color":"333333", "notifications":false, "description":null, "verified":false, "profile_background_color":"C0DEED", "geo_enabled":false, "profile_background_image_url_https":"https://si0.twimg.com/images/themes/theme1/bg.png", "time_zone":null, "profile_background_image_url":"http://a0.twimg.com/images/themes/theme1/bg.png", "default_profile_image":true, "friends_count":1, "statuses_count":21, "following":true, "screen_name":"coreyhainestest", "show_all_inline_media":false}, "recipient_id":379145826, "sender_id":11458102}}
2 |
--------------------------------------------------------------------------------
/lib/tweetstream/configuration.rb:
--------------------------------------------------------------------------------
1 | require 'tweetstream/version'
2 |
3 | module TweetStream
4 | # Defines constants and methods related to configuration
5 | module Configuration
6 | # An array of valid keys in the options hash when configuring TweetStream.
7 | VALID_OPTIONS_KEYS = [
8 | :parser,
9 | :username,
10 | :password,
11 | :user_agent,
12 | :auth_method,
13 | :proxy,
14 | :consumer_key,
15 | :consumer_secret,
16 | :oauth_token,
17 | :oauth_token_secret].freeze
18 |
19 | OAUTH_OPTIONS_KEYS = [
20 | :consumer_key,
21 | :consumer_secret,
22 | :oauth_token,
23 | :oauth_token_secret].freeze
24 |
25 | # By default, don't set a username
26 | DEFAULT_USERNAME = nil
27 |
28 | # By default, don't set a password
29 | DEFAULT_PASSWORD = nil
30 |
31 | # The user agent that will be sent to the API endpoint if none is set
32 | DEFAULT_USER_AGENT = "TweetStream Ruby Gem #{TweetStream::VERSION}".freeze
33 |
34 | # The default authentication method
35 | DEFAULT_AUTH_METHOD = :oauth
36 |
37 | DEFAULT_PROXY = nil
38 |
39 | VALID_FORMATS = [
40 | :basic,
41 | :oauth].freeze
42 |
43 | # By default, don't set an application key
44 | DEFAULT_CONSUMER_KEY = nil
45 |
46 | # By default, don't set an application secret
47 | DEFAULT_CONSUMER_SECRET = nil
48 |
49 | # By default, don't set a user oauth token
50 | DEFAULT_OAUTH_TOKEN = nil
51 |
52 | # By default, don't set a user oauth secret
53 | DEFAULT_OAUTH_TOKEN_SECRET = nil
54 |
55 | # @private
56 | attr_accessor(*VALID_OPTIONS_KEYS)
57 |
58 | # When this module is extended, set all configuration options to their default values
59 | def self.extended(base)
60 | base.reset
61 | end
62 |
63 | # Convenience method to allow configuration options to be set in a block
64 | def configure
65 | yield self
66 | end
67 |
68 | # Create a hash of options and their values
69 | def options
70 | Hash[*VALID_OPTIONS_KEYS.collect { |key| [key, send(key)] }.flatten]
71 | end
72 |
73 | # Create a hash of options and their values
74 | def oauth_options
75 | Hash[*OAUTH_OPTIONS_KEYS.collect { |key| [key, send(key)] }.flatten]
76 | end
77 |
78 | # Reset all configuration options to defaults
79 | def reset
80 | self.username = DEFAULT_USERNAME
81 | self.password = DEFAULT_PASSWORD
82 | self.user_agent = DEFAULT_USER_AGENT
83 | self.auth_method = DEFAULT_AUTH_METHOD
84 | self.proxy = DEFAULT_PROXY
85 | self.consumer_key = DEFAULT_CONSUMER_KEY
86 | self.consumer_secret = DEFAULT_CONSUMER_SECRET
87 | self.oauth_token = DEFAULT_OAUTH_TOKEN
88 | self.oauth_token_secret = DEFAULT_OAUTH_TOKEN_SECRET
89 | self
90 | end
91 | end
92 | end
93 |
--------------------------------------------------------------------------------
/spec/tweetstream/client_userstream_spec.rb:
--------------------------------------------------------------------------------
1 | require 'helper'
2 |
3 | describe TweetStream::Client do
4 | before(:each) do
5 | TweetStream.configure do |config|
6 | config.consumer_key = 'abc'
7 | config.consumer_secret = 'def'
8 | config.oauth_token = '123'
9 | config.oauth_token_secret = '456'
10 | end
11 | @client = TweetStream::Client.new
12 |
13 | @stream = double('EM::Twitter::Client',
14 | :connect => true,
15 | :unbind => true,
16 | :each => true,
17 | :on_error => true,
18 | :on_max_reconnects => true,
19 | :on_reconnect => true,
20 | :connection_completed => true,
21 | :on_no_data_received => true,
22 | :on_unauthorized => true,
23 | :on_enhance_your_calm => true,
24 | )
25 | allow(EM).to receive(:run).and_yield
26 | allow(EM::Twitter::Client).to receive(:connect).and_return(@stream)
27 | end
28 |
29 | describe 'User Stream support' do
30 | context 'when calling #userstream' do
31 | it 'sends the userstream host' do
32 | expect(EM::Twitter::Client).to receive(:connect).with(hash_including(:host => 'userstream.twitter.com')).and_return(@stream)
33 | @client.userstream
34 | end
35 |
36 | it 'uses the userstream uri' do
37 | expect(@client).to receive(:start).once.with('/1.1/user.json', an_instance_of(Hash)).and_return(@stream)
38 | @client.userstream
39 | end
40 |
41 | it "supports :replies => 'all'" do
42 | expect(@client).to receive(:start).once.with('/1.1/user.json', hash_including(:replies => 'all')).and_return(@stream)
43 | @client.userstream(:replies => 'all')
44 | end
45 |
46 | it "supports :stall_warnings => 'true'" do
47 | expect(@client).to receive(:start).once.with('/1.1/user.json', hash_including(:stall_warnings => 'true')).and_return(@stream)
48 | @client.userstream(:stall_warnings => 'true')
49 | end
50 |
51 | it "supports :with => 'followings'" do
52 | expect(@client).to receive(:start).once.with('/1.1/user.json', hash_including(:with => 'followings')).and_return(@stream)
53 | @client.userstream(:with => 'followings')
54 | end
55 |
56 | it "supports :with => 'user'" do
57 | expect(@client).to receive(:start).once.with('/1.1/user.json', hash_including(:with => 'user')).and_return(@stream)
58 | @client.userstream(:with => 'user')
59 | end
60 |
61 | it 'supports event callbacks' do
62 | event = nil
63 | expect(@stream).to receive(:each).and_yield(fixture('favorite.json'))
64 | @client.on_event(:favorite) do |e|
65 | event = e
66 | end.userstream
67 |
68 | expect(event[:source]).not_to be_nil
69 | expect(event[:target]).not_to be_nil
70 | end
71 | end
72 | end
73 | end
74 |
--------------------------------------------------------------------------------
/lib/tweetstream/site_stream_client.rb:
--------------------------------------------------------------------------------
1 | require 'em-http'
2 | require 'em-http/middleware/oauth'
3 | require 'em-http/middleware/json_response'
4 |
5 | module TweetStream
6 | class SiteStreamClient
7 | attr_accessor(*Configuration::OAUTH_OPTIONS_KEYS)
8 |
9 | def initialize(config_uri, oauth = {})
10 | @config_uri = config_uri
11 |
12 | options = TweetStream.oauth_options.merge(oauth)
13 | Configuration::OAUTH_OPTIONS_KEYS.each do |key|
14 | send("#{key}=", options[key])
15 | end
16 |
17 | EventMachine::HttpRequest.use EventMachine::Middleware::JSONResponse
18 | end
19 |
20 | def on_error(&block)
21 | if block_given?
22 | @on_error = block
23 | self
24 | else
25 | @on_error
26 | end
27 | end
28 |
29 | def info(&block)
30 | options = {:error_msg => 'Failed to retrieve SiteStream info.'}
31 | request(:get, info_path, options, &block)
32 | end
33 |
34 | def add_user(user_id, &block)
35 | options = {
36 | :error_msg => 'Failed to add user to SiteStream',
37 | :body => {
38 | 'user_id' => normalized_user_ids(user_id),
39 | },
40 | }
41 | request(:post, add_user_path, options, &block)
42 | end
43 |
44 | def remove_user(user_id, &block)
45 | options = {
46 | :error_msg => 'Failed to remove user from SiteStream.',
47 | :body => {
48 | 'user_id' => normalized_user_ids(user_id),
49 | },
50 | }
51 | request(:post, remove_user_path, options, &block)
52 | end
53 |
54 | def friends_ids(user_id, &block)
55 | options = {
56 | :error_msg => 'Failed to retrieve SiteStream friends ids.',
57 | :body => {
58 | 'user_id' => user_id,
59 | },
60 | }
61 | request(:post, friends_ids_path, options, &block)
62 | end
63 |
64 | private
65 |
66 | def connection
67 | return @conn if defined?(@conn)
68 | @conn = EventMachine::HttpRequest.new('https://sitestream.twitter.com/')
69 | @conn.use EventMachine::Middleware::OAuth, oauth_configuration
70 | @conn
71 | end
72 |
73 | def oauth_configuration
74 | {
75 | :consumer_key => consumer_key,
76 | :consumer_secret => consumer_secret,
77 | :access_token => oauth_token,
78 | :access_token_secret => oauth_token_secret,
79 | :ignore_extra_keys => true,
80 | }
81 | end
82 |
83 | def info_path
84 | @config_uri + '/info.json'
85 | end
86 |
87 | def add_user_path
88 | @config_uri + '/add_user.json'
89 | end
90 |
91 | def remove_user_path
92 | @config_uri + '/remove_user.json'
93 | end
94 |
95 | def friends_ids_path
96 | @config_uri + '/friends/ids.json'
97 | end
98 |
99 | def request(method, path, options, &block) # rubocop:disable CyclomaticComplexity, ParameterLists, PerceivedComplexity
100 | error_msg = options.delete(:error_msg)
101 | http = connection.send(method, options.merge(:path => path))
102 | http.callback do
103 | if http.response_header.status == 200 && block && block.is_a?(Proc)
104 | block.arity == 1 ? block.call(http.response) : block.call
105 | else
106 | @on_error.call(error_msg) if @on_error && @on_error.is_a?(Proc)
107 | end
108 | end
109 | http.errback do
110 | @on_error.call(error_msg) if @on_error && @on_error.is_a?(Proc)
111 | end
112 | end
113 |
114 | def normalized_user_ids(user_id)
115 | if user_id.is_a?(Array)
116 | user_id.join(',')
117 | else
118 | user_id.to_s
119 | end
120 | end
121 | end
122 | end
123 |
--------------------------------------------------------------------------------
/spec/tweetstream_spec.rb:
--------------------------------------------------------------------------------
1 | require 'helper'
2 |
3 | describe TweetStream do
4 | context 'when delegating to a client' do
5 | before do
6 | @stream = double('EM::Twitter::Client',
7 | :connect => true,
8 | :unbind => true,
9 | :each_item => true,
10 | :on_error => true,
11 | :on_max_reconnects => true,
12 | :on_reconnect => true,
13 | :connection_completed => true,
14 | :on_no_data_received => true,
15 | :on_unauthorized => true,
16 | :on_enhance_your_calm => true,
17 | )
18 | allow(EM).to receive(:run).and_yield
19 | allow(EM::Twitter::Client).to receive(:connect).and_return(@stream)
20 | end
21 |
22 | it 'returns the same results as a client' do
23 | expect(MultiJson).to receive(:decode).and_return({})
24 | expect(@stream).to receive(:each).and_yield(sample_tweets[0].to_json)
25 | TweetStream.track('abc', 'def')
26 | end
27 | end
28 |
29 | describe '.new' do
30 | it 'is a TweetStream::Client' do
31 | expect(TweetStream.new).to be_a TweetStream::Client
32 | end
33 | end
34 |
35 | describe '.respond_to?' do
36 | it 'takes an optional argument' do
37 | expect(TweetStream.respond_to?(:new, true)).to be true
38 | end
39 | end
40 |
41 | describe '.username' do
42 | it 'returns the default username' do
43 | expect(TweetStream.username).to eq(TweetStream::Configuration::DEFAULT_USERNAME)
44 | end
45 | end
46 |
47 | describe '.username=' do
48 | it 'sets the username' do
49 | TweetStream.username = 'jack'
50 | expect(TweetStream.username).to eq('jack')
51 | end
52 | end
53 |
54 | describe '.password' do
55 | it 'returns the default password' do
56 | expect(TweetStream.password).to eq(TweetStream::Configuration::DEFAULT_PASSWORD)
57 | end
58 | end
59 |
60 | describe '.password=' do
61 | it 'sets the password' do
62 | TweetStream.password = 'passw0rd'
63 | expect(TweetStream.password).to eq('passw0rd')
64 | end
65 | end
66 |
67 | describe '.auth_method' do
68 | it 'shold return the default auth method' do
69 | expect(TweetStream.auth_method).to eq(TweetStream::Configuration::DEFAULT_AUTH_METHOD)
70 | end
71 | end
72 |
73 | describe '.auth_method=' do
74 | it 'sets the auth method' do
75 | TweetStream.auth_method = :basic
76 | expect(TweetStream.auth_method).to eq(:basic)
77 | end
78 | end
79 |
80 | describe '.user_agent' do
81 | it 'returns the default user agent' do
82 | expect(TweetStream.user_agent).to eq(TweetStream::Configuration::DEFAULT_USER_AGENT)
83 | end
84 | end
85 |
86 | describe '.user_agent=' do
87 | it 'sets the user_agent' do
88 | TweetStream.user_agent = 'Custom User Agent'
89 | expect(TweetStream.user_agent).to eq('Custom User Agent')
90 | end
91 | end
92 |
93 | describe '.configure' do
94 | TweetStream::Configuration::VALID_OPTIONS_KEYS.each do |key|
95 | it "sets the #{key}" do
96 | TweetStream.configure do |config|
97 | config.send("#{key}=", key)
98 | expect(TweetStream.send(key)).to eq(key)
99 | end
100 | end
101 | end
102 | end
103 |
104 | describe '.options' do
105 | it 'returns the configuration as a hash' do
106 | expect(TweetStream.options).to be_kind_of(Hash)
107 | end
108 | end
109 |
110 | describe '.oauth_options' do
111 | it 'returns the oauth configuration as a hash' do
112 | expect(TweetStream.oauth_options).to be_kind_of(Hash)
113 | end
114 | end
115 |
116 | describe '.proxy' do
117 | it 'returns the default proxy' do
118 | expect(TweetStream.proxy).to eq(TweetStream::Configuration::DEFAULT_PROXY)
119 | end
120 | end
121 |
122 | describe '.proxy=' do
123 | it 'sets the proxy' do
124 | TweetStream.proxy = {:uri => 'http://someproxy:8081'}
125 | expect(TweetStream.proxy).to be_kind_of(Hash)
126 | end
127 | end
128 | end
129 |
--------------------------------------------------------------------------------
/spec/fixtures/favorite.json:
--------------------------------------------------------------------------------
1 | {"target":
2 | {"id": 598914668,
3 | "verified": false,
4 | "id_str": "598914668",
5 | "profile_background_tile": false,
6 | "location": "",
7 | "profile_sidebar_fill_color": "DDEEF6",
8 | "contributors_enabled": false,
9 | "notifications": false,
10 | "geo_enabled": false,
11 | "profile_image_url_https": "https://si0.twimg.com/profile_images/2276724218/raf46pju0mijc6ixm6ke_normal.jpeg",
12 | "utc_offset": null,
13 | "default_profile": true,
14 | "statuses_count": 151,
15 | "name": "dev4sns",
16 | "profile_background_color": "C0DEED",
17 | "friends_count": 15,
18 | "show_all_inline_media": false,
19 | "protected": false,
20 | "follow_request_sent": false,
21 | "screen_name": "dev4sns",
22 | "listed_count": 0,
23 | "profile_background_image_url": "http://a0.twimg.com/images/themes/theme1/bg.png",
24 | "lang": "ko",
25 | "profile_link_color": "0084B4",
26 | "time_zone": null,
27 | "description": "Dev 계정입니다.",
28 | "is_translator": false,
29 | "profile_use_background_image": true,
30 | "url": null,
31 | "profile_text_color": "333333",
32 | "created_at": "Mon Jun 04 04:07:27 +0000 2012",
33 | "default_profile_image": false,
34 | "profile_background_image_url_https":
35 | "https://si0.twimg.com/images/themes/theme1/bg.png",
36 | "favourites_count": 0,
37 | "profile_sidebar_border_color": "C0DEED",
38 | "profile_image_url":
39 | "http://a0.twimg.com/profile_images/2276724218/raf46pju0mijc6ixm6ke_normal.jpeg",
40 | "following": true,
41 | "followers_count": 5},
42 | "target_object":
43 | {"id_str": "228029422597443584",
44 | "in_reply_to_user_id": null,
45 | "truncated": false,
46 | "contributors": null,
47 | "retweet_count": 0,
48 | "possibly_sensitive_editable": true,
49 | "favorited": false,
50 | "coordinates": null,
51 | "geo": null,
52 | "possibly_sensitive": false,
53 | "user":
54 | {"id": 598914668,
55 | "verified": false,
56 | "id_str": "598914668",
57 | "profile_background_tile": false,
58 | "location": "",
59 | "profile_sidebar_fill_color": "DDEEF6",
60 | "contributors_enabled": false,
61 | "notifications": false,
62 | "geo_enabled": false,
63 | "profile_image_url_https":
64 | "https://si0.twimg.com/profile_images/2276724218/raf46pju0mijc6ixm6ke_normal.jpeg",
65 | "utc_offset": null,
66 | "default_profile": true,
67 | "statuses_count": 151,
68 | "name": "dev4sns",
69 | "profile_background_color": "C0DEED",
70 | "friends_count": 15,
71 | "show_all_inline_media": false,
72 | "protected": false,
73 | "follow_request_sent": false,
74 | "screen_name": "dev4sns",
75 | "listed_count": 0,
76 | "profile_background_image_url":
77 | "http://a0.twimg.com/images/themes/theme1/bg.png",
78 | "lang": "ko",
79 | "profile_link_color": "0084B4",
80 | "time_zone": null,
81 | "description": "Dev 계정입니다.",
82 | "is_translator": false,
83 | "profile_use_background_image": true,
84 | "url": null,
85 | "profile_text_color": "333333",
86 | "created_at": "Mon Jun 04 04:07:27 +0000 2012",
87 | "default_profile_image": false,
88 | "profile_background_image_url_https":
89 | "https://si0.twimg.com/images/themes/theme1/bg.png",
90 | "favourites_count": 0,
91 | "profile_sidebar_border_color": "C0DEED",
92 | "profile_image_url":
93 | "http://a0.twimg.com/profile_images/2276724218/raf46pju0mijc6ixm6ke_normal.jpeg",
94 | "following": true,
95 | "followers_count": 5},
96 | "in_reply_to_status_id_str": null,
97 | "in_reply_to_screen_name": null,
98 | "source": "Dev Acct",
99 | "in_reply_to_user_id_str": null,
100 | "retweeted": false,
101 | "in_reply_to_status_id": null,
102 | "id": 228029422597443584,
103 | "place": null,
104 | "text": "asdf http://t.co/CRX3fCRa",
105 | "created_at": "Wed Jul 25 07:30:25 +0000 2012"},
106 | "source":
107 | {"id": 459909498,
108 | "verified": false,
109 | "id_str": "459909498",
110 | "profile_background_tile": false,
111 | "location": "",
112 | "profile_sidebar_fill_color": "DDEEF6",
113 | "contributors_enabled": false,
114 | "notifications": false,
115 | "geo_enabled": false,
116 | "profile_image_url_https":
117 | "https://si0.twimg.com/sticky/default_profile_images/default_profile_6_normal.png",
118 | "utc_offset": null,
119 | "default_profile": true,
120 | "statuses_count": 124,
121 | "name": "Brian Park",
122 | "profile_background_color": "C0DEED",
123 | "friends_count": 3,
124 | "show_all_inline_media": false,
125 | "protected": false,
126 | "follow_request_sent": false,
127 | "screen_name": "bdares",
128 | "listed_count": 0,
129 | "profile_background_image_url":
130 | "http://a0.twimg.com/images/themes/theme1/bg.png",
131 | "lang": "en",
132 | "profile_link_color": "0084B4",
133 | "time_zone": null,
134 | "description": "",
135 | "is_translator": false,
136 | "profile_use_background_image": true,
137 | "url": null,
138 | "profile_text_color": "333333",
139 | "created_at": "Tue Jan 10 05:33:52 +0000 2012",
140 | "default_profile_image": true,
141 | "profile_background_image_url_https":
142 | "https://si0.twimg.com/images/themes/theme1/bg.png",
143 | "favourites_count": 14,
144 | "profile_sidebar_border_color": "C0DEED",
145 | "profile_image_url":
146 | "http://a0.twimg.com/sticky/default_profile_images/default_profile_6_normal.png",
147 | "following": false,
148 | "followers_count": 1},
149 | "event": "favorite",
150 | "created_at": "Mon Aug 06 02:24:16 +0000 2012"}
--------------------------------------------------------------------------------
/spec/tweetstream/client_site_stream_spec.rb:
--------------------------------------------------------------------------------
1 | require 'helper'
2 |
3 | describe TweetStream::Client do
4 | before(:each) do
5 | TweetStream.configure do |config|
6 | config.consumer_key = 'abc'
7 | config.consumer_secret = 'def'
8 | config.oauth_token = '123'
9 | config.oauth_token_secret = '456'
10 | end
11 | @client = TweetStream::Client.new
12 |
13 | @stream = double('EM::Twitter::Client',
14 | :connect => true,
15 | :unbind => true,
16 | :each => true,
17 | :on_error => true,
18 | :on_max_reconnects => true,
19 | :on_reconnect => true,
20 | :connection_completed => true,
21 | :on_no_data_received => true,
22 | :on_unauthorized => true,
23 | :on_enhance_your_calm => true,
24 | )
25 | allow(EM).to receive(:run).and_yield
26 | allow(EM::Twitter::Client).to receive(:connect).and_return(@stream)
27 | end
28 |
29 | describe 'Site Stream support' do
30 | context 'when calling #sitestream' do
31 | it 'sends the sitestream host' do
32 | expect(EM::Twitter::Client).to receive(:connect).with(hash_including(:host => 'sitestream.twitter.com')).and_return(@stream)
33 | @client.sitestream
34 | end
35 |
36 | it 'uses the sitestream uri' do
37 | expect(@client).to receive(:start).once.with('/1.1/site.json', an_instance_of(Hash)).and_return(@stream)
38 | @client.sitestream
39 | end
40 |
41 | it 'supports :followings => true' do
42 | expect(@client).to receive(:start).once.with('/1.1/site.json', hash_including(:with => 'followings')).and_return(@stream)
43 | @client.sitestream(['115192457'], :followings => true)
44 | end
45 |
46 | it "supports :with => 'followings'" do
47 | expect(@client).to receive(:start).once.with('/1.1/site.json', hash_including(:with => 'followings')).and_return(@stream)
48 | @client.sitestream(['115192457'], :with => 'followings')
49 | end
50 |
51 | it "supports :with => 'user'" do
52 | expect(@client).to receive(:start).once.with('/1.1/site.json', hash_including(:with => 'user')).and_return(@stream)
53 | @client.sitestream(['115192457'], :with => 'user')
54 | end
55 |
56 | it "supports :replies => 'all'" do
57 | expect(@client).to receive(:start).once.with('/1.1/site.json', hash_including(:replies => 'all')).and_return(@stream)
58 | @client.sitestream(['115192457'], :replies => 'all')
59 | end
60 |
61 | describe 'control management' do
62 | before do
63 | @control_response = {'control' =>
64 | {
65 | 'control_uri' => '/1.1/site/c/01_225167_334389048B872A533002B34D73F8C29FD09EFC50',
66 | },
67 | }
68 | end
69 | it 'assigns the control_uri' do
70 | expect(@stream).to receive(:each).and_yield(@control_response.to_json)
71 | @client.sitestream
72 |
73 | expect(@client.control_uri).to eq('/1.1/site/c/01_225167_334389048B872A533002B34D73F8C29FD09EFC50')
74 | end
75 |
76 | it 'invokes the on_control callback' do
77 | called = false
78 | expect(@stream).to receive(:each).and_yield(@control_response.to_json)
79 | @client.on_control { called = true }
80 | @client.sitestream
81 |
82 | expect(called).to be true
83 | end
84 |
85 | it 'is controllable when a control_uri has been received' do
86 | expect(@stream).to receive(:each).and_yield(@control_response.to_json)
87 | @client.sitestream
88 |
89 | expect(@client.controllable?).to be true
90 | end
91 |
92 | it 'instantiates a SiteStreamClient' do
93 | expect(@stream).to receive(:each).and_yield(@control_response.to_json)
94 | @client.sitestream
95 |
96 | expect(@client.control).to be_kind_of(TweetStream::SiteStreamClient)
97 | end
98 |
99 | it "passes the client's on_error to the SiteStreamClient" do
100 | called = false
101 | @client.on_error { called = true }
102 | expect(@stream).to receive(:each).and_yield(@control_response.to_json)
103 | @client.sitestream
104 |
105 | @client.control.on_error.call
106 |
107 | expect(called).to be true
108 | end
109 | end
110 |
111 | describe 'data handling' do
112 | context 'messages' do
113 | before do
114 | @ss_message = {'for_user' => '321', 'message' => {'id' => 123, 'user' => {'screen_name' => 'monkey'}, 'text' => 'Oo oo aa aa'}}
115 | end
116 |
117 | it 'yields a site stream message' do
118 | expect(@stream).to receive(:each).and_yield(@ss_message.to_json)
119 | yielded_status = nil
120 | @client.sitestream do |message|
121 | yielded_status = message
122 | end
123 | expect(yielded_status).not_to be_nil
124 | expect(yielded_status[:for_user]).to eq('321')
125 | expect(yielded_status[:message][:user][:screen_name]).to eq('monkey')
126 | expect(yielded_status[:message][:text]).to eq('Oo oo aa aa')
127 | end
128 | it 'yields itself if block has an arity of 2' do
129 | expect(@stream).to receive(:each).and_yield(@ss_message.to_json)
130 | yielded_client = nil
131 | @client.sitestream do |_, client|
132 | yielded_client = client
133 | end
134 | expect(yielded_client).not_to be_nil
135 | expect(yielded_client).to eq(@client)
136 | end
137 | end
138 |
139 | context 'friends list' do
140 | before do
141 | @friends_list = {'friends' => [123, 456]}
142 | end
143 |
144 | it 'yields a friends list array' do
145 | expect(@stream).to receive(:each).and_yield(@friends_list.to_json)
146 | yielded_list = nil
147 | @client.on_friends do |friends|
148 | yielded_list = friends
149 | end
150 | @client.sitestream
151 |
152 | expect(yielded_list).not_to be_nil
153 | expect(yielded_list).to be_an(Array)
154 | expect(yielded_list.first).to eq(123)
155 | end
156 | end
157 | end
158 | end
159 | end
160 | end
161 |
--------------------------------------------------------------------------------
/spec/tweetstream/site_stream_client_spec.rb:
--------------------------------------------------------------------------------
1 | require 'helper'
2 |
3 | describe TweetStream::SiteStreamClient do
4 | let(:keys) { TweetStream::Configuration::OAUTH_OPTIONS_KEYS }
5 | let(:config_uri) { '/2b/site/c/1_1_54e345d655ee3e8df359ac033648530bfbe26c5g' }
6 | let(:client) { TweetStream::SiteStreamClient.new(config_uri) }
7 |
8 | describe 'initialization' do
9 | context 'with module configuration' do
10 | before do
11 | TweetStream.configure do |config|
12 | keys.each do |key|
13 | config.send("#{key}=", key)
14 | end
15 | end
16 | end
17 |
18 | after do
19 | TweetStream.reset
20 | end
21 |
22 | it 'inherits module configuration' do
23 | api = TweetStream::SiteStreamClient.new('/config_uri')
24 | keys.each do |key|
25 | expect(api.send(key)).to eq(key)
26 | end
27 | end
28 | end
29 |
30 | context 'with class configuration' do
31 | let(:configuration) do
32 | {
33 | :consumer_key => 'CK',
34 | :consumer_secret => 'CS',
35 | :oauth_token => 'AT',
36 | :oauth_token_secret => 'AS',
37 | }
38 | end
39 |
40 | context 'during initialization' do
41 | it 'overrides module configuration' do
42 | api = TweetStream::SiteStreamClient.new('/config_uri', configuration)
43 | keys.each do |key|
44 | expect(api.send(key)).to eq(configuration[key])
45 | end
46 | end
47 | end
48 |
49 | context 'after initilization' do
50 | it 'overrides module configuration after initialization' do
51 | api = TweetStream::SiteStreamClient.new('/config_uri')
52 | configuration.each do |key, value|
53 | api.send("#{key}=", value)
54 | end
55 | keys.each do |key|
56 | expect(api.send(key)).to eq(configuration[key])
57 | end
58 | end
59 | end
60 | end
61 | end
62 |
63 | describe '#on_error' do
64 | it 'stores the on_error proc' do
65 | client = TweetStream::SiteStreamClient.new('/config_uri')
66 | client.on_error { puts 'hi' }
67 | expect(client.on_error).to be_kind_of(Proc)
68 | end
69 | end
70 |
71 | describe '#info' do
72 | context 'success' do
73 | it 'returns the information hash' do
74 | stub_request(:get, "https://sitestream.twitter.com#{config_uri}/info.json").
75 | to_return(:status => 200, :body => fixture('info.json'), :headers => {})
76 | stream_info = nil
77 |
78 | EM.run_block do
79 | client.info { |info| stream_info = info }
80 | end
81 | expect(stream_info).to be_kind_of(Hash)
82 | end
83 | end
84 |
85 | context 'failure' do
86 | it 'invokes the on_error callback' do
87 | stub_request(:get, "https://sitestream.twitter.com#{config_uri}/info.json").
88 | to_return(:status => 401, :body => '', :headers => {})
89 | called = false
90 |
91 | EM.run_block do
92 | client.on_error { called = true }
93 | client.info { |info| info }
94 | end
95 | expect(called).to be true
96 | end
97 | end
98 | end
99 |
100 | describe '#add_user' do
101 | context 'success' do
102 | it 'calls a block (if passed one)' do
103 | stub_request(:post, "https://sitestream.twitter.com#{config_uri}/add_user.json").
104 | to_return(:status => 200, :body => '', :headers => {})
105 | called = false
106 |
107 | EM.run_block do
108 | client.add_user(123) { called = true }
109 | end
110 | expect(called).to be true
111 | end
112 | end
113 |
114 | context 'failure' do
115 | it 'invokes the on_error callback' do
116 | stub_request(:post, "https://sitestream.twitter.com#{config_uri}/add_user.json").
117 | to_return(:status => 401, :body => '', :headers => {})
118 | called = false
119 |
120 | EM.run_block do
121 | client.on_error { called = true }
122 | client.add_user(123) { |info| info }
123 | end
124 | expect(called).to be true
125 | end
126 | end
127 |
128 | it 'accepts an array of user_ids' do
129 | conn = double('Connection')
130 | expect(conn).to receive(:post).
131 | with(:path => "#{config_uri}/add_user.json", :body => {'user_id' => '1234,5678'}).
132 | and_return(FakeHttp.new)
133 | allow(client).to receive(:connection) { conn }
134 | client.add_user(%w(1234 5678))
135 | end
136 |
137 | describe 'accepts a single user_id as' do
138 | before :each do
139 | conn = double('Connection')
140 | expect(conn).to receive(:post).
141 | with(:path => "#{config_uri}/add_user.json", :body => {'user_id' => '1234'}).
142 | and_return(FakeHttp.new)
143 | allow(client).to receive(:connection) { conn }
144 | end
145 |
146 | it 'a string' do
147 | client.add_user('1234')
148 | end
149 |
150 | it 'an integer' do
151 | client.add_user(1234)
152 | end
153 | end
154 | end
155 |
156 | describe '#remove_user' do
157 | context 'success' do
158 | it 'calls a block (if passed one)' do
159 | stub_request(:post, "https://sitestream.twitter.com#{config_uri}/remove_user.json").
160 | to_return(:status => 200, :body => '', :headers => {})
161 | called = false
162 |
163 | EM.run_block do
164 | client.remove_user(123) { called = true }
165 | end
166 | expect(called).to be true
167 | end
168 | end
169 |
170 | context 'failure' do
171 | it 'invokes the on_error callback' do
172 | stub_request(:post, "https://sitestream.twitter.com#{config_uri}/remove_user.json").
173 | to_return(:status => 401, :body => '', :headers => {})
174 | called = false
175 |
176 | EM.run_block do
177 | client.on_error { called = true }
178 | client.remove_user(123) { |info| info }
179 | end
180 | expect(called).to be true
181 | end
182 | end
183 |
184 | it 'accepts an array of user_ids' do
185 | conn = double('Connection')
186 | expect(conn).to receive(:post).
187 | with(:path => "#{config_uri}/remove_user.json", :body => {'user_id' => '1234,5678'}).
188 | and_return(FakeHttp.new)
189 | allow(client).to receive(:connection) { conn }
190 | client.remove_user(%w(1234 5678))
191 | end
192 |
193 | describe 'accepts a single user_id as' do
194 | before :each do
195 | conn = double('Connection')
196 | expect(conn).to receive(:post).
197 | with(:path => "#{config_uri}/remove_user.json", :body => {'user_id' => '1234'}).
198 | and_return(FakeHttp.new)
199 | allow(client).to receive(:connection) { conn }
200 | end
201 |
202 | it 'a string' do
203 | client.remove_user('1234')
204 | end
205 |
206 | it 'an integer' do
207 | client.remove_user(1234)
208 | end
209 | end
210 | end
211 |
212 | describe '#friends_ids' do
213 | context 'success' do
214 | it 'returns the information hash' do
215 | stub_request(:post, "https://sitestream.twitter.com#{config_uri}/friends/ids.json").
216 | to_return(:status => 200, :body => fixture('ids.json'), :headers => {})
217 | stream_info = nil
218 |
219 | EM.run_block do
220 | client.friends_ids(123) { |info| stream_info = info }
221 | end
222 | expect(stream_info).to be_kind_of(Hash)
223 | end
224 | end
225 |
226 | context 'failure' do
227 | it 'invokes the on_error callback' do
228 | stub_request(:post, "https://sitestream.twitter.com#{config_uri}/friends/ids.json").
229 | to_return(:status => 401, :body => '', :headers => {})
230 | called = false
231 |
232 | EM.run_block do
233 | client.on_error { called = true }
234 | client.friends_ids(123) { |info| info }
235 | end
236 | expect(called).to be true
237 | end
238 | end
239 | end
240 | end
241 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # TweetStream
2 |
3 | [][gem]
4 | [][travis]
5 | [][gemnasium]
6 | [][codeclimate]
7 | [][coveralls]
8 |
9 | [gem]: https://rubygems.org/gems/tweetstream
10 | [travis]: http://travis-ci.org/tweetstream/tweetstream
11 | [gemnasium]: https://gemnasium.com/tweetstream/tweetstream
12 | [codeclimate]: https://codeclimate.com/github/tweetstream/tweetstream
13 | [coveralls]: https://coveralls.io/r/tweetstream/tweetstream
14 |
15 | TweetStream provides simple Ruby access to [Twitter's Streaming API](https://dev.twitter.com/docs/streaming-apis).
16 |
17 | ## Installation
18 |
19 | gem install tweetstream
20 |
21 | ## Usage
22 |
23 | Using TweetStream is quite simple:
24 |
25 | ```ruby
26 | require 'tweetstream'
27 |
28 | TweetStream.configure do |config|
29 | config.consumer_key = 'abcdefghijklmnopqrstuvwxyz'
30 | config.consumer_secret = '0123456789'
31 | config.oauth_token = 'abcdefghijklmnopqrstuvwxyz'
32 | config.oauth_token_secret = '0123456789'
33 | config.auth_method = :oauth
34 | end
35 |
36 | # This will pull a sample of all tweets based on
37 | # your Twitter account's Streaming API role.
38 | TweetStream::Client.new.sample do |status|
39 | # The status object is a special Hash with
40 | # method access to its keys.
41 | puts "#{status.text}"
42 | end
43 | ```
44 |
45 | You can also use it to track keywords or follow a given set of
46 | user ids:
47 |
48 | ```ruby
49 | # Use 'track' to track a list of single-word keywords
50 | TweetStream::Client.new.track('term1', 'term2') do |status|
51 | puts "#{status.text}"
52 | end
53 |
54 | # Use 'follow' to follow a group of user ids (integers, not screen names)
55 | TweetStream::Client.new.follow(14252, 53235) do |status|
56 | puts "#{status.text}"
57 | end
58 | ```
59 |
60 | The methods available to TweetStream::Client are kept in parity
61 | with the methods available on the Streaming API wiki page.
62 |
63 | ## Changes in 2.0
64 |
65 | TweetStream 2.0 introduces a number of requested features and bug fixes. For
66 | the complete list refer to the [changelog](CHANGELOG.md#version-200). Notable
67 | additions in 2.0 include:
68 |
69 | ### OAuth
70 |
71 | OAuth is now the default authentication method. Both userstreams and Site
72 | Streams exclusively work with OAuth. TweetStream still supports Basic Auth,
73 | however it is no longer the default. If you are still using Basic Auth, you
74 | should plan to move to OAuth as soon as possible.
75 |
76 | ### Site Stream Support
77 |
78 | Site Streams are now fully supported, including the connection management functionality.
79 |
80 | ### Compatibility with the Twitter gem
81 |
82 | TweetStream now emits objects from the [Twitter gem](https://github.com/sferik/twitter) instead of custom hashes. These objects are already defined in the `twitter` gem and are superior to the custom objects in the following ways:
83 |
84 | 1. Object equivalence (`#==` returns true if `#id`s are the same).
85 | 2. The `#created_at` method returns a `Date` instead of a `String`.
86 | 3. Allows boolean methods to be called with a question mark (e.g.
87 | `User#protected?`)
88 |
89 | Additionally, any new features that are added to objects in the
90 | `twitter` gem (e.g. identity map) will be automatically inherited by TweetStream.
91 |
92 | ### em-twitter
93 |
94 | We've replaced the underlying gem that connects to the streaming API. [twitter-stream](https://github.com/voloko/twitter-stream) has been replaced with [em-twitter](https://github.com/spagalloco/em-twitter).
95 | It offers functionality parity with twitter-stream while also supporting several new features.
96 |
97 | ### Removal of on_interval callback
98 |
99 | We have removed the `on_interval` callback. If you require interval-based timers, it is possible to run
100 | TweetStream inside an already running EventMachine reactor in which you can define `EM::Timer` or `EM::PeriodicTimer`
101 | for time-based operations:
102 |
103 | ```ruby
104 | EM.run do
105 | client = TweetStream::Client.new
106 |
107 | EM::PeriodicTimer.new(10) do
108 | # do something on an interval
109 | end
110 | end
111 | ```
112 |
113 | ### Additional Notes
114 |
115 | The parser configuration method has been removed as MultiJson automatically detects existing parsers.
116 |
117 | ## Using the Twitter Userstream
118 |
119 | Using the Twitter userstream works similarly to regular streaming, except you use the `userstream` method.
120 |
121 | ```ruby
122 | # Use 'userstream' to get message from your stream
123 | client = TweetStream::Client.new
124 |
125 | client.userstream do |status|
126 | puts status.text
127 | end
128 | ```
129 |
130 | ## Using Twitter Site Streams
131 |
132 | ```ruby
133 | client = TweetStream::Client.new
134 |
135 | client.sitestream(['115192457'], :followings => true) do |status|
136 | puts status.inspect
137 | end
138 | ```
139 |
140 | Once connected, you can [control the Site Stream connection](https://dev.twitter.com/docs/streaming-apis/streams/site/control):
141 |
142 | ```ruby
143 | # add users to the stream
144 | client.control.add_user('2039761')
145 |
146 | # remove users from the stream
147 | client.control.remove_user('115192457')
148 |
149 | # obtain a list of followings of users in the stream
150 | client.control.friends_ids('115192457') do |friends|
151 | # do something
152 | end
153 |
154 | # obtain the current state of the stream
155 | client.control.info do |info|
156 | # do something
157 | end
158 | ```
159 |
160 | Note that per Twitter's documentation, connection management features are not
161 | immediately available when connected
162 |
163 | You also can use method hooks for both regular timeline statuses and direct messages.
164 |
165 | ```ruby
166 | client = TweetStream::Client.new
167 |
168 | client.on_direct_message do |direct_message|
169 | puts "direct message"
170 | puts direct_message.text
171 | end
172 |
173 | client.on_timeline_status do |status|
174 | puts "timeline status"
175 | puts status.text
176 | end
177 |
178 | client.userstream
179 | ```
180 |
181 | ## Authentication
182 |
183 | TweetStream supports OAuth and Basic Auth. `TweetStream::Client` now accepts
184 | a hash:
185 |
186 | ```ruby
187 | TweetStream::Client.new(:username => 'you', :password => 'pass')
188 | ```
189 |
190 | Alternatively, you can configure TweetStream via the configure method:
191 |
192 | ```ruby
193 | TweetStream.configure do |config|
194 | config.consumer_key = 'cVcIw5zoLFE2a4BdDsmmA'
195 | config.consumer_secret = 'yYgVgvTT9uCFAi2IuscbYTCqwJZ1sdQxzISvLhNWUA'
196 | config.oauth_token = '4618-H3gU7mjDQ7MtFkAwHhCqD91Cp4RqDTp1AKwGzpHGL3I'
197 | config.oauth_token_secret = 'xmc9kFgOXpMdQ590Tho2gV7fE71v5OmBrX8qPGh7Y'
198 | config.auth_method = :oauth
199 | end
200 | ```
201 |
202 | If you are using Basic Auth:
203 |
204 | ```ruby
205 | TweetStream.configure do |config|
206 | config.username = 'username'
207 | config.password = 'password'
208 | config.auth_method = :basic
209 | end
210 | ```
211 |
212 | TweetStream assumes OAuth by default. If you are using Basic Auth, it is recommended
213 | that you update your code to use OAuth as Twitter is likely to phase out Basic Auth
214 | support. Basic Auth is only available for public streams as User Stream and Site Stream
215 | functionality [only support OAuth](https://dev.twitter.com/docs/streaming-apis/connecting#Authentication).
216 |
217 | ## Parsing JSON
218 |
219 | TweetStream supports swappable JSON backends via MultiJson. Simply require your preferred
220 | JSON parser and it will be used to parse responses.
221 |
222 | ## Handling Deletes and Rate Limitations
223 |
224 | Sometimes the Streaming API will send messages other than statuses.
225 | Specifically, it does so when a status is deleted or rate limitations
226 | have caused some tweets not to appear in the stream. To handle these,
227 | you can use the on_delete, on_limit and on_enhance_your_calm methods. Example:
228 |
229 | ```ruby
230 | @client = TweetStream::Client.new
231 |
232 | @client.on_delete do |status_id, user_id|
233 | Tweet.delete(status_id)
234 | end
235 |
236 | @client.on_limit do |skip_count|
237 | # do something
238 | end
239 |
240 | @client.on_enhance_your_calm do
241 | # do something
242 | end
243 |
244 | @client.track('intridea')
245 | ```
246 |
247 | The on_delete and on_limit methods can also be chained:
248 |
249 | ```ruby
250 | TweetStream::Client.new.on_delete{ |status_id, user_id|
251 | Tweet.delete(status_id)
252 | }.on_limit { |skip_count|
253 | # do something
254 | }.track('intridea') do |status|
255 | # do something with the status like normal
256 | end
257 | ```
258 |
259 | You can also provide `:delete` and/or `:limit`
260 | options when you make your method call:
261 |
262 | ```ruby
263 | TweetStream::Client.new.track('intridea',
264 | :delete => proc{ |status_id, user_id| # do something },
265 | :limit => proc{ |skip_count| # do something }
266 | ) do |status|
267 | # do something with the status like normal
268 | end
269 | ```
270 |
271 | Twitter recommends honoring deletions as quickly as possible, and
272 | you would likely be wise to integrate this functionality into your
273 | application.
274 |
275 | ## Errors and Reconnecting
276 |
277 | TweetStream uses EventMachine to connect to the Twitter Streaming
278 | API, and attempts to honor Twitter's guidelines in terms of automatic
279 | reconnection. When Twitter becomes unavailable, the block specified
280 | by you in `on_error` will be called. Note that this does not
281 | indicate something is actually wrong, just that Twitter is momentarily
282 | down. It could be for routine maintenance, etc.
283 |
284 | ```ruby
285 | TweetStream::Client.new.on_error do |message|
286 | # Log your error message somewhere
287 | end.track('term') do |status|
288 | # Do things when nothing's wrong
289 | end
290 | ```
291 |
292 | However, if the maximum number of reconnect attempts has been reached,
293 | TweetStream will raise a `TweetStream::ReconnectError` with
294 | information about the timeout and number of retries attempted.
295 |
296 | On reconnect, the block specified by you in `on_reconnect` will be called:
297 |
298 | ```ruby
299 | TweetStream::Client.new.on_reconnect do |timeout, retries|
300 | # Do something with the reconnect
301 | end.track('term') do |status|
302 | # Do things when nothing's wrong
303 | end
304 | ```
305 |
306 | ## Terminating a TweetStream
307 |
308 | It is often the case that you will need to change the parameters of your
309 | track or follow tweet streams. In the case that you need to terminate
310 | a stream, you may add a second argument to your block that will yield
311 | the client itself:
312 |
313 | ```ruby
314 | # Stop after collecting 10 statuses
315 | @statuses = []
316 | TweetStream::Client.new.sample do |status, client|
317 | @statuses << status
318 | client.stop if @statuses.size >= 10
319 | end
320 | ```
321 |
322 | When `stop` is called, TweetStream will return from the block
323 | the last successfully yielded status, allowing you to make note of
324 | it in your application as necessary.
325 |
326 | ## Daemonizing
327 |
328 | It is also possible to create a daemonized script quite easily
329 | using the TweetStream library:
330 |
331 | ```ruby
332 | # The third argument is an optional process name
333 | TweetStream::Daemon.new('tracker').track('term1', 'term2') do |status|
334 | # do something in the background
335 | end
336 | ```
337 |
338 | If you put the above into a script and run the script with `ruby scriptname.rb`,
339 | you will see a list of daemonization commands such as start, stop, and run.
340 |
341 | A frequent use case is to use TweetStream along with ActiveRecord to insert new
342 | statuses to a database. The library TweetStream uses the `daemons` gem for
343 | daemonization which forks a new process when the daemon is created. After forking,
344 | you'll need to reconnect to the database:
345 |
346 | ```ruby
347 | ENV["RAILS_ENV"] ||= "production"
348 |
349 | root = File.expand_path(File.join(File.dirname(__FILE__), '..'))
350 | require File.join(root, "config", "environment")
351 |
352 | daemon = TweetStream::Daemon.new('tracker', :log_output => true)
353 | daemon.on_inited do
354 | ActiveRecord::Base.connection.reconnect!
355 | ActiveRecord::Base.logger = Logger.new(File.open('log/stream.log', 'w+'))
356 | end
357 | daemon.track('term1') do |tweet|
358 | Status.create_from_tweet(tweet)
359 | end
360 | ```
361 |
362 | ## Proxy Support
363 |
364 | TweetStream supports a configurable proxy:
365 |
366 | ```ruby
367 | TweetStream.configure do |config|
368 | config.proxy = { :uri => 'http://myproxy:8081' }
369 | end
370 | ```
371 |
372 | Your proxy will now be used for all connections.
373 |
374 | ## REST
375 |
376 | To access the Twitter REST API, we recommend the [Twitter][] gem.
377 |
378 | [twitter]: https://github.com/sferik/twitter
379 |
380 | ## Contributors
381 |
382 | * [Michael Bleigh](https://github.com/mbleigh) (initial gem)
383 | * [Steve Agalloco](https://github.com/spagalloco) (current maintainer)
384 | * [Erik Michaels-Ober](https://github.com/sferik) (current maintainer)
385 | * [Countless others](https://github.com/intridea/tweetstream/graphs/contributors)
386 |
387 | ## Copyright
388 |
389 | Copyright (c) 2012-2013 Intridea, Inc. (http://www.intridea.com/). See
390 | [LICENSE](LICENSE.md) for details.
391 |
--------------------------------------------------------------------------------
/spec/tweetstream/client_spec.rb:
--------------------------------------------------------------------------------
1 | require 'helper'
2 |
3 | describe TweetStream::Client do
4 | before(:each) do
5 | TweetStream.configure do |config|
6 | config.consumer_key = 'abc'
7 | config.consumer_secret = 'def'
8 | config.oauth_token = '123'
9 | config.oauth_token_secret = '456'
10 | end
11 | @client = TweetStream::Client.new
12 | end
13 |
14 | describe '#start' do
15 | before do
16 | @stream = double('EM::Twitter::Client',
17 | :connect => true,
18 | :unbind => true,
19 | :each => true,
20 | :on_error => true,
21 | :on_max_reconnects => true,
22 | :on_reconnect => true,
23 | :connection_completed => true,
24 | :on_no_data_received => true,
25 | :on_unauthorized => true,
26 | :on_enhance_your_calm => true,
27 | )
28 | allow(EM).to receive(:run).and_yield
29 | allow(EM::Twitter::Client).to receive(:connect).and_return(@stream)
30 | end
31 |
32 | it 'connects if the reactor is already running' do
33 | allow(EM).to receive(:reactor_running?).and_return(true)
34 | expect(@client).to receive(:connect)
35 | @client.track('abc')
36 | end
37 |
38 | it 'starts the reactor if not already running' do
39 | expect(EM).to receive(:run).once
40 | @client.track('abc')
41 | end
42 |
43 | it 'warns when callbacks are passed as options' do
44 | allow(@stream).to receive(:each)
45 | expect(Kernel).to receive(:warn).with(/Passing callbacks via the options hash is deprecated and will be removed in TweetStream 3.0/)
46 | @client.track('abc', :inited => proc {})
47 | end
48 |
49 | describe 'proxy usage' do
50 | it 'connects with a proxy' do
51 | @client = TweetStream::Client.new(:proxy => {:uri => 'http://someproxy:8081'})
52 | expect(EM::Twitter::Client).to receive(:connect).
53 | with(hash_including(:proxy => {:uri => 'http://someproxy:8081'})).and_return(@stream)
54 | expect(@stream).to receive(:each)
55 | @client.track('abc')
56 | end
57 | end
58 |
59 | describe '#each' do
60 | it 'calls the appropriate parser' do
61 | @client = TweetStream::Client.new
62 | expect(MultiJson).to receive(:decode).and_return({})
63 | expect(@stream).to receive(:each).and_yield(sample_tweets[0].to_json)
64 | @client.track('abc', 'def')
65 | end
66 |
67 | it 'yields a Twitter::Tweet' do
68 | expect(@stream).to receive(:each).and_yield(sample_tweets[0].to_json)
69 | @client.track('abc') { |s| expect(s).to be_kind_of(Twitter::Tweet) }
70 | end
71 |
72 | it 'yields the client if a block with arity 2 is given' do
73 | expect(@stream).to receive(:each).and_yield(sample_tweets[0].to_json)
74 | @client.track('abc') { |_, c| expect(c).to eq(@client) }
75 | end
76 |
77 | it 'includes the proper values' do
78 | tweet = sample_tweets[0]
79 | tweet[:id] = 123
80 | tweet[:user][:screen_name] = 'monkey'
81 | tweet[:text] = 'Oo oo aa aa'
82 | expect(@stream).to receive(:each).and_yield(tweet.to_json)
83 | @client.track('abc') do |s|
84 | expect(s.id).to eq(123)
85 | expect(s.user.screen_name).to eq('monkey')
86 | expect(s.text).to eq('Oo oo aa aa')
87 | end
88 | end
89 |
90 | it 'calls the on_stall_warning callback if specified' do
91 | expect(@stream).to receive(:each).and_yield(fixture('stall_warning.json'))
92 | @client.on_stall_warning do |warning|
93 | expect(warning[:code]).to eq('FALLING_BEHIND')
94 | end.track('abc')
95 | end
96 |
97 | it 'calls the on_scrub_geo callback if specified' do
98 | expect(@stream).to receive(:each).and_yield(fixture('scrub_geo.json'))
99 | @client.on_scrub_geo do |up_to_status_id, user_id|
100 | expect(up_to_status_id).to eq(987)
101 | expect(user_id).to eq(123)
102 | end.track('abc')
103 | end
104 |
105 | it 'calls the on_delete callback' do
106 | expect(@stream).to receive(:each).and_yield(fixture('delete.json'))
107 | @client.on_delete do |id, user_id|
108 | expect(id).to eq(123)
109 | expect(user_id).to eq(3)
110 | end.track('abc')
111 | end
112 |
113 | it 'calls the on_limit callback' do
114 | limit = nil
115 | expect(@stream).to receive(:each).and_yield(fixture('limit.json'))
116 | @client.on_limit do |l|
117 | limit = l
118 | end.track('abc')
119 |
120 | expect(limit).to eq(123)
121 | end
122 |
123 | it 'calls the on_status_withheld callback' do
124 | status = nil
125 | expect(@stream).to receive(:each).and_yield(fixture('status_withheld.json'))
126 | @client.on_status_withheld do |s|
127 | status = s
128 | end.track('abc')
129 |
130 | expect(status[:user_id]).to eq(123)
131 | end
132 |
133 | it 'calls the on_user_withheld callback' do
134 | status = nil
135 | expect(@stream).to receive(:each).and_yield(fixture('user_withheld.json'))
136 | @client.on_user_withheld do |s|
137 | status = s
138 | end.track('abc')
139 |
140 | expect(status[:id]).to eq(123)
141 | end
142 |
143 | context 'using on_anything' do
144 | it 'yields the raw hash' do
145 | hash = {:id => 123}
146 | expect(@stream).to receive(:each).and_yield(hash.to_json)
147 | yielded_hash = nil
148 | @client.on_anything do |h|
149 | yielded_hash = h
150 | end.track('abc')
151 |
152 | expect(yielded_hash).not_to be_nil
153 | expect(yielded_hash[:id]).to eq(123)
154 | end
155 | it 'yields itself if block has an arity of 2' do
156 | hash = {:id => 123}
157 | expect(@stream).to receive(:each).and_yield(hash.to_json)
158 | yielded_client = nil
159 | @client.on_anything do |_, client|
160 | yielded_client = client
161 | end.track('abc')
162 | expect(yielded_client).not_to be_nil
163 | expect(yielded_client).to eq(@client)
164 | end
165 | end
166 |
167 | context 'using on_timeline_status' do
168 | it 'yields a Status' do
169 | tweet = sample_tweets[0]
170 | tweet[:id] = 123
171 | tweet[:user][:screen_name] = 'monkey'
172 | tweet[:text] = 'Oo oo aa aa'
173 | expect(@stream).to receive(:each).and_yield(tweet.to_json)
174 | yielded_status = nil
175 | @client.on_timeline_status do |status|
176 | yielded_status = status
177 | end.track('abc')
178 | expect(yielded_status).not_to be_nil
179 | expect(yielded_status.id).to eq(123)
180 | expect(yielded_status.user.screen_name).to eq('monkey')
181 | expect(yielded_status.text).to eq('Oo oo aa aa')
182 | end
183 | it 'yields itself if block has an arity of 2' do
184 | expect(@stream).to receive(:each).and_yield(sample_tweets[0].to_json)
185 | yielded_client = nil
186 | @client.on_timeline_status do |_, client|
187 | yielded_client = client
188 | end.track('abc')
189 | expect(yielded_client).not_to be_nil
190 | expect(yielded_client).to eq(@client)
191 | end
192 | end
193 |
194 | context 'using on_direct_message' do
195 | it 'yields a DirectMessage' do
196 | direct_message = sample_direct_messages[0]
197 | direct_message[:direct_message][:id] = 123
198 | direct_message[:direct_message][:sender][:screen_name] = 'coder'
199 | expect(@stream).to receive(:each).and_yield(direct_message.to_json)
200 | yielded_dm = nil
201 | @client.on_direct_message do |dm|
202 | yielded_dm = dm
203 | end.userstream
204 | expect(yielded_dm).not_to be_nil
205 | expect(yielded_dm.id).to eq(123)
206 | expect(yielded_dm.sender.screen_name).to eq('coder')
207 | end
208 |
209 | it 'yields itself if block has an arity of 2' do
210 | expect(@stream).to receive(:each).and_yield(sample_direct_messages[0].to_json)
211 | yielded_client = nil
212 | @client.on_direct_message do |_, client|
213 | yielded_client = client
214 | end.userstream
215 | expect(yielded_client).to eq(@client)
216 | end
217 | end
218 |
219 | it 'calls on_error if a non-hash response is received' do
220 | expect(@stream).to receive(:each).and_yield('["favorited"]')
221 | @client.on_error do |message|
222 | expect(message).to eq('Unexpected JSON object in stream: ["favorited"]')
223 | end.track('abc')
224 | end
225 |
226 | it 'calls on_error if a json parse error occurs' do
227 | expect(@stream).to receive(:each).and_yield("{'a_key':}")
228 | @client.on_error do |message|
229 | expect(message).to eq("MultiJson::DecodeError occured in stream: {'a_key':}")
230 | end.track('abc')
231 | end
232 | end
233 |
234 | describe '#on_error' do
235 | it 'passes the message on to the error block' do
236 | expect(@stream).to receive(:on_error).and_yield('Uh oh')
237 | @client.on_error do |m|
238 | expect(m).to eq('Uh oh')
239 | end.track('abc')
240 | end
241 |
242 | it 'returns the block when defined' do
243 | @client.on_error { true }
244 | expect(@client.on_error).to be_kind_of(Proc)
245 | end
246 |
247 | it 'returns nil when undefined' do
248 | expect(@client.on_error).to be_nil
249 | end
250 | end
251 |
252 | describe '#on_max_reconnects' do
253 | it 'raises a ReconnectError' do
254 | expect(@stream).to receive(:on_max_reconnects).and_yield(30, 20)
255 | expect(lambda { @client.track('abc') }).to raise_error(TweetStream::ReconnectError, 'Failed to reconnect after 20 tries.')
256 | end
257 | end
258 | end
259 |
260 | describe 'API methods' do
261 | %w(firehose retweet sample links).each do |method|
262 | it "##{method} should make a call to start with \"statuses/#{method}\"" do
263 | expect(@client).to receive(:start).once.with('/1.1/statuses/' + method + '.json', {})
264 | @client.send(method)
265 | end
266 | end
267 |
268 | describe '#filter' do
269 | it "makes a call to 'statuses/filter' with the query params provided" do
270 | expect(@client).to receive(:start).once.with('/1.1/statuses/filter.json', :follow => 123, :method => :post)
271 | @client.filter(:follow => 123)
272 | end
273 | it "makes a call to 'statuses/filter' with the query params provided longitude/latitude pairs, separated by commas " do
274 | expect(@client).to receive(:start).once.with('/1.1/statuses/filter.json', :locations => '-122.75,36.8,-121.75,37.8,-74,40,-73,41', :method => :post)
275 | @client.filter(:locations => '-122.75,36.8,-121.75,37.8,-74,40,-73,41')
276 | end
277 | end
278 |
279 | describe '#follow' do
280 | it "makes a call to start with 'statuses/filter' and a follow query parameter" do
281 | expect(@client).to receive(:start).once.with('/1.1/statuses/filter.json', :follow => [123], :method => :post)
282 | @client.follow(123)
283 | end
284 |
285 | it 'comma-joins multiple arguments' do
286 | expect(@client).to receive(:start).once.with('/1.1/statuses/filter.json', :follow => [123, 456], :method => :post)
287 | @client.follow(123, 456)
288 | end
289 | end
290 |
291 | describe '#locations' do
292 | it "calls #start with 'statuses/filter' with the query params provided longitude/latitude pairs" do
293 | expect(@client).to receive(:start).once.with('/1.1/statuses/filter.json', :locations => ['-122.75,36.8,-121.75,37.8,-74,40,-73,41'], :method => :post)
294 | @client.locations('-122.75,36.8,-121.75,37.8,-74,40,-73,41')
295 | end
296 |
297 | it "calls #start with 'statuses/filter' with the query params provided longitude/latitude pairs and additional filter" do
298 | expect(@client).to receive(:start).once.with('/1.1/statuses/filter.json', :locations => ['-122.75,36.8,-121.75,37.8,-74,40,-73,41'], :track => 'rock', :method => :post)
299 | @client.locations('-122.75,36.8,-121.75,37.8,-74,40,-73,41', :track => 'rock')
300 | end
301 | end
302 |
303 | describe '#track' do
304 | it "makes a call to start with 'statuses/filter' and a track query parameter" do
305 | expect(@client).to receive(:start).once.with('/1.1/statuses/filter.json', :track => %w(test), :method => :post)
306 | @client.track('test')
307 | end
308 |
309 | it 'comma-joins multiple arguments' do
310 | expect(@client).to receive(:start).once.with('/1.1/statuses/filter.json', :track => %w(foo bar baz), :method => :post)
311 | @client.track('foo', 'bar', 'baz')
312 | end
313 |
314 | it 'comma-joins an array of arguments' do
315 | expect(@client).to receive(:start).once.with('/1.1/statuses/filter.json', :track => [%w(foo bar baz)], :method => :post)
316 | @client.track(%w(foo bar baz))
317 | end
318 |
319 | it "calls #start with 'statuses/filter' and the provided queries" do
320 | expect(@client).to receive(:start).once.with('/1.1/statuses/filter.json', :track => %w(rock), :method => :post)
321 | @client.track('rock')
322 | end
323 | end
324 | end
325 |
326 | %w(on_delete on_limit on_inited on_reconnect on_no_data_received on_unauthorized on_enhance_your_calm).each do |proc_setter|
327 | describe "##{proc_setter}" do
328 | it 'sets when a block is given' do
329 | block = proc { |a, _| puts a }
330 | @client.send(proc_setter, &block)
331 | expect(@client.send(proc_setter)).to eq(block)
332 | end
333 |
334 | it 'returns nil when undefined' do
335 | expect(@client.send(proc_setter)).to be_nil
336 | end
337 | end
338 | end
339 |
340 | describe '#stop' do
341 | it 'calls EventMachine::stop_event_loop' do
342 | expect(EventMachine).to receive(:stop_event_loop)
343 | expect(TweetStream::Client.new.stop).to be_nil
344 | end
345 |
346 | it 'returns the last status yielded' do
347 | expect(EventMachine).to receive(:stop_event_loop)
348 | client = TweetStream::Client.new
349 | client.send(:instance_variable_set, :@last_status, {})
350 | expect(client.stop).to eq({})
351 | end
352 | end
353 |
354 | describe '#close_connection' do
355 | it 'does not call EventMachine::stop_event_loop' do
356 | expect(EventMachine).not_to receive(:stop_event_loop)
357 | expect(TweetStream::Client.new.close_connection).to be_nil
358 | end
359 | end
360 |
361 | describe '#stop_stream' do
362 | before(:each) do
363 | @stream = double('EM::Twitter::Client',
364 | :connect => true,
365 | :unbind => true,
366 | :each => true,
367 | :on_error => true,
368 | :on_max_reconnects => true,
369 | :on_reconnect => true,
370 | :connection_completed => true,
371 | :on_no_data_received => true,
372 | :on_unauthorized => true,
373 | :on_enhance_your_calm => true,
374 | :stop => true,
375 | )
376 | allow(EM::Twitter::Client).to receive(:connect).and_return(@stream)
377 | @client = TweetStream::Client.new
378 | @client.connect('/')
379 | end
380 |
381 | it 'calls stream.stop to cleanly stop the current stream' do
382 | expect(@stream).to receive(:stop)
383 | @client.stop_stream
384 | end
385 |
386 | it 'does not stop eventmachine' do
387 | expect(EventMachine).not_to receive(:stop_event_loop)
388 | @client.stop_stream
389 | end
390 | end
391 | end
392 |
--------------------------------------------------------------------------------
/lib/tweetstream/client.rb:
--------------------------------------------------------------------------------
1 | require 'em-twitter'
2 | require 'eventmachine'
3 | require 'multi_json'
4 | require 'twitter'
5 | require 'forwardable'
6 |
7 | require 'tweetstream/arguments'
8 |
9 | module TweetStream
10 | # Provides simple access to the Twitter Streaming API (https://dev.twitter.com/docs/streaming-api)
11 | # for Ruby scripts that need to create a long connection to
12 | # Twitter for tracking and other purposes.
13 | #
14 | # Basic usage of the library is to call one of the provided
15 | # methods and provide a block that will perform actions on
16 | # a yielded Twitter::Tweet. For example:
17 | #
18 | # TweetStream::Client.new.track('fail') do |status|
19 | # puts "[#{status.user.screen_name}] #{status.text}"
20 | # end
21 | #
22 | # For information about a daemonized TweetStream client,
23 | # view the TweetStream::Daemon class.
24 | class Client # rubocop:disable ClassLength
25 | extend Forwardable
26 |
27 | OPTION_CALLBACKS = [:delete,
28 | :scrub_geo,
29 | :limit,
30 | :error,
31 | :enhance_your_calm,
32 | :unauthorized,
33 | :reconnect,
34 | :inited,
35 | :direct_message,
36 | :timeline_status,
37 | :anything,
38 | :no_data_received,
39 | :status_withheld,
40 | :user_withheld].freeze unless defined?(OPTION_CALLBACKS)
41 |
42 | # @private
43 | attr_accessor(*Configuration::VALID_OPTIONS_KEYS)
44 | attr_accessor :options
45 | attr_reader :control_uri, :control, :stream
46 |
47 | def_delegators :@control, :add_user, :remove_user, :info, :friends_ids
48 |
49 | # Creates a new API
50 | def initialize(options = {})
51 | self.options = options
52 | merged_options = TweetStream.options.merge(options)
53 | Configuration::VALID_OPTIONS_KEYS.each do |key|
54 | send("#{key}=", merged_options[key])
55 | end
56 | @control_uri = nil
57 | @control = nil
58 | @callbacks = {}
59 | end
60 |
61 | # Returns all public statuses. The Firehose is not a generally
62 | # available resource. Few applications require this level of access.
63 | # Creative use of a combination of other resources and various access
64 | # levels can satisfy nearly every application use case.
65 | def firehose(query_parameters = {}, &block)
66 | start('/1.1/statuses/firehose.json', query_parameters, &block)
67 | end
68 |
69 | # Returns all statuses containing http: and https:. The links stream is
70 | # not a generally available resource. Few applications require this level
71 | # of access. Creative use of a combination of other resources and various
72 | # access levels can satisfy nearly every application use case.
73 | def links(query_parameters = {}, &block)
74 | start('/1.1/statuses/links.json', query_parameters, &block)
75 | end
76 |
77 | # Returns all retweets. The retweet stream is not a generally available
78 | # resource. Few applications require this level of access. Creative
79 | # use of a combination of other resources and various access levels
80 | # can satisfy nearly every application use case. As of 9/11/2009,
81 | # the site-wide retweet feature has not yet launched,
82 | # so there are currently few, if any, retweets on this stream.
83 | def retweet(query_parameters = {}, &block)
84 | start('/1.1/statuses/retweet.json', query_parameters, &block)
85 | end
86 |
87 | # Returns a random sample of all public statuses. The default access level
88 | # provides a small proportion of the Firehose. The "Gardenhose" access
89 | # level provides a proportion more suitable for data mining and
90 | # research applications that desire a larger proportion to be statistically
91 | # significant sample.
92 | def sample(query_parameters = {}, &block)
93 | start('/1.1/statuses/sample.json', query_parameters, &block)
94 | end
95 |
96 | # Specify keywords to track. Queries are subject to Track Limitations,
97 | # described in Track Limiting and subject to access roles, described in
98 | # the statuses/filter method. Track keywords are case-insensitive logical
99 | # ORs. Terms are exact-matched, and also exact-matched ignoring
100 | # punctuation. Phrases, keywords with spaces, are not supported.
101 | # Keywords containing punctuation will only exact match tokens.
102 | # Query parameters may be passed as the last argument.
103 | def track(*keywords, &block)
104 | query = TweetStream::Arguments.new(keywords)
105 | filter(query.options.merge(:track => query), &block)
106 | end
107 |
108 | # Returns public statuses from or in reply to a set of users. Mentions
109 | # ("Hello @user!") and implicit replies ("@user Hello!" created without
110 | # pressing the reply "swoosh") are not matched. Requires integer user
111 | # IDs, not screen names. Query parameters may be passed as the last argument.
112 | def follow(*user_ids, &block)
113 | query = TweetStream::Arguments.new(user_ids)
114 | filter(query.options.merge(:follow => query), &block)
115 | end
116 |
117 | # Specifies a set of bounding boxes to track. Only tweets that are both created
118 | # using the Geotagging API and are placed from within a tracked bounding box will
119 | # be included in the stream -- the user's location field is not used to filter tweets
120 | # (e.g. if a user has their location set to "San Francisco", but the tweet was not created
121 | # using the Geotagging API and has no geo element, it will not be included in the stream).
122 | # Bounding boxes are specified as a comma separate list of longitude/latitude pairs, with
123 | # the first pair denoting the southwest corner of the box
124 | # longitude/latitude pairs, separated by commas. The first pair specifies the southwest corner of the box.
125 | def locations(*locations_map, &block)
126 | query = TweetStream::Arguments.new(locations_map)
127 | filter(query.options.merge(:locations => query), &block)
128 | end
129 |
130 | # Make a call to the statuses/filter method of the Streaming API,
131 | # you may provide :follow, :track or both as options
132 | # to follow the tweets of specified users or track keywords. This
133 | # method is provided separately for cases when it would conserve the
134 | # number of HTTP connections to combine track and follow.
135 | def filter(query_params = {}, &block)
136 | start('/1.1/statuses/filter.json', query_params.merge(:method => :post), &block)
137 | end
138 |
139 | # Make a call to the userstream api for currently authenticated user
140 | def userstream(query_params = {}, &block)
141 | stream_params = {:host => 'userstream.twitter.com'}
142 | query_params.merge!(:extra_stream_parameters => stream_params)
143 | start('/1.1/user.json', query_params, &block)
144 | end
145 |
146 | # Make a call to the userstream api
147 | def sitestream(user_ids = [], query_params = {}, &block)
148 | stream_params = {:host => 'sitestream.twitter.com'}
149 | query_params.merge!(
150 | :method => :post,
151 | :follow => user_ids,
152 | :extra_stream_parameters => stream_params,
153 | )
154 | query_params.merge!(:with => 'followings') if query_params.delete(:followings)
155 | start('/1.1/site.json', query_params, &block)
156 | end
157 |
158 | # Set a Proc to be run when a deletion notice is received
159 | # from the Twitter stream. For example:
160 | #
161 | # @client = TweetStream::Client.new
162 | # @client.on_delete do |status_id, user_id|
163 | # Tweet.delete(status_id)
164 | # end
165 | #
166 | # Block must take two arguments: the status id and the user id.
167 | # If no block is given, it will return the currently set
168 | # deletion proc. When a block is given, the TweetStream::Client
169 | # object is returned to allow for chaining.
170 | def on_delete(&block)
171 | on('delete', &block)
172 | end
173 |
174 | # Set a Proc to be run when a scrub_geo notice is received
175 | # from the Twitter stream. For example:
176 | #
177 | # @client = TweetStream::Client.new
178 | # @client.on_scrub_geo do |up_to_status_id, user_id|
179 | # Tweet.where(:status_id <= up_to_status_id)
180 | # end
181 | #
182 | # Block must take two arguments: the upper status id and the user id.
183 | # If no block is given, it will return the currently set
184 | # scrub_geo proc. When a block is given, the TweetStream::Client
185 | # object is returned to allow for chaining.
186 | def on_scrub_geo(&block)
187 | on('scrub_geo', &block)
188 | end
189 |
190 | # Set a Proc to be run when a rate limit notice is received
191 | # from the Twitter stream. For example:
192 | #
193 | # @client = TweetStream::Client.new
194 | # @client.on_limit do |discarded_count|
195 | # # Make note of discarded count
196 | # end
197 | #
198 | # Block must take one argument: the number of discarded tweets.
199 | # If no block is given, it will return the currently set
200 | # limit proc. When a block is given, the TweetStream::Client
201 | # object is returned to allow for chaining.
202 | def on_limit(&block)
203 | on('limit', &block)
204 | end
205 |
206 | # Set a Proc to be run when an HTTP error is encountered in the
207 | # processing of the stream. Note that TweetStream will automatically
208 | # try to reconnect, this is for reference only. Don't panic!
209 | #
210 | # @client = TweetStream::Client.new
211 | # @client.on_error do |message|
212 | # # Make note of error message
213 | # end
214 | #
215 | # Block must take one argument: the error message.
216 | # If no block is given, it will return the currently set
217 | # error proc. When a block is given, the TweetStream::Client
218 | # object is returned to allow for chaining.
219 | def on_error(&block)
220 | on('error', &block)
221 | end
222 |
223 | # Set a Proc to be run when an HTTP status 401 is encountered while
224 | # connecting to Twitter. This could happen when system clock drift
225 | # has occured.
226 | #
227 | # If no block is given, it will return the currently set
228 | # unauthorized proc. When a block is given, the TweetStream::Client
229 | # object is returned to allow for chaining.
230 | def on_unauthorized(&block)
231 | on('unauthorized', &block)
232 | end
233 |
234 | # Set a Proc to be run when a direct message is encountered in the
235 | # processing of the stream.
236 | #
237 | # @client = TweetStream::Client.new
238 | # @client.on_direct_message do |direct_message|
239 | # # do something with the direct message
240 | # end
241 | #
242 | # Block must take one argument: the direct message.
243 | # If no block is given, it will return the currently set
244 | # direct message proc. When a block is given, the TweetStream::Client
245 | # object is returned to allow for chaining.
246 | def on_direct_message(&block)
247 | on('direct_message', &block)
248 | end
249 |
250 | # Set a Proc to be run whenever anything is encountered in the
251 | # processing of the stream.
252 | #
253 | # @client = TweetStream::Client.new
254 | # @client.on_anything do |status|
255 | # # do something with the status
256 | # end
257 | #
258 | # Block can take one or two arguments. |status (, client)|
259 | # If no block is given, it will return the currently set
260 | # timeline status proc. When a block is given, the TweetStream::Client
261 | # object is returned to allow for chaining.
262 | def on_anything(&block)
263 | on('anything', &block)
264 | end
265 |
266 | # Set a Proc to be run when a regular timeline message is encountered in the
267 | # processing of the stream.
268 | #
269 | # @client = TweetStream::Client.new
270 | # @client.on_timeline_status do |status|
271 | # # do something with the status
272 | # end
273 | #
274 | # Block can take one or two arguments. |status (, client)|
275 | # If no block is given, it will return the currently set
276 | # timeline status proc. When a block is given, the TweetStream::Client
277 | # object is returned to allow for chaining.
278 | def on_timeline_status(&block)
279 | on('timeline_status', &block)
280 | end
281 |
282 | # Set a Proc to be run on reconnect.
283 | #
284 | # @client = TweetStream::Client.new
285 | # @client.on_reconnect do |timeout, retries|
286 | # # Make note of the reconnection
287 | # end
288 | #
289 | def on_reconnect(&block)
290 | on('reconnect', &block)
291 | end
292 |
293 | # Set a Proc to be run when connection established.
294 | # Called in EventMachine::Connection#post_init
295 | #
296 | # @client = TweetStream::Client.new
297 | # @client.on_inited do
298 | # puts 'Connected...'
299 | # end
300 | #
301 | def on_inited(&block)
302 | on('inited', &block)
303 | end
304 |
305 | # Set a Proc to be run when no data is received from the server
306 | # and a stall occurs. Twitter defines this to be 90 seconds.
307 | #
308 | # @client = TweetStream::Client.new
309 | # @client.on_no_data_received do
310 | # # Make note of no data, possi
311 | # end
312 | def on_no_data_received(&block)
313 | on('no_data_received', &block)
314 | end
315 |
316 | # Set a Proc to be run when enhance_your_calm signal is received.
317 | #
318 | # @client = TweetStream::Client.new
319 | # @client.on_enhance_your_calm do
320 | # # do something, your account has been blocked
321 | # end
322 | def on_enhance_your_calm(&block)
323 | on('enhance_your_calm', &block)
324 | end
325 |
326 | # Set a Proc to be run when a status_withheld message is received.
327 | #
328 | # @client = TweetStream::Client.new
329 | # @client.on_status_withheld do |status|
330 | # # do something with the status
331 | # end
332 | def on_status_withheld(&block)
333 | on('status_withheld', &block)
334 | end
335 |
336 | # Set a Proc to be run when a status_withheld message is received.
337 | #
338 | # @client = TweetStream::Client.new
339 | # @client.on_user_withheld do |status|
340 | # # do something with the status
341 | # end
342 | def on_user_withheld(&block)
343 | on('user_withheld', &block)
344 | end
345 |
346 | # Set a Proc to be run when a Site Stream friends list is received.
347 | #
348 | # @client = TweetStream::Client.new
349 | # @client.on_friends do |friends|
350 | # # do something with the friends list
351 | # end
352 | def on_friends(&block)
353 | on('friends', &block)
354 | end
355 |
356 | # Set a Proc to be run when a stall warning is received.
357 | #
358 | # @client = TweetStream::Client.new
359 | # @client.on_stall_warning do |warning|
360 | # # do something with the friends list
361 | # end
362 | def on_stall_warning(&block)
363 | on('stall_warning', &block)
364 | end
365 |
366 | # Set a Proc to be run on userstream events
367 | #
368 | # @client = TweetStream::Client.new
369 | # @client.on_event(:favorite) do |event|
370 | # # do something with the status
371 | # end
372 | def on_event(event, &block)
373 | on(event, &block)
374 | end
375 |
376 | # Set a Proc to be run when sitestream control is received
377 | #
378 | # @client = TweetStream::Client.new
379 | # @client.on_control do
380 | # # do something with the status
381 | # end
382 | def on_control(&block)
383 | on('control', &block)
384 | end
385 |
386 | def on(event, &block)
387 | if block_given?
388 | @callbacks[event.to_s] = block
389 | self
390 | else
391 | @callbacks[event.to_s]
392 | end
393 | end
394 |
395 | # connect to twitter while starting a new EventMachine run loop
396 | def start(path, query_parameters = {}, &block)
397 | if EventMachine.reactor_running?
398 | connect(path, query_parameters, &block)
399 | else
400 | EventMachine.epoll
401 | EventMachine.kqueue
402 |
403 | EventMachine.run do
404 | connect(path, query_parameters, &block)
405 | end
406 | end
407 | end
408 |
409 | # connect to twitter without starting a new EventMachine run loop
410 | def connect(path, options = {}, &block)
411 | stream_parameters, callbacks = connection_options(path, options)
412 |
413 | @stream = EM::Twitter::Client.connect(stream_parameters)
414 | @stream.each do |item|
415 | begin
416 | hash = MultiJson.decode(item, :symbolize_keys => true)
417 | rescue MultiJson::DecodeError
418 | invoke_callback(callbacks['error'], "MultiJson::DecodeError occured in stream: #{item}")
419 | next
420 | end
421 |
422 | unless hash.is_a?(::Hash)
423 | invoke_callback(callbacks['error'], "Unexpected JSON object in stream: #{item}")
424 | next
425 | end
426 |
427 | respond_to(hash, callbacks, &block)
428 |
429 | yield_message_to(callbacks['anything'], hash)
430 | end
431 |
432 | @stream.on_error do |message|
433 | invoke_callback(callbacks['error'], message)
434 | end
435 |
436 | @stream.on_unauthorized do
437 | invoke_callback(callbacks['unauthorized'])
438 | end
439 |
440 | @stream.on_enhance_your_calm do
441 | invoke_callback(callbacks['enhance_your_calm'])
442 | end
443 |
444 | @stream.on_reconnect do |timeout, retries|
445 | invoke_callback(callbacks['reconnect'], timeout, retries)
446 | end
447 |
448 | @stream.on_max_reconnects do |timeout, retries|
449 | fail TweetStream::ReconnectError.new(timeout, retries)
450 | end
451 |
452 | @stream.on_no_data_received do
453 | invoke_callback(callbacks['no_data_received'])
454 | end
455 |
456 | @stream
457 | end
458 |
459 | # Terminate the currently running TweetStream and close EventMachine loop
460 | def stop
461 | EventMachine.stop_event_loop
462 | @last_status
463 | end
464 |
465 | # Close the connection to twitter without closing the eventmachine loop
466 | def close_connection
467 | @stream.close_connection if @stream
468 | end
469 |
470 | def stop_stream
471 | @stream.stop if @stream
472 | end
473 |
474 | def controllable?
475 | !!@control # rubocop:disable DoubleNegation
476 | end
477 |
478 | protected
479 |
480 | def respond_to(hash, callbacks, &block) # rubocop:disable CyclomaticComplexity, PerceivedComplexity
481 | if hash[:control] && hash[:control][:control_uri]
482 | @control_uri = hash[:control][:control_uri]
483 | require 'tweetstream/site_stream_client'
484 | @control = TweetStream::SiteStreamClient.new(@control_uri, options)
485 | @control.on_error(&callbacks['error'])
486 | invoke_callback(callbacks['control'])
487 | elsif hash[:warning]
488 | invoke_callback(callbacks['stall_warning'], hash[:warning])
489 | elsif hash[:delete] && hash[:delete][:status]
490 | invoke_callback(callbacks['delete'], hash[:delete][:status][:id], hash[:delete][:status][:user_id])
491 | elsif hash[:scrub_geo] && hash[:scrub_geo][:up_to_status_id]
492 | invoke_callback(callbacks['scrub_geo'], hash[:scrub_geo][:up_to_status_id], hash[:scrub_geo][:user_id])
493 | elsif hash[:limit] && hash[:limit][:track]
494 | invoke_callback(callbacks['limit'], hash[:limit][:track])
495 | elsif hash[:direct_message]
496 | yield_message_to(callbacks['direct_message'], Twitter::DirectMessage.new(hash[:direct_message]))
497 | elsif hash[:status_withheld]
498 | invoke_callback(callbacks['status_withheld'], hash[:status_withheld])
499 | elsif hash[:user_withheld]
500 | invoke_callback(callbacks['user_withheld'], hash[:user_withheld])
501 | elsif hash[:event]
502 | invoke_callback(callbacks[hash[:event].to_s], hash)
503 | elsif hash[:friends]
504 | invoke_callback(callbacks['friends'], hash[:friends])
505 | elsif hash[:text] && hash[:user]
506 | @last_status = Twitter::Tweet.new(hash)
507 | yield_message_to(callbacks['timeline_status'], @last_status)
508 |
509 | yield_message_to(block, @last_status) if block_given?
510 | elsif hash[:for_user]
511 | yield_message_to(block, hash) if block_given?
512 | end
513 | end
514 |
515 | def normalize_filter_parameters(query_parameters = {})
516 | [:follow, :track, :locations].each do |param|
517 | if query_parameters[param].is_a?(Array)
518 | query_parameters[param] = query_parameters[param].flatten.collect(&:to_s).join(',')
519 | elsif query_parameters[param]
520 | query_parameters[param] = query_parameters[param].to_s
521 | end
522 | end
523 | query_parameters
524 | end
525 |
526 | def auth_params
527 | if auth_method.to_s == 'basic'
528 | {
529 | :basic => {
530 | :username => username,
531 | :password => password,
532 | },
533 | }
534 | else
535 | {
536 | :oauth => {
537 | :consumer_key => consumer_key,
538 | :consumer_secret => consumer_secret,
539 | :token => oauth_token,
540 | :token_secret => oauth_token_secret,
541 | },
542 | }
543 | end
544 | end
545 |
546 | # A utility method used to invoke callback methods against the Client
547 | def invoke_callback(callback, *args)
548 | callback.call(*args) if callback
549 | end
550 |
551 | def yield_message_to(procedure, message)
552 | return if procedure.nil?
553 | # Give the block the option to receive either one or two arguments,
554 | # depending on its arity.
555 | case procedure.arity
556 | when 1
557 | invoke_callback(procedure, message)
558 | when 2
559 | invoke_callback(procedure, message, self)
560 | end
561 | end
562 |
563 | def connection_options(path, options)
564 | warn_if_callbacks(options)
565 |
566 | callbacks = @callbacks.dup
567 | OPTION_CALLBACKS.each do |callback|
568 | callbacks.merge(callback.to_s => options.delete(callback)) if options[callback]
569 | end
570 |
571 | inited_proc = options.delete(:inited) || @callbacks['inited']
572 | extra_stream_parameters = options.delete(:extra_stream_parameters) || {}
573 |
574 | stream_params = {
575 | :path => path,
576 | :method => (options.delete(:method) || 'get').to_s.upcase,
577 | :user_agent => user_agent,
578 | :on_inited => inited_proc,
579 | :params => normalize_filter_parameters(options),
580 | :proxy => proxy,
581 | }.merge(extra_stream_parameters).merge(auth_params)
582 |
583 | [stream_params, callbacks]
584 | end
585 |
586 | def warn_if_callbacks(options = {})
587 | Kernel.warn('Passing callbacks via the options hash is deprecated and will be removed in TweetStream 3.0') if OPTION_CALLBACKS.select { |callback| options[callback] }.size > 0
588 | end
589 | end
590 | end
591 |
--------------------------------------------------------------------------------
/spec/fixtures/statuses.json:
--------------------------------------------------------------------------------
1 | {"created_at":"Fri Sep 07 16:35:24 +0000 2012","id":244111636544225280,"id_str":"244111636544225280","text":"Happy Birthday @imdane. Watch out for those @rally pranksters!","source":"\u003ca href=\"http:\/\/twitter.com\/download\/iphone\" rel=\"nofollow\"\u003eTwitter for iPhone\u003c\/a\u003e","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":5819322,"id_str":"5819322","name":"Maggie Utgoff","screen_name":"mutgoff","location":"san francisco","description":"I live every week like it's Shark Week. ","url":"http:\/\/www.mutgoff.com","entities":{"url":{"urls":[{"url":"http:\/\/www.mutgoff.com","expanded_url":null,"indices":[0,22]}]},"description":{"urls":[]}},"protected":false,"followers_count":263063,"friends_count":708,"listed_count":534,"created_at":"Mon May 07 01:02:52 +0000 2007","favourites_count":444,"utc_offset":-28800,"time_zone":"Pacific Time (US & Canada)","geo_enabled":true,"verified":false,"statuses_count":4604,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"FFFFFF","profile_background_image_url":"http:\/\/a0.twimg.com\/profile_background_images\/344662358\/x88fe902ff835983434794eb1f9d7370.jpg","profile_background_image_url_https":"https:\/\/si0.twimg.com\/profile_background_images\/344662358\/x88fe902ff835983434794eb1f9d7370.jpg","profile_background_tile":false,"profile_image_url":"http:\/\/a0.twimg.com\/profile_images\/1199277090\/Screen_shot_2010-12-26_at_11.31.51_AM_normal.png","profile_image_url_https":"https:\/\/si0.twimg.com\/profile_images\/1199277090\/Screen_shot_2010-12-26_at_11.31.51_AM_normal.png","profile_link_color":"9DDD95","profile_sidebar_border_color":"A0EEF5","profile_sidebar_fill_color":"1A3F57","profile_text_color":"72B9BF","profile_use_background_image":true,"show_all_inline_media":true,"default_profile":false,"default_profile_image":false,"following":true,"follow_request_sent":null,"notifications":null},"geo":{"type":"Point","coordinates":[43.46481998,-73.64247884]},"coordinates":{"type":"Point","coordinates":[-73.64247884,43.46481998]},"place":{"id":"003cd76c24b9fa3b","url":"https:\/\/api.twitter.com\/1.1\/geo\/id\/003cd76c24b9fa3b.json","place_type":"city","name":"Bolton","full_name":"Bolton, NY","country_code":"US","country":"United States","bounding_box":{"type":"Polygon","coordinates":[[[-73.750813,43.442073],[-73.525347,43.442073],[-73.525347,43.678377],[-73.750813,43.678377]]]},"attributes":{}},"contributors":null,"retweet_count":0,"entities":{"hashtags":[],"urls":[],"user_mentions":[{"screen_name":"imdane","name":"Dane Hurtubise","id":14076314,"id_str":"14076314","indices":[15,22]},{"screen_name":"rally","name":"Rally","id":16364838,"id_str":"16364838","indices":[44,50]}]},"favorited":false,"retweeted":false}
2 | {"created_at":"Fri Sep 07 16:33:36 +0000 2012","id":244111183165157376,"id_str":"244111183165157376","text":"If you like good real-life stories, check out @NarrativelyNY\u2019s just-launched site http:\/\/t.co\/wiUL07jE (and also visit http:\/\/t.co\/ZoyQxqWA)","source":"\u003ca href=\"http:\/\/tapbots.com\" rel=\"nofollow\"\u003eTweetbot for Mac\u003c\/a\u003e","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":14163141,"id_str":"14163141","name":"David Friedman","screen_name":"ironicsans","location":"New York","description":"Photographer. Idea blogger. Occasional historian.","url":"http:\/\/www.davidfriedman.info","entities":{"url":{"urls":[{"url":"http:\/\/www.davidfriedman.info","expanded_url":null,"indices":[0,29]}]},"description":{"urls":[]}},"protected":false,"followers_count":4131,"friends_count":1270,"listed_count":220,"created_at":"Mon Mar 17 13:47:33 +0000 2008","favourites_count":1377,"utc_offset":-18000,"time_zone":"Eastern Time (US & Canada)","geo_enabled":true,"verified":false,"statuses_count":4753,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"AAB4B5","profile_background_image_url":"http:\/\/a0.twimg.com\/profile_background_images\/66248418\/Untitled-1.gif","profile_background_image_url_https":"https:\/\/si0.twimg.com\/profile_background_images\/66248418\/Untitled-1.gif","profile_background_tile":true,"profile_image_url":"http:\/\/a0.twimg.com\/profile_images\/427291735\/n645611374_892426_9102_normal.jpg","profile_image_url_https":"https:\/\/si0.twimg.com\/profile_images\/427291735\/n645611374_892426_9102_normal.jpg","profile_link_color":"0084B4","profile_sidebar_border_color":"BDDCAD","profile_sidebar_fill_color":"DDFFCC","profile_text_color":"333333","profile_use_background_image":true,"show_all_inline_media":true,"default_profile":false,"default_profile_image":false,"following":true,"follow_request_sent":null,"notifications":null},"geo":null,"coordinates":null,"place":null,"contributors":null,"retweet_count":0,"entities":{"hashtags":[],"urls":[{"url":"http:\/\/t.co\/wiUL07jE","expanded_url":"http:\/\/narrative.ly","display_url":"narrative.ly","indices":[82,102]},{"url":"http:\/\/t.co\/ZoyQxqWA","expanded_url":"http:\/\/www.kickstarter.com\/projects\/narratively\/narratively","display_url":"kickstarter.com\/projects\/narra\u2026","indices":[119,139]}],"user_mentions":[{"screen_name":"NarrativelyNY","name":"Narratively","id":576457087,"id_str":"576457087","indices":[46,60]}]},"favorited":false,"retweeted":false,"possibly_sensitive":false}
3 | {"created_at":"Fri Sep 07 16:30:14 +0000 2012","id":244110336414859264,"id_str":"244110336414859264","text":"Something else to vote for: \"New Rails workshops to bring more women into the Boston software scene\" http:\/\/t.co\/eNBuckHc \/cc @bostonrb","source":"\u003ca href=\"http:\/\/itunes.apple.com\/us\/app\/twitter\/id409789998?mt=12\" rel=\"nofollow\"\u003eTwitter for Mac\u003c\/a\u003e","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":43234200,"id_str":"43234200","name":"Pat Shaughnessy","screen_name":"pat_shaughnessy","location":"Boston","description":"Blogger, Rubyist, Writing a new eBook: http:\/\/patshaughnessy.net\/ruby-under-a-microscope","url":"http:\/\/patshaughnessy.net","entities":{"url":{"urls":[{"url":"http:\/\/patshaughnessy.net","expanded_url":null,"indices":[0,25]}]},"description":{"urls":[]}},"protected":false,"followers_count":734,"friends_count":362,"listed_count":38,"created_at":"Fri May 29 00:55:48 +0000 2009","favourites_count":35,"utc_offset":-18000,"time_zone":"Eastern Time (US & Canada)","geo_enabled":false,"verified":false,"statuses_count":1620,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"C0DEED","profile_background_image_url":"http:\/\/a0.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_image_url_https":"https:\/\/si0.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_tile":false,"profile_image_url":"http:\/\/a0.twimg.com\/profile_images\/1950093297\/pat2_normal.jpg","profile_image_url_https":"https:\/\/si0.twimg.com\/profile_images\/1950093297\/pat2_normal.jpg","profile_link_color":"0084B4","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"show_all_inline_media":false,"default_profile":true,"default_profile_image":false,"following":true,"follow_request_sent":null,"notifications":null},"geo":null,"coordinates":null,"place":null,"contributors":null,"retweet_count":0,"entities":{"hashtags":[],"urls":[{"url":"http:\/\/t.co\/eNBuckHc","expanded_url":"http:\/\/news.ycombinator.com\/item?id=4489199","display_url":"news.ycombinator.com\/item?id=4489199","indices":[101,121]}],"user_mentions":[{"screen_name":"bostonrb","name":"Boston Ruby Group","id":21431343,"id_str":"21431343","indices":[126,135]}]},"favorited":false,"retweeted":false,"possibly_sensitive":false}
4 | {"created_at":"Fri Sep 07 16:28:05 +0000 2012","id":244109797308379136,"id_str":"244109797308379136","text":"Pushing the button to launch the site. http:\/\/t.co\/qLoEn5jG","source":"\u003ca href=\"http:\/\/instagr.am\" rel=\"nofollow\"\u003eInstagram\u003c\/a\u003e","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":1882641,"id_str":"1882641","name":"Caleb Elston","screen_name":"calebelston","location":"San Francisco","description":"Co-founder & CEO of Yobongo. Dubious of people who claim to be experts. Formerly VP Products at Justin.tv. Advisor to Simpler.","url":"http:\/\/www.calebelston.com","entities":{"url":{"urls":[{"url":"http:\/\/www.calebelston.com","expanded_url":null,"indices":[0,26]}]},"description":{"urls":[]}},"protected":false,"followers_count":1960,"friends_count":151,"listed_count":136,"created_at":"Thu Mar 22 14:34:22 +0000 2007","favourites_count":815,"utc_offset":-28800,"time_zone":"Pacific Time (US & Canada)","geo_enabled":true,"verified":false,"statuses_count":7068,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"666666","profile_background_image_url":"http:\/\/a0.twimg.com\/profile_background_images\/322151965\/ngb.gif","profile_background_image_url_https":"https:\/\/si0.twimg.com\/profile_background_images\/322151965\/ngb.gif","profile_background_tile":false,"profile_image_url":"http:\/\/a0.twimg.com\/profile_images\/2584558450\/elyaf9epw0kcnh9gxglp_normal.jpeg","profile_image_url_https":"https:\/\/si0.twimg.com\/profile_images\/2584558450\/elyaf9epw0kcnh9gxglp_normal.jpeg","profile_link_color":"0099CC","profile_sidebar_border_color":"E3E3E3","profile_sidebar_fill_color":"FFFFFF","profile_text_color":"292E38","profile_use_background_image":false,"show_all_inline_media":true,"default_profile":false,"default_profile_image":false,"following":true,"follow_request_sent":null,"notifications":null},"geo":null,"coordinates":null,"place":null,"contributors":null,"retweet_count":0,"entities":{"hashtags":[],"urls":[{"url":"http:\/\/t.co\/qLoEn5jG","expanded_url":"http:\/\/instagr.am\/p\/PR7YFvRhiO\/","display_url":"instagr.am\/p\/PR7YFvRhiO\/","indices":[39,59]}],"user_mentions":[]},"favorited":false,"retweeted":false,"possibly_sensitive":false}
5 | {"created_at":"Fri Sep 07 16:23:50 +0000 2012","id":244108728834592770,"id_str":"244108728834592770","text":"RT @olivercameron: Mosaic looks cool: http:\/\/t.co\/A8013C9k","source":"web","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":1882641,"id_str":"1882641","name":"Caleb Elston","screen_name":"calebelston","location":"San Francisco","description":"Co-founder & CEO of Yobongo. Dubious of people who claim to be experts. Formerly VP Products at Justin.tv. Advisor to Simpler.","url":"http:\/\/www.calebelston.com","entities":{"url":{"urls":[{"url":"http:\/\/www.calebelston.com","expanded_url":null,"indices":[0,26]}]},"description":{"urls":[]}},"protected":false,"followers_count":1960,"friends_count":151,"listed_count":136,"created_at":"Thu Mar 22 14:34:22 +0000 2007","favourites_count":815,"utc_offset":-28800,"time_zone":"Pacific Time (US & Canada)","geo_enabled":true,"verified":false,"statuses_count":7068,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"666666","profile_background_image_url":"http:\/\/a0.twimg.com\/profile_background_images\/322151965\/ngb.gif","profile_background_image_url_https":"https:\/\/si0.twimg.com\/profile_background_images\/322151965\/ngb.gif","profile_background_tile":false,"profile_image_url":"http:\/\/a0.twimg.com\/profile_images\/2584558450\/elyaf9epw0kcnh9gxglp_normal.jpeg","profile_image_url_https":"https:\/\/si0.twimg.com\/profile_images\/2584558450\/elyaf9epw0kcnh9gxglp_normal.jpeg","profile_link_color":"0099CC","profile_sidebar_border_color":"E3E3E3","profile_sidebar_fill_color":"FFFFFF","profile_text_color":"292E38","profile_use_background_image":false,"show_all_inline_media":true,"default_profile":false,"default_profile_image":false,"following":true,"follow_request_sent":null,"notifications":null},"geo":null,"coordinates":null,"place":null,"contributors":null,"retweeted_status":{"created_at":"Fri Sep 07 16:12:47 +0000 2012","id":244105944508796931,"id_str":"244105944508796931","text":"Mosaic looks cool: http:\/\/t.co\/A8013C9k","source":"\u003ca href=\"http:\/\/itunes.apple.com\/us\/app\/twitter\/id409789998?mt=12\" rel=\"nofollow\"\u003eTwitter for Mac\u003c\/a\u003e","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":13634322,"id_str":"13634322","name":"Oliver Cameron","screen_name":"olivercameron","location":"Palo Alto, CA","description":"Co-founder of @everyme.","url":"http:\/\/everyme.com","entities":{"url":{"urls":[{"url":"http:\/\/everyme.com","expanded_url":null,"indices":[0,18]}]},"description":{"urls":[]}},"protected":false,"followers_count":1365,"friends_count":218,"listed_count":57,"created_at":"Mon Feb 18 18:08:32 +0000 2008","favourites_count":8,"utc_offset":0,"time_zone":"London","geo_enabled":false,"verified":false,"statuses_count":3346,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"FFFFFF","profile_background_image_url":"http:\/\/a0.twimg.com\/profile_background_images\/5435833\/pat_20060420022220.gif","profile_background_image_url_https":"https:\/\/si0.twimg.com\/profile_background_images\/5435833\/pat_20060420022220.gif","profile_background_tile":true,"profile_image_url":"http:\/\/a0.twimg.com\/profile_images\/1237999642\/Oliver_normal.png","profile_image_url_https":"https:\/\/si0.twimg.com\/profile_images\/1237999642\/Oliver_normal.png","profile_link_color":"454545","profile_sidebar_border_color":"FFFFFF","profile_sidebar_fill_color":"FFFFFF","profile_text_color":"000000","profile_use_background_image":false,"show_all_inline_media":true,"default_profile":false,"default_profile_image":false,"following":null,"follow_request_sent":null,"notifications":null},"geo":null,"coordinates":null,"place":null,"contributors":null,"retweet_count":1,"entities":{"hashtags":[],"urls":[{"url":"http:\/\/t.co\/A8013C9k","expanded_url":"http:\/\/heymosaic.com\/i\/1Z8ssK","display_url":"heymosaic.com\/i\/1Z8ssK","indices":[19,39]}],"user_mentions":[]},"favorited":false,"retweeted":false,"possibly_sensitive":false},"retweet_count":1,"entities":{"hashtags":[],"urls":[{"url":"http:\/\/t.co\/A8013C9k","expanded_url":"http:\/\/heymosaic.com\/i\/1Z8ssK","display_url":"heymosaic.com\/i\/1Z8ssK","indices":[38,58]}],"user_mentions":[{"screen_name":"olivercameron","name":"Oliver Cameron","id":13634322,"id_str":"13634322","indices":[3,17]}]},"favorited":false,"retweeted":false,"possibly_sensitive":false}
6 | {"created_at":"Fri Sep 07 16:20:31 +0000 2012","id":244107890632294400,"id_str":"244107890632294400","text":"The Weatherman is Not a Moron: http:\/\/t.co\/ZwL5Gnq5. An excerpt from my book, THE SIGNAL AND THE NOISE (http:\/\/t.co\/fNXj8vCE)","source":"\u003ca href=\"http:\/\/www.tweetdeck.com\" rel=\"nofollow\"\u003eTweetDeck\u003c\/a\u003e","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":16017475,"id_str":"16017475","name":"Nate Silver","screen_name":"fivethirtyeight","location":"New York","description":"FiveThirtyEight blogger (http:\/\/nyti.ms\/Qp8cqb). Author, The Signal and the Noise (http:\/\/amzn.to\/QdyFYV). Sports\/politics\/food geek.","url":"http:\/\/amzn.to\/QdyFYV","entities":{"url":{"urls":[{"url":"http:\/\/amzn.to\/QdyFYV","expanded_url":null,"indices":[0,21]}]},"description":{"urls":[]}},"protected":false,"followers_count":183238,"friends_count":475,"listed_count":8160,"created_at":"Wed Aug 27 20:56:45 +0000 2008","favourites_count":6,"utc_offset":-18000,"time_zone":"Eastern Time (US & Canada)","geo_enabled":false,"verified":true,"statuses_count":6786,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"C0DEED","profile_background_image_url":"http:\/\/a0.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_image_url_https":"https:\/\/si0.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_tile":false,"profile_image_url":"http:\/\/a0.twimg.com\/profile_images\/1110592135\/fivethirtyeight73_twitter_normal.png","profile_image_url_https":"https:\/\/si0.twimg.com\/profile_images\/1110592135\/fivethirtyeight73_twitter_normal.png","profile_link_color":"0084B4","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"show_all_inline_media":false,"default_profile":true,"default_profile_image":false,"following":true,"follow_request_sent":null,"notifications":null},"geo":null,"coordinates":null,"place":null,"contributors":null,"retweet_count":19,"entities":{"hashtags":[],"urls":[{"url":"http:\/\/t.co\/ZwL5Gnq5","expanded_url":"http:\/\/nyti.ms\/OW7n5p","display_url":"nyti.ms\/OW7n5p","indices":[31,51]},{"url":"http:\/\/t.co\/fNXj8vCE","expanded_url":"http:\/\/amzn.to\/Qg2SEu","display_url":"amzn.to\/Qg2SEu","indices":[104,124]}],"user_mentions":[]},"favorited":false,"retweeted":false,"possibly_sensitive":false}
7 | {"created_at":"Fri Sep 07 16:20:15 +0000 2012","id":244107823733174272,"id_str":"244107823733174272","text":"RT @randomhacks: Going to Code Across Austin II: Y'all Come Hack Now, Sat, Sep 8 http:\/\/t.co\/Sk5BM7U3 We'll see y'all there! #rhok @cod ...","source":"web","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":64482503,"id_str":"64482503","name":"Code for America","screen_name":"codeforamerica","location":"San Francisco, California","description":"Code for America helps governments work better for everyone with the people and the power of the web.","url":"http:\/\/www.codeforamerica.org","entities":{"url":{"urls":[{"url":"http:\/\/www.codeforamerica.org","expanded_url":null,"indices":[0,29]}]},"description":{"urls":[]}},"protected":false,"followers_count":11824,"friends_count":783,"listed_count":981,"created_at":"Mon Aug 10 18:59:29 +0000 2009","favourites_count":20,"utc_offset":-28800,"time_zone":"Pacific Time (US & Canada)","geo_enabled":false,"verified":false,"statuses_count":3611,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"EBEBEB","profile_background_image_url":"http:\/\/a0.twimg.com\/images\/themes\/theme7\/bg.gif","profile_background_image_url_https":"https:\/\/si0.twimg.com\/images\/themes\/theme7\/bg.gif","profile_background_tile":false,"profile_image_url":"http:\/\/a0.twimg.com\/profile_images\/1118630094\/logosquare_bigger_normal.jpg","profile_image_url_https":"https:\/\/si0.twimg.com\/profile_images\/1118630094\/logosquare_bigger_normal.jpg","profile_link_color":"990000","profile_sidebar_border_color":"DFDFDF","profile_sidebar_fill_color":"F3F3F3","profile_text_color":"333333","profile_use_background_image":false,"show_all_inline_media":false,"default_profile":false,"default_profile_image":false,"following":true,"follow_request_sent":null,"notifications":null},"geo":null,"coordinates":null,"place":null,"contributors":null,"retweeted_status":{"created_at":"Fri Sep 07 16:11:02 +0000 2012","id":244105505390350336,"id_str":"244105505390350336","text":"Going to Code Across Austin II: Y'all Come Hack Now, Sat, Sep 8 http:\/\/t.co\/Sk5BM7U3 We'll see y'all there! #rhok @codeforamerica @TheaClay","source":"\u003ca href=\"http:\/\/twitter.com\/tweetbutton\" rel=\"nofollow\"\u003eTweet Button\u003c\/a\u003e","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":75361247,"id_str":"75361247","name":"Random Hacks","screen_name":"randomhacks","location":"USA","description":"Official Twitter account for Random Hacks of Kindness.","url":"http:\/\/www.rhok.org","entities":{"url":{"urls":[{"url":"http:\/\/www.rhok.org","expanded_url":null,"indices":[0,19]}]},"description":{"urls":[]}},"protected":false,"followers_count":3917,"friends_count":202,"listed_count":209,"created_at":"Fri Sep 18 19:22:26 +0000 2009","favourites_count":1,"utc_offset":-28800,"time_zone":"Pacific Time (US & Canada)","geo_enabled":true,"verified":false,"statuses_count":1173,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"C0DEED","profile_background_image_url":"http:\/\/a0.twimg.com\/profile_background_images\/102109549\/rhok_social_media_wallpaper.png","profile_background_image_url_https":"https:\/\/si0.twimg.com\/profile_background_images\/102109549\/rhok_social_media_wallpaper.png","profile_background_tile":false,"profile_image_url":"http:\/\/a0.twimg.com\/profile_images\/905274924\/rhok_social_media_logo_normal.png","profile_image_url_https":"https:\/\/si0.twimg.com\/profile_images\/905274924\/rhok_social_media_logo_normal.png","profile_link_color":"2087E7","profile_sidebar_border_color":"2087E7","profile_sidebar_fill_color":"E8E7E7","profile_text_color":"030303","profile_use_background_image":true,"show_all_inline_media":false,"default_profile":false,"default_profile_image":false,"following":null,"follow_request_sent":null,"notifications":null},"geo":null,"coordinates":null,"place":null,"contributors":null,"retweet_count":2,"entities":{"hashtags":[{"text":"rhok","indices":[109,114]}],"urls":[{"url":"http:\/\/t.co\/Sk5BM7U3","expanded_url":"http:\/\/zvents.com\/e\/IhP3T\/7o","display_url":"zvents.com\/e\/IhP3T\/7o","indices":[64,84]}],"user_mentions":[{"screen_name":"codeforamerica","name":"Code for America","id":64482503,"id_str":"64482503","indices":[115,130]},{"screen_name":"TheaClay","name":"Thea Clay","id":34324747,"id_str":"34324747","indices":[131,140]}]},"favorited":false,"retweeted":false,"possibly_sensitive":false},"retweet_count":2,"entities":{"hashtags":[{"text":"rhok","indices":[126,131]}],"urls":[{"url":"http:\/\/t.co\/Sk5BM7U3","expanded_url":"http:\/\/zvents.com\/e\/IhP3T\/7o","display_url":"zvents.com\/e\/IhP3T\/7o","indices":[81,101]}],"user_mentions":[{"screen_name":"randomhacks","name":"Random Hacks","id":75361247,"id_str":"75361247","indices":[3,15]},{"screen_name":"cod","name":"Chris OBrien","id":13791662,"id_str":"13791662","indices":[132,136]}]},"favorited":false,"retweeted":false,"possibly_sensitive":false}
8 | {"created_at":"Fri Sep 07 16:17:55 +0000 2012","id":244107236262170624,"id_str":"244107236262170624","text":"RT @jondot: Just published: \"Pragmatic Concurrency With #Ruby\" http:\/\/t.co\/kGEykswZ \/cc @JRuby @headius","source":"\u003ca href=\"http:\/\/twitter.com\/download\/iphone\" rel=\"nofollow\"\u003eTwitter for iPhone\u003c\/a\u003e","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":10248172,"id_str":"10248172","name":"Fredrik Bj\u00f6rk","screen_name":"fbjork","location":"San Francisco, CA","description":"Director of Engineering at @banjo","url":"http:\/\/ban.jo","entities":{"url":{"urls":[{"url":"http:\/\/ban.jo","expanded_url":null,"indices":[0,13]}]},"description":{"urls":[]}},"protected":false,"followers_count":266,"friends_count":343,"listed_count":18,"created_at":"Wed Nov 14 14:58:28 +0000 2007","favourites_count":7,"utc_offset":-28800,"time_zone":"Pacific Time (US & Canada)","geo_enabled":true,"verified":false,"statuses_count":944,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"131516","profile_background_image_url":"http:\/\/a0.twimg.com\/images\/themes\/theme14\/bg.gif","profile_background_image_url_https":"https:\/\/si0.twimg.com\/images\/themes\/theme14\/bg.gif","profile_background_tile":true,"profile_image_url":"http:\/\/a0.twimg.com\/profile_images\/2167836514\/252562_10150648192185221_786305220_19068177_4887761_n_normal.jpg","profile_image_url_https":"https:\/\/si0.twimg.com\/profile_images\/2167836514\/252562_10150648192185221_786305220_19068177_4887761_n_normal.jpg","profile_link_color":"009999","profile_sidebar_border_color":"EEEEEE","profile_sidebar_fill_color":"EFEFEF","profile_text_color":"333333","profile_use_background_image":true,"show_all_inline_media":true,"default_profile":false,"default_profile_image":false,"following":true,"follow_request_sent":null,"notifications":null},"geo":null,"coordinates":null,"place":null,"contributors":null,"retweeted_status":{"created_at":"Fri Sep 07 15:53:34 +0000 2012","id":244101108983803904,"id_str":"244101108983803904","text":"Just published: \"Pragmatic Concurrency With #Ruby\" http:\/\/t.co\/kGEykswZ \/cc @JRuby @headius","source":"web","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":25607541,"id_str":"25607541","name":"dotan nahum","screen_name":"jondot","location":"","description":"I'm just a mean code machine. Constantly scanning, hunting and building the next big thing.","url":"http:\/\/blog.paracode.com","entities":{"url":{"urls":[{"url":"http:\/\/blog.paracode.com","expanded_url":null,"indices":[0,24]}]},"description":{"urls":[]}},"protected":false,"followers_count":410,"friends_count":85,"listed_count":13,"created_at":"Sat Mar 21 00:26:10 +0000 2009","favourites_count":0,"utc_offset":-10800,"time_zone":"Greenland","geo_enabled":false,"verified":false,"statuses_count":784,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"2B1F44","profile_background_image_url":"http:\/\/a0.twimg.com\/profile_background_images\/437973354\/nwgrand_516_142243.jpg","profile_background_image_url_https":"https:\/\/si0.twimg.com\/profile_background_images\/437973354\/nwgrand_516_142243.jpg","profile_background_tile":false,"profile_image_url":"http:\/\/a0.twimg.com\/profile_images\/1181955409\/nd_normal.png","profile_image_url_https":"https:\/\/si0.twimg.com\/profile_images\/1181955409\/nd_normal.png","profile_link_color":"1C62B9","profile_sidebar_border_color":"F1A253","profile_sidebar_fill_color":"221309","profile_text_color":"755C8A","profile_use_background_image":true,"show_all_inline_media":false,"default_profile":false,"default_profile_image":false,"following":null,"follow_request_sent":null,"notifications":null},"geo":null,"coordinates":null,"place":null,"contributors":null,"retweet_count":8,"entities":{"hashtags":[{"text":"Ruby","indices":[44,49]}],"urls":[{"url":"http:\/\/t.co\/kGEykswZ","expanded_url":"http:\/\/blog.paracode.com\/2012\/09\/07\/pragmatic-concurrency-with-ruby\/","display_url":"blog.paracode.com\/2012\/09\/07\/pra\u2026","indices":[51,71]}],"user_mentions":[{"screen_name":"jruby","name":"JRuby Dev Team","id":16132186,"id_str":"16132186","indices":[78,84]},{"screen_name":"headius","name":"Charles Nutter","id":9989362,"id_str":"9989362","indices":[85,93]}]},"favorited":false,"retweeted":false,"possibly_sensitive":false},"retweet_count":8,"entities":{"hashtags":[{"text":"Ruby","indices":[56,61]}],"urls":[{"url":"http:\/\/t.co\/kGEykswZ","expanded_url":"http:\/\/blog.paracode.com\/2012\/09\/07\/pragmatic-concurrency-with-ruby\/","display_url":"blog.paracode.com\/2012\/09\/07\/pra\u2026","indices":[63,83]}],"user_mentions":[{"screen_name":"jondot","name":"dotan nahum","id":25607541,"id_str":"25607541","indices":[3,10]},{"screen_name":"jruby","name":"JRuby Dev Team","id":16132186,"id_str":"16132186","indices":[90,96]},{"screen_name":"headius","name":"Charles Nutter","id":9989362,"id_str":"9989362","indices":[97,105]}]},"favorited":false,"retweeted":false,"possibly_sensitive":false}
9 | {"created_at":"Fri Sep 07 16:14:53 +0000 2012","id":244106476048764928,"id_str":"244106476048764928","text":"If you are wondering how we computed the split bubbles: http:\/\/t.co\/BcaqSs5u","source":"\u003ca href=\"http:\/\/itunes.apple.com\/us\/app\/twitter\/id409789998?mt=12\" rel=\"nofollow\"\u003eTwitter for Mac\u003c\/a\u003e","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":43593,"id_str":"43593","name":"Mike Bostock","screen_name":"mbostock","location":"San Francisco, CA","description":"Purveyor of fine misinformations.","url":"http:\/\/bost.ocks.org","entities":{"url":{"urls":[{"url":"http:\/\/bost.ocks.org","expanded_url":null,"indices":[0,20]}]},"description":{"urls":[]}},"protected":false,"followers_count":4090,"friends_count":181,"listed_count":227,"created_at":"Tue Dec 05 21:57:30 +0000 2006","favourites_count":124,"utc_offset":-28800,"time_zone":"Pacific Time (US & Canada)","geo_enabled":true,"verified":false,"statuses_count":2237,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"131516","profile_background_image_url":"http:\/\/a0.twimg.com\/images\/themes\/theme14\/bg.gif","profile_background_image_url_https":"https:\/\/si0.twimg.com\/images\/themes\/theme14\/bg.gif","profile_background_tile":true,"profile_image_url":"http:\/\/a0.twimg.com\/profile_images\/1434042628\/mbostock-sf_normal.png","profile_image_url_https":"https:\/\/si0.twimg.com\/profile_images\/1434042628\/mbostock-sf_normal.png","profile_link_color":"9F0606","profile_sidebar_border_color":"EEEEEE","profile_sidebar_fill_color":"EFEFEF","profile_text_color":"333333","profile_use_background_image":true,"show_all_inline_media":true,"default_profile":false,"default_profile_image":false,"following":true,"follow_request_sent":null,"notifications":null},"geo":null,"coordinates":null,"place":null,"contributors":null,"retweet_count":1,"entities":{"hashtags":[],"urls":[{"url":"http:\/\/t.co\/BcaqSs5u","expanded_url":"http:\/\/bl.ocks.org\/3422480","display_url":"bl.ocks.org\/3422480","indices":[56,76]}],"user_mentions":[]},"favorited":false,"retweeted":false,"possibly_sensitive":false}
10 | {"created_at":"Fri Sep 07 16:11:24 +0000 2012","id":244105599351148544,"id_str":"244105599351148544","text":"\u201cWrite drunk. Edit sober.\u201d\u2014Ernest Hemingway","source":"\u003ca href=\"http:\/\/stone.com\/neue\" rel=\"nofollow\"\u003eTwittelator Neue\u003c\/a\u003e","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":485409945,"id_str":"485409945","name":"Fake Jack Dorsey","screen_name":"FakeDorsey","location":"San Francisco","description":"Simplify, bitches.","url":"http:\/\/square.twitter.com","entities":{"url":{"urls":[{"url":"http:\/\/square.twitter.com","expanded_url":null,"indices":[0,25]}]},"description":{"urls":[]}},"protected":false,"followers_count":3275,"friends_count":1,"listed_count":61,"created_at":"Tue Feb 07 05:16:26 +0000 2012","favourites_count":4,"utc_offset":null,"time_zone":null,"geo_enabled":false,"verified":false,"statuses_count":86,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"C0DEED","profile_background_image_url":"http:\/\/a0.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_image_url_https":"https:\/\/si0.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_tile":false,"profile_image_url":"http:\/\/a0.twimg.com\/profile_images\/1810072255\/Untitled_normal.png","profile_image_url_https":"https:\/\/si0.twimg.com\/profile_images\/1810072255\/Untitled_normal.png","profile_link_color":"0084B4","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"show_all_inline_media":false,"default_profile":true,"default_profile_image":false,"following":true,"follow_request_sent":null,"notifications":null},"geo":null,"coordinates":null,"place":null,"contributors":null,"retweet_count":14,"entities":{"hashtags":[],"urls":[],"user_mentions":[]},"favorited":false,"retweeted":false}
11 | {"created_at":"Fri Sep 07 16:07:16 +0000 2012","id":244104558433951744,"id_str":"244104558433951744","text":"RT @wcmaier: Better banking through better ops: build something new with us @Simplify (remote, PDX) http:\/\/t.co\/8WgzKZH3","source":"\u003ca href=\"http:\/\/tapbots.com\/tweetbot\" rel=\"nofollow\"\u003eTweetbot for iOS\u003c\/a\u003e","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":18713,"id_str":"18713","name":"Alex Payne","screen_name":"al3x","location":"Berlin (Aug 31 - Sept 21)","description":"Programmer. Writer. Secular Humanist.","url":"http:\/\/al3x.net","entities":{"url":{"urls":[{"url":"http:\/\/al3x.net","expanded_url":null,"indices":[0,15]}]},"description":{"urls":[]}},"protected":false,"followers_count":36487,"friends_count":323,"listed_count":2272,"created_at":"Thu Nov 23 19:29:11 +0000 2006","favourites_count":4615,"utc_offset":3600,"time_zone":"Berlin","geo_enabled":true,"verified":false,"statuses_count":23134,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"E5E9EB","profile_background_image_url":"http:\/\/a0.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_image_url_https":"https:\/\/si0.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_tile":false,"profile_image_url":"http:\/\/a0.twimg.com\/profile_images\/357750272\/small_3_normal.png","profile_image_url_https":"https:\/\/si0.twimg.com\/profile_images\/357750272\/small_3_normal.png","profile_link_color":"336699","profile_sidebar_border_color":"333333","profile_sidebar_fill_color":"C3CBD0","profile_text_color":"232323","profile_use_background_image":false,"show_all_inline_media":true,"default_profile":false,"default_profile_image":false,"following":true,"follow_request_sent":null,"notifications":null},"geo":null,"coordinates":null,"place":null,"contributors":null,"retweeted_status":{"created_at":"Fri Sep 07 15:27:38 +0000 2012","id":244094582411890689,"id_str":"244094582411890689","text":"Better banking through better ops: build something new with us @Simplify (remote, PDX) http:\/\/t.co\/8WgzKZH3","source":"\u003ca href=\"http:\/\/tapbots.com\/tweetbot\" rel=\"nofollow\"\u003eTweetbot for iOS\u003c\/a\u003e","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":11125102,"id_str":"11125102","name":"Will Maier","screen_name":"wcmaier","location":"Madison, WI, USA","description":"I help @Simplify ship beautiful things. Previously @lt_kije.","url":"http:\/\/wcm.aier.us\/","entities":{"url":{"urls":[{"url":"http:\/\/wcm.aier.us\/","expanded_url":null,"indices":[0,19]}]},"description":{"urls":[]}},"protected":false,"followers_count":240,"friends_count":193,"listed_count":16,"created_at":"Thu Dec 13 12:35:31 +0000 2007","favourites_count":2,"utc_offset":-21600,"time_zone":"Central Time (US & Canada)","geo_enabled":false,"verified":false,"statuses_count":3898,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"C0DEED","profile_background_image_url":"http:\/\/a0.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_image_url_https":"https:\/\/si0.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_tile":false,"profile_image_url":"http:\/\/a0.twimg.com\/profile_images\/39909052\/kije-final_normal.jpg","profile_image_url_https":"https:\/\/si0.twimg.com\/profile_images\/39909052\/kije-final_normal.jpg","profile_link_color":"0084B4","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"show_all_inline_media":true,"default_profile":true,"default_profile_image":false,"following":null,"follow_request_sent":null,"notifications":null},"geo":null,"coordinates":null,"place":null,"contributors":null,"retweet_count":3,"entities":{"hashtags":[],"urls":[{"url":"http:\/\/t.co\/8WgzKZH3","expanded_url":"http:\/\/careers.simple.com\/apply\/LKW4tQ\/Operations-Engineer.html","display_url":"careers.simple.com\/apply\/LKW4tQ\/O\u2026","indices":[87,107]}],"user_mentions":[{"screen_name":"Simplify","name":"Simple","id":71165241,"id_str":"71165241","indices":[63,72]}]},"favorited":false,"retweeted":false,"possibly_sensitive":false},"retweet_count":3,"entities":{"hashtags":[],"urls":[{"url":"http:\/\/t.co\/8WgzKZH3","expanded_url":"http:\/\/careers.simple.com\/apply\/LKW4tQ\/Operations-Engineer.html","display_url":"careers.simple.com\/apply\/LKW4tQ\/O\u2026","indices":[100,120]}],"user_mentions":[{"screen_name":"wcmaier","name":"Will Maier","id":11125102,"id_str":"11125102","indices":[3,11]},{"screen_name":"Simplify","name":"Simple","id":71165241,"id_str":"71165241","indices":[76,85]}]},"favorited":false,"retweeted":false,"possibly_sensitive":false}
12 | {"created_at":"Fri Sep 07 16:05:38 +0000 2012","id":244104146997870594,"id_str":"244104146997870594","text":"We just announced Mosaic, what we've been working on since the Yobongo acquisition. My personal post, http:\/\/t.co\/ELOyIRZU @heymosaic","source":"web","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":1882641,"id_str":"1882641","name":"Caleb Elston","screen_name":"calebelston","location":"San Francisco","description":"Co-founder & CEO of Yobongo. Dubious of people who claim to be experts. Formerly VP Products at Justin.tv. Advisor to Simpler.","url":"http:\/\/www.calebelston.com","entities":{"url":{"urls":[{"url":"http:\/\/www.calebelston.com","expanded_url":null,"indices":[0,26]}]},"description":{"urls":[]}},"protected":false,"followers_count":1960,"friends_count":151,"listed_count":136,"created_at":"Thu Mar 22 14:34:22 +0000 2007","favourites_count":815,"utc_offset":-28800,"time_zone":"Pacific Time (US & Canada)","geo_enabled":true,"verified":false,"statuses_count":7068,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"666666","profile_background_image_url":"http:\/\/a0.twimg.com\/profile_background_images\/322151965\/ngb.gif","profile_background_image_url_https":"https:\/\/si0.twimg.com\/profile_background_images\/322151965\/ngb.gif","profile_background_tile":false,"profile_image_url":"http:\/\/a0.twimg.com\/profile_images\/2584558450\/elyaf9epw0kcnh9gxglp_normal.jpeg","profile_image_url_https":"https:\/\/si0.twimg.com\/profile_images\/2584558450\/elyaf9epw0kcnh9gxglp_normal.jpeg","profile_link_color":"0099CC","profile_sidebar_border_color":"E3E3E3","profile_sidebar_fill_color":"FFFFFF","profile_text_color":"292E38","profile_use_background_image":false,"show_all_inline_media":true,"default_profile":false,"default_profile_image":false,"following":true,"follow_request_sent":null,"notifications":null},"geo":null,"coordinates":null,"place":null,"contributors":null,"retweet_count":4,"entities":{"hashtags":[],"urls":[{"url":"http:\/\/t.co\/ELOyIRZU","expanded_url":"http:\/\/calebelston.com\/2012\/09\/07\/meet-mosaic\/","display_url":"calebelston.com\/2012\/09\/07\/mee\u2026","indices":[102,122]}],"user_mentions":[{"screen_name":"heymosaic","name":"Mosaic","id":772256556,"id_str":"772256556","indices":[123,133]}]},"favorited":false,"retweeted":false,"possibly_sensitive":false}
13 | {"created_at":"Fri Sep 07 16:01:18 +0000 2012","id":244103057175113729,"id_str":"244103057175113729","text":"Donate $10 or more --> get your favorite car magnet: http:\/\/t.co\/NfRhl2s2 #Obama2012","source":"web","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":813286,"id_str":"813286","name":"Barack Obama","screen_name":"BarackObama","location":"Washington, DC","description":"This account is run by #Obama2012 campaign staff. Tweets from the President are signed -bo.","url":"http:\/\/www.barackobama.com","entities":{"url":{"urls":[{"url":"http:\/\/www.barackobama.com","expanded_url":null,"indices":[0,26]}]},"description":{"urls":[]}},"protected":false,"followers_count":19449227,"friends_count":673288,"listed_count":175179,"created_at":"Mon Mar 05 22:08:25 +0000 2007","favourites_count":0,"utc_offset":-18000,"time_zone":"Eastern Time (US & Canada)","geo_enabled":false,"verified":true,"statuses_count":5968,"lang":"en","contributors_enabled":true,"is_translator":false,"profile_background_color":"77B0DC","profile_background_image_url":"http:\/\/a0.twimg.com\/profile_background_images\/584034019\/tkwyaf768hs9bylnus1k.jpeg","profile_background_image_url_https":"https:\/\/si0.twimg.com\/profile_background_images\/584034019\/tkwyaf768hs9bylnus1k.jpeg","profile_background_tile":false,"profile_image_url":"http:\/\/a0.twimg.com\/profile_images\/2325704772\/wrrmef61i6jl91kwkmzq_normal.png","profile_image_url_https":"https:\/\/si0.twimg.com\/profile_images\/2325704772\/wrrmef61i6jl91kwkmzq_normal.png","profile_link_color":"2574AD","profile_sidebar_border_color":"C2E0F6","profile_sidebar_fill_color":"C2E0F6","profile_text_color":"333333","profile_use_background_image":true,"show_all_inline_media":false,"default_profile":false,"default_profile_image":false,"following":true,"follow_request_sent":null,"notifications":null},"geo":null,"coordinates":null,"place":null,"contributors":null,"retweet_count":147,"entities":{"hashtags":[{"text":"Obama2012","indices":[77,87]}],"urls":[{"url":"http:\/\/t.co\/NfRhl2s2","expanded_url":"http:\/\/OFA.BO\/eWNq2T","display_url":"OFA.BO\/eWNq2T","indices":[56,76]}],"user_mentions":[]},"favorited":false,"retweeted":false,"possibly_sensitive":false}
14 | {"created_at":"Fri Sep 07 16:00:25 +0000 2012","id":244102834398851073,"id_str":"244102834398851073","text":"RT @tenderlove: If corporations are people, can we use them to drive in the carpool lane?","source":"\u003ca href=\"http:\/\/sites.google.com\/site\/yorufukurou\/\" rel=\"nofollow\"\u003eYoruFukurou\u003c\/a\u003e","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":20941662,"id_str":"20941662","name":"James Edward Gray II","screen_name":"JEG2","location":"Edmond, OK","description":"Rubyist, Husband, Father, Atheist, Oklahoman, and all around weird guy.","url":"http:\/\/blog.grayproductions.net","entities":{"url":{"urls":[{"url":"http:\/\/blog.grayproductions.net","expanded_url":null,"indices":[0,31]}]},"description":{"urls":[]}},"protected":false,"followers_count":4206,"friends_count":174,"listed_count":389,"created_at":"Sun Feb 15 22:05:54 +0000 2009","favourites_count":0,"utc_offset":-21600,"time_zone":"Central Time (US & Canada)","geo_enabled":true,"verified":false,"statuses_count":11780,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"EBEBEB","profile_background_image_url":"http:\/\/a0.twimg.com\/images\/themes\/theme7\/bg.gif","profile_background_image_url_https":"https:\/\/si0.twimg.com\/images\/themes\/theme7\/bg.gif","profile_background_tile":false,"profile_image_url":"http:\/\/a0.twimg.com\/profile_images\/2311650093\/fkgorpzafxmsafxpf6wi_normal.png","profile_image_url_https":"https:\/\/si0.twimg.com\/profile_images\/2311650093\/fkgorpzafxmsafxpf6wi_normal.png","profile_link_color":"990000","profile_sidebar_border_color":"DFDFDF","profile_sidebar_fill_color":"F3F3F3","profile_text_color":"333333","profile_use_background_image":true,"show_all_inline_media":true,"default_profile":false,"default_profile_image":false,"following":true,"follow_request_sent":null,"notifications":null},"geo":null,"coordinates":null,"place":null,"contributors":null,"retweeted_status":{"created_at":"Fri Sep 07 15:57:28 +0000 2012","id":244102091730210816,"id_str":"244102091730210816","text":"If corporations are people, can we use them to drive in the carpool lane?","source":"\u003ca href=\"http:\/\/www.echofon.com\/\" rel=\"nofollow\"\u003eEchofon\u003c\/a\u003e","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":14761655,"id_str":"14761655","name":"Aaron Patterson","screen_name":"tenderlove","location":"Seattle, WA","description":"\u3072\u3052\u306e\u5c71\u7537\u3002 When I'm not trimming my beard, I'm hanging out with my lady, @ebiltwin.","url":"http:\/\/tenderlovemaking.com","entities":{"url":{"urls":[{"url":"http:\/\/tenderlovemaking.com","expanded_url":null,"indices":[0,27]}]},"description":{"urls":[]}},"protected":false,"followers_count":10869,"friends_count":365,"listed_count":862,"created_at":"Tue May 13 17:25:31 +0000 2008","favourites_count":275,"utc_offset":-28800,"time_zone":"Pacific Time (US & Canada)","geo_enabled":true,"verified":false,"statuses_count":14623,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"C0DEED","profile_background_image_url":"http:\/\/a0.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_image_url_https":"https:\/\/si0.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_tile":false,"profile_image_url":"http:\/\/a0.twimg.com\/profile_images\/1261953917\/headshot_normal.png","profile_image_url_https":"https:\/\/si0.twimg.com\/profile_images\/1261953917\/headshot_normal.png","profile_link_color":"0084B4","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"show_all_inline_media":false,"default_profile":true,"default_profile_image":false,"following":null,"follow_request_sent":null,"notifications":null},"geo":null,"coordinates":null,"place":null,"contributors":null,"retweet_count":10,"entities":{"hashtags":[],"urls":[],"user_mentions":[]},"favorited":false,"retweeted":false},"retweet_count":10,"entities":{"hashtags":[],"urls":[],"user_mentions":[{"screen_name":"tenderlove","name":"Aaron Patterson","id":14761655,"id_str":"14761655","indices":[3,14]}]},"favorited":false,"retweeted":false}
15 | {"created_at":"Fri Sep 07 16:00:03 +0000 2012","id":244102741125890048,"id_str":"244102741125890048","text":"LDN\u2014Obama's nomination; Putin woos APEC; Bombs hit Damascus; Quakes shake China; Canada cuts Iran ties; weekend read: http:\/\/t.co\/OFs6dVW4","source":"web","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":632391565,"id_str":"632391565","name":"Evening Edition","screen_name":"eveningedition","location":"","description":"The perfect commute-sized way to catch up on the day\u2019s news after a long day at work. Brought to you by @MuleDesign.","url":"http:\/\/evening-edition.com","entities":{"url":{"urls":[{"url":"http:\/\/evening-edition.com","expanded_url":null,"indices":[0,26]}]},"description":{"urls":[]}},"protected":false,"followers_count":3357,"friends_count":3,"listed_count":115,"created_at":"Tue Jul 10 23:02:44 +0000 2012","favourites_count":19,"utc_offset":null,"time_zone":null,"geo_enabled":false,"verified":false,"statuses_count":76,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"FFFFFF","profile_background_image_url":"http:\/\/a0.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_image_url_https":"https:\/\/si0.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_tile":false,"profile_image_url":"http:\/\/a0.twimg.com\/profile_images\/2406639576\/q8cnprnmdv0z0gt6wtda_normal.png","profile_image_url_https":"https:\/\/si0.twimg.com\/profile_images\/2406639576\/q8cnprnmdv0z0gt6wtda_normal.png","profile_link_color":"CC3333","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":false,"show_all_inline_media":false,"default_profile":false,"default_profile_image":false,"following":true,"follow_request_sent":null,"notifications":null},"geo":null,"coordinates":null,"place":null,"contributors":null,"retweet_count":3,"entities":{"hashtags":[],"urls":[{"url":"http:\/\/t.co\/OFs6dVW4","expanded_url":"http:\/\/evening-edition.com","display_url":"evening-edition.com","indices":[118,138]}],"user_mentions":[]},"favorited":false,"retweeted":false,"possibly_sensitive":false}
16 | {"created_at":"Fri Sep 07 16:00:00 +0000 2012","id":244102729860009984,"id_str":"244102729860009984","text":"RT @ggreenwald: Democrats parade Osama bin Laden's corpse as their proudest achievement: why this goulish jingoism is so warped http:\/\/t ...","source":"\u003ca href=\"http:\/\/www.echofon.com\/\" rel=\"nofollow\"\u003eEchofon\u003c\/a\u003e","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":14561327,"id_str":"14561327","name":"DHH","screen_name":"dhh","location":"Chicago, USA","description":"Creator of Ruby on Rails, Partner at 37signals, Co-author of NYT Best-Seller Rework, and racing driver in ALMS.","url":"http:\/\/david.heinemeierhansson.com","entities":{"url":{"urls":[{"url":"http:\/\/david.heinemeierhansson.com","expanded_url":null,"indices":[0,34]}]},"description":{"urls":[]}},"protected":false,"followers_count":63074,"friends_count":140,"listed_count":4874,"created_at":"Sun Apr 27 20:19:25 +0000 2008","favourites_count":5,"utc_offset":-21600,"time_zone":"Central Time (US & Canada)","geo_enabled":true,"verified":true,"statuses_count":8710,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"C0DEED","profile_background_image_url":"http:\/\/a0.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_image_url_https":"https:\/\/si0.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_tile":false,"profile_image_url":"http:\/\/a0.twimg.com\/profile_images\/2556368541\/alng5gtlmjhrdlr3qxqv_normal.jpeg","profile_image_url_https":"https:\/\/si0.twimg.com\/profile_images\/2556368541\/alng5gtlmjhrdlr3qxqv_normal.jpeg","profile_link_color":"0084B4","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"show_all_inline_media":true,"default_profile":true,"default_profile_image":false,"following":true,"follow_request_sent":null,"notifications":null},"geo":null,"coordinates":null,"place":null,"contributors":null,"retweeted_status":{"created_at":"Fri Sep 07 15:23:23 +0000 2012","id":244093513216696321,"id_str":"244093513216696321","text":"Democrats parade Osama bin Laden's corpse as their proudest achievement: why this goulish jingoism is so warped http:\/\/t.co\/kood278s","source":"web","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":16076032,"id_str":"16076032","name":"Glenn Greenwald","screen_name":"ggreenwald","location":"","description":"Columnist & blogger for the Guardian (http:\/\/is.gd\/WWjIKY) - author, With Liberty and Justice for Some - dog\/animal fanatic ","url":"http:\/\/is.gd\/WWjIKY","entities":{"url":{"urls":[{"url":"http:\/\/is.gd\/WWjIKY","expanded_url":null,"indices":[0,19]}]},"description":{"urls":[]}},"protected":false,"followers_count":93616,"friends_count":610,"listed_count":5680,"created_at":"Mon Sep 01 03:13:32 +0000 2008","favourites_count":22,"utc_offset":-16200,"time_zone":"Caracas","geo_enabled":false,"verified":true,"statuses_count":22441,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"C0DEED","profile_background_image_url":"http:\/\/a0.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_image_url_https":"https:\/\/si0.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_tile":false,"profile_image_url":"http:\/\/a0.twimg.com\/profile_images\/2182259529\/glenn_normal.jpg","profile_image_url_https":"https:\/\/si0.twimg.com\/profile_images\/2182259529\/glenn_normal.jpg","profile_link_color":"0084B4","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"show_all_inline_media":false,"default_profile":true,"default_profile_image":false,"following":null,"follow_request_sent":null,"notifications":null},"geo":null,"coordinates":null,"place":null,"contributors":null,"retweet_count":95,"entities":{"hashtags":[],"urls":[{"url":"http:\/\/t.co\/kood278s","expanded_url":"http:\/\/is.gd\/6rrjXd","display_url":"is.gd\/6rrjXd","indices":[112,132]}],"user_mentions":[]},"favorited":false,"retweeted":false,"possibly_sensitive":false},"retweet_count":95,"entities":{"hashtags":[],"urls":[],"user_mentions":[{"screen_name":"ggreenwald","name":"Glenn Greenwald","id":16076032,"id_str":"16076032","indices":[3,14]}]},"favorited":false,"retweeted":false}
17 | {"created_at":"Fri Sep 07 15:59:03 +0000 2012","id":244102490646278146,"id_str":"244102490646278146","text":"The story of Mars Curiosity's gears, made by a factory in Rockford, IL: http:\/\/t.co\/MwCRsHQg","source":"\u003ca href=\"http:\/\/itunes.apple.com\/us\/app\/twitter\/id409789998?mt=12\" rel=\"nofollow\"\u003eTwitter for Mac\u003c\/a\u003e","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":14372143,"id_str":"14372143","name":"Jason Fried","screen_name":"jasonfried","location":"Chicago, IL","description":"Founder of 37signals. Co-author of REWORK. Credo: It's simple until you make it complicated.","url":"http:\/\/www.37signals.com","entities":{"url":{"urls":[{"url":"http:\/\/www.37signals.com","expanded_url":null,"indices":[0,24]}]},"description":{"urls":[]}},"protected":false,"followers_count":90623,"friends_count":94,"listed_count":6724,"created_at":"Sun Apr 13 01:31:17 +0000 2008","favourites_count":502,"utc_offset":-21600,"time_zone":"Central Time (US & Canada)","geo_enabled":false,"verified":true,"statuses_count":11501,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"000000","profile_background_image_url":"http:\/\/a0.twimg.com\/profile_background_images\/157820538\/37sicon1.png","profile_background_image_url_https":"https:\/\/si0.twimg.com\/profile_background_images\/157820538\/37sicon1.png","profile_background_tile":false,"profile_image_url":"http:\/\/a0.twimg.com\/profile_images\/585991126\/jasonfried-avatar_normal.jpg","profile_image_url_https":"https:\/\/si0.twimg.com\/profile_images\/585991126\/jasonfried-avatar_normal.jpg","profile_link_color":"0099CC","profile_sidebar_border_color":"FFF8AD","profile_sidebar_fill_color":"F6FFD1","profile_text_color":"000000","profile_use_background_image":true,"show_all_inline_media":true,"default_profile":false,"default_profile_image":false,"following":true,"follow_request_sent":null,"notifications":null},"geo":null,"coordinates":null,"place":null,"contributors":null,"retweet_count":4,"entities":{"hashtags":[],"urls":[{"url":"http:\/\/t.co\/MwCRsHQg","expanded_url":"http:\/\/kottke.org\/12\/09\/the-story-of-mars-curiositys-gears","display_url":"kottke.org\/12\/09\/the-stor\u2026","indices":[72,92]}],"user_mentions":[]},"favorited":false,"retweeted":false,"possibly_sensitive":false}
18 | {"created_at":"Fri Sep 07 15:57:56 +0000 2012","id":244102209942458368,"id_str":"244102209942458368","text":"@episod @twitterapi now https:\/\/t.co\/I17jUTu2 and https:\/\/t.co\/deDu4Hgw seem to be missing \"1.1\" from the URL.","source":"\u003ca href=\"http:\/\/itunes.apple.com\/us\/app\/twitter\/id409789998?mt=12\" rel=\"nofollow\"\u003eTwitter for Mac\u003c\/a\u003e","truncated":false,"in_reply_to_status_id":244100786940964865,"in_reply_to_status_id_str":"244100786940964865","in_reply_to_user_id":819797,"in_reply_to_user_id_str":"819797","in_reply_to_screen_name":"episod","user":{"id":7505382,"id_str":"7505382","name":"Erik Michaels-Ober","screen_name":"sferik","location":"San Francisco","description":"Vagabond.","url":"https:\/\/github.com\/sferik","entities":{"url":{"urls":[{"url":"https:\/\/github.com\/sferik","expanded_url":null,"indices":[0,25]}]},"description":{"urls":[]}},"protected":false,"followers_count":2383,"friends_count":210,"listed_count":126,"created_at":"Mon Jul 16 12:59:01 +0000 2007","favourites_count":4157,"utc_offset":-28800,"time_zone":"Pacific Time (US & Canada)","geo_enabled":true,"verified":false,"statuses_count":8342,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"000000","profile_background_image_url":"http:\/\/a0.twimg.com\/profile_background_images\/643217856\/we_concept_bg2.png","profile_background_image_url_https":"https:\/\/si0.twimg.com\/profile_background_images\/643217856\/we_concept_bg2.png","profile_background_tile":false,"profile_image_url":"http:\/\/a0.twimg.com\/profile_images\/1759857427\/image1326743606_normal.png","profile_image_url_https":"https:\/\/si0.twimg.com\/profile_images\/1759857427\/image1326743606_normal.png","profile_link_color":"0084B4","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"show_all_inline_media":true,"default_profile":false,"default_profile_image":false,"following":true,"follow_request_sent":null,"notifications":null},"geo":null,"coordinates":null,"place":null,"contributors":null,"retweet_count":0,"entities":{"hashtags":[],"urls":[{"url":"https:\/\/t.co\/I17jUTu2","expanded_url":"https:\/\/dev.twitter.com\/docs\/api\/post\/direct_messages\/destroy","display_url":"dev.twitter.com\/docs\/api\/post\/\u2026","indices":[24,45]},{"url":"https:\/\/t.co\/deDu4Hgw","expanded_url":"https:\/\/dev.twitter.com\/docs\/api\/post\/direct_messages\/new","display_url":"dev.twitter.com\/docs\/api\/post\/\u2026","indices":[50,71]}],"user_mentions":[{"screen_name":"episod","name":"Taylor Singletary","id":819797,"id_str":"819797","indices":[0,7]},{"screen_name":"twitterapi","name":"Twitter API","id":6253282,"id_str":"6253282","indices":[8,19]}]},"favorited":false,"retweeted":false,"possibly_sensitive":false}
19 | {"created_at":"Fri Sep 07 15:50:47 +0000 2012","id":244100411563339777,"id_str":"244100411563339777","text":"@episod @twitterapi Did you catch https:\/\/t.co\/VHsQvZT0 as well?","source":"\u003ca href=\"http:\/\/itunes.apple.com\/us\/app\/twitter\/id409789998?mt=12\" rel=\"nofollow\"\u003eTwitter for Mac\u003c\/a\u003e","truncated":false,"in_reply_to_status_id":244097234432565248,"in_reply_to_status_id_str":"244097234432565248","in_reply_to_user_id":819797,"in_reply_to_user_id_str":"819797","in_reply_to_screen_name":"episod","user":{"id":7505382,"id_str":"7505382","name":"Erik Michaels-Ober","screen_name":"sferik","location":"San Francisco","description":"Vagabond.","url":"https:\/\/github.com\/sferik","entities":{"url":{"urls":[{"url":"https:\/\/github.com\/sferik","expanded_url":null,"indices":[0,25]}]},"description":{"urls":[]}},"protected":false,"followers_count":2383,"friends_count":210,"listed_count":126,"created_at":"Mon Jul 16 12:59:01 +0000 2007","favourites_count":4157,"utc_offset":-28800,"time_zone":"Pacific Time (US & Canada)","geo_enabled":true,"verified":false,"statuses_count":8342,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"000000","profile_background_image_url":"http:\/\/a0.twimg.com\/profile_background_images\/643217856\/we_concept_bg2.png","profile_background_image_url_https":"https:\/\/si0.twimg.com\/profile_background_images\/643217856\/we_concept_bg2.png","profile_background_tile":false,"profile_image_url":"http:\/\/a0.twimg.com\/profile_images\/1759857427\/image1326743606_normal.png","profile_image_url_https":"https:\/\/si0.twimg.com\/profile_images\/1759857427\/image1326743606_normal.png","profile_link_color":"0084B4","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"show_all_inline_media":true,"default_profile":false,"default_profile_image":false,"following":true,"follow_request_sent":null,"notifications":null},"geo":null,"coordinates":null,"place":null,"contributors":null,"retweet_count":0,"entities":{"hashtags":[],"urls":[{"url":"https:\/\/t.co\/VHsQvZT0","expanded_url":"https:\/\/twitter.com\/sferik\/status\/243988000076337152","display_url":"twitter.com\/sferik\/status\/\u2026","indices":[34,55]}],"user_mentions":[{"screen_name":"episod","name":"Taylor Singletary","id":819797,"id_str":"819797","indices":[0,7]},{"screen_name":"twitterapi","name":"Twitter API","id":6253282,"id_str":"6253282","indices":[8,19]}]},"favorited":false,"retweeted":false,"possibly_sensitive":false}
20 | {"created_at":"Fri Sep 07 15:47:01 +0000 2012","id":244099460672679938,"id_str":"244099460672679938","text":"Gentlemen, you can't fight in here! This is the war room! http:\/\/t.co\/kMxMYyqF","source":"\u003ca href=\"http:\/\/instagr.am\" rel=\"nofollow\"\u003eInstagram\u003c\/a\u003e","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":2897431,"id_str":"2897431","name":"Dave Wiskus ","screen_name":"dwiskus","location":"Denver \/ Amsterdam","description":"I draw pictures of software for money.","url":"http:\/\/betterelevation.com\/","entities":{"url":{"urls":[{"url":"http:\/\/betterelevation.com\/","expanded_url":null,"indices":[0,27]}]},"description":{"urls":[]}},"protected":false,"followers_count":2367,"friends_count":271,"listed_count":187,"created_at":"Thu Mar 29 21:37:02 +0000 2007","favourites_count":3314,"utc_offset":-25200,"time_zone":"Mountain Time (US & Canada)","geo_enabled":false,"verified":false,"statuses_count":12827,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"C6E2EE","profile_background_image_url":"http:\/\/a0.twimg.com\/images\/themes\/theme2\/bg.gif","profile_background_image_url_https":"https:\/\/si0.twimg.com\/images\/themes\/theme2\/bg.gif","profile_background_tile":false,"profile_image_url":"http:\/\/a0.twimg.com\/profile_images\/1514640834\/dwiskus-avatar-2011_normal.png","profile_image_url_https":"https:\/\/si0.twimg.com\/profile_images\/1514640834\/dwiskus-avatar-2011_normal.png","profile_link_color":"1F98C7","profile_sidebar_border_color":"C6E2EE","profile_sidebar_fill_color":"DAECF4","profile_text_color":"663B12","profile_use_background_image":true,"show_all_inline_media":false,"default_profile":false,"default_profile_image":false,"following":true,"follow_request_sent":null,"notifications":null},"geo":null,"coordinates":null,"place":null,"contributors":null,"retweet_count":0,"entities":{"hashtags":[],"urls":[{"url":"http:\/\/t.co\/kMxMYyqF","expanded_url":"http:\/\/instagr.am\/p\/PR5cDLzFz5\/","display_url":"instagr.am\/p\/PR5cDLzFz5\/","indices":[58,78]}],"user_mentions":[]},"favorited":false,"retweeted":false,"possibly_sensitive":false}
21 |
--------------------------------------------------------------------------------