├── Gemfile ├── spec ├── fixtures │ ├── twitter │ │ └── statuses │ │ │ └── update.json │ ├── config │ │ └── test.yml │ ├── github │ │ ├── repos │ │ │ ├── languages │ │ │ │ ├── basho_riak │ │ │ │ ├── dahlbyk_FSRazor │ │ │ │ ├── tomwaddington_suggestedshare │ │ │ │ └── ginatrapani_ThinkUp │ │ │ ├── dahlbyk_FSRazor │ │ │ ├── basho_riak │ │ │ ├── tomwaddington_suggestedshare │ │ │ └── ginatrapani_ThinkUp │ │ └── users │ │ │ ├── ginatrapani │ │ │ ├── tomwaddington │ │ │ ├── basho │ │ │ └── dahlbyk │ ├── bitly │ │ └── mockdown.json │ ├── topsy │ │ ├── trackbacks │ │ │ ├── ginatrapani_ThinkUp_page2 │ │ │ ├── basho_riak │ │ │ ├── ginatrapani_ThinkUp_page1 │ │ │ └── ginatrapani_ThinkUp │ │ └── search │ │ │ ├── site_github_single │ │ │ ├── site_stackoverflow_single │ │ │ ├── site_github_nonproject │ │ │ ├── site_github_page2 │ │ │ ├── site_github_page1 │ │ │ ├── site_github_later │ │ │ ├── site_github │ │ │ └── site_stackoverflow │ └── stackoverflow │ │ └── 4663725 ├── spec_helper.rb ├── ext │ ├── hash_spec.rb │ └── date_spec.rb ├── loader_spec.rb ├── config_spec.rb ├── twitter │ ├── tweet_notifier_spec.rb │ ├── github_trackback_loader_spec.rb │ └── trackback_loader_spec.rb ├── notifier_spec.rb └── registry_spec.rb ├── .gitignore ├── .bundle └── config ├── lib ├── grapevine │ ├── version.rb │ ├── setup.rb │ ├── twitter.rb │ ├── ext │ │ ├── Hash.rb │ │ └── Date.rb │ ├── model │ │ ├── topic_tag.rb │ │ ├── tag.rb │ │ ├── message.rb │ │ ├── notification.rb │ │ └── topic.rb │ ├── model.rb │ ├── loader.rb │ ├── config.rb │ ├── registry.rb │ ├── notifier.rb │ └── twitter │ │ ├── github_trackback_loader.rb │ │ ├── tweet_notifier.rb │ │ └── trackback_loader.rb └── grapevine.rb ├── CHANGELOG ├── bin ├── grapevined └── grapevine ├── grapevine.gemspec ├── Rakefile ├── Gemfile.lock └── README.md /Gemfile: -------------------------------------------------------------------------------- 1 | source :gemcutter 2 | gemspec 3 | -------------------------------------------------------------------------------- /spec/fixtures/twitter/statuses/update.json: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.pid 3 | coverage/ 4 | -------------------------------------------------------------------------------- /.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_DISABLE_SHARED_GEMS: "1" 3 | -------------------------------------------------------------------------------- /spec/fixtures/config/test.yml: -------------------------------------------------------------------------------- 1 | bitly_username: foo 2 | bitly_api_key: bar -------------------------------------------------------------------------------- /lib/grapevine/version.rb: -------------------------------------------------------------------------------- 1 | module Grapevine 2 | VERSION = "0.3.11" 3 | end 4 | -------------------------------------------------------------------------------- /lib/grapevine/setup.rb: -------------------------------------------------------------------------------- 1 | # Setup DataMapper 2 | DataMapper.setup(:default, Grapevine::Config.database) 3 | DataMapper.auto_upgrade! 4 | -------------------------------------------------------------------------------- /lib/grapevine/twitter.rb: -------------------------------------------------------------------------------- 1 | require 'grapevine/twitter/trackback_loader' 2 | require 'grapevine/twitter/github_trackback_loader' 3 | 4 | require 'grapevine/twitter/tweet_notifier' 5 | -------------------------------------------------------------------------------- /lib/grapevine/ext/Hash.rb: -------------------------------------------------------------------------------- 1 | class Hash 2 | # Returns a new hash that has all string keys converted to symbols. 3 | def symbolize 4 | hash = {} 5 | 6 | each do |k, v| 7 | hash[k.to_sym] = v 8 | end 9 | 10 | return hash 11 | end 12 | end -------------------------------------------------------------------------------- /lib/grapevine/model/topic_tag.rb: -------------------------------------------------------------------------------- 1 | class TopicTag 2 | include ::DataMapper::Resource 3 | 4 | property :topic_id, Integer, :key => true, :min => 1 5 | property :tag_id, Integer, :key => true, :min => 1 6 | 7 | belongs_to :topic, :key => true 8 | belongs_to :tag, :key => true 9 | end -------------------------------------------------------------------------------- /lib/grapevine/model/tag.rb: -------------------------------------------------------------------------------- 1 | class Tag 2 | include ::DataMapper::Resource 3 | has n, :topic_tags 4 | has n, :topics, :through => :topic_tags 5 | 6 | property :id, Serial 7 | property :type, String, :unique_index => :type_value 8 | property :value, String, :unique_index => :type_value 9 | 10 | validates_presence_of :type 11 | validates_presence_of :value 12 | end -------------------------------------------------------------------------------- /lib/grapevine/model/message.rb: -------------------------------------------------------------------------------- 1 | class Message 2 | include ::DataMapper::Resource 3 | belongs_to :topic 4 | 5 | property :id, Serial 6 | property :source, String 7 | property :source_id, String 8 | property :author, String 9 | property :url, Text 10 | property :content, Text 11 | timestamps :at 12 | 13 | validates_presence_of :topic_id 14 | end 15 | -------------------------------------------------------------------------------- /lib/grapevine/model/notification.rb: -------------------------------------------------------------------------------- 1 | class Notification 2 | include ::DataMapper::Resource 3 | belongs_to :topic 4 | 5 | property :id, Serial 6 | property :source, String, :index => true 7 | property :content, Text, :length => 65535 8 | property :created_at, DateTime 9 | 10 | validates_presence_of :source 11 | validates_presence_of :created_at 12 | end 13 | -------------------------------------------------------------------------------- /lib/grapevine/model.rb: -------------------------------------------------------------------------------- 1 | DataMapper::Model.raise_on_save_failure = true 2 | 3 | DataMapper::Property::String.length(255) 4 | DataMapper::Property::Text.length(65535) 5 | DataMapper::Property::Boolean.allow_nil(false) 6 | 7 | require 'grapevine/model/topic' 8 | require 'grapevine/model/tag' 9 | require 'grapevine/model/topic_tag' 10 | require 'grapevine/model/notification' 11 | require 'grapevine/model/message' 12 | 13 | DataMapper.finalize 14 | -------------------------------------------------------------------------------- /spec/fixtures/github/repos/languages/basho_riak: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Server: nginx/0.7.67 3 | Date: Thu, 06 Jan 2011 20:43:42 GMT 4 | Content-Type: application/x-yaml; charset=utf-8 5 | Connection: keep-alive 6 | Status: 200 OK 7 | X-RateLimit-Limit: 60 8 | ETag: "3731ce5271463356cf631750f05f181e" 9 | X-RateLimit-Remaining: 59 10 | X-Runtime: 10ms 11 | Content-Length: 36 12 | Cache-Control: private, max-age=0, must-revalidate 13 | 14 | --- 15 | languages: 16 | Emacs Lisp: 1254 17 | -------------------------------------------------------------------------------- /spec/fixtures/github/repos/languages/dahlbyk_FSRazor: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Server: nginx/0.7.67 3 | Date: Thu, 06 Jan 2011 21:50:00 GMT 4 | Content-Type: application/x-yaml; charset=utf-8 5 | Connection: keep-alive 6 | Status: 200 OK 7 | X-RateLimit-Limit: 60 8 | ETag: "6f9de271e084b077e62211a21aadd193" 9 | X-RateLimit-Remaining: 58 10 | X-Runtime: 15ms 11 | Content-Length: 28 12 | Cache-Control: private, max-age=0, must-revalidate 13 | 14 | --- 15 | languages: 16 | F#: 4364 17 | -------------------------------------------------------------------------------- /spec/fixtures/github/repos/languages/tomwaddington_suggestedshare: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Server: nginx/0.7.67 3 | Date: Wed, 26 Jan 2011 04:12:30 GMT 4 | Content-Type: application/x-yaml; charset=utf-8 5 | Connection: keep-alive 6 | Status: 200 OK 7 | X-RateLimit-Limit: 60 8 | ETag: "c643bfc5cc16b7bb1926443abfee2dbc" 9 | X-RateLimit-Remaining: 59 10 | X-Runtime: 14ms 11 | Content-Length: 36 12 | Cache-Control: private, max-age=0, must-revalidate 13 | 14 | --- 15 | languages: 16 | JavaScript: 5699 17 | -------------------------------------------------------------------------------- /spec/fixtures/github/repos/languages/ginatrapani_ThinkUp: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Server: nginx/0.7.67 3 | Date: Thu, 06 Jan 2011 21:28:43 GMT 4 | Content-Type: application/x-yaml; charset=utf-8 5 | Connection: keep-alive 6 | Status: 200 OK 7 | X-RateLimit-Limit: 60 8 | ETag: "f59da7b49fab3e2c794087b2a3a30bab" 9 | X-RateLimit-Remaining: 59 10 | X-Runtime: 14ms 11 | Content-Length: 1000 12 | Cache-Control: private, max-age=0, must-revalidate 13 | 14 | --- 15 | languages: 16 | PHP: 800 17 | Python: 300 18 | JavaScript: 100 19 | -------------------------------------------------------------------------------- /lib/grapevine/model/topic.rb: -------------------------------------------------------------------------------- 1 | class Topic 2 | include ::DataMapper::Resource 3 | has n, :messages 4 | has n, :topic_tags 5 | has n, :tags, :through => :topic_tags 6 | has n, :notifications 7 | 8 | property :id, Serial 9 | property :source, String 10 | property :name, String 11 | property :description, Text, :length => 65535 12 | property :url, Text, :length => 65535 13 | property :created_at, DateTime 14 | timestamps :at 15 | 16 | validates_presence_of :source 17 | validates_presence_of :name 18 | end -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | dir = File.dirname(File.expand_path(__FILE__)) 2 | $:.unshift(File.join(dir, '..', 'lib')) 3 | $:.unshift(dir) 4 | 5 | require 'rubygems' 6 | require 'bundler/setup' 7 | require 'grapevine' 8 | 9 | require 'rspec' 10 | require 'mocha' 11 | require 'unindentable' 12 | require 'fakeweb' 13 | require 'timecop' 14 | 15 | # Configure RSpec 16 | Rspec.configure do |c| 17 | c.mock_with :mocha 18 | end 19 | 20 | # Setup DataMapper 21 | DataMapper.setup(:default, 'sqlite::memory:') 22 | 23 | # Turn off log 24 | Grapevine.log.outputters = [] -------------------------------------------------------------------------------- /spec/fixtures/bitly/mockdown.json: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Server: nginx 3 | Date: Thu, 03 Feb 2011 05:11:47 GMT 4 | Content-Type: application/json; charset=utf-8 5 | Connection: keep-alive 6 | MIME-Version: 1.0 7 | Content-Length: 400 8 | 9 | { 10 | "status_code": 200, 11 | "status_txt": "OK", 12 | "data": { 13 | "long_url": "http:\/\/github.com\/benbjohnson\/mockdown", 14 | "url": "http:\/\/bit.ly\/fYL1vu", 15 | "hash": "fYL1vu", 16 | "global_hash": "ciu4hS", 17 | "new_hash": 1 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /spec/ext/hash_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(File.expand_path(__FILE__)), '../spec_helper') 2 | 3 | describe Hash do 4 | ############################################################################## 5 | # Tests 6 | ############################################################################## 7 | 8 | it 'should convert all string keys to symbols' do 9 | hash = {'foo' => 1, 'bar' => 2, :baz => 3} 10 | hash = hash.symbolize 11 | hash.key?('foo').should == false 12 | hash.key?(:foo).should == true 13 | hash.key?('bar').should == false 14 | hash.key?(:bar).should == true 15 | hash.key?('baz').should == false 16 | hash.key?(:baz).should == true 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/fixtures/github/users/ginatrapani: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Server: nginx/0.7.67 3 | Date: Thu, 06 Jan 2011 21:26:13 GMT 4 | Content-Type: application/x-yaml; charset=utf-8 5 | Connection: keep-alive 6 | Status: 200 OK 7 | X-RateLimit-Limit: 60 8 | ETag: "43769610cb148b3a1b046e6bcbfa817c" 9 | X-RateLimit-Remaining: 59 10 | X-Runtime: 18ms 11 | Content-Length: 364 12 | Cache-Control: private, max-age=0, must-revalidate 13 | 14 | --- 15 | user: 16 | gravatar_id: 44230311a3dcd684b6c5f81bf2ec9f60 17 | company: Expert Labs 18 | name: Gina Trapani 19 | created_at: 2009-03-05 17:33:05 -08:00 20 | location: San Diego, CA 21 | public_repo_count: 8 22 | public_gist_count: 5 23 | blog: http://ginatrapani.org 24 | following_count: 29 25 | id: 60632 26 | type: User 27 | permission: 28 | followers_count: 411 29 | login: ginatrapani 30 | email: 31 | -------------------------------------------------------------------------------- /spec/fixtures/github/users/tomwaddington: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Server: nginx/0.7.67 3 | Date: Wed, 26 Jan 2011 01:11:49 GMT 4 | Content-Type: application/x-yaml; charset=utf-8 5 | Connection: keep-alive 6 | Status: 200 OK 7 | X-RateLimit-Limit: 60 8 | ETag: "08ab68041ed725422ed23b6d19ca924c" 9 | X-RateLimit-Remaining: 58 10 | X-Runtime: 20ms 11 | Content-Length: 371 12 | Cache-Control: private, max-age=0, must-revalidate 13 | 14 | --- 15 | user: 16 | gravatar_id: a8d544dadaef2fff6e5afc62c0d6e53e 17 | company: Cut Out + Keep 18 | name: Tom Waddington 19 | created_at: 2009-03-20 15:44:35 -07:00 20 | location: London 21 | public_repo_count: 3 22 | public_gist_count: 1 23 | blog: http://www.tomwaddington.co.uk 24 | following_count: 3 25 | id: 65419 26 | type: User 27 | permission: 28 | followers_count: 0 29 | login: tomwaddington 30 | email: "" 31 | -------------------------------------------------------------------------------- /spec/fixtures/github/users/basho: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Server: nginx/0.7.67 3 | Date: Thu, 06 Jan 2011 20:38:16 GMT 4 | Content-Type: application/x-yaml; charset=utf-8 5 | Connection: keep-alive 6 | Status: 200 OK 7 | X-RateLimit-Limit: 60 8 | ETag: "057d9941e52f7230977089c02f115bcb" 9 | X-RateLimit-Remaining: 58 10 | X-Runtime: 19ms 11 | Content-Length: 387 12 | Cache-Control: private, max-age=0, must-revalidate 13 | 14 | --- 15 | user: 16 | gravatar_id: ce5141b78d2fe237e8bfba49d6aff405 17 | company: 18 | name: Basho Technologies 19 | created_at: "2010-01-04T11:05:19-08:00" 20 | location: Cambridge, MA 21 | public_repo_count: 29 22 | public_gist_count: 0 23 | blog: http://basho.com 24 | following_count: 0 25 | billing_email: dev@basho.com 26 | id: 176293 27 | type: Organization 28 | permission: 29 | followers_count: 61 30 | login: basho 31 | email: 32 | -------------------------------------------------------------------------------- /spec/fixtures/github/users/dahlbyk: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Server: nginx/0.7.67 3 | Date: Thu, 06 Jan 2011 21:29:52 GMT 4 | Content-Type: application/x-yaml; charset=utf-8 5 | Connection: keep-alive 6 | Status: 200 OK 7 | X-RateLimit-Limit: 60 8 | ETag: "8d9415c388c8f470f7a11de65999a01e" 9 | X-RateLimit-Remaining: 58 10 | X-Runtime: 13ms 11 | Content-Length: 379 12 | Cache-Control: private, max-age=0, must-revalidate 13 | 14 | --- 15 | user: 16 | gravatar_id: 43a5676a300a54ffdc903e49a2db9060 17 | company: 18 | name: Keith Dahlby 19 | created_at: 2009-10-01 19:13:10 -07:00 20 | location: Cedar Rapids, IA 21 | public_repo_count: 14 22 | public_gist_count: 5 23 | blog: http://solutionizing.net/ 24 | following_count: 10 25 | id: 133987 26 | type: User 27 | permission: 28 | followers_count: 12 29 | login: dahlbyk 30 | email: keith@solutionizing.net 31 | -------------------------------------------------------------------------------- /lib/grapevine/ext/Date.rb: -------------------------------------------------------------------------------- 1 | class Date 2 | # Parses a human readable period of time into seconds. 3 | # 4 | # @param [String] period the time period to parse. 5 | # 6 | # @return [Fixnum] the amount of time in the period, in seconds. 7 | def self.parse_time_period(period) 8 | return nil if period.nil? || period == '' 9 | 10 | # Extract from format: _y_M_w_d_h_m_s 11 | match, years, months, weeks, days, hours, mins, secs = 12 | *period.match(/^(\d+y)?(\d+M)?(\d+w)?(\d+d)?(\d+h)?(\d+m)?(\d+s)?$/) 13 | 14 | # Return nil if in invalid format 15 | return nil if match.nil? 16 | 17 | # Sum all time parts 18 | num = 0 19 | num += years.to_i * 31_536_000 20 | num += months.to_i * 2_592_000 21 | num += weeks.to_i * 604_800 22 | num += days.to_i * 86_400 23 | num += hours.to_i * 3600 24 | num += mins.to_i * 60 25 | num += secs.to_i 26 | 27 | return num 28 | end 29 | end -------------------------------------------------------------------------------- /spec/fixtures/github/repos/dahlbyk_FSRazor: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Server: nginx/0.7.67 3 | Date: Thu, 06 Jan 2011 21:49:45 GMT 4 | Content-Type: application/x-yaml; charset=utf-8 5 | Connection: keep-alive 6 | Status: 200 OK 7 | X-RateLimit-Limit: 60 8 | ETag: "4211be03988d562b5da1fe265163f05a" 9 | X-RateLimit-Remaining: 59 10 | X-Runtime: 10ms 11 | Content-Length: 544 12 | Cache-Control: private, max-age=0, must-revalidate 13 | 14 | --- 15 | repository: 16 | :has_wiki: true 17 | :pushed_at: !timestamp 18 | at: "2011-01-05 20:51:22.484473 -08:00" 19 | "@marshal_with_utc_coercion": false 20 | :url: https://github.com/dahlbyk/FSRazor 21 | :homepage: "" 22 | :open_issues: 0 23 | :created_at: !timestamp 24 | at: "2011-01-05 20:50:21 -08:00" 25 | "@marshal_with_utc_coercion": false 26 | :fork: false 27 | :watchers: 1 28 | :forks: 1 29 | :has_issues: true 30 | :size: 3864 31 | :private: false 32 | :name: FSRazor 33 | :owner: dahlbyk 34 | :has_downloads: true 35 | :description: F# Parser & Code Generator for Razor View Engine 36 | -------------------------------------------------------------------------------- /spec/loader_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(File.expand_path(__FILE__)), 'spec_helper') 2 | 3 | describe Grapevine::Loader do 4 | ############################################################################## 5 | # Setup 6 | ############################################################################## 7 | 8 | before do 9 | @loader = Grapevine::Loader.new() 10 | end 11 | 12 | after do 13 | @loader = nil 14 | end 15 | 16 | 17 | ############################################################################## 18 | # Tests 19 | ############################################################################## 20 | 21 | ##################################### 22 | # Static methods 23 | ##################################### 24 | 25 | it 'should create a loader' do 26 | loader = Grapevine::Loader.create( 27 | 'twitter-github', 28 | :name => 'foo', 29 | :frequency => '5m' 30 | ) 31 | loader.class.should == Grapevine::Twitter::GitHubTrackbackLoader 32 | loader.name.should == 'foo' 33 | loader.frequency.should == 300 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/fixtures/github/repos/basho_riak: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Server: nginx/0.7.67 3 | Date: Thu, 06 Jan 2011 20:42:20 GMT 4 | Content-Type: application/x-yaml; charset=utf-8 5 | Connection: keep-alive 6 | Status: 200 OK 7 | X-RateLimit-Limit: 60 8 | ETag: "08585727aa1228bb96c53c47c9d28bd8" 9 | X-RateLimit-Remaining: 59 10 | X-Runtime: 10ms 11 | Content-Length: 603 12 | Cache-Control: private, max-age=0, must-revalidate 13 | 14 | --- 15 | repository: 16 | :has_wiki: false 17 | :pushed_at: !timestamp 18 | at: "2011-01-05 18:11:47.833316 -08:00" 19 | "@marshal_with_utc_coercion": false 20 | :url: https://github.com/basho/riak 21 | :homepage: http://wiki.basho.com/display/RIAK 22 | :open_issues: 0 23 | :created_at: !timestamp 24 | at: "2010-04-16 07:51:16 -07:00" 25 | "@marshal_with_utc_coercion": false 26 | :fork: false 27 | :watchers: 268 28 | :forks: 23 29 | :has_issues: false 30 | :organization: basho 31 | :size: 796 32 | :private: false 33 | :name: riak 34 | :owner: basho 35 | :has_downloads: true 36 | :description: Riak is a decentralized datastore from Basho Technologies. 37 | -------------------------------------------------------------------------------- /spec/fixtures/github/repos/tomwaddington_suggestedshare: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Server: nginx/0.7.67 3 | Date: Wed, 26 Jan 2011 04:10:40 GMT 4 | Content-Type: application/x-yaml; charset=utf-8 5 | Connection: keep-alive 6 | Status: 200 OK 7 | X-RateLimit-Limit: 60 8 | ETag: "a05bdc81930794b4b12e69ccae0a7567" 9 | X-RateLimit-Remaining: 59 10 | X-Runtime: 10ms 11 | Content-Length: 617 12 | Cache-Control: private, max-age=0, must-revalidate 13 | 14 | --- 15 | repository: 16 | :has_wiki: true 17 | :url: https://github.com/tomwaddington/suggestedshare 18 | :forks: 1 19 | :homepage: http://www.tomwaddington.co.uk/projects/suggested-share 20 | :open_issues: 0 21 | :created_at: !timestamp 22 | at: "2010-10-21 12:47:39 -07:00" 23 | "@marshal_with_utc_coercion": false 24 | :fork: false 25 | :has_issues: true 26 | :size: 176 27 | :private: false 28 | :has_downloads: true 29 | :name: suggestedshare 30 | :owner: tomwaddington 31 | :pushed_at: !timestamp 32 | at: "2010-12-03 16:17:44 -08:00" 33 | "@marshal_with_utc_coercion": false 34 | :watchers: 2 35 | :description: Share content on Facebook with like-minded friends 36 | -------------------------------------------------------------------------------- /spec/fixtures/github/repos/ginatrapani_ThinkUp: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Server: nginx/0.7.67 3 | Date: Thu, 06 Jan 2011 21:27:22 GMT 4 | Content-Type: application/x-yaml; charset=utf-8 5 | Connection: keep-alive 6 | Status: 200 OK 7 | X-RateLimit-Limit: 60 8 | ETag: "066c8000804c2c21d692665ab89b373f" 9 | X-RateLimit-Remaining: 59 10 | X-Runtime: 14ms 11 | Content-Length: 642 12 | Cache-Control: private, max-age=0, must-revalidate 13 | 14 | --- 15 | repository: 16 | :has_wiki: true 17 | :pushed_at: !timestamp 18 | at: "2010-12-29 14:33:57.407040 -08:00" 19 | "@marshal_with_utc_coercion": false 20 | :url: https://github.com/ginatrapani/ThinkUp 21 | :homepage: http://thinkupapp.com 22 | :open_issues: 117 23 | :created_at: !timestamp 24 | at: "2009-06-06 12:27:42 -07:00" 25 | "@marshal_with_utc_coercion": false 26 | :fork: false 27 | :watchers: 715 28 | :forks: 129 29 | :has_issues: true 30 | :size: 14882 31 | :private: false 32 | :name: ThinkUp 33 | :owner: ginatrapani 34 | :has_downloads: true 35 | :description: ThinkUp captures and filters conversations with your social circle on Twitter, Facebook, and eventually, beyond. 36 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | v0.3.8 2 | * Catching and ignoring Timeout::Error interrupts. 3 | 4 | v0.3.7 5 | * Upgrade Twitter gem v1.3.0. 6 | 7 | v0.3.6 8 | * Fix scoping of Twitter error in notifier. 9 | * Fixed duplicate tag issue... again. 10 | 11 | v0.3.5 12 | * Fix Topsy nil result error. 13 | * Fix Log4r error that was killing server. 14 | * Add STDOUT log to grapevined.output. 15 | 16 | v0.3.4 17 | * Fix Topsy error logging. 18 | * Fix DataMapper Text default bug. 19 | 20 | v0.3.3 21 | * Add grapevined to gemspec under list of executables. 22 | 23 | v0.3.2 24 | * Add error handler for bad request errors from Twitter. Can be caused by 25 | invalid Unicode characters being sent. 26 | 27 | v0.3.1 28 | * Add number of messages to topics on 'show notifier'. 29 | * Fix window bugs. 30 | 31 | v0.3.0 32 | * Add option to use alternative SQL adapters: MySQL, Postgres, SQLite, etc. 33 | * Refactor topic & tag model for improved performance. 34 | 35 | v0.2.1 36 | * Duplicate tracking fixed for trackbacks. Topsy API was not returning Twitter 37 | message identifiers. Matching now occurs on content and username. 38 | 39 | v0.2.0 40 | * Remove 'grapevine' namespace from models. 41 | 42 | v0.1.0 43 | * Initial release. 44 | -------------------------------------------------------------------------------- /bin/grapevined: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | dir = File.dirname(File.expand_path(__FILE__)) 4 | $:.unshift(File.join(dir, '..', 'lib')) 5 | 6 | require 'rubygems' 7 | require 'daemons' 8 | require 'grapevine' 9 | 10 | # Catch CTRL-C and exit cleanly 11 | trap("INT") do 12 | puts 13 | exit() 14 | end 15 | 16 | # Load configuration properties 17 | Grapevine::Config.load_file() 18 | 19 | # Create a registry of all loaders and notifiers 20 | registry = Grapevine::Registry.new() 21 | registry.load_config() 22 | 23 | require 'grapevine/setup' 24 | 25 | Daemons.run_proc('grapevined', :log_output => true) do 26 | Grapevine.log.debug("Loaders: #{registry.loaders.length}") 27 | Grapevine.log.debug("Notifiers: #{registry.notifiers.length}") 28 | 29 | loop do 30 | # Run all registry loaders 31 | registry.loaders.each do |loader| 32 | begin 33 | loader.load() 34 | rescue StandardError => e 35 | Grapevine.log_error("Loader (#{loader.name})", e) 36 | rescue Timeout::Error => e 37 | end 38 | end 39 | 40 | # Run all registry notifiers 41 | registry.notifiers.each do |notifier| 42 | begin 43 | notifier.send() 44 | rescue StandardError => e 45 | Grapevine.log_error("Notifier (#{notifier.name})", e) 46 | rescue Timeout::Error => e 47 | end 48 | end 49 | 50 | sleep(30) 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/config_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(File.expand_path(__FILE__)), 'spec_helper') 2 | 3 | describe Grapevine::Config do 4 | ############################################################################## 5 | # Setup 6 | ############################################################################## 7 | 8 | before do 9 | @config = Grapevine::Config.new 10 | end 11 | 12 | after do 13 | @config = nil 14 | end 15 | 16 | 17 | ############################################################################## 18 | # Tests 19 | ############################################################################## 20 | 21 | ##################################### 22 | # Topics 23 | ##################################### 24 | 25 | it 'should set configuration with a hash' do 26 | hash = {:bitly_username => 'foo', 'bitly_api_key' => 'bar', :no_such_key => ''} 27 | @config.load(hash) 28 | @config.bitly_username.should == 'foo' 29 | @config.bitly_api_key.should == 'bar' 30 | end 31 | 32 | it 'should load configuration from a file' do 33 | filename = File.join(File.dirname(File.expand_path(__FILE__)), 'fixtures', 'config', 'test.yml') 34 | @config.load_file(filename) 35 | @config.bitly_username.should == 'foo' 36 | @config.bitly_api_key.should == 'bar' 37 | end 38 | 39 | it 'should set singleton configuration options' do 40 | Grapevine::Config.bitly_username = 'foo' 41 | Grapevine::Config.bitly_username.should == 'foo' 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/grapevine.rb: -------------------------------------------------------------------------------- 1 | $:.unshift(File.dirname(__FILE__)) 2 | 3 | require 'log4r' 4 | require 'yaml' 5 | require 'open-uri' 6 | require 'dm-core' 7 | require 'dm-migrations' 8 | require 'dm-timestamps' 9 | require 'dm-validations' 10 | require 'topsy' 11 | require 'octopi' 12 | require 'twitter' 13 | require 'bitly' 14 | require 'slowweb' 15 | require 'unindentable' 16 | 17 | require 'grapevine/ext/date' 18 | require 'grapevine/ext/hash' 19 | 20 | require 'grapevine/config' 21 | require 'grapevine/loader' 22 | require 'grapevine/model' 23 | require 'grapevine/notifier' 24 | require 'grapevine/registry' 25 | require 'grapevine/twitter' 26 | require 'grapevine/version' 27 | 28 | # Setup the request governor 29 | SlowWeb.limit('github.com', 60, 60) 30 | 31 | # Use Bit.ly API v3 32 | Bitly.use_api_version_3 33 | 34 | module Grapevine 35 | def self.log 36 | if @log.nil? 37 | @log = Log4r::Logger.new('') 38 | @log.outputters = [ 39 | Log4r::FileOutputter.new( 40 | 'error_log', 41 | :level => Log4r::ERROR, 42 | :filename => File.expand_path('~/grapevine.log') 43 | ), 44 | Log4r::Outputter.stdout 45 | ] 46 | end 47 | 48 | return @log 49 | end 50 | 51 | def self.log_error(message, error=nil) 52 | begin 53 | if error.nil? 54 | log.error "#{message}" 55 | else 56 | log.error "#{message}: #{error.inspect}\n#{error.backtrace}" 57 | end 58 | rescue StandardError => e 59 | puts "Logging error: #{e}" 60 | end 61 | end 62 | end -------------------------------------------------------------------------------- /spec/ext/date_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(File.expand_path(__FILE__)), '../spec_helper') 2 | 3 | describe Date do 4 | ############################################################################## 5 | # Tests 6 | ############################################################################## 7 | 8 | it 'should parse a time period in seconds' do 9 | Date.parse_time_period('200s').should == 200 10 | end 11 | 12 | it 'should parse a time period in minutes' do 13 | Date.parse_time_period('5m').should == 300 14 | end 15 | 16 | it 'should parse a time period in hours' do 17 | Date.parse_time_period('3h').should == 10_800 18 | end 19 | 20 | it 'should parse a time period in days' do 21 | Date.parse_time_period('2d').should == 172_800 22 | end 23 | 24 | it 'should parse a time period in weeks' do 25 | Date.parse_time_period('4w').should == 2_419_200 26 | end 27 | 28 | it 'should parse a time period in months' do 29 | Date.parse_time_period('3M').should == 7_776_000 30 | end 31 | 32 | it 'should parse a time period in years' do 33 | Date.parse_time_period('4y').should == 126_144_000 34 | end 35 | 36 | it 'should parse a compound time period' do 37 | Date.parse_time_period('1y2M3w4d5h6m7s').should == 38_898_367 38 | end 39 | 40 | it 'should return nil when parsing invalid format' do 41 | Date.parse_time_period('foo bar').should be_nil 42 | end 43 | 44 | it 'should return nil when parsing invalid time period' do 45 | Date.parse_time_period('12i').should be_nil 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /grapevine.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | lib = File.expand_path('../lib/', __FILE__) 3 | $:.unshift lib unless $:.include?(lib) 4 | 5 | require 'grapevine/version' 6 | 7 | Gem::Specification.new do |s| 8 | s.name = 'grapevine' 9 | s.version = Grapevine::VERSION 10 | s.platform = Gem::Platform::RUBY 11 | s.authors = ['Ben Johnson'] 12 | s.email = ['benbjohnson@yahoo.com'] 13 | s.homepage = 'http://github.com/benbjohnson/grapevine' 14 | s.summary = 'Message aggregator' 15 | s.executables = ['grapevine', 'grapevined'] 16 | s.default_executable = 'grapevine' 17 | 18 | s.add_dependency('rack', '~> 1.2.0') 19 | s.add_dependency('OptionParser', '~> 0.5.1') 20 | s.add_dependency('daemons', '~> 1.1.0') 21 | s.add_dependency('data_mapper', '~> 1.0.2') 22 | s.add_dependency('topsy', '~> 0.3.6') 23 | s.add_dependency('octopi', '~> 0.4.0') 24 | s.add_dependency('bitly', '~> 0.6.1') 25 | s.add_dependency('twitter', '~> 1.4.1') 26 | s.add_dependency('commander', '~> 4.0.3') 27 | s.add_dependency('terminal-table', '~> 1.4.2') 28 | s.add_dependency('slowweb', '~> 0.1.1') 29 | s.add_dependency('log4r', '~> 1.1.6') 30 | s.add_dependency('unindentable', '~> 0.1.0') 31 | 32 | s.add_development_dependency('rspec', '~> 2.4.0') 33 | s.add_development_dependency('mocha', '~> 0.9.12') 34 | s.add_development_dependency('fakeweb', '~> 1.3.0') 35 | s.add_development_dependency('timecop', '~> 0.3.5') 36 | s.add_development_dependency('rcov', '~> 0.9.9') 37 | s.add_development_dependency('dm-sqlite-adapter', '~> 1.0.2') 38 | 39 | s.test_files = Dir.glob('test/**/*') 40 | s.files = Dir.glob('lib/**/*') + %w(README.md) 41 | s.require_path = 'lib' 42 | end 43 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('lib', File.dirname(__FILE__)) 2 | $:.unshift lib unless $:.include?(lib) 3 | 4 | require 'rubygems' 5 | require 'rake' 6 | require 'rake/rdoctask' 7 | require 'rspec/core/rake_task' 8 | require 'grapevine' 9 | 10 | ############################################################################# 11 | # 12 | # Standard tasks 13 | # 14 | ############################################################################# 15 | 16 | require 'rcov/rcovtask' 17 | Rcov::RcovTask.new do |t| 18 | t.libs << "spec" 19 | t.test_files = FileList['spec/**/*_spec.rb'] 20 | t.rcov_opts = ['--exclude', 'gems\/,spec\/'] 21 | t.verbose = true 22 | end 23 | 24 | Rake::RDocTask.new do |rdoc| 25 | rdoc.rdoc_dir = 'rdoc' 26 | rdoc.title = "Grapevine #{Grapevine::VERSION}" 27 | rdoc.rdoc_files.include('README*') 28 | rdoc.rdoc_files.include('lib/**/*.rb') 29 | end 30 | 31 | task :console do 32 | sh "irb -rubygems -r ./lib/grapevine.rb" 33 | end 34 | 35 | 36 | ############################################################################# 37 | # 38 | # Packaging tasks 39 | # 40 | ############################################################################# 41 | 42 | task :release do 43 | puts "" 44 | print "Are you sure you want to relase Grapevine #{Grapevine::VERSION}? [y/N] " 45 | exit unless STDIN.gets.index(/y/i) == 0 46 | 47 | unless `git branch` =~ /^\* master$/ 48 | puts "You must be on the master branch to release!" 49 | exit! 50 | end 51 | 52 | # Build gem and upload 53 | sh "gem build grapevine.gemspec" 54 | sh "gem push grapevine-#{Grapevine::VERSION}.gem" 55 | sh "rm grapevine-#{Grapevine::VERSION}.gem" 56 | 57 | # Commit 58 | sh "git commit --allow-empty -a -m 'v#{Grapevine::VERSION}'" 59 | sh "git tag v#{Grapevine::VERSION}" 60 | sh "git push origin master" 61 | sh "git push origin v#{Grapevine::VERSION}" 62 | end 63 | -------------------------------------------------------------------------------- /spec/twitter/tweet_notifier_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(File.expand_path(__FILE__)), '..', 'spec_helper') 2 | 3 | describe Grapevine::Notifier do 4 | ############################################################################## 5 | # Setup 6 | ############################################################################## 7 | 8 | before do 9 | FakeWeb.allow_net_connect = false 10 | DataMapper.auto_migrate! 11 | Grapevine::Config.config = nil 12 | Grapevine::Config.load({ 13 | 'bitly_username' => 'foo', 14 | 'bitly_api_key' => 'bar', 15 | }) 16 | create_data() 17 | @notifier = Grapevine::Twitter::TweetNotifier.new() 18 | 19 | @fixtures_dir = File.join(File.dirname(File.expand_path(__FILE__)), '..', 'fixtures') 20 | FakeWeb.register_uri(:get, "http://api.bit.ly/v3/shorten?login=foo&longUrl=http%3A%2F%2Fgithub.com%2Fbenbjohnson%2Fmockdown&apiKey=bar", :response => IO.read("#{@fixtures_dir}/bitly/mockdown.json")) 21 | end 22 | 23 | after do 24 | @notifier = nil 25 | end 26 | 27 | def create_data() 28 | @t0 = create_topic('foo', 'http://github.com/benbjohnson/smeagol') 29 | @t1 = create_topic('bar', 'http://github.com/benbjohnson/mockdown') 30 | 31 | Message.create(:topic => @t0) 32 | Message.create(:topic => @t0) 33 | Message.create(:topic => @t1) 34 | Message.create(:topic => @t1) 35 | Message.create(:topic => @t1) 36 | end 37 | 38 | def create_topic(name, url) 39 | Topic.create(:source => 'twitter-github', :name => 'foo', :url => url, :created_at => Time.now) 40 | end 41 | 42 | 43 | ############################################################################## 44 | # Tests 45 | ############################################################################## 46 | 47 | ##################################### 48 | # Topics 49 | ##################################### 50 | 51 | it 'should send a tweet for the most popular topic' do 52 | #FakeWeb.register_uri(:post, "https://api.twitter.com/1/statuses/update.json", :response => IO.read("#{@fixtures_dir}/twitter/statuses/update.json")) 53 | #@notifier.send() 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /spec/fixtures/topsy/trackbacks/ginatrapani_ThinkUp_page2: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Cache-Control: max-age=5 3 | Content-Type: application/json; charset=utf-8 4 | Expires: Fri, 07 Jan 2011 04:09:55 GMT 5 | Last-Modified: Fri, 07 Jan 2011 04:09:50 GMT 6 | Server: lighttpd/1.4.26 7 | Content-Length: 12607 8 | Date: Fri, 07 Jan 2011 04:09:52 GMT 9 | X-Varnish: 645797005 645796834 10 | Age: 2 11 | Via: 1.1 varnish 12 | X-Served-By: ps163 13 | X-Cache: HIT 14 | X-Cache-Hits: 1 15 | X-RateLimit-Limit: 10000 16 | X-RateLimit-Remaining: 9997 17 | X-RateLimit-Reset: 1294376899 18 | Connection: close 19 | 20 | { 21 | "request": { 22 | "parameters": { 23 | "window": "hour", 24 | "page": "2", 25 | "url": "https://github.com/ginatrapani/ThinkUp/wiki/Installing-ThinkUp-on-Amazon-EC2", 26 | "perpage": "2" 27 | }, 28 | "response_type": "json", 29 | "resource": "trackbacks", 30 | "url": "http://otter.topsy.com/trackbacks.json?page=1&perpage=100&url=https%3A%2F%2Fgithub.com%2Fginatrapani%2FThinkUp%2Fwiki%2FInstalling-ThinkUp-on-Amazon-EC2&window=hour" 31 | }, 32 | "response": { 33 | "page": 2, 34 | "trackback_total": 3, 35 | "total": 3, 36 | "perpage": 2, 37 | "last_offset": 4, 38 | "topsy_trackback_url": "http://topsy.com/trackback?url=https%3A%2F%2Fgithub.com%2Fginatrapani%2FThinkUp%2Fwiki%2FInstalling-ThinkUp-on-Amazon-EC2", 39 | "hidden": 0, 40 | "list": [{ 41 | "permalink_url": "http://twitter.com/derwebarchitekt/status/22935307057889280", 42 | "date": 1294303177, 43 | "content": "Installing ThinkUp on Amazon EC2 http://bit.ly/e12MSr #tutorial", 44 | "date_alpha": "20 hours ago", 45 | "author": { 46 | "name": "Kai Thrun", 47 | "url": "http://twitter.com/derwebarchitekt", 48 | "nick": "derwebarchitekt", 49 | "topsy_author_url": "http://topsy.com/twitter/derwebarchitekt?utm_source=otter", 50 | "photo_url": "http://a2.twimg.com/profile_images/1164652940/kai_thrun_normal.jpg" 51 | }, 52 | "type": "tweet" 53 | }], 54 | "offset": 0 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /spec/fixtures/topsy/search/site_github_single: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Cache-Control: max-age=5 3 | Content-Type: application/json; charset=utf-8 4 | Expires: Sun, 09 Jan 2011 01:12:23 GMT 5 | Last-Modified: Sun, 09 Jan 2011 01:12:18 GMT 6 | Server: lighttpd/1.4.26 7 | Content-Length: 9696 8 | Date: Sun, 09 Jan 2011 01:12:19 GMT 9 | X-Varnish: 655867910 10 | Age: 0 11 | Via: 1.1 varnish 12 | X-Served-By: ps163 13 | X-Cache: MISS 14 | X-RateLimit-Limit: 10000 15 | X-RateLimit-Remaining: 7667 16 | X-RateLimit-Reset: 1294536057 17 | Connection: close 18 | 19 | { 20 | "request": { 21 | "parameters": { 22 | "window": "realtime", 23 | "page": "1", 24 | "q": "github.com", 25 | "type": "cited", 26 | "perpage": "10" 27 | }, 28 | "response_type": "json", 29 | "resource": "search", 30 | "url": "http://otter.topsy.com/search.json?page=1&perpage=10&q=github.com&type=cited&window=realtime" 31 | }, 32 | "response": { 33 | "window": "a", 34 | "page": 1, 35 | "total": 1, 36 | "perpage": 10, 37 | "last_offset": 10, 38 | "hidden": 0, 39 | "list": [{ 40 | "trackback_permalink": "http://twitter.com/coplusk/status/23909517578211328", 41 | "trackback_author_url": "http://twitter.com/coplusk", 42 | "content": "[suggestedshare] http://bit.ly/dEeDxh Tom Waddington - caching", 43 | "trackback_date": 1294535447, 44 | "topsy_author_img": "http://s.twimg.com/a/1292392187/images/default_profile_1_normal.png", 45 | "hits": 1, 46 | "topsy_trackback_url": "http://topsy.com/trackback?url=https%3A%2F%2Fgithub.com%2Ftomwaddington%2Fsuggestedshare%2Fcommit%2F1e4117f001d224cd15039ff030bc39b105f24a13&utm_source=otter", 47 | "firstpost_date": 1294535447, 48 | "url": "https://github.com/tomwaddington/suggestedshare/commit/1e4117f001d224cd15039ff030bc39b105f24a13", 49 | "trackback_author_nick": "coplusk", 50 | "highlight": "[suggestedshare] http://bit.ly/dEeDxh Tom Waddington - caching ", 51 | "topsy_author_url": "http://topsy.com/twitter/coplusk?utm_source=otter", 52 | "mytype": "link", 53 | "score": 0.195851, 54 | "trackback_total": 1, 55 | "title": "suggestedshare] Tom Waddington - caching" 56 | }], 57 | "offset": 0 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /lib/grapevine/loader.rb: -------------------------------------------------------------------------------- 1 | module Grapevine 2 | # This is an abstract base class for all loaders in the system. The loader 3 | # imports messages in from a source and aggregates them into topics. 4 | class Loader 5 | ############################################################################ 6 | # Static Attributes 7 | ############################################################################ 8 | 9 | # Registers a class by type 10 | def self.register(type, clazz) 11 | @classes ||= {} 12 | @classes[type] = clazz 13 | end 14 | 15 | # Creates an instance of a loader by type 16 | # 17 | # @param [String] type the type of loader to create. 18 | # @param [Hash] options a list of options to set on the loader. 19 | # 20 | # @return [Grapevine::Loader] the new instance of a loader. 21 | def self.create(type, options={}) 22 | @classes ||= {} 23 | clazz = @classes[type] 24 | raise "No loader has been registered as: #{type}" if clazz.nil? 25 | loader = clazz.new 26 | 27 | # Set options 28 | options = options.symbolize 29 | 30 | # Required attributes 31 | name = options.delete(:name) 32 | raise "Name required on loader definition" if name.nil? 33 | raise "Frequency required on loader: #{name}" if options[:frequency].nil? 34 | 35 | # Set the loader name 36 | loader.name = name 37 | 38 | # Parse frequency 39 | loader.frequency = Date.parse_time_period(options[:frequency]) 40 | raise "Invalid frequency: #{frequency}" if loader.frequency.nil? 41 | options.delete(:frequency) 42 | 43 | # Set attributes on the loader 44 | options.each do |k, v| 45 | loader.__send__ "#{k.to_str}=", v 46 | end 47 | 48 | return loader 49 | end 50 | 51 | 52 | 53 | ############################################################################ 54 | # Public Attributes 55 | ############################################################################ 56 | 57 | # The name of this loader type. 58 | attr_accessor :name 59 | 60 | # The frequency that this loader should run. 61 | attr_accessor :frequency 62 | 63 | 64 | ############################################################################ 65 | # Public Methods 66 | ############################################################################ 67 | 68 | # Loads a list of messages. 69 | def load() 70 | end 71 | end 72 | end -------------------------------------------------------------------------------- /spec/fixtures/topsy/search/site_stackoverflow_single: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Cache-Control: max-age=5 3 | Content-Type: application/json; charset=utf-8 4 | Expires: Wed, 12 Jan 2011 00:10:16 GMT 5 | Last-Modified: Wed, 12 Jan 2011 00:10:11 GMT 6 | Server: lighttpd/1.4.26 7 | Content-Length: 10230 8 | Date: Wed, 12 Jan 2011 00:10:11 GMT 9 | X-Varnish: 1080030036 10 | Age: 0 11 | Via: 1.1 varnish 12 | X-Served-By: ps391 13 | X-Cache: MISS 14 | X-RateLimit-Limit: 10000 15 | X-RateLimit-Remaining: 9990 16 | X-RateLimit-Reset: 1294794611 17 | Connection: close 18 | 19 | { 20 | "request": { 21 | "parameters": { 22 | "window": "realtime", 23 | "page": "1", 24 | "q": "stackoverflow.com", 25 | "type": "cited", 26 | "perpage": "10" 27 | }, 28 | "response_type": "json", 29 | "resource": "search", 30 | "url": "http://otter.topsy.com/search.json?page=1&perpage=10&q=stackoverflow.com&type=cited&window=realtime" 31 | }, 32 | "response": { 33 | "window": "a", 34 | "page": 1, 35 | "total": 1, 36 | "perpage": 1, 37 | "last_offset": 1, 38 | "hidden": 0, 39 | "list": [{ 40 | "trackback_permalink": "http://twitter.com/iphoneatso/status/24981377287987201", 41 | "trackback_author_url": "http://twitter.com/iphoneatso", 42 | "content": "iPhone - keyboard with OK button to dismiss, with return key accepted in the UITextView http://bit.ly/fo5wTS", 43 | "trackback_date": 1294790999, 44 | "topsy_author_img": "http://a1.twimg.com/a/1292975674/images/default_profile_1_normal.png", 45 | "hits": 1, 46 | "topsy_trackback_url": "http://topsy.com/stackoverflow.com/questions/4663725/iphone-keyboard-with-ok-button-to-dismiss-with-return-key-accepted-in-the-uite?utm_source=otter", 47 | "firstpost_date": 1294788565, 48 | "url": "http://stackoverflow.com/questions/4663725/iphone-keyboard-with-ok-button-to-dismiss-with-return-key-accepted-in-the-uite", 49 | "trackback_author_nick": "iphoneatso", 50 | "highlight": "iPhone - keyboard with OK button to dismiss, with return key accepted in the UITextView http://bit.ly/fo5wTS ", 51 | "topsy_author_url": "http://topsy.com/twitter/iphoneatso?utm_source=otter", 52 | "mytype": "link", 53 | "score": 0.201032, 54 | "trackback_total": 2, 55 | "title": "iPhone - keyboard with OK button to dismiss, with return key accepted in the UITextView - Stack Overflow" 56 | }], 57 | "offset": 0 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /lib/grapevine/config.rb: -------------------------------------------------------------------------------- 1 | module Grapevine 2 | # This class holds configuration settings loaded from the local config file 3 | # or set by the command line. 4 | class Config 5 | ########################################################################## 6 | # Static Methods 7 | ########################################################################## 8 | 9 | def self.method_missing(sym, *args, &block) 10 | self.config.__send__ sym, *args, &block 11 | end 12 | 13 | def self.config 14 | @config ||= self.new 15 | end 16 | 17 | def self.config=(value) 18 | @config = value 19 | end 20 | 21 | 22 | ########################################################################## 23 | # Constructor 24 | ########################################################################## 25 | 26 | def initialize() 27 | @database = "sqlite://#{File.expand_path('~/grapevine.db')}" 28 | end 29 | 30 | 31 | ########################################################################## 32 | # Database 33 | ########################################################################## 34 | 35 | # The database URI. Defaults to using a SQLite database in the user's 36 | # home directory. 37 | attr_accessor :database 38 | 39 | 40 | ########################################################################## 41 | # Bit.ly API 42 | ########################################################################## 43 | 44 | # The username used to login to bit.ly's API. 45 | attr_accessor :bitly_username 46 | 47 | # The API key used to login to bit.ly's API. 48 | attr_accessor :bitly_api_key 49 | 50 | 51 | ########################################################################## 52 | # Twitter API 53 | ########################################################################## 54 | 55 | # The consumer key used to login to Twitter's API. 56 | attr_accessor :twitter_consumer_key 57 | 58 | # The consumer secret used to login to Twitter's API. 59 | attr_accessor :twitter_consumer_secret 60 | 61 | 62 | 63 | ########################################################################## 64 | # Public Methods 65 | ########################################################################## 66 | 67 | # Loads the configuration from a hash. 68 | # 69 | # @param [Hash] hash a hash of configuration settings to set. 70 | def load(hash) 71 | hash.each_pair do |key, value| 72 | mutator_name = "#{key.to_s}=" 73 | self.__send__(mutator_name, value) if methods.include?(mutator_name) 74 | end 75 | end 76 | 77 | # Loads the configuration from a file. 78 | # 79 | # @param [String] filename the YAML configuration file to load. 80 | def load_file(filename='~/grapevine.yml') 81 | filename = File.expand_path(filename) 82 | hash = YAML.load(File.open(filename)) 83 | load(hash) 84 | end 85 | end 86 | end -------------------------------------------------------------------------------- /spec/fixtures/topsy/trackbacks/basho_riak: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Cache-Control: max-age=5 3 | Content-Type: application/json; charset=utf-8 4 | Expires: Sat, 08 Jan 2011 20:34:23 GMT 5 | Last-Modified: Sat, 08 Jan 2011 20:34:18 GMT 6 | Server: lighttpd/1.4.26 7 | Content-Length: 13601 8 | Date: Sat, 08 Jan 2011 20:34:18 GMT 9 | X-Varnish: 1573715573 10 | Age: 0 11 | Via: 1.1 varnish 12 | X-Served-By: ps160 13 | X-Cache: MISS 14 | X-RateLimit-Limit: 10000 15 | X-RateLimit-Remaining: 9999 16 | X-RateLimit-Reset: 1294522458 17 | Connection: close 18 | 19 | { 20 | "request": { 21 | "parameters": { 22 | "window": "hour", 23 | "page": "1", 24 | "url": "https://github.com/basho/riak/raw/riak-0.14.0/releasenotes/riak-0.14.0.txt", 25 | "perpage": "100" 26 | }, 27 | "response_type": "json", 28 | "resource": "trackbacks", 29 | "url": "http://otter.topsy.com/trackbacks.json?page=1&perpage=100&url=https%3A%2F%2Fgithub.com%2Fbasho%2Friak%2Fraw%2Friak-0.14.0%2Freleasenotes%2Friak-0.14.0.txt&window=hour" 30 | }, 31 | "response": { 32 | "page": 1, 33 | "trackback_total": 25, 34 | "total": 25, 35 | "perpage": 100, 36 | "last_offset": 25, 37 | "topsy_trackback_url": "http://topsy.com/trackback?url=https%3A%2F%2Fgithub.com%2Fbasho%2Friak%2Fraw%2Friak-0.14.0%2Freleasenotes%2Friak-0.14.0.txt", 38 | "hidden": 0, 39 | "list": [{ 40 | "permalink_url": "http://twitter.com/mmayo/status/23483779108569088", 41 | "date": 1294433943, 42 | "content": "RT @basho: Riak 0.14 Release is out. Blog post: http://bit.ly/eIOQnK Release: notes: http://bit.ly/fXwjM0 HN Comments: http://bit.ly/foJshE", 43 | "date_alpha": "1 day ago", 44 | "author": { 45 | "name": "Mark Mayo", 46 | "url": "http://twitter.com/mmayo", 47 | "nick": "mmayo", 48 | "topsy_author_url": "http://topsy.com/twitter/mmayo?utm_source=otter", 49 | "photo_url": "http://a0.twimg.com/profile_images/19261432/IMG_0846_normal.jpg", 50 | "influence_level": 8 51 | }, 52 | "type": "tweet" 53 | }, 54 | { 55 | "permalink_url": "http://twitter.com/demotera/status/22988133704404992", 56 | "date": 1294315772, 57 | "content": "RT @basho: Riak 0.14 Release is out. Blog post: http://bit.ly/eIOQnK Release: notes: http://bit.ly/fXwjM0 HN Comments: http://bit.ly/foJshE", 58 | "date_alpha": "2 days ago", 59 | "author": { 60 | "name": "demotera", 61 | "url": "http://twitter.com/demotera", 62 | "nick": "demotera", 63 | "topsy_author_url": "http://topsy.com/twitter/demotera?utm_source=otter", 64 | "photo_url": "http://a2.twimg.com/profile_images/956004814/demotera_graphic_normal.png" 65 | }, 66 | "type": "tweet" 67 | }], 68 | "offset": 0 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /spec/fixtures/topsy/trackbacks/ginatrapani_ThinkUp_page1: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Cache-Control: max-age=5 3 | Content-Type: application/json; charset=utf-8 4 | Expires: Fri, 07 Jan 2011 04:09:55 GMT 5 | Last-Modified: Fri, 07 Jan 2011 04:09:50 GMT 6 | Server: lighttpd/1.4.26 7 | Content-Length: 12607 8 | Date: Fri, 07 Jan 2011 04:09:52 GMT 9 | X-Varnish: 645797005 645796834 10 | Age: 2 11 | Via: 1.1 varnish 12 | X-Served-By: ps163 13 | X-Cache: HIT 14 | X-Cache-Hits: 1 15 | X-RateLimit-Limit: 10000 16 | X-RateLimit-Remaining: 9997 17 | X-RateLimit-Reset: 1294376899 18 | Connection: close 19 | 20 | { 21 | "request": { 22 | "parameters": { 23 | "window": "hour", 24 | "page": "1", 25 | "url": "https://github.com/ginatrapani/ThinkUp/wiki/Installing-ThinkUp-on-Amazon-EC2", 26 | "perpage": "2" 27 | }, 28 | "response_type": "json", 29 | "resource": "trackbacks", 30 | "url": "http://otter.topsy.com/trackbacks.json?page=1&perpage=100&url=https%3A%2F%2Fgithub.com%2Fginatrapani%2FThinkUp%2Fwiki%2FInstalling-ThinkUp-on-Amazon-EC2&window=hour" 31 | }, 32 | "response": { 33 | "page": 1, 34 | "trackback_total": 3, 35 | "total": 3, 36 | "perpage": 2, 37 | "last_offset": 2, 38 | "topsy_trackback_url": "http://topsy.com/trackback?url=https%3A%2F%2Fgithub.com%2Fginatrapani%2FThinkUp%2Fwiki%2FInstalling-ThinkUp-on-Amazon-EC2", 39 | "hidden": 0, 40 | "list": [{ 41 | "permalink_url": "http://twitter.com/vivaceuk/status/23031898938802176", 42 | "date": 1294326207, 43 | "content": "RT @thinkupapp: Get your own ThinkUp instance up and running for free/cheap on Amazon EC2 in 10 minutes. Here's how: http://bit.ly/edHUGZ", 44 | "date_alpha": "13 hours ago", 45 | "author": { 46 | "name": "Matthew Atkins", 47 | "url": "http://twitter.com/vivaceuk", 48 | "nick": "vivaceuk", 49 | "topsy_author_url": "http://topsy.com/twitter/vivaceuk?utm_source=otter", 50 | "photo_url": "http://a3.twimg.com/profile_images/504551651/twitterProfilePhoto_normal.jpg" 51 | }, 52 | "type": "tweet" 53 | }, 54 | { 55 | "permalink_url": "http://twitter.com/pvantees/status/22955482826145792", 56 | "date": 1294307988, 57 | "content": "RT @thinkupapp: Get your own ThinkUp instance up and running for free/cheap on Amazon EC2 in 10 minutes. http://bit.ly/edHUGZ @myen", 58 | "date_alpha": "18 hours ago", 59 | "author": { 60 | "name": "Peter van Teeseling", 61 | "url": "http://twitter.com/pvantees", 62 | "nick": "pvantees", 63 | "topsy_author_url": "http://topsy.com/twitter/pvantees?utm_source=otter", 64 | "photo_url": "http://a0.twimg.com/profile_images/780708510/pvantees-avatar_normal.jpg", 65 | "influence_level": 10 66 | }, 67 | "type": "tweet" 68 | }], 69 | "offset": 0 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /spec/fixtures/topsy/search/site_github_nonproject: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Cache-Control: max-age=5 3 | Content-Type: application/json; charset=utf-8 4 | Expires: Thu, 06 Jan 2011 05:56:02 GMT 5 | Last-Modified: Thu, 06 Jan 2011 05:55:57 GMT 6 | Server: lighttpd/1.4.26 7 | Content-Length: 26627 8 | Date: Thu, 06 Jan 2011 05:55:58 GMT 9 | X-Varnish: 640448297 10 | Age: 0 11 | Via: 1.1 varnish 12 | X-Served-By: ps163 13 | X-Cache: MISS 14 | X-RateLimit-Limit: 10000 15 | X-RateLimit-Remaining: 9940 16 | X-RateLimit-Reset: 1294295688 17 | Connection: close 18 | 19 | { 20 | "request": { 21 | "parameters": { 22 | "window": "h", 23 | "q": "site:github.com", 24 | "type": "cited", 25 | "perpage": "50" 26 | }, 27 | "response_type": "json", 28 | "resource": "search", 29 | "url": "http://otter.topsy.com/search.json?perpage=50&q=site%3Agithub.com&type=cited&window=h1" 30 | }, 31 | "response": { 32 | "window": "h", 33 | "page": 1, 34 | "total": 28, 35 | "perpage": 50, 36 | "last_offset": 28, 37 | "hidden": 0, 38 | "list": [{ 39 | "trackback_permalink": "http://twitter.com/curphey/status/22880523839868929", 40 | "trackback_author_url": "http://twitter.com/curphey", 41 | "content": "Want to track the cost of expensive meetings (in real-time) ? - http://t.co/MWmXXj2", 42 | "trackback_date": 1294290116, 43 | "topsy_author_img": "http://a0.twimg.com/profile_images/1112333640/curphey_normal.jpg", 44 | "hits": 2, 45 | "topsy_trackback_url": "http://topsy.com/tobytripp.github.com/meeting-ticker/?utm_source=otter", 46 | "firstpost_date": 1241540885, 47 | "url": "http://tobytripp.github.com/meeting-ticker/", 48 | "trackback_author_nick": "curphey", 49 | "highlight": "Want to track the cost of expensive meetings (in real-time) ? - http://t.co/MWmXXj2 ", 50 | "topsy_author_url": "http://topsy.com/twitter/curphey?utm_source=otter", 51 | "mytype": "link", 52 | "score": 11.1888, 53 | "trackback_total": 1651, 54 | "title": "Meeting Ticker" 55 | }, 56 | { 57 | "trackback_permalink": "http://twitter.com/redsquirrel/status/22884621951705088", 58 | "trackback_author_url": "http://twitter.com/redsquirrel", 59 | "content": "RT @Morendil: "Fact and folklore in software engineering" - http://bit.ly/gv7nfA - is mostly a teardown of McConnell's "10x" article.", 60 | "trackback_date": 1294291093, 61 | "topsy_author_img": "http://a2.twimg.com/profile_images/1095172835/Dave_-_Cropped_-_Square_normal.jpg", 62 | "hits": 1, 63 | "topsy_trackback_url": "http://topsy.com/morendil.github.com/folklore.html?utm_source=otter", 64 | "firstpost_date": 1294251436, 65 | "url": "http://morendil.github.com/folklore.html", 66 | "trackback_author_nick": "redsquirrel", 67 | "highlight": "RT @Morendil: "Fact and folklore in software engineering" - http://bit.ly/gv7nfA - is mostly a teardown of McConnell's "10x" article. ", 68 | "topsy_author_url": "http://topsy.com/twitter/redsquirrel?utm_source=otter", 69 | "mytype": "link", 70 | "score": 9.93029, 71 | "trackback_total": 53, 72 | "title": "Fact and folklore in software engineering" 73 | }], 74 | "offset": 0 75 | } 76 | } -------------------------------------------------------------------------------- /spec/fixtures/topsy/search/site_github_page2: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Cache-Control: max-age=5 3 | Content-Type: application/json; charset=utf-8 4 | Expires: Sun, 09 Jan 2011 01:12:23 GMT 5 | Last-Modified: Sun, 09 Jan 2011 01:12:18 GMT 6 | Server: lighttpd/1.4.26 7 | Content-Length: 9696 8 | Date: Sun, 09 Jan 2011 01:12:19 GMT 9 | X-Varnish: 655867910 10 | Age: 0 11 | Via: 1.1 varnish 12 | X-Served-By: ps163 13 | X-Cache: MISS 14 | X-RateLimit-Limit: 10000 15 | X-RateLimit-Remaining: 7667 16 | X-RateLimit-Reset: 1294536057 17 | Connection: close 18 | 19 | { 20 | "request": { 21 | "parameters": { 22 | "window": "realtime", 23 | "page": "1", 24 | "q": "github.com", 25 | "type": "cited", 26 | "perpage": "2" 27 | }, 28 | "response_type": "json", 29 | "resource": "search", 30 | "url": "http://otter.topsy.com/search.json?page=1&perpage=2&q=github.com&type=cited&window=realtime" 31 | }, 32 | "response": { 33 | "window": "a", 34 | "page": 1, 35 | "total": 4, 36 | "perpage": 2, 37 | "last_offset": 4, 38 | "hidden": 0, 39 | "list": [{ 40 | "trackback_permalink": "http://twitter.com/mbolingbroke/status/23905555005317120", 41 | "trackback_author_url": "http://twitter.com/mbolingbroke", 42 | "content": "Implementing the GHC runtime system in Haskell for http://bit.ly/fw0vZH. I never appreciated how subtle async. exceptions were until now..", 43 | "trackback_date": 1294534503, 44 | "topsy_author_img": "http://a0.twimg.com/profile_images/1160468370/me-pumpkin_normal.png", 45 | "hits": 1, 46 | "topsy_trackback_url": "http://topsy.com/trackback?url=https%3A%2F%2Fgithub.com%2Fbatterseapower%2Fconcurrency-test&utm_source=otter", 47 | "firstpost_date": 1294534503, 48 | "url": "https://github.com/batterseapower/concurrency-test", 49 | "trackback_author_nick": "mbolingbroke", 50 | "highlight": "Implementing the GHC runtime system in Haskell for http://bit.ly/fw0vZH. I never appreciated how subtle async. exceptions were until now.. ", 51 | "topsy_author_url": "http://topsy.com/twitter/mbolingbroke?utm_source=otter", 52 | "mytype": "link", 53 | "score": 0.185219, 54 | "trackback_total": 1, 55 | "title": "batterseapower/concurrency-test - GitHub" 56 | }, 57 | { 58 | "trackback_permalink": "http://twitter.com/mariovisic/status/23905388042653696", 59 | "trackback_author_url": "http://twitter.com/mariovisic", 60 | "content": "Lol @ like a boss gem, I still don't know what it does after reading the readme http://j.mp/feBL1f /cc @Sutto", 61 | "trackback_date": 1294534463, 62 | "topsy_author_img": "http://a0.twimg.com/profile_images/1145304275/e535495ad0ff9f536b686c65dc6b7d17_normal.jpeg", 63 | "hits": 1, 64 | "topsy_trackback_url": "http://topsy.com/trackback?url=https%3A%2F%2Fgithub.com%2Fdaneharrigan%2Flike_a_boss&utm_source=otter", 65 | "firstpost_date": 1294534463, 66 | "url": "https://github.com/daneharrigan/like_a_boss", 67 | "trackback_author_nick": "mariovisic", 68 | "highlight": "Lol @ like a boss gem, I still don't know what it does after reading the readme http://j.mp/feBL1f /cc @Sutto ", 69 | "topsy_author_url": "http://topsy.com/twitter/mariovisic?utm_source=otter", 70 | "mytype": "link", 71 | "score": 0.192984, 72 | "trackback_total": 1, 73 | "title": "daneharrigan/like_a_boss - GitHub" 74 | }], 75 | "offset": 0 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /spec/notifier_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(File.expand_path(__FILE__)), 'spec_helper') 2 | 3 | describe Grapevine::Notifier do 4 | ############################################################################## 5 | # Setup 6 | ############################################################################## 7 | 8 | before do 9 | DataMapper.auto_migrate! 10 | create_data() 11 | @notifier = Grapevine::Notifier.new() 12 | @notifier.name = 'my-notifier' 13 | end 14 | 15 | after do 16 | @notifier = nil 17 | end 18 | 19 | 20 | def create_data() 21 | @t0 = create_topic('foo') 22 | @t1 = create_topic('bar') 23 | 24 | @m0_0 = create_message(@t0) 25 | @m0_1 = create_message(@t0) 26 | @m1_0 = create_message(@t1) 27 | @m1_1 = create_message(@t1) 28 | @m1_2 = create_message(@t1) 29 | end 30 | 31 | def create_topic(name) 32 | Topic.create(:source => 'twitter-github', :name => 'foo', :created_at => Time.now) 33 | end 34 | 35 | def create_message(topic) 36 | Message.create(:topic => topic) 37 | end 38 | 39 | def create_tag(topic, type, value) 40 | tag = Tag.first_or_create(:type => type, :value => value) 41 | topic.tags << tag 42 | topic.save 43 | end 44 | 45 | def create_notification(topic, source, created_at) 46 | Notification.create(:topic => topic, :source => source, :created_at => created_at) 47 | end 48 | 49 | 50 | ############################################################################## 51 | # Tests 52 | ############################################################################## 53 | 54 | ##################################### 55 | # Static methods 56 | ##################################### 57 | 58 | it 'should create a notifier' do 59 | loader = Grapevine::Notifier.create( 60 | 'twitter', 61 | :name => 'foo', 62 | :frequency => '5m', 63 | :username => 'github_js', 64 | :oauth_token => 'foo', 65 | :oauth_token_secret => 'bar', 66 | :source => 'github', 67 | :window => '1d', 68 | :tags => ['language:javascript', 'language:ruby'] 69 | ) 70 | loader.class.should == Grapevine::Twitter::TweetNotifier 71 | loader.name.should == 'foo' 72 | loader.frequency.should == 300 73 | loader.username.should == 'github_js' 74 | loader.oauth_token.should == 'foo' 75 | loader.oauth_token_secret.should == 'bar' 76 | loader.source.should == 'github' 77 | loader.window.should == 86_400 78 | loader.tags.length.should == 2 79 | end 80 | 81 | 82 | ##################################### 83 | # Topics 84 | ##################################### 85 | 86 | it 'should return topics in order of popularity' do 87 | topics = @notifier.popular_topics 88 | topics.length.should == 2 89 | topics[0].should == @t1 90 | topics[1].should == @t0 91 | end 92 | 93 | it 'should filter out popular topics by notification window' do 94 | Timecop.freeze(Time.local(2010, 1, 8)) do 95 | create_notification(@t0, 'my-notifier', Time.local(2010, 1, 1)) 96 | create_notification(@t1, 'my-notifier', Time.local(2010, 1, 4)) 97 | 98 | @notifier.window = 84600 * 7 # 1 week 99 | topics = @notifier.popular_topics 100 | topics.length.should == 1 101 | topics[0].should == @t0 102 | end 103 | end 104 | 105 | it 'should filter out popular topics by tag' do 106 | create_tag(@t0, 'language', 'ruby') 107 | create_tag(@t0, 'language', 'javascript') 108 | create_tag(@t1, 'language', 'ruby') 109 | 110 | @notifier.tags = ['language:javascript'] 111 | topics = @notifier.popular_topics 112 | topics.length.should == 1 113 | topics[0].should == @t0 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /spec/fixtures/topsy/search/site_github_page1: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Cache-Control: max-age=5 3 | Content-Type: application/json; charset=utf-8 4 | Expires: Sun, 09 Jan 2011 01:12:23 GMT 5 | Last-Modified: Sun, 09 Jan 2011 01:12:18 GMT 6 | Server: lighttpd/1.4.26 7 | Content-Length: 9696 8 | Date: Sun, 09 Jan 2011 01:12:19 GMT 9 | X-Varnish: 655867910 10 | Age: 0 11 | Via: 1.1 varnish 12 | X-Served-By: ps163 13 | X-Cache: MISS 14 | X-RateLimit-Limit: 10000 15 | X-RateLimit-Remaining: 7667 16 | X-RateLimit-Reset: 1294536057 17 | Connection: close 18 | 19 | { 20 | "request": { 21 | "parameters": { 22 | "window": "realtime", 23 | "page": "1", 24 | "q": "github.com", 25 | "type": "cited", 26 | "perpage": "2" 27 | }, 28 | "response_type": "json", 29 | "resource": "search", 30 | "url": "http://otter.topsy.com/search.json?page=1&perpage=10&q=github.com&type=cited&window=realtime" 31 | }, 32 | "response": { 33 | "window": "a", 34 | "page": 1, 35 | "total": 4, 36 | "perpage": 2, 37 | "last_offset": 2, 38 | "hidden": 0, 39 | "list": [{ 40 | "trackback_permalink": "http://twitter.com/coplusk/status/23909517578211328", 41 | "trackback_author_url": "http://twitter.com/coplusk", 42 | "content": "[suggestedshare] http://bit.ly/dEeDxh Tom Waddington - caching", 43 | "trackback_date": 1294535447, 44 | "topsy_author_img": "http://s.twimg.com/a/1292392187/images/default_profile_1_normal.png", 45 | "hits": 1, 46 | "topsy_trackback_url": "http://topsy.com/trackback?url=https%3A%2F%2Fgithub.com%2Ftomwaddington%2Fsuggestedshare%2Fcommit%2F1e4117f001d224cd15039ff030bc39b105f24a13&utm_source=otter", 47 | "firstpost_date": 1294535447, 48 | "url": "https://github.com/tomwaddington/suggestedshare/commit/1e4117f001d224cd15039ff030bc39b105f24a13", 49 | "trackback_author_nick": "coplusk", 50 | "highlight": "[suggestedshare] http://bit.ly/dEeDxh Tom Waddington - caching ", 51 | "topsy_author_url": "http://topsy.com/twitter/coplusk?utm_source=otter", 52 | "mytype": "link", 53 | "score": 0.195851, 54 | "trackback_total": 1, 55 | "title": "suggestedshare] Tom Waddington - caching" 56 | }, 57 | { 58 | "trackback_permalink": "http://twitter.com/oscurrency/status/23907860031209472", 59 | "trackback_author_url": "http://twitter.com/oscurrency", 60 | "content": "[oscurrency] http://bit.ly/es19Dl Tom Brown - groupy branch: display all of the admin of a group on the membership administration page", 61 | "trackback_date": 1294535052, 62 | "topsy_author_img": "http://s.twimg.com/a/1292022067/images/default_profile_1_normal.png", 63 | "hits": 1, 64 | "topsy_trackback_url": "http://topsy.com/trackback?url=https%3A%2F%2Fgithub.com%2Faustintimeexchange%2Foscurrency%2Fcommit%2F35f06c911f2c9b521e24bf73f936b8c783d52e17&utm_source=otter", 65 | "firstpost_date": 1294535052, 66 | "url": "https://github.com/austintimeexchange/oscurrency/commit/35f06c911f2c9b521e24bf73f936b8c783d52e17", 67 | "trackback_author_nick": "oscurrency", 68 | "highlight": "[oscurrency] http://bit.ly/es19Dl Tom Brown - groupy branch: display all of the admin of a group on the membership administration page ", 69 | "topsy_author_url": "http://topsy.com/twitter/oscurrency?utm_source=otter", 70 | "mytype": "link", 71 | "score": 0.189456, 72 | "trackback_total": 1, 73 | "title": "Commit 35f06c911f2c9b521e24bf73f936b8c783d52e17 to austintimeexchange's oscurrency - GitHub" 74 | }], 75 | "offset": 0 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /spec/fixtures/topsy/trackbacks/ginatrapani_ThinkUp: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Cache-Control: max-age=5 3 | Content-Type: application/json; charset=utf-8 4 | Expires: Fri, 07 Jan 2011 04:09:55 GMT 5 | Last-Modified: Fri, 07 Jan 2011 04:09:50 GMT 6 | Server: lighttpd/1.4.26 7 | Content-Length: 12607 8 | Date: Fri, 07 Jan 2011 04:09:52 GMT 9 | X-Varnish: 645797005 645796834 10 | Age: 2 11 | Via: 1.1 varnish 12 | X-Served-By: ps163 13 | X-Cache: HIT 14 | X-Cache-Hits: 1 15 | X-RateLimit-Limit: 10000 16 | X-RateLimit-Remaining: 9997 17 | X-RateLimit-Reset: 1294376899 18 | Connection: close 19 | 20 | { 21 | "request": { 22 | "parameters": { 23 | "window": "hour", 24 | "page": "1", 25 | "url": "https://github.com/ginatrapani/ThinkUp/wiki/Installing-ThinkUp-on-Amazon-EC2", 26 | "perpage": "100" 27 | }, 28 | "response_type": "json", 29 | "resource": "trackbacks", 30 | "url": "http://otter.topsy.com/trackbacks.json?page=1&perpage=100&url=https%3A%2F%2Fgithub.com%2Fginatrapani%2FThinkUp%2Fwiki%2FInstalling-ThinkUp-on-Amazon-EC2&window=hour" 31 | }, 32 | "response": { 33 | "page": 1, 34 | "trackback_total": 3, 35 | "total": 3, 36 | "perpage": 100, 37 | "last_offset": 3, 38 | "topsy_trackback_url": "http://topsy.com/trackback?url=https%3A%2F%2Fgithub.com%2Fginatrapani%2FThinkUp%2Fwiki%2FInstalling-ThinkUp-on-Amazon-EC2", 39 | "hidden": 0, 40 | "list": [{ 41 | "permalink_url": "http://twitter.com/vivaceuk/status/23031898938802176", 42 | "date": 1294326207, 43 | "content": "RT @thinkupapp: Get your own ThinkUp instance up and running for free/cheap on Amazon EC2 in 10 minutes. Here's how: http://bit.ly/edHUGZ", 44 | "date_alpha": "13 hours ago", 45 | "author": { 46 | "name": "Matthew Atkins", 47 | "url": "http://twitter.com/vivaceuk", 48 | "nick": "vivaceuk", 49 | "topsy_author_url": "http://topsy.com/twitter/vivaceuk?utm_source=otter", 50 | "photo_url": "http://a3.twimg.com/profile_images/504551651/twitterProfilePhoto_normal.jpg" 51 | }, 52 | "type": "tweet" 53 | }, 54 | { 55 | "permalink_url": "http://twitter.com/pvantees/status/22955482826145792", 56 | "date": 1294307988, 57 | "content": "RT @thinkupapp: Get your own ThinkUp instance up and running for free/cheap on Amazon EC2 in 10 minutes. http://bit.ly/edHUGZ @myen", 58 | "date_alpha": "18 hours ago", 59 | "author": { 60 | "name": "Peter van Teeseling", 61 | "url": "http://twitter.com/pvantees", 62 | "nick": "pvantees", 63 | "topsy_author_url": "http://topsy.com/twitter/pvantees?utm_source=otter", 64 | "photo_url": "http://a0.twimg.com/profile_images/780708510/pvantees-avatar_normal.jpg", 65 | "influence_level": 10 66 | }, 67 | "type": "tweet" 68 | }, 69 | { 70 | "permalink_url": "http://twitter.com/derwebarchitekt/status/22935307057889280", 71 | "date": 1294303177, 72 | "content": "Installing ThinkUp on Amazon EC2 http://bit.ly/e12MSr #tutorial", 73 | "date_alpha": "20 hours ago", 74 | "author": { 75 | "name": "Kai Thrun", 76 | "url": "http://twitter.com/derwebarchitekt", 77 | "nick": "derwebarchitekt", 78 | "topsy_author_url": "http://topsy.com/twitter/derwebarchitekt?utm_source=otter", 79 | "photo_url": "http://a2.twimg.com/profile_images/1164652940/kai_thrun_normal.jpg" 80 | }, 81 | "type": "tweet" 82 | }], 83 | "offset": 0 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /lib/grapevine/registry.rb: -------------------------------------------------------------------------------- 1 | module Grapevine 2 | # This class manages a group of loaders and notifiers. It can instantiate 3 | # objects from a YAML configuration file. 4 | class Registry 5 | ############################################################################ 6 | # Constructor 7 | ############################################################################ 8 | 9 | def initialize() 10 | @loaders = [] 11 | @notifiers = [] 12 | end 13 | 14 | 15 | ############################################################################ 16 | # Public Attributes 17 | ############################################################################ 18 | 19 | # The list of registered loaders. 20 | attr_reader :loaders 21 | 22 | # The list of registered notifiers. 23 | attr_reader :notifiers 24 | 25 | 26 | ############################################################################ 27 | # Public Methods 28 | ############################################################################ 29 | 30 | ################################### 31 | # Loaders 32 | ################################### 33 | 34 | # Registers a loader to the registry. 35 | # 36 | # @param [Grapevine::Loader] loader the loader to add. 37 | def add_loader(loader) 38 | @loaders << loader 39 | end 40 | 41 | # Unregisters a loader from the registry. 42 | # 43 | # @param [Grapevine::Loader] loader the loader to remove. 44 | def remove_loader(loader) 45 | @loaders.delete(loader) 46 | end 47 | 48 | # Retrieves a registered loader by name. 49 | # 50 | # @param [String] name the name of the loader. 51 | # 52 | # @return [Grapevine::Loader] the loader with the given name. 53 | def get_loader(name) 54 | @loaders.each do |loader| 55 | return loader if loader.name == name 56 | end 57 | end 58 | 59 | 60 | ################################### 61 | # Notifiers 62 | ################################### 63 | 64 | # Registers a notifier to the registry. 65 | # 66 | # @param [Grapevine::Notifier] notifier the notifier to add. 67 | def add_notifier(notifier) 68 | @notifiers << notifier 69 | end 70 | 71 | # Unregisters a notifier from the registry. 72 | # 73 | # @param [Grapevine::Notifier] notifier the notifier to remove. 74 | def remove_notifier(notifier) 75 | @notifiers.delete(notifier) 76 | end 77 | 78 | # Retrieves a registered notifier by name. 79 | # 80 | # @param [String] name the name of the notifier. 81 | # 82 | # @return [Grapevine::Notifier] the notifier with the given name. 83 | def get_notifier(name) 84 | @notifiers.each do |notifier| 85 | return notifier if notifier.name == name 86 | end 87 | end 88 | 89 | 90 | ################################### 91 | # Configuration 92 | ################################### 93 | 94 | # Creates a set of loaders and notifiers based on a YAML config. 95 | # 96 | # @param [String] filename the path to the YAML config file. 97 | def load_config(filename='~/grapevine.yml') 98 | data = YAML.load(IO.read(File.expand_path(filename))) 99 | 100 | # Create loaders 101 | if data.key?('sources') && data['sources'].is_a?(Array) 102 | data['sources'].each do |hash| 103 | type = hash.delete('type') 104 | loader = Grapevine::Loader.create(type, hash) 105 | add_loader(loader) 106 | end 107 | end 108 | 109 | # Create notifiers 110 | if data.key?('notifiers') && data['notifiers'].is_a?(Array) 111 | data['notifiers'].each do |hash| 112 | type = hash.delete('type') 113 | notifier = Grapevine::Notifier.create(type, hash) 114 | add_notifier(notifier) 115 | end 116 | end 117 | 118 | nil 119 | end 120 | end 121 | end -------------------------------------------------------------------------------- /spec/twitter/github_trackback_loader_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(File.expand_path(__FILE__)), '..', 'spec_helper') 2 | 3 | describe Grapevine::Twitter::GitHubTrackbackLoader do 4 | ############################################################################## 5 | # Setup 6 | ############################################################################## 7 | 8 | before do 9 | DataMapper.auto_migrate! 10 | FakeWeb.allow_net_connect = false 11 | @loader = Grapevine::Twitter::GitHubTrackbackLoader.new 12 | @loader.name = 'my-github-loader' 13 | @fixtures_dir = File.join(File.dirname(File.expand_path(__FILE__)), '..', 'fixtures') 14 | end 15 | 16 | after do 17 | FakeWeb.clean_registry 18 | end 19 | 20 | def register_topsy_search_uri(filename, options={}) 21 | options = {:page=>1, :perpage=>10, :site=>'github.com'}.merge(options) 22 | FakeWeb.register_uri(:get, "http://otter.topsy.com/search.json?page=#{options[:page]}&perpage=#{options[:perpage]}&window=realtime&q=#{CGI.escape(options[:site])}", :response => IO.read("#{@fixtures_dir}/topsy/search/#{filename}")) 23 | end 24 | 25 | def register_github_user_uri(username, filename, options={}) 26 | options = {}.merge(options) 27 | FakeWeb.register_uri(:get, "https://github.com/api/v2/yaml/user/show/#{username}", :response => IO.read("#{@fixtures_dir}/github/users/#{filename}")) 28 | end 29 | 30 | def register_github_repo_uri(username, repo_name, filename, options={}) 31 | options = {}.merge(options) 32 | FakeWeb.register_uri(:get, "https://github.com/api/v2/yaml/repos/show/#{username}/#{repo_name}", :response => IO.read("#{@fixtures_dir}/github/repos/#{filename}")) 33 | end 34 | 35 | def register_github_repo_language_uri(username, repo_name, filename, options={}) 36 | options = {}.merge(options) 37 | FakeWeb.register_uri(:get, "https://github.com/api/v2/yaml/repos/show/#{username}/#{repo_name}/languages", :response => IO.read("#{@fixtures_dir}/github/repos/languages/#{filename}")) 38 | end 39 | 40 | 41 | ############################################################################## 42 | # Tests 43 | ############################################################################## 44 | 45 | it 'should error when loading without site defined' do 46 | @loader.site = nil 47 | lambda {@loader.load()}.should raise_error('Cannot load trackbacks without a site defined') 48 | end 49 | 50 | it 'should return a single trackback with a GitHub project root URL' do 51 | register_topsy_search_uri('site_github_single') 52 | 53 | @loader.load() 54 | 55 | Message.all.length.should == 1 56 | message = Message.first 57 | message.source.should == 'my-github-loader' 58 | message.source_id.should == '23909517578211328' 59 | message.author.should == 'coplusk' 60 | message.url.should == 'https://github.com/tomwaddington/suggestedshare/commit/1e4117f001d224cd15039ff030bc39b105f24a13' 61 | end 62 | 63 | it 'should filter out non-project URLs' do 64 | register_topsy_search_uri('site_github_nonproject') 65 | 66 | @loader.load() 67 | Message.all.length.should == 0 68 | end 69 | 70 | 71 | ##################################### 72 | # Aggregation 73 | ##################################### 74 | 75 | it 'should create topic from message' do 76 | register_topsy_search_uri('site_github_single') 77 | register_github_user_uri('tomwaddington', 'tomwaddington') 78 | register_github_repo_uri('tomwaddington', 'suggestedshare', 'tomwaddington_suggestedshare') 79 | register_github_repo_language_uri('tomwaddington', 'suggestedshare', 'tomwaddington_suggestedshare') 80 | @loader.load() 81 | 82 | Topic.all.length.should == 1 83 | topic = Topic.first 84 | topic.source.should == 'my-github-loader' 85 | topic.name.should == 'suggestedshare' 86 | topic.description.should == 'Share content on Facebook with like-minded friends' 87 | topic.url.should == 'https://github.com/tomwaddington/suggestedshare' 88 | topic.tags.length.should == 1 89 | topic.tags[0].type.should == 'language' 90 | topic.tags[0].value.should == 'javascript' 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | grapevine (0.3.10) 5 | OptionParser (~> 0.5.1) 6 | bitly (~> 0.6.1) 7 | commander (~> 4.0.3) 8 | daemons (~> 1.1.0) 9 | data_mapper (~> 1.0.2) 10 | log4r (~> 1.1.6) 11 | octopi (~> 0.4.0) 12 | rack (~> 1.2.0) 13 | slowweb (~> 0.1.1) 14 | terminal-table (~> 1.4.2) 15 | topsy (~> 0.3.6) 16 | twitter (~> 1.4.1) 17 | unindentable (~> 0.1.0) 18 | 19 | GEM 20 | remote: http://rubygems.org/ 21 | specs: 22 | OptionParser (0.5.1) 23 | addressable (2.2.4) 24 | api_cache (0.2.3) 25 | bitly (0.6.1) 26 | crack (>= 0.1.4) 27 | httparty (>= 0.5.2) 28 | oauth2 (>= 0.1.1) 29 | commander (4.0.4) 30 | highline (>= 1.5.0) 31 | crack (0.1.8) 32 | daemons (1.1.3) 33 | data_mapper (1.0.2) 34 | dm-aggregates (= 1.0.2) 35 | dm-constraints (= 1.0.2) 36 | dm-core (= 1.0.2) 37 | dm-migrations (= 1.0.2) 38 | dm-serializer (= 1.0.2) 39 | dm-timestamps (= 1.0.2) 40 | dm-transactions (= 1.0.2) 41 | dm-types (= 1.0.2) 42 | dm-validations (= 1.0.2) 43 | data_objects (0.10.3) 44 | addressable (~> 2.1) 45 | diff-lcs (1.1.2) 46 | dm-aggregates (1.0.2) 47 | dm-core (~> 1.0.2) 48 | dm-constraints (1.0.2) 49 | dm-core (~> 1.0.2) 50 | dm-migrations (~> 1.0.2) 51 | dm-core (1.0.2) 52 | addressable (~> 2.2) 53 | extlib (~> 0.9.15) 54 | dm-do-adapter (1.0.2) 55 | data_objects (~> 0.10.2) 56 | dm-core (~> 1.0.2) 57 | dm-migrations (1.0.2) 58 | dm-core (~> 1.0.2) 59 | dm-serializer (1.0.2) 60 | dm-core (~> 1.0.2) 61 | fastercsv (~> 1.5.3) 62 | json_pure (~> 1.4) 63 | dm-sqlite-adapter (1.0.2) 64 | dm-do-adapter (~> 1.0.2) 65 | do_sqlite3 (~> 0.10.2) 66 | dm-timestamps (1.0.2) 67 | dm-core (~> 1.0.2) 68 | dm-transactions (1.0.2) 69 | dm-core (~> 1.0.2) 70 | dm-types (1.0.2) 71 | dm-core (~> 1.0.2) 72 | fastercsv (~> 1.5.3) 73 | json_pure (~> 1.4) 74 | stringex (~> 1.1.0) 75 | uuidtools (~> 2.1.1) 76 | dm-validations (1.0.2) 77 | dm-core (~> 1.0.2) 78 | do_sqlite3 (0.10.3) 79 | data_objects (= 0.10.3) 80 | extlib (0.9.15) 81 | fakeweb (1.3.0) 82 | faraday (0.6.1) 83 | addressable (~> 2.2.4) 84 | multipart-post (~> 1.1.0) 85 | rack (>= 1.1.0, < 2) 86 | faraday_middleware (0.6.3) 87 | faraday (~> 0.6.0) 88 | fastercsv (1.5.4) 89 | hashie (1.0.0) 90 | highline (1.6.2) 91 | httparty (0.7.7) 92 | crack (= 0.1.8) 93 | json_pure (1.5.1) 94 | log4r (1.1.9) 95 | mechanize (1.0.0) 96 | nokogiri (>= 1.2.1) 97 | mocha (0.9.12) 98 | multi_json (1.0.2) 99 | multi_xml (0.2.2) 100 | multipart-post (1.1.1) 101 | nokogiri (1.4.4) 102 | oauth2 (0.4.1) 103 | faraday (~> 0.6.1) 104 | multi_json (>= 0.0.5) 105 | octopi (0.4.4) 106 | api_cache 107 | httparty (>= 0.4.5) 108 | mechanize (>= 0.9.3) 109 | nokogiri (>= 1.3.1) 110 | rack (1.2.2) 111 | rash (0.3.0) 112 | hashie (~> 1.0.0) 113 | rcov (0.9.9) 114 | rspec (2.4.0) 115 | rspec-core (~> 2.4.0) 116 | rspec-expectations (~> 2.4.0) 117 | rspec-mocks (~> 2.4.0) 118 | rspec-core (2.4.0) 119 | rspec-expectations (2.4.0) 120 | diff-lcs (~> 1.1.2) 121 | rspec-mocks (2.4.0) 122 | simple_oauth (0.1.5) 123 | slowweb (0.1.1) 124 | stringex (1.1.0) 125 | terminal-table (1.4.2) 126 | timecop (0.3.5) 127 | topsy (0.3.6) 128 | hashie (~> 1.0.0) 129 | httparty (>= 0.4.5) 130 | twitter (1.4.1) 131 | faraday (~> 0.6.1) 132 | faraday_middleware (~> 0.6.3) 133 | hashie (~> 1.0.0) 134 | multi_json (~> 1.0.0) 135 | multi_xml (~> 0.2.0) 136 | rash (~> 0.3.0) 137 | simple_oauth (~> 0.1.4) 138 | unindentable (0.1.0) 139 | uuidtools (2.1.2) 140 | 141 | PLATFORMS 142 | ruby 143 | 144 | DEPENDENCIES 145 | dm-sqlite-adapter (~> 1.0.2) 146 | fakeweb (~> 1.3.0) 147 | grapevine! 148 | mocha (~> 0.9.12) 149 | rcov (~> 0.9.9) 150 | rspec (~> 2.4.0) 151 | timecop (~> 0.3.5) 152 | -------------------------------------------------------------------------------- /spec/registry_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(File.expand_path(__FILE__)), 'spec_helper') 2 | 3 | describe Grapevine::Registry do 4 | ############################################################################## 5 | # Setup 6 | ############################################################################## 7 | 8 | before do 9 | @registry = Grapevine::Registry.new() 10 | end 11 | 12 | after do 13 | @registry = nil 14 | end 15 | 16 | 17 | ############################################################################## 18 | # Tests 19 | ############################################################################## 20 | 21 | ##################################### 22 | # Loaders 23 | ##################################### 24 | 25 | it 'should add loader to registry' do 26 | loader = Grapevine::Loader.new() 27 | @registry.add_loader(loader) 28 | @registry.loaders.index(loader).should_not be_nil 29 | end 30 | 31 | it 'should remove loader from registry' do 32 | loader = Grapevine::Loader.new() 33 | @registry.add_loader(loader) 34 | @registry.remove_loader(loader) 35 | @registry.loaders.length.should == 0 36 | end 37 | 38 | it 'should retrieve loader by name' do 39 | loader1 = Grapevine::Loader.new() 40 | loader1.name = 'foo' 41 | @registry.add_loader(loader1) 42 | loader2 = Grapevine::Loader.new() 43 | loader2.name = 'bar' 44 | @registry.add_loader(loader2) 45 | 46 | @registry.get_loader('bar').should == loader2 47 | end 48 | 49 | 50 | ##################################### 51 | # Notifiers 52 | ##################################### 53 | 54 | it 'should add notifier to registry' do 55 | notifier = Grapevine::Notifier.new() 56 | @registry.add_notifier(notifier) 57 | @registry.notifiers.index(notifier).should_not be_nil 58 | end 59 | 60 | it 'should remove notifier from registry' do 61 | notifier = Grapevine::Notifier.new() 62 | @registry.add_notifier(notifier) 63 | @registry.remove_notifier(notifier) 64 | @registry.notifiers.length.should == 0 65 | end 66 | 67 | it 'should retrieve notifier by name' do 68 | notifier1 = Grapevine::Notifier.new() 69 | notifier1.name = 'foo' 70 | @registry.add_notifier(notifier1) 71 | notifier2 = Grapevine::Notifier.new() 72 | notifier2.name = 'bar' 73 | @registry.add_notifier(notifier2) 74 | 75 | @registry.get_notifier('bar').should == notifier2 76 | end 77 | 78 | 79 | ##################################### 80 | # Config 81 | ##################################### 82 | 83 | it 'should create loaders from configuration file' do 84 | IO.expects(:read).with('/tmp/grapevine.yml').returns( 85 | <<-BLOCK.unindent 86 | sources: 87 | - name: my-loader-1 88 | type: twitter-github 89 | frequency: 1m 90 | - name: my-loader-2 91 | type: twitter-trackback 92 | frequency: 2h 93 | BLOCK 94 | ) 95 | @registry.load_config('/tmp/grapevine.yml') 96 | @registry.loaders.length.should == 2 97 | loader1, loader2 = *@registry.loaders 98 | loader1.class.should == Grapevine::Twitter::GitHubTrackbackLoader 99 | loader1.name.should == 'my-loader-1' 100 | loader1.frequency.should == 60 101 | loader2.class.should == Grapevine::Twitter::TrackbackLoader 102 | loader2.name.should == 'my-loader-2' 103 | loader2.frequency.should == 7_200 104 | end 105 | 106 | it 'should create notifiers from configuration file' do 107 | IO.expects(:read).with('/tmp/grapevine.yml').returns( 108 | <<-BLOCK.unindent 109 | notifiers: 110 | - name: my-notifier 111 | type: twitter 112 | username: test_notifier 113 | oauth_token: foo 114 | oauth_token_secret: bar 115 | source: my-loader 116 | frequency: 8h 117 | tags: [language:javascript, language:html] 118 | BLOCK 119 | ) 120 | @registry.load_config('/tmp/grapevine.yml') 121 | @registry.notifiers.length.should == 1 122 | notifier = *@registry.notifiers 123 | notifier.class.should == Grapevine::Twitter::TweetNotifier 124 | notifier.name.should == 'my-notifier' 125 | notifier.username.should == 'test_notifier' 126 | notifier.oauth_token.should == 'foo' 127 | notifier.oauth_token_secret.should == 'bar' 128 | notifier.source.should == 'my-loader' 129 | notifier.frequency.should == 28_800 130 | notifier.tags.length.should == 2 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /lib/grapevine/notifier.rb: -------------------------------------------------------------------------------- 1 | module Grapevine 2 | # This is an abstract base class for all notifiers in the system. The notifier 3 | # announces the most popular topic. 4 | class Notifier 5 | ############################################################################ 6 | # Static Attributes 7 | ############################################################################ 8 | 9 | # Registers a class by type 10 | def self.register(type, clazz) 11 | @classes ||= {} 12 | @classes[type] = clazz 13 | end 14 | 15 | # Creates an instance of a notifier by type 16 | # 17 | # @param [String] type the type of notifier to create. 18 | # @param [Hash] options a list of options to set on the notifier. 19 | # 20 | # @return [Grapevine::Notifier] the new instance of a notifier. 21 | def self.create(type, options={}) 22 | @classes ||= {} 23 | clazz = @classes[type] 24 | raise "No notifier has been registered as: #{type}" if clazz.nil? 25 | notifier = clazz.new 26 | 27 | # Set options 28 | options = options.symbolize 29 | 30 | # Required attributes 31 | name = options.delete(:name) 32 | raise "Name required on notifier definition" if name.nil? 33 | raise "Frequency required on notifier: #{name}" if options[:frequency].nil? 34 | 35 | # Set the notifier name 36 | notifier.name = name 37 | 38 | # Parse frequency 39 | notifier.frequency = Date.parse_time_period(options[:frequency]) 40 | raise "Invalid frequency: #{frequency}" if notifier.frequency.nil? 41 | options.delete(:frequency) 42 | 43 | # Parse window 44 | if options.key?(:window) 45 | notifier.window = Date.parse_time_period(options[:window]) 46 | raise "Invalid window: #{window}" if notifier.window.nil? 47 | options.delete(:window) 48 | end 49 | 50 | # Set attributes on the notifier 51 | options.each do |k, v| 52 | notifier.__send__ "#{k.to_s}=", v 53 | end 54 | 55 | return notifier 56 | end 57 | 58 | 59 | ############################################################################ 60 | # Constructor 61 | ############################################################################ 62 | 63 | def initialize() 64 | end 65 | 66 | 67 | ############################################################################ 68 | # Public Attributes 69 | ############################################################################ 70 | 71 | # The name of this notifier. 72 | attr_accessor :name 73 | 74 | # The name of the source to retrieve topics from. 75 | attr_accessor :source 76 | 77 | # A list of tags to filter topics by. 78 | attr_accessor :tags 79 | 80 | # A time window in which topics can not be renotified. This window is 81 | # defined in seconds. 82 | attr_accessor :window 83 | 84 | 85 | ############################################################################ 86 | # Public Methods 87 | ############################################################################ 88 | 89 | # Sends a notification. 90 | def send(options={}) 91 | end 92 | 93 | 94 | # A list of the most popular topics. 95 | def popular_topics 96 | # Retrieve a union of topics based on tags 97 | topics = [] 98 | if tags && tags.length > 0 99 | tags.each do |tag| 100 | m, tag_type, tag_value = *tag.match(/^(\w+):(.+)$/) 101 | db_tag = Tag.first(:type => tag_type, :value => tag_value) 102 | topics |= db_tag.topics 103 | end 104 | # If no tags are specified, use all topics 105 | else 106 | topics = Topic.all 107 | end 108 | 109 | # Loop over aggregate results 110 | arr = [] 111 | topics.each do |topic| 112 | # Remove topic if it has been notified within the window 113 | notification = topic.notifications.first(:source => name, :order => :created_at.desc) 114 | 115 | if notification 116 | elapsed = Time.now-Time.parse(notification.created_at.to_s) 117 | if elapsed < window 118 | next 119 | end 120 | end 121 | 122 | # Add topics to new array 123 | arr << topic 124 | end 125 | topics = arr 126 | 127 | # Sort topics by popularity 128 | topics = topics.sort! {|x,y| x.messages.length <=> y.messages.length} 129 | topics.reverse! 130 | 131 | return topics 132 | end 133 | end 134 | end -------------------------------------------------------------------------------- /lib/grapevine/twitter/github_trackback_loader.rb: -------------------------------------------------------------------------------- 1 | module Grapevine 2 | module Twitter 3 | # This class loads trackbacks from Twitter that point to a GitHub project. 4 | # This loader also retrieves meta data about the project from the GitHub 5 | # API. 6 | class GitHubTrackbackLoader < TrackbackLoader 7 | ########################################################################## 8 | # Setup 9 | ########################################################################## 10 | 11 | Grapevine::Loader.register('twitter-github', self) 12 | 13 | 14 | ########################################################################## 15 | # Constructor 16 | ########################################################################## 17 | 18 | def initialize 19 | super 20 | @site = 'github.com' 21 | @language_threshold = 30 22 | end 23 | 24 | 25 | ########################################################################## 26 | # Public Attributes 27 | ########################################################################## 28 | 29 | # The percentage of total bytes of a GitHub project that should use a 30 | # language in order to be tagged. 31 | attr_accessor :language_threshold 32 | 33 | 34 | ########################################################################## 35 | # Protected Methods 36 | ########################################################################## 37 | 38 | # Creates a message from a tweet. 39 | def create_message(item) 40 | username, repo_name = *extract_repo_info(item.url) 41 | 42 | # Skip tweet if it's not a GitHub project url 43 | if username.nil? 44 | return nil 45 | else 46 | return super(item) 47 | end 48 | end 49 | 50 | # Creates a topic from a message 51 | def create_topic(message, url=nil) 52 | # Reformat the URL 53 | username, repo_name = *extract_repo_info(message.url) 54 | url = create_base_url(username, repo_name) 55 | 56 | topic = super(message, url) 57 | 58 | # Ignore GitHub info if call fails 59 | begin 60 | # Only retrieve repository info if we don't have it yet 61 | if topic.tags.length == 0 62 | repo = Octopi::Repository.find(:user => username, :repo => repo_name) 63 | topic.description = repo.description 64 | 65 | get_repository_language_tags(repo).each do |language| 66 | # Don't create duplicate tags 67 | if topic.tags(:type => 'language', :value => language).length == 0 68 | tag = Tag.first_or_create( 69 | :type => 'language', 70 | :value => language 71 | ) 72 | topic.tags << tag 73 | topic.save 74 | end 75 | end 76 | end 77 | rescue Octopi::APIError => e 78 | # If there was a problem with the API, we'll try again next time. 79 | rescue Octopi::NotFound => e 80 | # If not found, it's probably private or mispelled 81 | rescue Exception => e 82 | Grapevine.log_error("GitHub API (#{name})", e) 83 | end 84 | 85 | return topic 86 | end 87 | 88 | # Generates a topic name 89 | def set_topic_name(topic) 90 | username, repo_name = *extract_repo_info(topic.url) 91 | 92 | # Use repo name if GitHub call fails 93 | topic.name = repo_name[0..250] 94 | end 95 | 96 | 97 | ########################################################################## 98 | # Private Methods 99 | ########################################################################## 100 | 101 | # Parses a GitHub URL and extracts repo information 102 | def extract_repo_info(url) 103 | m, username, repo_name = *url.match(/^https?:\/\/(?:www.)?github.com\/([^\/]+)\/([^\/#?]+)/i) 104 | return (m ? [username, repo_name] : nil) 105 | end 106 | 107 | # Generates the root URL for a GitHub project. 108 | def create_base_url(username, repo_name) 109 | return "https://github.com/#{username}/#{repo_name}" 110 | end 111 | 112 | # Retrieves a list of language tags associated with a repository. 113 | def get_repository_language_tags(repo) 114 | lookup = repo.languages 115 | total = lookup.values.inject(0) {|s,v| s += v} 116 | 117 | # Find languages that meet the threshold 118 | languages = [] 119 | lookup.each_pair do |k,v| 120 | k = k.downcase.gsub(/\s+/, '-') 121 | languages << k if v.to_i >= total*(language_threshold.to_f/100) 122 | end 123 | 124 | return languages 125 | end 126 | end 127 | end 128 | end -------------------------------------------------------------------------------- /lib/grapevine/twitter/tweet_notifier.rb: -------------------------------------------------------------------------------- 1 | module Grapevine 2 | module Twitter 3 | # This class sends notifications as Twitter tweets. 4 | class TweetNotifier < Grapevine::Notifier 5 | ########################################################################## 6 | # Setup 7 | ########################################################################## 8 | 9 | Grapevine::Notifier.register('twitter', self) 10 | 11 | 12 | ########################################################################## 13 | # Constructor 14 | ########################################################################## 15 | 16 | def initialize 17 | end 18 | 19 | 20 | ########################################################################## 21 | # Public Attributes 22 | ########################################################################## 23 | 24 | # The frequency that notifications can be sent. This is specified in 25 | # seconds. 26 | attr_accessor :frequency 27 | 28 | # The Twitter username that will post the status update. 29 | attr_accessor :username 30 | 31 | # The OAuth token used to authenticate to Twitter. 32 | attr_accessor :oauth_token 33 | 34 | # The OAuth token secret used to authenticate to Twitter. 35 | attr_accessor :oauth_token_secret 36 | 37 | 38 | ########################################################################## 39 | # Public Methods 40 | ########################################################################## 41 | 42 | # The current state of the notifier. Will be "ready" if it is ready to 43 | # send a notification or "waiting" if the specified frequency has not 44 | # elapsed since the last notification. 45 | def state 46 | if !frequency.nil? && frequency > 0 47 | notification = Notification.first( 48 | :source => self.name, 49 | :order => :created_at.desc 50 | ) 51 | 52 | elapsed = Time.now-Time.parse(notification.created_at.to_s) 53 | if notification && elapsed < frequency 54 | return 'waiting' 55 | end 56 | end 57 | 58 | return 'ready' 59 | end 60 | 61 | # Sends a notification for the most popular topic. 62 | def send(options={}) 63 | force = options[:force] 64 | 65 | # Wait if the nofifier is not ready 66 | return if !force && state == 'waiting' 67 | 68 | # Wait at least the number of seconds specified in frequency before 69 | # sending another notification 70 | if !force && !frequency.nil? && frequency > 0 71 | notification = Notification.first( 72 | :source => self.name, 73 | :order => :created_at.desc 74 | ) 75 | 76 | if notification && Time.now-Time.parse(notification.created_at.to_s) < frequency 77 | return 78 | end 79 | end 80 | 81 | # Find most popular topic 82 | topic = popular_topics.first 83 | return false if topic.nil? 84 | 85 | # Shorten the topic URL 86 | bitly = Bitly.new(Grapevine::Config.bitly_username, Grapevine::Config.bitly_api_key) 87 | url = bitly.shorten(topic.url).short_url 88 | 89 | # Configure Twitter API 90 | ::Twitter.configure do |config| 91 | config.consumer_key = Grapevine::Config.twitter_consumer_key 92 | config.consumer_secret = Grapevine::Config.twitter_consumer_secret 93 | config.oauth_token = self.oauth_token 94 | config.oauth_token_secret = self.oauth_token_secret 95 | end 96 | 97 | # Limit description length 98 | description = topic.description || '' 99 | max_length = 140 - topic.name.length - url.length - username.length - 10 100 | if description.length > max_length 101 | # Try to cut off at the last space if possible 102 | index = description.rindex(' ', max_length) || max_length 103 | description = description[0..index-1] 104 | end 105 | 106 | # Build tweet message 107 | content = "#{topic.name} - #{description} #{url}" 108 | 109 | # Send tweet 110 | client = ::Twitter::Client.new 111 | begin 112 | client.update(content); 113 | rescue ::Twitter::BadRequest => e 114 | # If we have a bad Unicode character or something weird, just delete 115 | # the topic. 116 | topic.destroy() 117 | end 118 | 119 | # Log notification 120 | Notification.create( 121 | :topic => topic, 122 | :source => name, 123 | :content => content, 124 | :created_at => Time.now 125 | ) 126 | 127 | Grapevine.log.debug "Notify: #{name} -> #{topic.name}" 128 | end 129 | end 130 | end 131 | end -------------------------------------------------------------------------------- /spec/twitter/trackback_loader_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(File.expand_path(__FILE__)), '..', 'spec_helper') 2 | 3 | describe Grapevine::Twitter::TrackbackLoader do 4 | ############################################################################## 5 | # Setup 6 | ############################################################################## 7 | 8 | before do 9 | DataMapper.auto_migrate! 10 | FakeWeb.allow_net_connect = false 11 | @loader = Grapevine::Twitter::TrackbackLoader.new 12 | @loader.name = 'my_loader' 13 | @loader.site = 'github.com' 14 | @fixtures_dir = File.join(File.dirname(File.expand_path(__FILE__)), '..', 'fixtures') 15 | end 16 | 17 | after do 18 | FakeWeb.clean_registry 19 | end 20 | 21 | def register_topsy_search_uri(filename, options={}) 22 | options = {:page=>1, :perpage=>10, :site=>'github.com'}.merge(options) 23 | FakeWeb.register_uri(:get, "http://otter.topsy.com/search.json?page=#{options[:page]}&perpage=#{options[:perpage]}&window=realtime&q=#{CGI.escape(options[:site])}", :response => IO.read("#{@fixtures_dir}/topsy/search/#{filename}")) 24 | end 25 | 26 | 27 | ############################################################################## 28 | # Tests 29 | ############################################################################## 30 | 31 | ##################################### 32 | # Loading 33 | ##################################### 34 | 35 | it 'should error when loading without site defined' do 36 | @loader.site = nil 37 | lambda {@loader.load()}.should raise_error('Cannot load trackbacks without a site defined') 38 | end 39 | 40 | it 'should return a single trackback' do 41 | FakeWeb.register_uri(:get, "https://github.com/tomwaddington/suggestedshare/commit/1e4117f001d224cd15039ff030bc39b105f24a13", :body => 'tomwaddington/suggestedshare - GitHub') 42 | register_topsy_search_uri('site_github_single') 43 | 44 | @loader.load() 45 | 46 | messages = Message.all 47 | messages.length.should == 1 48 | message = *messages 49 | message.source.should == 'my_loader' 50 | message.source_id.should == '23909517578211328' 51 | message.author.should == 'coplusk' 52 | message.url.should == 'https://github.com/tomwaddington/suggestedshare/commit/1e4117f001d224cd15039ff030bc39b105f24a13' 53 | message.content.should == '[suggestedshare] http://bit.ly/dEeDxh Tom Waddington - caching' 54 | end 55 | 56 | it 'should page search results' do 57 | FakeWeb.register_uri(:get, "https://github.com/tomwaddington/suggestedshare/commit/1e4117f001d224cd15039ff030bc39b105f24a13", :body => '') 58 | FakeWeb.register_uri(:get, "https://github.com/austintimeexchange/oscurrency/commit/35f06c911f2c9b521e24bf73f936b8c783d52e17", :body => '') 59 | FakeWeb.register_uri(:get, "https://github.com/batterseapower/concurrency-test", :body => '') 60 | FakeWeb.register_uri(:get, "https://github.com/daneharrigan/like_a_boss", :body => '') 61 | register_topsy_search_uri('site_github_page1', :page => 1, :perpage => 2) 62 | register_topsy_search_uri('site_github_page2', :page => 2, :perpage => 2) 63 | 64 | @loader.per_page = 2 65 | @loader.load() 66 | 67 | Message.all.length.should == 4 68 | end 69 | 70 | it 'should not load messages that have already been loaded' do 71 | FakeWeb.register_uri(:get, "https://github.com/tomwaddington/suggestedshare/commit/1e4117f001d224cd15039ff030bc39b105f24a13", :body => '') 72 | FakeWeb.register_uri(:get, "https://github.com/austintimeexchange/oscurrency/commit/35f06c911f2c9b521e24bf73f936b8c783d52e17", :body => '') 73 | FakeWeb.register_uri(:get, "https://github.com/batterseapower/concurrency-test", :body => '') 74 | FakeWeb.register_uri(:get, "https://github.com/daneharrigan/like_a_boss", :body => '') 75 | FakeWeb.register_uri(:get, "https://github.com/mangos/one/commit/ee572c8ee639a13bdf9d81d7e451c94e0cb1baa7", :body => '') 76 | FakeWeb.register_uri(:get, "https://github.com/mongodb/mongo/commit/bcb127567ddd8690ec1897a34c3fb81f27866b6b", :body => '') 77 | FakeWeb.register_uri(:get, "https://github.com/marak/session.js", :body => '') 78 | register_topsy_search_uri('site_github') 79 | @loader.load() 80 | Message.all.length.should == 7 81 | 82 | register_topsy_search_uri('site_github_later') 83 | @loader.load() 84 | Message.all.length.should == 9 85 | end 86 | 87 | 88 | ##################################### 89 | # Aggregation 90 | ##################################### 91 | 92 | it 'should create topic from message' do 93 | register_topsy_search_uri('site_stackoverflow_single', :site => 'stackoverflow.com') 94 | FakeWeb.register_uri(:get, "http://stackoverflow.com/questions/4663725/iphone-keyboard-with-ok-button-to-dismiss-with-return-key-accepted-in-the-uite", :response => IO.read("#{@fixtures_dir}/stackoverflow/4663725")) 95 | @loader.site = 'stackoverflow.com' 96 | @loader.load() 97 | 98 | topics = Topic.all 99 | topics.length.should == 1 100 | topic = *topics 101 | topic.source.should == 'my_loader' 102 | topic.name.should == 'iPhone - keyboard with OK button to dismiss, with return key accepted in the UITextView - Stack Overflow' 103 | topic.url.should == 'http://stackoverflow.com/questions/4663725/iphone-keyboard-with-ok-button-to-dismiss-with-return-key-accepted-in-the-uite' 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/grapevine/twitter/trackback_loader.rb: -------------------------------------------------------------------------------- 1 | module Grapevine 2 | module Twitter 3 | # This class loads trackbacks from Twitter using the Topsy service. The 4 | # loader accepts a site to search within and has the ability to filter 5 | # trackbacks as they come in. 6 | class TrackbackLoader < Grapevine::Loader 7 | ########################################################################## 8 | # Setup 9 | ########################################################################## 10 | 11 | Grapevine::Loader.register('twitter-trackback', self) 12 | 13 | 14 | ########################################################################## 15 | # Constructor 16 | ########################################################################## 17 | 18 | def initialize 19 | @per_page = 10 20 | @duplicate_max_count = 10 21 | end 22 | 23 | 24 | ########################################################################## 25 | # Public Attributes 26 | ########################################################################## 27 | 28 | # A URL describing the domain to search within. 29 | attr_accessor :site 30 | 31 | # The number of items to return per page. 32 | attr_accessor :per_page 33 | 34 | # The number of times duplicates can be found before the search is 35 | # stopped. 36 | attr_accessor :duplicate_max_count 37 | 38 | 39 | ########################################################################## 40 | # Public Methods 41 | ########################################################################## 42 | 43 | # Loads a list of trackbacks from Twitter for a given site. 44 | def load() 45 | raise 'Cannot load trackbacks without a site defined' if site.nil? 46 | 47 | # Paginate through search 48 | page = 1 49 | duplicate_count = 0 50 | 51 | begin 52 | begin 53 | results = Topsy.search(site, :window => :realtime, :page => page, :perpage => per_page) 54 | rescue Topsy::InformTopsy => e 55 | Grapevine.log_error("Topsy Search (#{name})") 56 | rescue Topsy::Unavailable => e 57 | Grapevine.log_error("Topsy Unavailable (Limit: #{Topsy.rate_limit.remaining} of #{Topsy.rate_limit.limit})") 58 | end 59 | 60 | # Exit load if Topsy does not return results 61 | if results.nil? 62 | return 63 | end 64 | 65 | # Loop over links and load trackbacks for each one 66 | results.list.each do |item| 67 | # Create and append message 68 | message = create_message(item) 69 | 70 | if !message.nil? 71 | # Attempt to create a topic 72 | topic = create_topic(message) 73 | if topic.nil? 74 | next 75 | end 76 | 77 | # Skip message from a user if it's a duplicate 78 | if Message.first(:author => message.author, :topic => topic) 79 | duplicate_count += 1 80 | 81 | # Exit if we've encountered too many duplicates 82 | if duplicate_count > duplicate_max_count 83 | return 84 | # Otherwise continue to next item 85 | else 86 | next; 87 | end 88 | # Reset the duplicate count if we see a new tweet 89 | else 90 | duplicate_count = 0 91 | end 92 | 93 | # Assign topic and save message 94 | message.topic = topic 95 | message.save 96 | 97 | Grapevine.log.debug "MESSAGE: [#{message.author}] #{message.content}" 98 | end 99 | end 100 | 101 | page += 1 102 | end while results.last_offset < results.total && page < 10 103 | end 104 | 105 | 106 | ########################################################################## 107 | # Protected Methods 108 | ########################################################################## 109 | 110 | protected 111 | 112 | # Creates a message from a tweet. 113 | def create_message(item) 114 | # Extract tweet identifier 115 | m, id = *item.trackback_permalink.match(/(\d+)$/) 116 | 117 | message = Message.new() 118 | message.source = name 119 | message.source_id = id 120 | message.author = item.trackback_author_nick 121 | message.url = item.url 122 | message.content = item.content 123 | message.created_at = Time.at(item.trackback_date) 124 | 125 | return message 126 | end 127 | 128 | # Creates a topic from a message 129 | def create_topic(message, url=nil) 130 | url ||= message.url 131 | 132 | topic = Topic.first(:url => url) 133 | 134 | if topic.nil? 135 | topic = Topic.new(:source => name, :url => url) 136 | set_topic_name(topic) 137 | 138 | Grapevine.log.debug "#{topic.errors.full_messages.join(',')}" unless topic.valid? 139 | topic.save 140 | Grapevine.log.debug "TOPIC: #{topic.name}" 141 | end 142 | 143 | return topic 144 | end 145 | 146 | # Generates a topic name 147 | def set_topic_name(topic) 148 | topic_name = '' 149 | 150 | # Find topic name from the title of the URL 151 | open(topic.url) do |f| 152 | m, topic_name = *f.read.match(/(.+?)<\/title>/) 153 | topic_name ||= '<unknown>' 154 | end 155 | 156 | topic.name = topic_name[0..250] 157 | end 158 | end 159 | end 160 | end -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Grapevine - Message Aggregator 2 | ============================== 3 | 4 | ## DESCRIPTION 5 | 6 | Grapevine is a server for loading messages from various sources, aggregating and 7 | trending messages into topics, and then sending notifications for trending 8 | topics to Twitter. 9 | 10 | Grapevine follows the rules of [Semantic Versioning](http://semver.org/). 11 | 12 | 13 | ## RUNNING 14 | 15 | To install Grapevine, simply install the gem: 16 | 17 | $ [sudo] gem install grapevine 18 | 19 | And then install the DataMapper adapter you'll be using. By default, Grapevine 20 | will use a SQLite3 database at `~/grapevine.db`. You can change this in the 21 | configuration file. 22 | 23 | $ gem install dm-sqlite-adapter 24 | $ gem install dm-mysql-adapter 25 | 26 | Then run the `grapevine` command to manage your server. 27 | 28 | $ grapevine start 29 | $ grapevine stop 30 | $ grapevine restart 31 | 32 | Grapevine also comes with a command line interface to find more information on 33 | your messages and topics. The following commands are available: 34 | 35 | $ grapevine load 36 | $ grapevine notify 37 | $ grapevine show messages 38 | $ grapevine show notifiers 39 | $ grapevine show notifier [NAME] 40 | $ grapevine show sources 41 | $ grapevine show tags 42 | $ grapevine show topics 43 | $ grapevine show topic [NAME] 44 | 45 | For more information on each command, you can view the inline help: 46 | 47 | $ grapevine help [COMMAND] 48 | 49 | 50 | ## SOURCES & NOTIFIERS 51 | 52 | Grapevine has a pluggable architecture that allows any message sources to be 53 | matched up with any set of topic notification mechanisms. 54 | 55 | Currently Grapevine supports the following sources: 56 | 57 | * `twitter-trackback` - Retrieves tweets for a given site. 58 | * `twitter-github` - Retrieves tweets associated with GitHub projects. Allows 59 | filtering of projects based on programming language. 60 | 61 | And the following notification mechanisms are available: 62 | 63 | * `twitter` - Tweets a message to a specified account for the most popular 64 | aggregated topic at the moment. Frequency of tweets and windowing options can 65 | be specified. 66 | 67 | 68 | ## CONFIGURATION 69 | 70 | Sources, notification mechanisms and other options can be specified in the 71 | `~/grapevine.yml` file. Global configuration options for the Bit.ly API key and 72 | Twitter API consumer keys are listed at the top. 73 | 74 | The following is an example configuration file: 75 | 76 | database: mysql://user@pass:localhost/my_grapevine_db 77 | 78 | bitly_username: johndoe 79 | bitly_api_key: R_ae81e4e8ef7d10728725a57e90e1933 80 | 81 | twitter_consumer_key: YpHAA9xFYruS06yk2Jvxy 82 | twitter_consumer_secret: aMYXpyl4Sa89xx4YD5UwftkveuSfjtoDZlarJHR1ZHH 83 | 84 | sources: 85 | - name: my_github_source 86 | type: twitter-github 87 | frequency: 1h 88 | 89 | notifiers: 90 | - name: github_js 91 | type: twitter 92 | username: github_js 93 | oauth_token: 1023929394-M8wtmerAMnI7ndH9x0ADzHTOWOD0sxx9UsjvgcxNNx 94 | oauth_token_secret: m6Ryi8h7Y6yBxa0x0ffsaWUybE2vrxx8a9sYFnDB9QFG 95 | source: my_github_source 96 | frequency: 1h30m 97 | window: 6M 98 | tags: [language:javascript] 99 | 100 | - name: github_rb 101 | type: twitter 102 | username: github_rb 103 | oauth_token: 310128260-VKGv2UDYMNF0x0A0fsfqZh3QwxiMkd0xfa0sf3vv 104 | oauth_token_secret: GAfa9xk6wyQ98mjXmXfrPN0as00zxkStjxdzwTlEt 105 | source: my_github_source 106 | frequency: 2h 107 | window: 8M 108 | tags: [language:ruby] 109 | 110 | This configuration file sets up a single source to retrieve messages from 111 | Twitter that mention GitHub projects. It then sets up two notifiers to send out 112 | trending topics pulled from `my_github_source` that are tagged with the 113 | `javascript` and `ruby` languages, respectively. The Twitter authorization is 114 | specified for each notifier with the `username`, `oauth_token` and 115 | `oauth_token_secret` settings. 116 | 117 | The `frequency` property sets how often topics will be sent out. In this example 118 | the `github_js` Twitter account will send out every hour and a half while the 119 | `github_rb` Twitter account will send out every two hours. The `window` property 120 | specifies how long until a trending topic can be mentioned again. In this 121 | example, topics can be mentioned again six months after their last mention. 122 | 123 | The `frequency` and `window` properties are time periods that can be defined 124 | in a short hand. The following are the available time periods: 125 | 126 | * `y` - Years 127 | * `M` - Months 128 | * `w` - Weeks 129 | * `d` - Days 130 | * `h` - Hours 131 | * `m` - Minutes 132 | * `s` - Seconds 133 | 134 | 135 | ## TWITTER AUTHORIZATION 136 | 137 | OAuth is not an easy process for people who are not familiar with it. Luckily, 138 | there is [Authoritarian](https://github.com/benbjohnson/authoritarian). 139 | 140 | To start, register your Twitter application here: 141 | 142 | [Twitter Developers](http://dev.twitter.com/) 143 | 144 | Then install Authoritarian and add your application: 145 | 146 | $ gem install authoritarian 147 | $ authoritarian add application 148 | # Follow the prompts 149 | 150 | You can find your Consumer Key and Consumer Secret on your Twitter application's 151 | page at the Twitter Developers site mentioned above. 152 | 153 | Next add the Twitter users you want to authorize: 154 | 155 | $ authoritarian add user 156 | # Input your username and password 157 | 158 | Once you've added all your users, you can find the OAuth token and token secrets 159 | by listing all your users: 160 | 161 | $ authoritarian show users --all 162 | 163 | Simple copy the consumer token and secret to your Grapevine configuration. 164 | 165 | 166 | ## CONTRIBUTE 167 | 168 | If you'd like to contribute to Grapevine, start by forking the repository 169 | on GitHub: 170 | 171 | http://github.com/benbjohnson/grapevine 172 | 173 | Then follow these steps to send your changes: 174 | 175 | 1. Clone down your fork 176 | 1. Create a topic branch to contain your change 177 | 1. Code 178 | 1. All code must have MiniTest::Unit test coverage. 179 | 1. If you are adding new functionality, document it in the README 180 | 1. If necessary, rebase your commits into logical chunks, without errors 181 | 1. Push the branch up to GitHub 182 | 1. Send me a pull request for your branch -------------------------------------------------------------------------------- /spec/fixtures/topsy/search/site_github_later: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Cache-Control: max-age=5 3 | Content-Type: application/json; charset=utf-8 4 | Expires: Sun, 09 Jan 2011 01:12:23 GMT 5 | Last-Modified: Sun, 09 Jan 2011 01:12:18 GMT 6 | Server: lighttpd/1.4.26 7 | Content-Length: 9696 8 | Date: Sun, 09 Jan 2011 01:12:19 GMT 9 | X-Varnish: 655867910 10 | Age: 0 11 | Via: 1.1 varnish 12 | X-Served-By: ps163 13 | X-Cache: MISS 14 | X-RateLimit-Limit: 10000 15 | X-RateLimit-Remaining: 7667 16 | X-RateLimit-Reset: 1294536057 17 | Connection: close 18 | 19 | { 20 | "request": { 21 | "parameters": { 22 | "window": "realtime", 23 | "page": "1", 24 | "q": "github.com", 25 | "type": "cited", 26 | "perpage": "10" 27 | }, 28 | "response_type": "json", 29 | "resource": "search", 30 | "url": "http://otter.topsy.com/search.json?page=1&perpage=10&q=github.com&type=cited&window=realtime" 31 | }, 32 | "response": { 33 | "window": "a", 34 | "page": 1, 35 | "total": 10, 36 | "perpage": 10, 37 | "last_offset": 10, 38 | "hidden": 0, 39 | "list": [{ 40 | "trackback_permalink": "http://twitter.com/nodemodules/status/239038732512313", 41 | "trackback_author_url": "http://twitter.com/topsy_top20k_technology_xxx", 42 | "content": "And MongoDB became even faster:", 43 | "trackback_date": 1299535447, 44 | "topsy_author_img": "", 45 | "hits": 1, 46 | "topsy_trackback_url": "http://topsy.com/trackback?url=https%3A%2F%2Fgithub.com%2Fmongodb%2Fmongo%2Fcommit%2Fbcb127567ddd8690ec1897a34c3fb81f27866b6b&utm_source=otter", 47 | "firstpost_date": 1299535447, 48 | "url": "https://github.com/mongodb/mongo/commit/bcb127567ddd8690ec1897a34c3fb81f27866b6b", 49 | "trackback_author_nick": "topsy_top20k_technology_xxx", 50 | "highlight": "And MongoDB became even faster: ", 51 | "topsy_author_url": "http://topsy.com/twitter/topsy_top20k_technology_xxx?utm_source=otter", 52 | "mytype": "link", 53 | "score": 0.203469, 54 | "trackback_total": 11, 55 | "title": "Commit bcb127567ddd8690ec1897a34c3fb81f27866b6b to mongodb's mongo - GitHub" 56 | }, 57 | { 58 | "trackback_permalink": "http://twitter.com/nodemodules_xxx/status/239038712323", 59 | "trackback_author_url": "http://twitter.com/nodemodules_xxx", 60 | "content": "sesh (0.1.0) - http://bit.ly/fiBwma", 61 | "trackback_date": 1298535447, 62 | "topsy_author_img": "http://a1.twimg.com/profile_images/1121199905/nodemodules_xxx_normal.jpg", 63 | "hits": 1, 64 | "topsy_trackback_url": "http://topsy.com/trackback?url=https%3A%2F%2Fgithub.com%2Fmarak%2Fsession.js&utm_source=otter", 65 | "firstpost_date": 1298535447, 66 | "url": "https://github.com/marak/session.js", 67 | "trackback_author_nick": "nodemodules_xxx", 68 | "highlight": "sesh (0.1.0) - http://bit.ly/fiBwma ", 69 | "topsy_author_url": "http://topsy.com/twitter/nodemodules_xxx?utm_source=otter", 70 | "mytype": "link", 71 | "score": 0.237032, 72 | "trackback_total": 2, 73 | "title": "Marak/session.js - GitHub" 74 | }, 75 | { 76 | "trackback_permalink": "http://twitter.com/coplusk/status/23909517578211328", 77 | "trackback_author_url": "http://twitter.com/coplusk", 78 | "content": "[suggestedshare] http://bit.ly/dEeDxh Tom Waddington - caching", 79 | "trackback_date": 1294535447, 80 | "topsy_author_img": "http://s.twimg.com/a/1292392187/images/default_profile_1_normal.png", 81 | "hits": 1, 82 | "topsy_trackback_url": "http://topsy.com/trackback?url=https%3A%2F%2Fgithub.com%2Ftomwaddington%2Fsuggestedshare%2Fcommit%2F1e4117f001d224cd15039ff030bc39b105f24a13&utm_source=otter", 83 | "firstpost_date": 1294535447, 84 | "url": "https://github.com/tomwaddington/suggestedshare/commit/1e4117f001d224cd15039ff030bc39b105f24a13", 85 | "trackback_author_nick": "coplusk", 86 | "highlight": "[suggestedshare] http://bit.ly/dEeDxh Tom Waddington - caching ", 87 | "topsy_author_url": "http://topsy.com/twitter/coplusk?utm_source=otter", 88 | "mytype": "link", 89 | "score": 0.195851, 90 | "trackback_total": 1, 91 | "title": "suggestedshare] Tom Waddington - caching" 92 | }, 93 | { 94 | "trackback_permalink": "http://twitter.com/oscurrency/status/23907860031209472", 95 | "trackback_author_url": "http://twitter.com/oscurrency", 96 | "content": "[oscurrency] http://bit.ly/es19Dl Tom Brown - groupy branch: display all of the admin of a group on the membership administration page", 97 | "trackback_date": 1294535052, 98 | "topsy_author_img": "http://s.twimg.com/a/1292022067/images/default_profile_1_normal.png", 99 | "hits": 1, 100 | "topsy_trackback_url": "http://topsy.com/trackback?url=https%3A%2F%2Fgithub.com%2Faustintimeexchange%2Foscurrency%2Fcommit%2F35f06c911f2c9b521e24bf73f936b8c783d52e17&utm_source=otter", 101 | "firstpost_date": 1294535052, 102 | "url": "https://github.com/austintimeexchange/oscurrency/commit/35f06c911f2c9b521e24bf73f936b8c783d52e17", 103 | "trackback_author_nick": "oscurrency", 104 | "highlight": "[oscurrency] http://bit.ly/es19Dl Tom Brown - groupy branch: display all of the admin of a group on the membership administration page ", 105 | "topsy_author_url": "http://topsy.com/twitter/oscurrency?utm_source=otter", 106 | "mytype": "link", 107 | "score": 0.189456, 108 | "trackback_total": 1, 109 | "title": "Commit 35f06c911f2c9b521e24bf73f936b8c783d52e17 to austintimeexchange's oscurrency - GitHub" 110 | }, 111 | { 112 | "trackback_permalink": "http://twitter.com/mbolingbroke/status/23905555005317120", 113 | "trackback_author_url": "http://twitter.com/mbolingbroke", 114 | "content": "Implementing the GHC runtime system in Haskell for http://bit.ly/fw0vZH. I never appreciated how subtle async. exceptions were until now..", 115 | "trackback_date": 1294534503, 116 | "topsy_author_img": "http://a0.twimg.com/profile_images/1160468370/me-pumpkin_normal.png", 117 | "hits": 1, 118 | "topsy_trackback_url": "http://topsy.com/trackback?url=https%3A%2F%2Fgithub.com%2Fbatterseapower%2Fconcurrency-test&utm_source=otter", 119 | "firstpost_date": 1294534503, 120 | "url": "https://github.com/batterseapower/concurrency-test", 121 | "trackback_author_nick": "mbolingbroke", 122 | "highlight": "Implementing the GHC runtime system in Haskell for http://bit.ly/fw0vZH. I never appreciated how subtle async. exceptions were until now.. ", 123 | "topsy_author_url": "http://topsy.com/twitter/mbolingbroke?utm_source=otter", 124 | "mytype": "link", 125 | "score": 0.185219, 126 | "trackback_total": 1, 127 | "title": "batterseapower/concurrency-test - GitHub" 128 | }, 129 | { 130 | "trackback_permalink": "http://twitter.com/mariovisic/status/23905388042653696", 131 | "trackback_author_url": "http://twitter.com/mariovisic", 132 | "content": "Lol @ like a boss gem, I still don't know what it does after reading the readme http://j.mp/feBL1f /cc @Sutto", 133 | "trackback_date": 1294534463, 134 | "topsy_author_img": "http://a0.twimg.com/profile_images/1145304275/e535495ad0ff9f536b686c65dc6b7d17_normal.jpeg", 135 | "hits": 1, 136 | "topsy_trackback_url": "http://topsy.com/trackback?url=https%3A%2F%2Fgithub.com%2Fdaneharrigan%2Flike_a_boss&utm_source=otter", 137 | "firstpost_date": 1294534463, 138 | "url": "https://github.com/daneharrigan/like_a_boss", 139 | "trackback_author_nick": "mariovisic", 140 | "highlight": "Lol @ like a boss gem, I still don't know what it does after reading the readme http://j.mp/feBL1f /cc @Sutto ", 141 | "topsy_author_url": "http://topsy.com/twitter/mariovisic?utm_source=otter", 142 | "mytype": "link", 143 | "score": 0.192984, 144 | "trackback_total": 1, 145 | "title": "daneharrigan/like_a_boss - GitHub" 146 | }, 147 | { 148 | "trackback_permalink": "http://twitter.com/mangos_tbc/status/23905130306871296", 149 | "trackback_author_url": "http://twitter.com/mangos_tbc", 150 | "content": "[s0651] Some comments from not backportable commit. http://bit.ly/fxLEL9", 151 | "trackback_date": 1294534401, 152 | "topsy_author_img": "http://a3.twimg.com/profile_images/788634474/mangos_twitter_logo_normal.png", 153 | "hits": 1, 154 | "topsy_trackback_url": "http://topsy.com/trackback?url=https%3A%2F%2Fgithub.com%2Fmangos%2Fone%2Fcommit%2Fee572c8ee639a13bdf9d81d7e451c94e0cb1baa7&utm_source=otter", 155 | "firstpost_date": 1294534401, 156 | "url": "https://github.com/mangos/one/commit/ee572c8ee639a13bdf9d81d7e451c94e0cb1baa7", 157 | "trackback_author_nick": "mangos_tbc", 158 | "highlight": "[s0651] Some comments from not backportable commit. http://bit.ly/fxLEL9 ", 159 | "topsy_author_url": "http://topsy.com/twitter/mangos_tbc?utm_source=otter", 160 | "mytype": "link", 161 | "score": 0.191296, 162 | "trackback_total": 1, 163 | "title": "Commit ee572c8ee639a13bdf9d81d7e451c94e0cb1baa7 to mangos's one - GitHub" 164 | }], 165 | "offset": 0 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /bin/grapevine: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | dir = File.dirname(File.expand_path(__FILE__)) 4 | $:.unshift(File.join(dir, '..', 'lib')) 5 | 6 | require 'rubygems' 7 | require 'grapevine' 8 | require 'commander/import' 9 | require 'terminal-table/import' 10 | 11 | # FOR TESTING 12 | # require 'http/wiretap' 13 | # HTTP::Wiretap.start() 14 | 15 | program :name, 'Grapevine' 16 | program :version, Grapevine::VERSION 17 | program :description, 'A simple message aggregator.' 18 | 19 | 20 | ################################################################################ 21 | # Initialization 22 | ################################################################################ 23 | 24 | # Catch CTRL-C and exit cleanly 25 | trap("INT") do 26 | puts 27 | exit() 28 | end 29 | 30 | # Load configuration properties 31 | Grapevine::Config.load_file() 32 | 33 | # Create a registry of all loaders and notifiers 34 | registry = Grapevine::Registry.new() 35 | registry.load_config() 36 | 37 | require 'grapevine/setup' 38 | 39 | 40 | ################################################################################ 41 | # Source 42 | ################################################################################ 43 | 44 | command :load do |c| 45 | c.syntax = 'grapevine load [name] [options]' 46 | c.description = 'Loads messages from a specified source.' 47 | c.when_called do|args, options| 48 | name = *args 49 | raise 'Source name is required' if name.nil? 50 | loader = registry.get_loader(name) 51 | raise "Source not found: #{name}" if loader.nil? 52 | loader.load() 53 | end 54 | end 55 | 56 | command :'show sources' do |c| 57 | c.syntax = 'grapevine show source [options]' 58 | c.description = 'Shows all registered sources.' 59 | c.when_called do|args, options| 60 | loaders = registry.loaders 61 | 62 | if loaders.length == 0 63 | puts "No sources have been registered." 64 | else 65 | tbl = table do |t| 66 | t.headings = 'Name', 'Type', 'Frequency (s)' 67 | loaders.each do |loader| 68 | t << [loader.name, loader.class.to_s.split(':').pop, loader.frequency] 69 | end 70 | end 71 | 72 | puts tbl 73 | end 74 | end 75 | end 76 | 77 | 78 | ################################################################################ 79 | # Notifiers 80 | ################################################################################ 81 | 82 | command :notify do |c| 83 | c.syntax = 'grapevine notify [name] [options]' 84 | c.description = 'Manually sends a notification for a given notifier.' 85 | c.option '--force', 'Ignores the frequency of notification when sending.' 86 | c.when_called do|args, options| 87 | name = *args 88 | raise 'Notifier name is required' if name.nil? 89 | notifier = registry.get_notifier(name) 90 | raise "Notifier not found: #{name}" if notifier.nil? 91 | notifier.send(:force => options.force) 92 | end 93 | end 94 | 95 | command :'show notifiers' do |c| 96 | c.syntax = 'grapevine show notifiers [options]' 97 | c.description = 'Shows all registered notifiers.' 98 | c.when_called do|args, options| 99 | notifiers = registry.notifiers 100 | 101 | if notifiers.length == 0 102 | puts "No notifiers have been registered." 103 | else 104 | tbl = table do |t| 105 | t.headings = 'Name', 'Type', 'Frequency (s)' 106 | notifiers.each do |notifier| 107 | t << [ 108 | notifier.name, 109 | notifier.class.to_s.split(':').pop, 110 | notifier.frequency 111 | ] 112 | end 113 | end 114 | puts tbl 115 | end 116 | end 117 | end 118 | 119 | command :'show notifier' do |c| 120 | c.syntax = 'grapevine show notifier [name] [options]' 121 | c.description = 'Shows details about a single registered notifier.' 122 | c.when_called do|args, options| 123 | name = *args 124 | raise 'Notifier name is required' if name.nil? 125 | notifier = registry.get_notifier(name) 126 | raise "Notifier not found: #{name}" if notifier.nil? 127 | 128 | topics = notifier.popular_topics 129 | notifications = Notification.all(:source => name) 130 | 131 | puts "Name: #{notifier.name}" 132 | puts "Source: #{notifier.source}" 133 | puts "Frequency: #{notifier.frequency}s" 134 | puts "Window: #{notifier.window }s" 135 | puts "Tags: #{notifier.tags.join(',')}" 136 | puts "State: #{notifier.state}" 137 | puts "" 138 | puts "Popular Topics (#{topics.length})" 139 | topics[0..9].each_index do |index| 140 | topic = topics[index] 141 | puts "#{index+1}. #{topic.name} (#{topic.messages.length} msgs)" 142 | end 143 | puts "" 144 | end 145 | end 146 | 147 | command :'clear notifications' do |c| 148 | c.syntax = 'grapevine clear notifications [name] [options]' 149 | c.description = 'Removes all notifications for a notifier.' 150 | c.when_called do|args, options| 151 | name = *args 152 | raise 'Notifier name is required' if name.nil? 153 | 154 | notifications = Notification.all(:source => name) 155 | count = notifications.length 156 | notifications.destroy 157 | puts "#{notifications.length} notifications were removed." 158 | end 159 | end 160 | 161 | 162 | ################################################################################ 163 | # Topics 164 | ################################################################################ 165 | 166 | command :'show topics' do |c| 167 | c.syntax = 'grapevine show topics' 168 | c.description = 'Displays the topics in the database.' 169 | c.option '--tag TAG', String, 'Filters the topics by tag.' 170 | c.when_called do|args, options| 171 | topics = Topic.all 172 | tag = options.tag 173 | 174 | if topics.length == 0 175 | say "No topics exist in the database" 176 | else 177 | user_table = table do |t| 178 | t.headings = 'ID', 'Msgs', 'Name', 'Description' 179 | topics.each do |topic| 180 | # Filter by tag if specified 181 | if !tag.nil? 182 | match = false 183 | topic.tags.each do |item| 184 | if "#{item.type}:#{item.value}" == tag 185 | match = true 186 | end 187 | end 188 | next unless match 189 | end 190 | 191 | t << [topic.id, topic.messages.length, topic.name[0..20], topic.description[0..35]] 192 | end 193 | end 194 | 195 | puts user_table 196 | end 197 | end 198 | end 199 | 200 | command :'show topic' do |c| 201 | c.syntax = 'grapevine show topic [name] [options]' 202 | c.description = 'Shows details about a single topic.' 203 | c.when_called do|args, options| 204 | name = *args 205 | raise 'Topic name is required' if name.nil? 206 | topic = Topic.first(:name => name) 207 | raise "Topic not found: #{name}" if topic.nil? 208 | 209 | puts "Name: #{topic.name}" 210 | puts "Desc: #{topic.description}" 211 | puts "URL: #{topic.url}" 212 | puts "" 213 | puts "Tags (#{topic.tags.length})" 214 | topic.tags.each do |tag| 215 | puts "- #{tag.type}:#{tag.value}" 216 | end 217 | puts "" 218 | puts "Messages (#{topic.messages.length})" 219 | topic.messages[0..9].each do |message| 220 | puts "- #{message.source_id}: #{message.author}" 221 | end 222 | puts "" 223 | end 224 | end 225 | 226 | 227 | ################################################################################ 228 | # Messages 229 | ################################################################################ 230 | 231 | command :'show messages' do |c| 232 | c.syntax = 'grapevine show messages [options]' 233 | c.description = 'Displays the messages in the database.' 234 | c.option '--topic-id ID', String, 'Filters the messages by topic id.' 235 | 236 | c.when_called do|args, options| 237 | filter = {} 238 | filter.merge!(:topic_id => options.topic_id.to_i) if options.topic_id 239 | messages = Message.all(filter) 240 | 241 | if messages.length > 0 242 | user_table = table do |t| 243 | t.headings = 'ID', 'Source ID', 'Author' 244 | messages.each do |message| 245 | t << [message.id, message.source_id, message.author[0..50]] 246 | end 247 | end 248 | 249 | puts user_table 250 | end 251 | end 252 | end 253 | 254 | 255 | ################################################################################ 256 | # Tags 257 | ################################################################################ 258 | 259 | command :'show tags' do |c| 260 | c.syntax = 'grapevine show tags' 261 | c.description = 'Displays a list of all tags in the database.' 262 | c.when_called do|args, options| 263 | tags = Tag.all(:order => [:type, :value]) 264 | 265 | if tags.length == 0 266 | say "No tags exist in the database." 267 | else 268 | tbl = table do |t| 269 | t.headings = 'Type', 'Value', 'Topics' 270 | tags.each do |tag| 271 | t << [tag.type, tag.value, tag.topics.length] 272 | end 273 | end 274 | puts tbl 275 | end 276 | end 277 | end 278 | 279 | 280 | ################################################################################ 281 | # Database Maintenance 282 | ################################################################################ 283 | 284 | command :info do |c| 285 | c.syntax = 'grapevine info' 286 | c.description = 'Displays information about the contents of the database.' 287 | c.when_called do|args, options| 288 | puts "Database: #{Grapevine::Config.database}" 289 | puts "Topics: #{Topic.all.length}" 290 | puts "Messages: #{Message.all.length}" 291 | puts "Tags: #{Tag.all.length}" 292 | puts "Notifications: #{Notification.all.length}" 293 | end 294 | end 295 | 296 | command :clean do |c| 297 | c.syntax = 'grapevine clean' 298 | c.description = 'Removes all data from the grapevine database.' 299 | c.when_called do|args, options| 300 | DataMapper.auto_migrate! 301 | end 302 | end 303 | 304 | -------------------------------------------------------------------------------- /spec/fixtures/topsy/search/site_github: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Cache-Control: max-age=5 3 | Content-Type: application/json; charset=utf-8 4 | Expires: Sun, 09 Jan 2011 01:12:23 GMT 5 | Last-Modified: Sun, 09 Jan 2011 01:12:18 GMT 6 | Server: lighttpd/1.4.26 7 | Content-Length: 9696 8 | Date: Sun, 09 Jan 2011 01:12:19 GMT 9 | X-Varnish: 655867910 10 | Age: 0 11 | Via: 1.1 varnish 12 | X-Served-By: ps163 13 | X-Cache: MISS 14 | X-RateLimit-Limit: 10000 15 | X-RateLimit-Remaining: 7667 16 | X-RateLimit-Reset: 1294536057 17 | Connection: close 18 | 19 | { 20 | "request": { 21 | "parameters": { 22 | "window": "realtime", 23 | "page": "1", 24 | "q": "github.com", 25 | "type": "cited", 26 | "perpage": "10" 27 | }, 28 | "response_type": "json", 29 | "resource": "search", 30 | "url": "http://otter.topsy.com/search.json?page=1&perpage=10&q=github.com&type=cited&window=realtime" 31 | }, 32 | "response": { 33 | "window": "a", 34 | "page": 1, 35 | "total": 10, 36 | "perpage": 10, 37 | "last_offset": 10, 38 | "hidden": 0, 39 | "list": [{ 40 | "trackback_permalink": "http://twitter.com/coplusk/status/23909517578211328", 41 | "trackback_author_url": "http://twitter.com/coplusk", 42 | "content": "[suggestedshare] http://bit.ly/dEeDxh Tom Waddington - caching", 43 | "trackback_date": 1294535447, 44 | "topsy_author_img": "http://s.twimg.com/a/1292392187/images/default_profile_1_normal.png", 45 | "hits": 1, 46 | "topsy_trackback_url": "http://topsy.com/trackback?url=https%3A%2F%2Fgithub.com%2Ftomwaddington%2Fsuggestedshare%2Fcommit%2F1e4117f001d224cd15039ff030bc39b105f24a13&utm_source=otter", 47 | "firstpost_date": 1294535447, 48 | "url": "https://github.com/tomwaddington/suggestedshare/commit/1e4117f001d224cd15039ff030bc39b105f24a13", 49 | "trackback_author_nick": "coplusk", 50 | "highlight": "[suggestedshare] http://bit.ly/dEeDxh Tom Waddington - caching ", 51 | "topsy_author_url": "http://topsy.com/twitter/coplusk?utm_source=otter", 52 | "mytype": "link", 53 | "score": 0.195851, 54 | "trackback_total": 1, 55 | "title": "suggestedshare] Tom Waddington - caching" 56 | }, 57 | { 58 | "trackback_permalink": "http://twitter.com/oscurrency/status/23907860031209472", 59 | "trackback_author_url": "http://twitter.com/oscurrency", 60 | "content": "[oscurrency] http://bit.ly/es19Dl Tom Brown - groupy branch: display all of the admin of a group on the membership administration page", 61 | "trackback_date": 1294535052, 62 | "topsy_author_img": "http://s.twimg.com/a/1292022067/images/default_profile_1_normal.png", 63 | "hits": 1, 64 | "topsy_trackback_url": "http://topsy.com/trackback?url=https%3A%2F%2Fgithub.com%2Faustintimeexchange%2Foscurrency%2Fcommit%2F35f06c911f2c9b521e24bf73f936b8c783d52e17&utm_source=otter", 65 | "firstpost_date": 1294535052, 66 | "url": "https://github.com/austintimeexchange/oscurrency/commit/35f06c911f2c9b521e24bf73f936b8c783d52e17", 67 | "trackback_author_nick": "oscurrency", 68 | "highlight": "[oscurrency] http://bit.ly/es19Dl Tom Brown - groupy branch: display all of the admin of a group on the membership administration page ", 69 | "topsy_author_url": "http://topsy.com/twitter/oscurrency?utm_source=otter", 70 | "mytype": "link", 71 | "score": 0.189456, 72 | "trackback_total": 1, 73 | "title": "Commit 35f06c911f2c9b521e24bf73f936b8c783d52e17 to austintimeexchange's oscurrency - GitHub" 74 | }, 75 | { 76 | "trackback_permalink": "http://twitter.com/mbolingbroke/status/23905555005317120", 77 | "trackback_author_url": "http://twitter.com/mbolingbroke", 78 | "content": "Implementing the GHC runtime system in Haskell for http://bit.ly/fw0vZH. I never appreciated how subtle async. exceptions were until now..", 79 | "trackback_date": 1294534503, 80 | "topsy_author_img": "http://a0.twimg.com/profile_images/1160468370/me-pumpkin_normal.png", 81 | "hits": 1, 82 | "topsy_trackback_url": "http://topsy.com/trackback?url=https%3A%2F%2Fgithub.com%2Fbatterseapower%2Fconcurrency-test&utm_source=otter", 83 | "firstpost_date": 1294534503, 84 | "url": "https://github.com/batterseapower/concurrency-test", 85 | "trackback_author_nick": "mbolingbroke", 86 | "highlight": "Implementing the GHC runtime system in Haskell for http://bit.ly/fw0vZH. I never appreciated how subtle async. exceptions were until now.. ", 87 | "topsy_author_url": "http://topsy.com/twitter/mbolingbroke?utm_source=otter", 88 | "mytype": "link", 89 | "score": 0.185219, 90 | "trackback_total": 1, 91 | "title": "batterseapower/concurrency-test - GitHub" 92 | }, 93 | { 94 | "trackback_permalink": "http://twitter.com/mariovisic/status/23905388042653696", 95 | "trackback_author_url": "http://twitter.com/mariovisic", 96 | "content": "Lol @ like a boss gem, I still don't know what it does after reading the readme http://j.mp/feBL1f /cc @Sutto", 97 | "trackback_date": 1294534463, 98 | "topsy_author_img": "http://a0.twimg.com/profile_images/1145304275/e535495ad0ff9f536b686c65dc6b7d17_normal.jpeg", 99 | "hits": 1, 100 | "topsy_trackback_url": "http://topsy.com/trackback?url=https%3A%2F%2Fgithub.com%2Fdaneharrigan%2Flike_a_boss&utm_source=otter", 101 | "firstpost_date": 1294534463, 102 | "url": "https://github.com/daneharrigan/like_a_boss", 103 | "trackback_author_nick": "mariovisic", 104 | "highlight": "Lol @ like a boss gem, I still don't know what it does after reading the readme http://j.mp/feBL1f /cc @Sutto ", 105 | "topsy_author_url": "http://topsy.com/twitter/mariovisic?utm_source=otter", 106 | "mytype": "link", 107 | "score": 0.192984, 108 | "trackback_total": 1, 109 | "title": "daneharrigan/like_a_boss - GitHub" 110 | }, 111 | { 112 | "trackback_permalink": "http://twitter.com/mangos_tbc/status/23905130306871296", 113 | "trackback_author_url": "http://twitter.com/mangos_tbc", 114 | "content": "[s0651] Some comments from not backportable commit. http://bit.ly/fxLEL9", 115 | "trackback_date": 1294534401, 116 | "topsy_author_img": "http://a3.twimg.com/profile_images/788634474/mangos_twitter_logo_normal.png", 117 | "hits": 1, 118 | "topsy_trackback_url": "http://topsy.com/trackback?url=https%3A%2F%2Fgithub.com%2Fmangos%2Fone%2Fcommit%2Fee572c8ee639a13bdf9d81d7e451c94e0cb1baa7&utm_source=otter", 119 | "firstpost_date": 1294534401, 120 | "url": "https://github.com/mangos/one/commit/ee572c8ee639a13bdf9d81d7e451c94e0cb1baa7", 121 | "trackback_author_nick": "mangos_tbc", 122 | "highlight": "[s0651] Some comments from not backportable commit. http://bit.ly/fxLEL9 ", 123 | "topsy_author_url": "http://topsy.com/twitter/mangos_tbc?utm_source=otter", 124 | "mytype": "link", 125 | "score": 0.191296, 126 | "trackback_total": 1, 127 | "title": "Commit ee572c8ee639a13bdf9d81d7e451c94e0cb1baa7 to mangos's one - GitHub" 128 | }, 129 | { 130 | "trackback_permalink": "http://twitter.com/nodemodules/status/23903873252", 131 | "trackback_author_url": "http://twitter.com/topsy_top20k_technology", 132 | "content": "And MongoDB became even faster:", 133 | "trackback_date": 1294534144, 134 | "topsy_author_img": "", 135 | "hits": 1, 136 | "topsy_trackback_url": "http://topsy.com/trackback?url=https%3A%2F%2Fgithub.com%2Fmongodb%2Fmongo%2Fcommit%2Fbcb127567ddd8690ec1897a34c3fb81f27866b6b&utm_source=otter", 137 | "firstpost_date": 1294520325, 138 | "url": "https://github.com/mongodb/mongo/commit/bcb127567ddd8690ec1897a34c3fb81f27866b6b", 139 | "trackback_author_nick": "topsy_top20k_technology", 140 | "highlight": "And MongoDB became even faster: ", 141 | "topsy_author_url": "http://topsy.com/twitter/topsy_top20k_technology?utm_source=otter", 142 | "mytype": "link", 143 | "score": 0.203469, 144 | "trackback_total": 11, 145 | "title": "Commit bcb127567ddd8690ec1897a34c3fb81f27866b6b to mongodb's mongo - GitHub" 146 | }, 147 | { 148 | "trackback_permalink": "http://twitter.com/nodemodules/status/23903873252982784", 149 | "trackback_author_url": "http://twitter.com/nodemodules", 150 | "content": "sesh (0.1.0) - http://bit.ly/fiBwma", 151 | "trackback_date": 1294534102, 152 | "topsy_author_img": "http://a1.twimg.com/profile_images/1121199905/nodemodules_normal.jpg", 153 | "hits": 1, 154 | "topsy_trackback_url": "http://topsy.com/trackback?url=https%3A%2F%2Fgithub.com%2Fmarak%2Fsession.js&utm_source=otter", 155 | "firstpost_date": 1290847884, 156 | "url": "https://github.com/marak/session.js", 157 | "trackback_author_nick": "nodemodules", 158 | "highlight": "sesh (0.1.0) - http://bit.ly/fiBwma ", 159 | "topsy_author_url": "http://topsy.com/twitter/nodemodules?utm_source=otter", 160 | "mytype": "link", 161 | "score": 0.237032, 162 | "trackback_total": 2, 163 | "title": "Marak/session.js - GitHub" 164 | }], 165 | "offset": 0 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /spec/fixtures/topsy/search/site_stackoverflow: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Cache-Control: max-age=5 3 | Content-Type: application/json; charset=utf-8 4 | Expires: Wed, 12 Jan 2011 00:10:16 GMT 5 | Last-Modified: Wed, 12 Jan 2011 00:10:11 GMT 6 | Server: lighttpd/1.4.26 7 | Content-Length: 10230 8 | Date: Wed, 12 Jan 2011 00:10:11 GMT 9 | X-Varnish: 1080030036 10 | Age: 0 11 | Via: 1.1 varnish 12 | X-Served-By: ps391 13 | X-Cache: MISS 14 | X-RateLimit-Limit: 10000 15 | X-RateLimit-Remaining: 9990 16 | X-RateLimit-Reset: 1294794611 17 | Connection: close 18 | 19 | { 20 | "request": { 21 | "parameters": { 22 | "window": "realtime", 23 | "page": "1", 24 | "q": "stackoverflow.com", 25 | "type": "cited", 26 | "perpage": "10" 27 | }, 28 | "response_type": "json", 29 | "resource": "search", 30 | "url": "http://otter.topsy.com/search.json?page=1&perpage=10&q=stackoverflow.com&type=cited&window=realtime" 31 | }, 32 | "response": { 33 | "window": "a", 34 | "page": 1, 35 | "total": 504271, 36 | "perpage": 10, 37 | "last_offset": 10, 38 | "hidden": 0, 39 | "list": [{ 40 | "trackback_permalink": "http://twitter.com/iphoneatso/status/24981377287987201", 41 | "trackback_author_url": "http://twitter.com/iphoneatso", 42 | "content": "iPhone - keyboard with OK button to dismiss, with return key accepted in the UITextView http://bit.ly/fo5wTS", 43 | "trackback_date": 1294790999, 44 | "topsy_author_img": "http://a1.twimg.com/a/1292975674/images/default_profile_1_normal.png", 45 | "hits": 1, 46 | "topsy_trackback_url": "http://topsy.com/stackoverflow.com/questions/4663725/iphone-keyboard-with-ok-button-to-dismiss-with-return-key-accepted-in-the-uite?utm_source=otter", 47 | "firstpost_date": 1294788565, 48 | "url": "http://stackoverflow.com/questions/4663725/iphone-keyboard-with-ok-button-to-dismiss-with-return-key-accepted-in-the-uite", 49 | "trackback_author_nick": "iphoneatso", 50 | "highlight": "iPhone - keyboard with OK button to dismiss, with return key accepted in the UITextView http://bit.ly/fo5wTS ", 51 | "topsy_author_url": "http://topsy.com/twitter/iphoneatso?utm_source=otter", 52 | "mytype": "link", 53 | "score": 0.201032, 54 | "trackback_total": 2, 55 | "title": "iPhone - keyboard with OK button to dismiss, with return key accepted in the UITextView - Stack Overflow" 56 | }, 57 | { 58 | "trackback_permalink": "http://twitter.com/railsatso/status/24981352193466368", 59 | "trackback_author_url": "http://twitter.com/railsatso", 60 | "content": "Testing helpers in Rails 3 with Rspec 2 and Devise http://bit.ly/ewtw8a", 61 | "trackback_date": 1294790993, 62 | "topsy_author_img": "http://a0.twimg.com/profile_images/1124673124/RailsAtSO_normal.png", 63 | "hits": 1, 64 | "topsy_trackback_url": "http://topsy.com/stackoverflow.com/questions/4663897/testing-helpers-in-rails-3-with-rspec-2-and-devise?utm_source=otter", 65 | "firstpost_date": 1294790993, 66 | "url": "http://stackoverflow.com/questions/4663897/testing-helpers-in-rails-3-with-rspec-2-and-devise", 67 | "trackback_author_nick": "railsatso", 68 | "highlight": "Testing helpers in Rails 3 with Rspec 2 and Devise http://bit.ly/ewtw8a ", 69 | "topsy_author_url": "http://topsy.com/twitter/railsatso?utm_source=otter", 70 | "mytype": "link", 71 | "score": 0.211638, 72 | "trackback_total": 1, 73 | "title": "Testing helpers in Rails 3 with Rspec 2 and Devise" 74 | }, 75 | { 76 | "trackback_permalink": "http://twitter.com/lanzalibre/status/24980639421833216", 77 | "trackback_author_url": "http://twitter.com/lanzalibre", 78 | "content": "#StackOverflow package import problem in mathematica http://bit.ly/h6HNgY", 79 | "trackback_date": 1294790823, 80 | "topsy_author_img": "http://a2.twimg.com/profile_images/859238013/lanzalibre_normal.png", 81 | "hits": 1, 82 | "topsy_trackback_url": "http://topsy.com/stackoverflow.com/questions/4664091/package-import-problem-in-mathematica?utm_source=otter", 83 | "firstpost_date": 1294790823, 84 | "url": "http://stackoverflow.com/questions/4664091/package-import-problem-in-mathematica", 85 | "trackback_author_nick": "lanzalibre", 86 | "highlight": "#<span class=\"highlight-term\">StackOverflow</span> package import problem in mathematica http://bit.ly/h6HNgY ", 87 | "topsy_author_url": "http://topsy.com/twitter/lanzalibre?utm_source=otter", 88 | "mytype": "link", 89 | "score": 0.220167, 90 | "trackback_total": 1, 91 | "title": "package import problem in mathematica - Stack Overflow" 92 | }, 93 | { 94 | "trackback_permalink": "http://twitter.com/stackappbot/status/24980188450267137", 95 | "trackback_author_url": "http://twitter.com/stackappbot", 96 | "content": "@dezfafara Stack Overflow post http://bit.ly/gjv2F8 WPF 4 DataGrid: Getting the Row Number into the RowHeader", 97 | "trackback_date": 1294790715, 98 | "topsy_author_img": "http://a3.twimg.com/profile_images/1035018377/phead_crop_normal.jpg", 99 | "hits": 1, 100 | "topsy_trackback_url": "http://topsy.com/stackoverflow.com/questions/4663771/wpf-4-datagrid-getting-the-row-number-into-the-rowheader?utm_source=otter", 101 | "firstpost_date": 1294790715, 102 | "url": "http://stackoverflow.com/questions/4663771/wpf-4-datagrid-getting-the-row-number-into-the-rowheader", 103 | "trackback_author_nick": "stackappbot", 104 | "highlight": "@dezfafara Stack Overflow post http://bit.ly/gjv2F8 WPF 4 DataGrid: Getting the Row Number into the RowHeader ", 105 | "topsy_author_url": "http://topsy.com/twitter/stackappbot?utm_source=otter", 106 | "mytype": "link", 107 | "score": 0.170313, 108 | "trackback_total": 1, 109 | "title": "wpfdatagrid - WPF 4 DataGrid: Getting the Row Number into the RowHeader - Stack Overflow" 110 | }, 111 | { 112 | "trackback_permalink": "http://twitter.com/stackappbot/status/24980181798092800", 113 | "trackback_author_url": "http://twitter.com/stackappbot", 114 | "content": "@dezfafara Stack Overflow post http://bit.ly/hNFkbz WPF ClickOnce Bootstrap Dection Failure on One Machine", 115 | "trackback_date": 1294790714, 116 | "topsy_author_img": "http://a3.twimg.com/profile_images/1035018377/phead_crop_normal.jpg", 117 | "hits": 1, 118 | "topsy_trackback_url": "http://topsy.com/stackoverflow.com/questions/4663810/wpf-clickonce-bootstrap-dection-failure-on-one-machine?utm_source=otter", 119 | "firstpost_date": 1294790714, 120 | "url": "http://stackoverflow.com/questions/4663810/wpf-clickonce-bootstrap-dection-failure-on-one-machine", 121 | "trackback_author_nick": "stackappbot", 122 | "highlight": "@dezfafara Stack Overflow post http://bit.ly/hNFkbz WPF ClickOnce Bootstrap Dection Failure on One Machine ", 123 | "topsy_author_url": "http://topsy.com/twitter/stackappbot?utm_source=otter", 124 | "mytype": "link", 125 | "score": 0.157834, 126 | "trackback_total": 1, 127 | "title": "WPF ClickOnce Bootstrap Dection Failure on One Machine - Stack Overflow" 128 | }, 129 | { 130 | "trackback_permalink": "http://twitter.com/stackappbot/status/24980175414362112", 131 | "trackback_author_url": "http://twitter.com/stackappbot", 132 | "content": "@dezfafara Stack Overflow post http://bit.ly/dMGyEq WPF grid's SharedSizeGroup and * sizing", 133 | "trackback_date": 1294790712, 134 | "topsy_author_img": "http://a3.twimg.com/profile_images/1035018377/phead_crop_normal.jpg", 135 | "hits": 1, 136 | "topsy_trackback_url": "http://topsy.com/stackoverflow.com/questions/4664008/wpf-grids-sharedsizegroup-and-sizing?utm_source=otter", 137 | "firstpost_date": 1294790712, 138 | "url": "http://stackoverflow.com/questions/4664008/wpf-grids-sharedsizegroup-and-sizing", 139 | "trackback_author_nick": "stackappbot", 140 | "highlight": "@dezfafara Stack Overflow post http://bit.ly/dMGyEq WPF grid's SharedSizeGroup and * sizing ", 141 | "topsy_author_url": "http://topsy.com/twitter/stackappbot?utm_source=otter", 142 | "mytype": "link", 143 | "score": 0.172035, 144 | "trackback_total": 1, 145 | "title": "wpf controls - WPF grid's SharedSizeGroup and * sizing - Stack Overflow" 146 | }, 147 | { 148 | "trackback_permalink": "http://twitter.com/stackappbot/status/24980164735664129", 149 | "trackback_author_url": "http://twitter.com/stackappbot", 150 | "content": "@dezfafara Stack Overflow post http://bit.ly/ex2O49 Accessing User.Identity from Master Page", 151 | "trackback_date": 1294790710, 152 | "topsy_author_img": "http://a3.twimg.com/profile_images/1035018377/phead_crop_normal.jpg", 153 | "hits": 1, 154 | "topsy_trackback_url": "http://topsy.com/stackoverflow.com/questions/4663968/accessing-user-identity-from-master-page?utm_source=otter", 155 | "firstpost_date": 1294790109, 156 | "url": "http://stackoverflow.com/questions/4663968/accessing-user-identity-from-master-page", 157 | "trackback_author_nick": "stackappbot", 158 | "highlight": "@dezfafara Stack Overflow post http://bit.ly/ex2O49 Accessing User.Identity from Master Page ", 159 | "topsy_author_url": "http://topsy.com/twitter/stackappbot?utm_source=otter", 160 | "mytype": "link", 161 | "score": 0.18986, 162 | "trackback_total": 2, 163 | "title": "c# - Accessing User.Identity from Master Page - Stack Overflow" 164 | }, 165 | { 166 | "trackback_permalink": "http://twitter.com/stackappbot/status/24980147757121537", 167 | "trackback_author_url": "http://twitter.com/stackappbot", 168 | "content": "@dezfafara Stack Overflow post http://bit.ly/enhGN4 System.Runtime.InteropServices.COMException (0x80040154):", 169 | "trackback_date": 1294790705, 170 | "topsy_author_img": "http://a3.twimg.com/profile_images/1035018377/phead_crop_normal.jpg", 171 | "hits": 1, 172 | "topsy_trackback_url": "http://topsy.com/stackoverflow.com/questions/4663994/system-runtime-interopservices-comexception-0x80040154?utm_source=otter", 173 | "firstpost_date": 1294790104, 174 | "url": "http://stackoverflow.com/questions/4663994/system-runtime-interopservices-comexception-0x80040154", 175 | "trackback_author_nick": "stackappbot", 176 | "highlight": "@dezfafara Stack Overflow post http://bit.ly/enhGN4 System.Runtime.InteropServices.COMException (0x80040154): ", 177 | "topsy_author_url": "http://topsy.com/twitter/stackappbot?utm_source=otter", 178 | "mytype": "link", 179 | "score": 0.158091, 180 | "trackback_total": 2, 181 | "title": "c# - System.Runtime.InteropServices.COMException (0x80040154): - Stack Overflow" 182 | }, 183 | { 184 | "trackback_permalink": "http://twitter.com/javascriptatso/status/24980143457968128", 185 | "trackback_author_url": "http://twitter.com/javascriptatso", 186 | "content": "Using jQuery in a class http://bit.ly/i2rIes", 187 | "trackback_date": 1294790704, 188 | "topsy_author_img": "http://a2.twimg.com/a/1292975674/images/default_profile_2_normal.png", 189 | "hits": 1, 190 | "topsy_trackback_url": "http://topsy.com/stackoverflow.com/questions/4663739/using-jquery-in-a-class?utm_source=otter", 191 | "firstpost_date": 1294790704, 192 | "url": "http://stackoverflow.com/questions/4663739/using-jquery-in-a-class", 193 | "trackback_author_nick": "javascriptatso", 194 | "highlight": "Using jQuery in a class http://bit.ly/i2rIes ", 195 | "topsy_author_url": "http://topsy.com/twitter/javascriptatso?utm_source=otter", 196 | "mytype": "link", 197 | "score": 0.275008, 198 | "trackback_total": 1, 199 | "title": "javascript - Using jQuery in a class - Stack Overflow" 200 | }, 201 | { 202 | "trackback_permalink": "http://twitter.com/javascriptatso/status/24980139313987586", 203 | "trackback_author_url": "http://twitter.com/javascriptatso", 204 | "content": "How to exclude undesired descendants? http://bit.ly/hQHSAP", 205 | "trackback_date": 1294790703, 206 | "topsy_author_img": "http://a2.twimg.com/a/1292975674/images/default_profile_2_normal.png", 207 | "hits": 1, 208 | "topsy_trackback_url": "http://topsy.com/stackoverflow.com/questions/4663793/how-to-exclude-undesired-descendants?utm_source=otter", 209 | "firstpost_date": 1294790703, 210 | "url": "http://stackoverflow.com/questions/4663793/how-to-exclude-undesired-descendants", 211 | "trackback_author_nick": "javascriptatso", 212 | "highlight": "How to exclude undesired descendants? http://bit.ly/hQHSAP ", 213 | "topsy_author_url": "http://topsy.com/twitter/javascriptatso?utm_source=otter", 214 | "mytype": "link", 215 | "score": 0.20034, 216 | "trackback_total": 1, 217 | "title": "javascript - How to exclude undesired descendants? - Stack Overflow" 218 | }], 219 | "offset": 0 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /spec/fixtures/stackoverflow/4663725: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Cache-Control: public, max-age=60 3 | Content-Type: text/html; charset=utf-8 4 | Expires: Wed, 12 Jan 2011 00:13:41 GMT 5 | Last-Modified: Wed, 12 Jan 2011 00:12:41 GMT 6 | Vary: * 7 | Date: Wed, 12 Jan 2011 00:12:40 GMT 8 | Content-Length: 32383 9 | 10 | 11 | 12 | <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd"> 13 | <html> 14 | <head> 15 | 16 | <title>iPhone - keyboard with OK button to dismiss, with return key accepted in the UITextView - Stack Overflow 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 |
62 | 63 | 66 | 67 |
68 |
69 |
70 | 130 | 131 |
132 | 133 | 134 | 137 | 138 |
139 | 140 | 141 | 142 |
143 | 144 |
145 |
146 | 147 | 148 | 149 | 150 | 151 | 152 | 166 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 217 | 218 | 219 |
153 | 154 |
155 | 156 | up vote 157 | 0 158 | down vote 159 | 160 | favorite 161 |
162 | 163 |
164 | 165 |
167 |
168 |
169 |

