├── 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 ||= ''
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": "#StackOverflow 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 |
13 |
14 |
15 |
16 | 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 |
153 |
154 |
164 |
165 |
166 |
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 |
184 |
185 |
186 |
187 |
188 |
189 |
190 |
191 | 95% accept rate
192 |
193 |
194 |
195 |
196 |
197 |
198 |
199 |
200 |
201 |
202 |
203 |
204 |
205 |
215 |
216 |
217 |
218 |
219 |
220 |
221 |
222 |
223 |
224 |
225 |
226 |
227 |
228 |
229 |
240 |
241 |
242 |
243 |
244 |
245 |
246 |
247 |
248 |
249 |
250 |
257 |
258 |
259 |
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 |
267 |
268 |
269 |
270 |
271 |
272 |
273 |
274 |
275 |
276 |
277 |
278 |
279 |
280 |
281 |
282 |
283 |
311 |
312 |
313 |
314 |
315 |
316 |
317 |
318 |
319 |
393 |
394 |
395 |
396 |
397 |
398 | Not the answer you're looking for?
399 |
400 | Browse other questions tagged iphone button keyboard uitextview dismiss
401 |
402 | or ask your own question .
403 |
404 |
405 |
406 |
407 |
408 |
409 |
540 |
541 |
542 |
545 |
546 |
547 |
548 |
549 |
550 |
551 |
552 |
default
553 |
554 |
555 |
556 |
557 |
588 |
589 |
590 | Stack Overflow works best with JavaScript enabled
591 |
592 |
593 |
594 |
595 |
596 |
597 |
--------------------------------------------------------------------------------