├── .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 Version](http://img.shields.io/gem/v/tweetstream.svg)][gem] 4 | [![Build Status](http://img.shields.io/travis/tweetstream/tweetstream.svg)][travis] 5 | [![Dependency Status](http://img.shields.io/gemnasium/tweetstream/tweetstream.svg)][gemnasium] 6 | [![Code Climate](http://img.shields.io/codeclimate/github/tweetstream/tweetstream.svg)][codeclimate] 7 | [![Coverage Status](http://img.shields.io/coveralls/tweetstream/tweetstream.svg)][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 | --------------------------------------------------------------------------------