I'm searching for a good method to display a keyboard with a done button to allow it to dismiss when the user have finished modifying a UITextView. The UITextView may accept return keys so I can't use the keyboard button to dismiss it.

170 | 171 |

It would be also really great if this keyboard was modal, or if a click anywhere on another control than the UITextView would dismiss it.

172 | 173 |

Do you know how to do it and do it well ?

174 | 175 |

I tried a lot of code found on the Web, and I'm a little bit tired for now trying to make such a simple and standard thing...

176 | 177 |
178 | 181 | 182 | 183 | 187 | 188 | 194 | 195 |
184 |
link|flag
185 | 186 |
189 | 190 |
191 | 192 | 193 |
196 |
197 |
205 |
206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 |
214 |
215 | 216 |
220 | 221 |
222 | 223 | 224 | 225 | 226 |
227 | 228 | 229 |
230 |
231 |

1 Answer

232 |
233 | active 234 | newest 235 | votes 236 | 237 |
238 |
239 |
240 | 241 | 242 | 243 | 244 |
245 | 246 | 247 | 248 | 259 | 277 | 278 | 279 | 280 | 281 | 282 | 313 | 314 |
249 | 250 |
251 | 252 | up vote 253 | 1 254 | down vote 255 | accepted 256 |
257 | 258 |
260 |

