stats_combiner 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,8 @@
1
+ tpm_combiner.rb
2
+ *.DS_Store
3
+ *stats_db.sqlite3
4
+ spec/spec_credentials.rb
5
+ rewrites.rb
6
+ test/*
7
+ pkg/*
8
+ *.gem
data/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # StatsCombiner
2
+
3
+ StatsCombiner is a Ruby gem for generating most-viewed widgets from the [Chartbeat API](http://chartbeat.pbworks.com/). Unlike most analytics systems, Chartbeat doesn't give you cumulative visitor counts. Rather, it takes live snapshots of people sitting on pages at a given time. StatsCombiner asks Chartbeat what these numbers are every `n` minutes during a given `ttl` and combines visitor counts where it finds matching `<title>`s, to allow popular stories to bubble up the list. When `ttl` expires, it will publish out a static HTML file with your top ten list to a location of your choosing, trash its database and start collecting again.
4
+
5
+ This gem is a rewrite from the PHP implementation I wrote about [here](http://blog.chartbeat.com/2009/08/04/guest-post-how-talking-points-memo-uses-chartbeat/).
6
+
7
+ ## Installation
8
+
9
+ `gem install stats_combiner`
10
+
11
+ ## Usage
12
+
13
+ Write a short combiner script that tells StatsCombiner your Chartbeat API parameters, how long it should combine for and where to put the flat file. Add filters to manipulate the data it will publish.
14
+
15
+ Here's an example:
16
+
17
+ require 'rubygems'
18
+ require 'stats_combiner'
19
+
20
+ # initialize the script
21
+ s = StatsCombiner::Combiner.new({
22
+ :ttl => 3600, #1 hour from first run
23
+ :host => 'yourdomain.com',
24
+ :api_key => 'YOURKEY',
25
+ :flat_file => '/path/to/staticfile/top_ten.html'
26
+ })
27
+
28
+ # create a filters object and add some filters
29
+ e = StatsCombiner::Filterer.new
30
+ e.add :path_regex => /(\/$|\/index.php$)/, :exclude => true
31
+
32
+ # run it!
33
+ s.run({
34
+ :filters => e.filters,
35
+ :verbose => true #this option reports combining status and TTL when true
36
+ })
37
+
38
+ Then add this script to your crontab. I recommend running it every 5 minutes. Just be a good API-consumer when setting your cron:
39
+
40
+ */5 * * * * cd /path/to/my_combiner && ruby my_combiner.rb
41
+
42
+ ### Filters
43
+
44
+ http://{prefix}.{host}/{path}{suffix}
45
+
46
+ search by..
47
+
48
+ :title_regex => regexp # Filter on a title pattern
49
+ :path_regex=> regexp # Filter on a path pattern
50
+
51
+ ..to add a:
52
+
53
+ :suffix => string or regexp # a path modification
54
+ :prefix => string # a subdomain
55
+ :modify_title => bool or regexp # Modify the title inline
56
+ # (true replaces title match with '')
57
+
58
+ ..or, to ignore the entry:
59
+
60
+ :exclude => bool # Exclude matching URLs from the top ten list
61
+
62
+ Some examples from TPM:
63
+
64
+ e.add :prefix => 'tpmdc', :title_regex => /\| TPMDC/, :modify_title => true
65
+ e.add :path_regex => /((\?|&)ref=.*)/, :suffix => ''
66
+ e.add :path_regex => /(\?(page|img)=(.*)($|&))/, :suffix => '?\2=1'
67
+ e.add :path_regex => /(\/$|\/index.php$)/, :exclude => true
68
+
69
+ ### A note on testing
70
+
71
+ You may need to `sudo spec` if your user doesn't have write access to the gems directory.
72
+
73
+ ## Author
74
+
75
+ Al Shaw (al@talkingpointsmemo.com)
76
+
77
+ ## License (MIT)
78
+
79
+ Copyright (c) 2010 TPM Media LLC
80
+
81
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
82
+
83
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
84
+
85
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/Rakefile ADDED
@@ -0,0 +1,25 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+
4
+ begin
5
+ require 'jeweler'
6
+ Jeweler::Tasks.new do |gem|
7
+ gem.name = "stats_combiner"
8
+ gem.summary = %Q{StatsCombiner creates most-viewed story widgets from the Chartbeat API}
9
+ gem.description = %Q{A tool to create most-viewed story widgets from the Chartbeat API.}
10
+ gem.email = "almshaw@gmail.com"
11
+ gem.homepage = "http://github.com/tpm/stats_combiner"
12
+ gem.authors = ["Al Shaw"]
13
+ gem.add_dependency 'crack'
14
+ gem.add_dependency 'sequel'
15
+ gem.add_development_dependency "rspec"
16
+ gem.add_development_dependency "fakeweb"
17
+ gem.add_development_dependency "timecop"
18
+ gem.add_development_dependency "hpricot"
19
+
20
+ # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
21
+ end
22
+ Jeweler::GemcutterTasks.new
23
+ rescue LoadError
24
+ puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
25
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.0.1
@@ -0,0 +1,26 @@
1
+ require 'rubygems'
2
+ require 'stats_combiner'
3
+
4
+ s = StatsCombiner::Combiner.new({
5
+ :ttl => 3600,
6
+ :host => 'yourhost.com',
7
+ :api_key => 'YOURKEY',
8
+ :flat_file => '/path/to/top_ten.html'
9
+ })
10
+
11
+ e = StatsCombiner::Filterer.new
12
+ e.add :prefix => 'tpmdc', :title_regex => /\| TPMDC/, :modify_title => true
13
+ e.add :prefix => 'tpmmuckraker', :title_regex => /\| TPMMuckraker/, :modify_title => true
14
+ e.add :prefix => 'tpmtv', :title_regex => /\| TPMTV/, :modify_title => true
15
+ e.add :prefix => 'tpmcafe', :title_regex => /\| TPMCafe/, :modify_title => true
16
+ e.add :prefix => 'tpmlivewire', :title_regex => /\| TPM LiveWire/, :modify_title => true
17
+ e.add :prefix => 'tpmpolltracker', :title_regex => /\| TPM PollTracker/, :modify_title => true
18
+ e.add :prefix => 'www', :title_regex => /\|.*$/, :modify_title => true
19
+
20
+ #put excluders last
21
+ e.add :path_regex => /(\/$|\/index.php$)/, :exclude => true
22
+
23
+ s.run({
24
+ :filters => e.filters,
25
+ :verbose => true
26
+ })
@@ -0,0 +1,182 @@
1
+ require 'open-uri'
2
+ require 'fileutils'
3
+ require 'crack/json'
4
+ require 'sequel'
5
+ require 'stats_combiner/filterer'
6
+
7
+ module StatsCombiner
8
+
9
+ class Combiner
10
+
11
+ # Usage:
12
+ #
13
+ # s = StatsCombiner::Combiner.new({
14
+ # :api_key => 'your_key',
15
+ # :host => 'talkingpointsmemo.com',
16
+ # :ttl => 3600,
17
+ # :flat_file => '/var/www/html/topten.html'
18
+ # })
19
+ def initialize(opts = {})
20
+ @init_options = {
21
+ :ttl => 3600, #one hour by default
22
+ :story_count => 10,
23
+ :flat_file => 'topten.html',
24
+ }.merge!(opts)
25
+ @db_file = "#{@init_options[:host]}_stats_db.sqlite3"
26
+ end
27
+
28
+ # check where we are in the cycle
29
+ # and run the necessary functions
30
+ # call this after initializing StatsCombiner
31
+ #
32
+ # Usage:
33
+ # s.run({
34
+ # :filters => e.filters (result of a StatsCombiner::Filterer filters hash)
35
+ # :verbose => true
36
+ # })
37
+ def run(opts = {})
38
+ { :filters => nil,
39
+ :verbose => false
40
+ }.merge!(opts)
41
+
42
+ @filters = opts[:filters]
43
+
44
+ now = Time.now
45
+ if File::exists?(@db_file)
46
+ @db = Sequel.sqlite(@db_file)
47
+ @table_create_time = self.table_create_time
48
+ @table_destroy_time = @table_create_time + @init_options[:ttl]
49
+ @ttl = @table_destroy_time.to_i - Time.now.to_i
50
+
51
+ if now.to_i < @table_destroy_time.to_i
52
+ self.combine
53
+ if opts[:verbose]
54
+ puts "Combining. DB has #{@ttl} seconds to live"
55
+ end
56
+ else
57
+ self.report_and_cleanup
58
+ if opts[:verbose]
59
+ puts "ttl expired. reporting and cleaning up"
60
+ end
61
+ end
62
+ else
63
+ self.setup
64
+ if opts[:verbose]
65
+ puts "No DB detected. I set one up"
66
+ end
67
+ end
68
+ end
69
+
70
+
71
+ protected
72
+
73
+ # Set up the database.
74
+ # This is done once every timeout cycle
75
+ def setup
76
+ @db = Sequel.sqlite(@db_file)
77
+ @db.create_table :stories do
78
+ primary_key :id, :type => Integer
79
+ String :title, :text => true
80
+ String :path, :text => true
81
+ Fixnum :visitors
82
+ DateTime :created_at
83
+ end
84
+
85
+ @db.create_table :create_time do
86
+ primary_key :id, :type => Integer
87
+ DateTime :timestamp
88
+ end
89
+
90
+ now = Time.now
91
+ time_table = @db[:create_time]
92
+ time_table.insert(:timestamp => now)
93
+ end
94
+
95
+ def table_create_time
96
+ @db[:create_time].select(:timestamp).first[:timestamp]
97
+ end
98
+
99
+ def destroy
100
+ FileUtils.rm_rf(@db_file)
101
+ end
102
+
103
+ # grab the data, and parse it into something we can use
104
+ # and combine it
105
+ def combine
106
+ host = @init_options[:host]
107
+ api_key = @init_options[:api_key]
108
+ @url = "http://api.chartbeat.com/toppages/?host=#{host}&limit=50&apikey=#{api_key}"
109
+
110
+ @data = open(@url).read
111
+ @data = Crack::JSON.parse(@data)
112
+
113
+ @data.each do |datum|
114
+ visitors = datum['visitors']
115
+ path = datum['path']
116
+ title = datum['i']
117
+
118
+ # if the story is already in the db, combine visitor count
119
+ # otherwise insert a new row
120
+ existing_story = @db[:stories].where(:title => title).first || ''
121
+
122
+ if not existing_story.empty?
123
+ existing_visitors = existing_story[:visitors]
124
+ @db[:stories].where(:title => title).update :visitors => existing_visitors + visitors
125
+ else
126
+ @db[:stories].insert({
127
+ :title => title,
128
+ :visitors => visitors,
129
+ :path => path
130
+ })
131
+ end
132
+
133
+ end
134
+ end
135
+
136
+ # Pull data out of the db, apply filters and write the flat file and dump the db.
137
+ # This is done once every timeout cycle.
138
+ def report_and_cleanup
139
+ stories = @db[:stories].order(:visitors.desc).all
140
+
141
+ #filter the array if applicable, then narrow down to top ten
142
+ if @filters
143
+ stories.each do |story|
144
+ StatsCombiner::Filterer.apply_filters!(@filters,story)
145
+ end
146
+ end
147
+
148
+ #sweep away the excludes
149
+ stories.reject! {|story| story if story[:title].nil? || story[:path].nil? }
150
+
151
+ top_ten = stories[0..9]
152
+ now = Time.now
153
+ flat_file = @init_options[:flat_file]
154
+ host = @init_options[:host]
155
+
156
+ #write it out
157
+ html = '<ol>'
158
+
159
+ top_ten.each do |story|
160
+ title = story[:title]
161
+ path = story[:path]
162
+ visitors = story[:visitors]
163
+
164
+ if not story[:prefix].nil?
165
+ prefix = story[:prefix] + '.'
166
+ end
167
+
168
+ html << "<li><a href=\"http://#{prefix}#{host}#{path}\">#{title}</a></li> <!-- #{visitors} -->"
169
+ end
170
+
171
+ html << '</ol>'
172
+ html << "<!-- This report was generated at #{now} -->"
173
+
174
+ flat_file = File.new(flat_file, "w+")
175
+ flat_file.write(html)
176
+ flat_file.close
177
+
178
+ #Destroy the DB and start the journey over again.
179
+ self.destroy
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,111 @@
1
+ module StatsCombiner
2
+
3
+ class Filterer
4
+
5
+ attr_accessor :filters
6
+
7
+ # Initialize a filters object:
8
+ # e = StatsCombiner::Filterer.new
9
+ #
10
+ def initialize()
11
+ @filters ||= []
12
+ end
13
+
14
+
15
+ # Add a filter that StatsCombiner can use to manipulate paths and titles it
16
+ # gets from Chartbeat.
17
+ #
18
+ # Options: Pattern: <tt>http://{prefix}.{host}/{path}{suffix}</tt>
19
+ #
20
+ # search by..
21
+ # title_regex => nil Filter on a title pattern
22
+ # path_regex=> nil Filter on a path pattern
23
+ #
24
+ # ..to add a:
25
+ # suffix => nil a path modification
26
+ # prefix => nil a subdomain
27
+ # modify_title => bool or regexp Modify the title inline
28
+ #
29
+ # Or, to ignore the entry:
30
+ # exclude => true Exclude this pattern from the top ten list
31
+ #
32
+ # Some examples from TPM:
33
+ # e.add :prefix => 'tpmdc', :title_regex => /\| TPMDC/, :modify_title => true
34
+ # e.add :path_regex => /(\?ref=.*$|\&ref=.*$|)/, :suffix => '', :modify_path => true
35
+ # e.add :path_regex => /(\?(page|img)=(.*)($|&))/, :suffix => '?\2=1'
36
+ # e.add :path_regex => /(\/$|\/index.php$)/, :exclude => true
37
+ def add(options={})
38
+ { :prefix => nil,
39
+ :suffix => nil,
40
+ :title_regex => nil,
41
+ :path_regex => nil,
42
+ :modify_title => false,
43
+ :exclude => false,
44
+ }.merge!(options)
45
+
46
+ filter = {}
47
+ filter[:rule] = {}.merge!(options)
48
+
49
+ @filters << filter
50
+ end
51
+
52
+ # sanity check
53
+ def list_filters
54
+ @filters.each do |filter|
55
+ p filter[:rule]
56
+ end
57
+ end
58
+
59
+ # a datum comes in from chartbeat data, and is manipulated
60
+ # with the apply_filters method, and sent back to Combiner to write out
61
+ #
62
+ # Grab filters with <tt>e.filters</tt>
63
+ def self.apply_filters!(filters,datum={})
64
+ { :title => nil,
65
+ :path => nil,
66
+ :prefix => nil
67
+ }.merge!(datum)
68
+
69
+ filters.each do |filter|
70
+
71
+ # set prefixes where they match title regexes
72
+ # /\| TPMDC/ => http://tpmdc
73
+ if (filter[:rule][:prefix] && filter[:rule][:title_regex]) && datum[:title].match(filter[:rule][:title_regex])
74
+ datum[:prefix] = filter[:rule][:prefix]
75
+ end
76
+
77
+ # modify path => '?q=new_suffix'
78
+ # append to path with regex replacement variables => '\1&new_suffix'
79
+ if (filter[:rule][:suffix] && filter[:rule][:path_regex]) && datum[:path].match(filter[:rule][:path_regex])
80
+ datum[:path].gsub!(filter[:rule][:path_regex],filter[:rule][:suffix])
81
+ end
82
+
83
+ # apply title mods
84
+ # modify_title => true /==>
85
+ # modify_title => "DC Central"
86
+ # title_regex => /| TPMDC/, modify_title => '\1 Central' ==> TPMDC Central
87
+ if filter[:rule][:modify_title]
88
+ filter[:rule][:modify_title] = '' unless filter[:rule][:modify_title].is_a?(String)
89
+ datum[:title].gsub!(filter[:rule][:title_regex], filter[:rule][:modify_title])
90
+ datum[:title].strip!
91
+ end
92
+
93
+ # apply excludes.
94
+ # this should take out the whole record if it matches a path or title regex
95
+ if filter[:rule][:exclude] && ((filter[:rule][:path_regex].is_a?(Regexp) && datum[:path].match(filter[:rule][:path_regex])) || (filter[:rule][:title_regex].is_a?(Regexp) && datum[:title].match(filter[:rule][:title_regex])))
96
+ # nil out datum.
97
+ # StatsCombiner::Combiner will sweep away the nils later
98
+ datum[:title] = datum[:path] = datum[:prefix] = nil
99
+ end
100
+
101
+ end
102
+
103
+ datum
104
+
105
+ end
106
+
107
+
108
+ end
109
+
110
+ end
111
+
@@ -0,0 +1,294 @@
1
+ require '../lib/stats_combiner'
2
+ require 'test_data'
3
+
4
+ require 'spec'
5
+ require 'timecop'
6
+ require 'hpricot'
7
+
8
+ HOST = 'fake.com'
9
+ KEY = 'fake_key'
10
+
11
+ describe "an unfiltered StatsCombiner cycle" do
12
+
13
+ before :each do
14
+ @flat_file = File.dirname(__FILE__) + '/test_flat_file.html'
15
+ @ttl = 3600
16
+ @s = StatsCombiner::Combiner.new({
17
+ :ttl => @ttl,
18
+ :host=> HOST,
19
+ :api_key=> KEY,
20
+ :flat_file => @flat_file,
21
+ })
22
+ @db_file = "#{HOST}_stats_db.sqlite3"
23
+ end
24
+
25
+ it 'should do a first-time run, setting up the db' do
26
+ @s.run()
27
+
28
+
29
+ File.exist?(@db_file).should == true
30
+
31
+ @db = Sequel.sqlite(@db_file)
32
+ @db[:stories].all.should be_a(Array)
33
+ @db[:create_time].all.should be_a(Array)
34
+
35
+ #allow for a 2 second variation in timestamp
36
+ @db[:create_time].select(:timestamp).first[:timestamp].to_i.should be_close((Time.now.to_i - 2),(Time.now.to_i + 2))
37
+
38
+ @first_run_time = Time.now
39
+ end
40
+
41
+ it 'should do a second-time run, capturing data' do
42
+ #set Time.now to 5 seconds from now
43
+ t = Time.now
44
+ Timecop.travel(t + 5)
45
+
46
+ @s.run()
47
+
48
+ File.exist?(@db_file).should == true
49
+
50
+ @db = Sequel.sqlite(@db_file)
51
+ @db[:stories].all.length.should be >= 1
52
+ first_story = @db[:stories].first
53
+ first_story[:visitors].should be_a(Fixnum)
54
+ first_story[:title].should be_a(String)
55
+ first_story[:path].should be_a(String)
56
+
57
+ #save first_story title & first_story visitors for use in the combiner test
58
+ FIRST_STORY_TITLE = first_story[:title]
59
+ FIRST_STORY_VISITORS = first_story[:visitors]
60
+ Timecop.return
61
+ end
62
+
63
+ it 'should do a combining data capture' do
64
+ @s.run()
65
+
66
+ File.exist?(@db_file).should == true
67
+ @db = Sequel.sqlite(@db_file)
68
+
69
+ # let's check to see that the titles array is unique
70
+ # i.e., that dupes have been added together
71
+ test_titles = []
72
+ stories = @db[:stories].all
73
+ stories.each do |story|
74
+ test_titles << story[:title]
75
+ end
76
+ test_titles.uniq.size.should eql(stories.size)
77
+
78
+ # now let's check that visit counts have been combined
79
+ # for this, we'll *assume* that the first story in the array
80
+ # (which has the higest visitor ct) will be combined.
81
+ # We'll use @first_story_title we got in the second-time-run test
82
+ @combined_story = @db[:stories].where(:title => FIRST_STORY_TITLE).first
83
+ @combined_story[:title].should be_a(String)
84
+ @combined_story[:visitors].should > FIRST_STORY_VISITORS
85
+ end
86
+
87
+ it 'should report data and dump db' do
88
+ # set Time.now to 5 seconds past ttl
89
+ t = Time.now
90
+ Timecop.travel(t + @ttl + 5)
91
+
92
+ @s.run()
93
+
94
+ File.exist?(@flat_file).should == true
95
+ File.exist?(@db_file).should == false
96
+
97
+ Timecop.return
98
+ end
99
+
100
+
101
+ after :all do
102
+ FileUtils.rm_rf(@db_file)
103
+ FileUtils.rm_rf(@flat_file)
104
+ end
105
+
106
+ end
107
+
108
+ describe "basic Filterer filtering" do
109
+
110
+ before :each do
111
+ @f = StatsCombiner::Filterer.new
112
+ end
113
+
114
+ it 'should add a rule to the filters array' do
115
+ @f.add :prefix => 'tpmdc', :title_regex => /\| TPMDC/, :modify_title => true
116
+
117
+ @f.filters.size.should eql(1)
118
+ end
119
+
120
+ # various filter cases
121
+ it 'should set a prefix according to a title_regex' do
122
+ @f.add :prefix => 'tpmdc', :title_regex => /\| TPMDC/
123
+ datum = {:visitors=>366, :created_at=>nil, :path=>"/2010/05/with-specter-suffering-white-house-and-gop-looking-at-surging-sestak.php", :id=>3, :title=>"With Specter Suffering, White House And GOP Looking At Surging Sestak | TPMDC"}
124
+
125
+ result = StatsCombiner::Filterer.apply_filters!(@f.filters,datum)
126
+ result[:prefix].should eql("tpmdc")
127
+ end
128
+
129
+ it 'should modify a title based on a title_regex and a modify_title boolean' do
130
+ @f.add :prefix => 'tpmdc', :title_regex => /\| TPMDC/, :modify_title => true
131
+
132
+ datum = {:visitors=>366, :created_at=>nil, :path=>"/2010/05/with-specter-suffering-white-house-and-gop-looking-at-surging-sestak.php", :id=>3, :title=>"With Specter Suffering, White House And GOP Looking At Surging Sestak | TPMDC"}
133
+
134
+ result = StatsCombiner::Filterer.apply_filters!(@f.filters,datum)
135
+ result[:title].should eql("With Specter Suffering, White House And GOP Looking At Surging Sestak")
136
+ end
137
+
138
+ it 'should modify a title based on a title_regex and a modify_title regex' do
139
+ @f.add :prefix => 'tpmdc', :title_regex => /(\| TPMDC)$/, :modify_title => '\1 Central'
140
+
141
+ datum = {:visitors=>366, :created_at=>nil, :path=>"/2010/05/with-specter-suffering-white-house-and-gop-looking-at-surging-sestak.php", :id=>3, :title=>"With Specter Suffering, White House And GOP Looking At Surging Sestak | TPMDC"}
142
+
143
+ result = StatsCombiner::Filterer.apply_filters!(@f.filters,datum)
144
+ result[:title].should eql("With Specter Suffering, White House And GOP Looking At Surging Sestak | TPMDC Central")
145
+ end
146
+
147
+ it 'should set a suffix where it matches a path_regex and modify a path according to a suffix regex' do
148
+ @f.add :path_regex => /(\?ref=.*)$/, :suffix => '\1&foo=bar', :modify_path => true
149
+
150
+ datum = {:visitors=>366, :created_at=>nil, :path=>"/2010/05/with-specter-suffering-white-house-and-gop-looking-at-surging-sestak.php?ref=fpa", :id=>3, :title=>"With Specter Suffering, White House And GOP Looking At Surging Sestak | TPMDC"}
151
+
152
+ result = StatsCombiner::Filterer.apply_filters!(@f.filters,datum)
153
+ result[:path].should eql("/2010/05/with-specter-suffering-white-house-and-gop-looking-at-surging-sestak.php?ref=fpa&foo=bar")
154
+
155
+ @f.filters.clear
156
+
157
+ #another example to kill unwanted query strings
158
+ @f.add :path_regex => /((\?|&)ref=.*)/, :suffix => '', :modify_path => true
159
+
160
+ datum = {:visitors=>366, :created_at=>nil, :path=>"/2010/05/with-specter-suffering-white-house-and-gop-looking-at-surging-sestak.php?id=keepme&ref=killme", :id=>3, :title=>"With Specter Suffering, White House And GOP Looking At Surging Sestak | TPMDC"}
161
+
162
+ result = StatsCombiner::Filterer.apply_filters!(@f.filters,datum)
163
+ result[:path].should eql("/2010/05/with-specter-suffering-white-house-and-gop-looking-at-surging-sestak.php?id=keepme")
164
+ end
165
+
166
+
167
+ it 'should nil out data where exclude is true and path or title regexes are matched' do
168
+ #first, two examples of a matching regex
169
+
170
+ @f.add :path_regex => /(\/$|\/index.php$)/, :exclude => true
171
+
172
+ datum = {:visitors=>3090, :created_at=>nil, :path=>"/", :id=>1, :title=>"Talking Points Memo | Breaking News and Analysis"}
173
+
174
+ result = StatsCombiner::Filterer.apply_filters!(@f.filters,datum)
175
+ result[:title].should be_nil
176
+ result[:path].should be_nil
177
+
178
+ @f.filters.clear
179
+
180
+ @f.add :path_regex => /talk\/blogs/, :exclude => true
181
+
182
+ datum = {:visitors=>6, :created_at=>nil, :path=>"/talk/blogs/a/m/americandad/2010/03/an-open-letter-to-conservative.php/", :id=>31, :title=>"An open letter to conservatives | AmericanDad's Blog"}
183
+
184
+ result = StatsCombiner::Filterer.apply_filters!(@f.filters,datum)
185
+ result[:title].should be_nil
186
+ result[:path].should be_nil
187
+
188
+ @f.filters.clear
189
+
190
+ #now, let's look for invalid data. run a regex against a nonmatch
191
+ @f.add :path_regex => /(\/$|\/index.php$)/, :exclude => true
192
+
193
+ datum = {:visitors=>6, :created_at=>nil, :path=>"/talk/blogs/a/m/americandad/2010/03/an-open-letter-to-conservative.php", :id=>31, :title=>"An open letter to conservatives | AmericanDad's Blog"}
194
+
195
+ result = StatsCombiner::Filterer.apply_filters!(@f.filters,datum)
196
+ result[:title].should_not be_nil
197
+ result[:path].should_not be_nil
198
+
199
+ @f.filters.clear
200
+
201
+ #try a title match
202
+ @f.add :title_regex => /(Breaking News and Analysis)/, :exclude => true
203
+
204
+ datum = {:visitors=>3090, :created_at=>nil, :path=>"/", :id=>1, :title=>"Talking Points Memo | Breaking News and Analysis"}
205
+
206
+ result = StatsCombiner::Filterer.apply_filters!(@f.filters,datum)
207
+ result[:title].should be_nil
208
+ result[:path].should be_nil
209
+
210
+ end
211
+
212
+ end
213
+
214
+ # best way to do this (without prying open the Class) might be
215
+ # to define a set of filters, timetravel our way to the flat file stage,
216
+ # open it and parse the HTML with hpricot for rules.
217
+ # Caveats - filters will be pretty specific
218
+ # to whatever account is running this suite. So, proceed with caution.
219
+ describe "filtered StatsCombining" do
220
+
221
+ before :each do
222
+ @flat_file = File.dirname(__FILE__) + '/test_flat_file.html'
223
+ @ttl = 3600
224
+ @s = StatsCombiner::Combiner.new({
225
+ :ttl => @ttl,
226
+ :host=> HOST,
227
+ :api_key=> KEY,
228
+ :flat_file => @flat_file,
229
+ })
230
+ @db_file = "#{HOST}_stats_db.sqlite3"
231
+ end
232
+
233
+ it 'should run its way through the cycle and publish out a top ten list according to filter rules' do
234
+
235
+ # first, let's set the filters we want to apply
236
+ e = StatsCombiner::Filterer.new
237
+ e.add :prefix => 'tpmdc', :title_regex => /\| TPMDC/, :modify_title => true
238
+ e.add :prefix => 'tpmmuckraker', :title_regex => /\| TPMMuckraker/, :modify_title => true
239
+ e.add :prefix => 'tpmtv', :title_regex => /\| TPMTV/, :modify_title => true
240
+ e.add :prefix => 'tpmcafe', :title_regex => /\| TPMCafe/, :modify_title => true
241
+ e.add :prefix => 'tpmlivewire', :title_regex => /\| TPM LiveWire/, :modify_title => true
242
+ e.add :prefix => 'polltracker', :title_regex => /\| TPM PollTracker/, :modify_title => true
243
+ e.add :prefix => 'www', :title_regex => /\|.*$/, :modify_title => true
244
+ e.add :path_regex => /(\/$|\/index.php$)/, :exclude => true
245
+
246
+ # now, let's go through the rigamarole to get this thing pubbed.
247
+ # run to setup db
248
+ @s.run :filters => e.filters
249
+ # run again to start publishing
250
+ @s.run :filters => e.filters
251
+ # timetravel to pub time and do it.
252
+ # * set Time.now to 5 seconds past ttl
253
+ t = Time.now
254
+ Timecop.travel(t + @ttl + 5)
255
+
256
+ # add filters
257
+ @s.run :filters => e.filters
258
+
259
+ # sanity check
260
+ File.exist?(@flat_file).should == true
261
+
262
+ # open the file we just made
263
+ list = File.open(@flat_file).read
264
+ list = Hpricot(list)
265
+
266
+ # collect urls and titles
267
+ urls = []
268
+ titles = []
269
+ list.search("a").each do |a|
270
+ urls << a.attributes['href']
271
+ titles << a.inner_html
272
+ end
273
+
274
+ # let's make sure we have 10 stories
275
+ urls.size.should eql(10)
276
+
277
+ # pull prefixes from rules array
278
+ prefixes = e.filters.collect { |filter| filter[:rule][:prefix] }
279
+
280
+ # test prefixes against subdomains
281
+ urls.each do |url|
282
+ subdomain = URI.parse(url).host.split('.')[0]
283
+ prefixes.include?(subdomain).should == true
284
+ end
285
+
286
+ Timecop.return
287
+ end
288
+
289
+ after :all do
290
+ FileUtils.rm_rf(@db_file)
291
+ FileUtils.rm_rf(@flat_file)
292
+ end
293
+
294
+ end
data/spec/test_data.rb ADDED
@@ -0,0 +1,9 @@
1
+ require 'fakeweb'
2
+
3
+ FakeWeb.allow_net_connect = false
4
+
5
+ TEST_DATA = <<-DOCUMENT
6
+ [{"i": "Talking Points Memo | Breaking News and Analysis", "path": "\/", "visitors": 529}, {"i": "Theories of the Fall | Talking Points Memo", "path": "\/archives\/2010\/05\/theories_of_the_fall.php", "visitors": 66}, {"i": "What A Week: Rand Paul Takes The National Stage | TPMDC", "path": "\/2010\/05\/what-a-week-rand-paul-takes-the-national-stage.php?ref=fpa", "visitors": 37}, {"i": "Texas Board Of Ed Approves Right-Wing History Textbook Standards (VIDEO) | TPMMuckraker", "path": "\/2010\/05\/texas_history_textbooks_final_vote.php", "visitors": 15}, {"i": "Ron Paul Appeared On Meet The Press In '07 And Spoke Out Against Civil Rights Act (VIDEO) | TPMDC", "path": "\/2010\/05\/ron-paul-appeared-on-meet-the-press-in-07-and-spoke-out-against-civil-rights-act-video.php?ref=fpb", "visitors": 12}, {"i": "Texas Board Of Ed Approves Right-Wing History Textbook Standards (VIDEO) | TPMMuckraker", "path": "\/2010\/05\/texas_history_textbooks_final_vote.php?ref=fpb", "visitors": 11}, {"i": "CNN Headline: 'Miss USA: Muslim Trailblazer Or Hezbollah Spy?' | TPM LiveWire", "path": "\/2010\/05\/cnn-headline-miss-usa-muslim-trailblazer-or-hezbollah-spy.php?ref=fpb", "visitors": 8}, {"i": "GOP Kills Science Jobs Bill By Forcing Dems To Vote For Porn | TPMDC", "path": "\/2010\/05\/gop-kills-science-jobs-bill-by-forcing-dems-to-vote-for-porn.php", "visitors": 7}, {"i": "Texas Board Of Ed Approves Right-Wing History Textbook Standards (VIDEO) | TPMMuckraker", "path": "\/2010\/05\/texas_history_textbooks_final_vote.php?ref=fpa", "visitors": 7}, {"i": "Will Wall Street Reform Negotiators Publicly Weaken Derivative-Trading Regulations? (VIDEO) | TPMDC", "path": "\/2010\/05\/will-wall-street-reform-negotiators-publicly-weaken-derivative-trading-regulations-video.php?ref=fpb", "visitors": 6}, {"i": "What A Week: Rand Paul Takes The National Stage | TPMDC", "path": "\/2010\/05\/what-a-week-rand-paul-takes-the-national-stage.php?ref=tn", "visitors": 6}, {"i": "Unity In Kentucky As GOPers Rally 'Round Rand | TPMDC", "path": "\/2010\/05\/this-is-what-kentucky-unity-looks-like-gopers-rally-round-rand.php?ref=fpb", "visitors": 6}, {"i": "So Not Ready For Prime Time (or Even Sunday) | Talking Points Memo", "path": "\/archives\/2010\/05\/so_not_ready_for_prime_time_or_even_sunday.php", "visitors": 6}, {"i": "Paul Backs Out Of Meet The Press Appearance | TPMDC", "path": "\/2010\/05\/paul-backs-out-of-meet-the-press-appearance.php?ref=fpb", "visitors": 6}, {"i": "Jack Conway To TPMDC: Paul Civil Rights Comments 'Relevant' To General Election Campaign | TPMDC", "path": "\/2010\/05\/jack-conway-to-tpmdc-paul-civil-rights-comments-relevant-to-general-election-campaign.php?ref=fpb", "visitors": 5}, {"i": "TPMDC Saturday Roundup | TPMDC", "path": "\/2010\/05\/obama-announces-commission-on-oil-spill.php", "visitors": 5}, {"i": "What A Week: Rand Paul Takes The National Stage | TPMDC", "path": "\/2010\/05\/what-a-week-rand-paul-takes-the-national-stage.php", "visitors": 5}, {"i": "Dems Brace For Loss Of Usually Ultra-Safe Hawaii House Seat On Saturday | TPMDC", "path": "\/2010\/05\/dems-brace-for-loss-of-usually-ultra-safe-hawaii-house-seat-on-saturday.php", "visitors": 4}, {"i": "That Other Thing: Blumenthal And The Swim Team | TPMMuckraker", "path": "\/2010\/05\/that_other_thing_blumenthal_and_the_swim_team.php?ref=fpc", "visitors": 4}, {"i": "Franken Raises Money To Oppose Rand Paul -- And He Likes Brunch | TPMDC", "path": "\/2010\/05\/franken-raises-money-to-oppose-rand-paul----and-he-likes-brunch.php?ref=fpi", "visitors": 4}, {"i": "What Did Rand Paul Really Say On Maddow Last Night? | TPMDC", "path": "\/2010\/05\/what-did-rand-paul-really-say-on-maddow-last-night.php", "visitors": 4}, {"i": "Like a Friggin' Rock | Talking Points Memo", "path": "\/archives\/2010\/05\/like_a_friggin_rock.php", "visitors": 4}, {"i": "Franken Raises Money To Oppose Rand Paul -- And He Likes Brunch | TPMDC", "path": "\/2010\/05\/franken-raises-money-to-oppose-rand-paul----and-he-likes-brunch.php", "visitors": 4}, {"i": "Unity In Kentucky As GOPers Rally 'Round Rand | TPMDC", "path": "\/2010\/05\/this-is-what-kentucky-unity-looks-like-gopers-rally-round-rand.php?ref=fpa", "visitors": 4}, {"i": "Rand Paul: Economic Collapse Could Lead To 'A Hitler' Coming To Power | TPMMuckraker", "path": "\/2010\/05\/rand_paul_economic_collapse_could_lead_to_a_hitler.php", "visitors": 4}, {"i": "Rand Paul: Obama's BP Comments Sound 'Really Un-American' (VIDEO) | TPM LiveWire", "path": "\/2010\/05\/rand-paul-obamas-bp-comments-sound-really-un-american-video.php?ref=fpblg", "visitors": 4}, {"i": "CNN Headline: 'Miss USA: Muslim Trailblazer Or Hezbollah Spy?' | TPM LiveWire", "path": "\/2010\/05\/cnn-headline-miss-usa-muslim-trailblazer-or-hezbollah-spy.php", "visitors": 3}, {"i": "A Red Tulip Confirms All Crows Are Black | Fred Moolten's Blog", "path": "\/talk\/blogs\/fredmoolten\/2009\/06\/a-red-tulip-confirms-all-crows.php", "visitors": 3}, {"i": "Peter Beinart Unbound? | TPMCafe", "path": "\/2010\/05\/19\/beinart_unbound_here_and_in_bookforumcom\/", "visitors": 3}, {"i": "Bill Clinton To Campaign For Blanche Lincoln In Arkansas | TPMDC", "path": "\/2010\/05\/bill-clinton-to-campaign-for-blanche-lincoln-in-arkansas.php?ref=fpb", "visitors": 3}, {"i": "Texas Board Of Ed Approves Right-Wing History Textbook Standards (VIDEO) | TPMMuckraker", "path": "\/2010\/05\/texas_history_textbooks_final_vote.php?ref=tn", "visitors": 3}, {"i": "What A Week: Rand Paul Takes The National Stage | TPMDC", "path": "\/2010\/05\/what-a-week-rand-paul-takes-the-national-stage.php?ref=dcblt", "visitors": 3}, {"i": "Bwakfat's Dashboard | All", "path": "\/cgi-bin\/mt-current\/mt-cp.cgi?__mode=my_dashboard", "visitors": 3}, {"i": "Paul Has Been Guest Of Conspiracy Theorist Shock Jock Alex Jones (VIDEO) | TPMMuckraker", "path": "\/2010\/05\/paul_has_been_guest_of_conspiracy_theorist_shock_j.php", "visitors": 3}, {"i": "Full Text Of Newsmax Column Suggesting Military Coup Against Obama | TPM News Pages", "path": "\/news\/2009\/09\/full_text_of_newsmax_column_suggesting_military_co.php\/", "visitors": 3}, {"i": "Ron Paul Appeared On Meet The Press In '07 And Spoke Out Against Civil Rights Act (VIDEO) | TPMDC", "path": "\/2010\/05\/ron-paul-appeared-on-meet-the-press-in-07-and-spoke-out-against-civil-rights-act-video.php", "visitors": 3}, {"i": "Rand Paul In '08: Beware The NAFTA Superhighway! (VIDEO) | TPMMuckraker", "path": "\/2010\/05\/rand_paul_beware_the_nafta_superhighway_video.php", "visitors": 3}, {"i": "Jack Conway To TPMDC: Paul Civil Rights Comments 'Relevant' To General Election Campaign | TPMDC", "path": "\/2010\/05\/jack-conway-to-tpmdc-paul-civil-rights-comments-relevant-to-general-election-campaign.php?ref=tn", "visitors": 3}, {"i": "Ahistorical | Talking Points Memo", "path": "\/archives\/2010\/05\/ahistorical.php", "visitors": 3}, {"i": "Harvard Needs To Fire Dershowitz | TPMCafe", "path": "\/2010\/05\/22\/harvard_needs_to_fire_dershowitz\/", "visitors": 3}, {"i": "Scientists see video, adjust Gulf leak estimates | TPM News Pages", "path": "\/news\/2010\/05\/scientists_see_video_adjust_gulf_leak_estimates.php?ref=fpa", "visitors": 3}, {"i": "Rand Paul Defends Criticism Of Civil Rights Act To Rachel Maddow (VIDEO) | TPM LiveWire", "path": "\/2010\/05\/rand-paul-defends-criticism-of-civil-rights-act-to-rachel-maddow.php?ref=fpa", "visitors": 3}, {"i": "Dems Brace For Loss Of Usually Ultra-Safe Hawaii House Seat On Saturday | TPMDC", "path": "\/2010\/05\/dems-brace-for-loss-of-usually-ultra-safe-hawaii-house-seat-on-saturday.php?ref=fpb", "visitors": 3}, {"i": "LisB Blows A Gasket | LisB's Blog", "path": "\/talk\/blogs\/l\/i\/lisb\/2010\/05\/lisb-blows-a-gasket.php?ref=reccafe", "visitors": 2}, {"i": "Rand Paul In '08: Beware The NAFTA Superhighway! (VIDEO) | TPMMuckraker", "path": "\/2010\/05\/rand_paul_beware_the_nafta_superhighway_video.php?ref=fpblg", "visitors": 2}, {"i": "Texas Textbook Hearings | TPMMuckraker", "path": "\/texas_textbook_hearings\/", "visitors": 2}, {"i": "California Gov. Candidate Pushes Plan For 'Pedophile Island' | TPM LiveWire", "path": "\/2010\/05\/california-gov-candidate-pushes-plan-for-pedophile-island.php?ref=fpblg", "visitors": 2}, {"i": "Jack Conway Strikes Back At Paul's 'Un-American' Comment | TPM LiveWire", "path": "\/2010\/05\/jack-conway-strikes-back-at-pauls-un-american-comment.php?ref=fpa", "visitors": 2}, {"i": "Theories of the Fall | Talking Points Memo", "path": "\/archives\/2010\/05\/theories_of_the_fall.php?ref=fpblg", "visitors": 2}, {"i": "Jack Conway Strikes Back At Paul's 'Un-American' Comment | TPM LiveWire", "path": "\/2010\/05\/jack-conway-strikes-back-at-pauls-un-american-comment.php", "visitors": 2}]
7
+ DOCUMENT
8
+
9
+ FakeWeb.register_uri(:get, "http://api.chartbeat.com/toppages/?host=fake.com&limit=50&apikey=fake_key", :body => TEST_DATA)
@@ -0,0 +1,68 @@
1
+ # Generated by jeweler
2
+ # DO NOT EDIT THIS FILE DIRECTLY
3
+ # Instead, edit Jeweler::Tasks in Rakefile, and run the gemspec command
4
+ # -*- encoding: utf-8 -*-
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = %q{stats_combiner}
8
+ s.version = "0.0.1"
9
+
10
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
+ s.authors = ["Al Shaw"]
12
+ s.date = %q{2010-05-24}
13
+ s.description = %q{A tool to create most-viewed story widgets from the Chartbeat API.}
14
+ s.email = %q{almshaw@gmail.com}
15
+ s.extra_rdoc_files = [
16
+ "README.md"
17
+ ]
18
+ s.files = [
19
+ ".gitignore",
20
+ "README.md",
21
+ "Rakefile",
22
+ "VERSION",
23
+ "combiner_sample.rb",
24
+ "lib/stats_combiner.rb",
25
+ "lib/stats_combiner/filterer.rb",
26
+ "spec/stats_combiner_spec.rb",
27
+ "spec/test_data.rb",
28
+ "stats_combiner.gemspec"
29
+ ]
30
+ s.homepage = %q{http://github.com/tpm/stats_combiner}
31
+ s.rdoc_options = ["--charset=UTF-8"]
32
+ s.require_paths = ["lib"]
33
+ s.rubygems_version = %q{1.3.6}
34
+ s.summary = %q{StatsCombiner creates most-viewed story widgets from the Chartbeat API}
35
+ s.test_files = [
36
+ "spec/stats_combiner_spec.rb",
37
+ "spec/test_data.rb"
38
+ ]
39
+
40
+ if s.respond_to? :specification_version then
41
+ current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
42
+ s.specification_version = 3
43
+
44
+ if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
45
+ s.add_runtime_dependency(%q<crack>, [">= 0"])
46
+ s.add_runtime_dependency(%q<sequel>, [">= 0"])
47
+ s.add_development_dependency(%q<rspec>, [">= 0"])
48
+ s.add_development_dependency(%q<fakeweb>, [">= 0"])
49
+ s.add_development_dependency(%q<timecop>, [">= 0"])
50
+ s.add_development_dependency(%q<hpricot>, [">= 0"])
51
+ else
52
+ s.add_dependency(%q<crack>, [">= 0"])
53
+ s.add_dependency(%q<sequel>, [">= 0"])
54
+ s.add_dependency(%q<rspec>, [">= 0"])
55
+ s.add_dependency(%q<fakeweb>, [">= 0"])
56
+ s.add_dependency(%q<timecop>, [">= 0"])
57
+ s.add_dependency(%q<hpricot>, [">= 0"])
58
+ end
59
+ else
60
+ s.add_dependency(%q<crack>, [">= 0"])
61
+ s.add_dependency(%q<sequel>, [">= 0"])
62
+ s.add_dependency(%q<rspec>, [">= 0"])
63
+ s.add_dependency(%q<fakeweb>, [">= 0"])
64
+ s.add_dependency(%q<timecop>, [">= 0"])
65
+ s.add_dependency(%q<hpricot>, [">= 0"])
66
+ end
67
+ end
68
+
metadata ADDED
@@ -0,0 +1,143 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: stats_combiner
3
+ version: !ruby/object:Gem::Version
4
+ prerelease: false
5
+ segments:
6
+ - 0
7
+ - 0
8
+ - 1
9
+ version: 0.0.1
10
+ platform: ruby
11
+ authors:
12
+ - Al Shaw
13
+ autorequire:
14
+ bindir: bin
15
+ cert_chain: []
16
+
17
+ date: 2010-05-24 00:00:00 -04:00
18
+ default_executable:
19
+ dependencies:
20
+ - !ruby/object:Gem::Dependency
21
+ name: crack
22
+ prerelease: false
23
+ requirement: &id001 !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ segments:
28
+ - 0
29
+ version: "0"
30
+ type: :runtime
31
+ version_requirements: *id001
32
+ - !ruby/object:Gem::Dependency
33
+ name: sequel
34
+ prerelease: false
35
+ requirement: &id002 !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ segments:
40
+ - 0
41
+ version: "0"
42
+ type: :runtime
43
+ version_requirements: *id002
44
+ - !ruby/object:Gem::Dependency
45
+ name: rspec
46
+ prerelease: false
47
+ requirement: &id003 !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ segments:
52
+ - 0
53
+ version: "0"
54
+ type: :development
55
+ version_requirements: *id003
56
+ - !ruby/object:Gem::Dependency
57
+ name: fakeweb
58
+ prerelease: false
59
+ requirement: &id004 !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ segments:
64
+ - 0
65
+ version: "0"
66
+ type: :development
67
+ version_requirements: *id004
68
+ - !ruby/object:Gem::Dependency
69
+ name: timecop
70
+ prerelease: false
71
+ requirement: &id005 !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ segments:
76
+ - 0
77
+ version: "0"
78
+ type: :development
79
+ version_requirements: *id005
80
+ - !ruby/object:Gem::Dependency
81
+ name: hpricot
82
+ prerelease: false
83
+ requirement: &id006 !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ segments:
88
+ - 0
89
+ version: "0"
90
+ type: :development
91
+ version_requirements: *id006
92
+ description: A tool to create most-viewed story widgets from the Chartbeat API.
93
+ email: almshaw@gmail.com
94
+ executables: []
95
+
96
+ extensions: []
97
+
98
+ extra_rdoc_files:
99
+ - README.md
100
+ files:
101
+ - .gitignore
102
+ - README.md
103
+ - Rakefile
104
+ - VERSION
105
+ - combiner_sample.rb
106
+ - lib/stats_combiner.rb
107
+ - lib/stats_combiner/filterer.rb
108
+ - spec/stats_combiner_spec.rb
109
+ - spec/test_data.rb
110
+ - stats_combiner.gemspec
111
+ has_rdoc: true
112
+ homepage: http://github.com/tpm/stats_combiner
113
+ licenses: []
114
+
115
+ post_install_message:
116
+ rdoc_options:
117
+ - --charset=UTF-8
118
+ require_paths:
119
+ - lib
120
+ required_ruby_version: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ segments:
125
+ - 0
126
+ version: "0"
127
+ required_rubygems_version: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ segments:
132
+ - 0
133
+ version: "0"
134
+ requirements: []
135
+
136
+ rubyforge_project:
137
+ rubygems_version: 1.3.6
138
+ signing_key:
139
+ specification_version: 3
140
+ summary: StatsCombiner creates most-viewed story widgets from the Chartbeat API
141
+ test_files:
142
+ - spec/stats_combiner_spec.rb
143
+ - spec/test_data.rb