Take a look at Custom Views for Data Input.

261 | 262 |

Also, you don't "click" in iOS, you "tap".

263 |
264 | 265 | 266 | 270 | 271 | 274 | 275 |
267 |
link|flag
268 | 269 |
272 | 273 |
276 |
283 |
284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 |
@Jim : Thank you. While I'm trying, can you tell me if you know how to prevent theuser to make changes in the windows while editing the textView (modal keyboard... transparent things... resign everywhere...) ? – Oliver 37 mins ago
As a general rule of thumb, it's better usability-wise to set up the controls in a standard way and let the user get on with it rather than micromanaging the user's interactions. Most of the time when people ask me to do something like this, it's because they are trying to ignore the medium and break platform conventions. Apple seem to deliberately design the SDK to make this difficult. – Jim 29 mins ago
@Jim : I've read the apple doc, but I can't achieve setting the inputAccessoryView. Can you help me to place the code at the right place ? I'm initing with a nib. – Oliver 24 mins ago
Every UIResponder subclass (such as UITextView) has the inputAccessoryView property. So you need to assign a view to that property for each text view you want to have the extra button. Create an IBOutlet in your view controller for the text view, then hook it up in Interface Builder. Then when your view is being set up, set the inputAccessoryView on the IBOutlet. – Jim 9 mins ago
310 |
311 | 312 |
315 |
316 | 317 | 318 | 319 |
320 |

Your Answer

321 | 322 | 323 | 324 | 325 | 333 | 334 | 335 |
336 | 337 | 338 |
339 |
340 | 341 |
342 | 343 |
 
344 | 345 | 346 | 347 |
348 | 349 | 350 |
351 | 352 | 353 |
354 |
355 |
356 | 357 | 358 | 364 | 365 | 366 | 370 | 385 | 386 | 387 |
367 |
or
368 |
369 |
371 |
372 | 373 | 374 |
375 |
376 | 377 | 378 | never shown 379 |
380 |
381 | 382 | 383 |
384 |
388 |
389 | 390 |
391 | 392 |
393 | 394 | 395 | 396 |

397 | 398 | Not the answer you're looking for? 399 | 400 | Browse other questions tagged 401 | 402 | or ask your own question. 403 | 404 |

405 | 406 |
407 |
408 | 409 | 540 | 541 | 542 | 545 | 546 | 547 | 548 | 551 | 552 | 553 | 554 | 555 |
556 |
557 | 588 | 589 | 592 | 593 | 594 | 595 | 596 | 597 | --------------------------------------------------------------------------------