A simple script that finds overlooked questions that may deserve to be answered on Stack Overflow using the Stack Overflow API. It also includes a basic rspec test.
Last active
July 8, 2016 16:47
-
-
Save tatwell/c70e13b37c44e6ef4504 to your computer and use it in GitHub Desktop.
Ruby Stack Overflow Script (with rspec test)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| source "https://rubygems.org" | |
| gem 'pry' | |
| gem 'httparty' | |
| gem 'rspec' |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # | |
| # ScoredQuestion Model | |
| # | |
| # Represents a StackOverflow question that has been returned by the API and that | |
| # will be scored according to rubric in :score method. | |
| # | |
| class ScoredQuestion | |
| attr_reader :json, :question, :owner | |
| # Class Constants (tweak as suits you) | |
| MIN_OWNER_REPUTATION = 25 | |
| MIN_OWNER_ACCEPT_RATE = 60 | |
| MAX_QUESTION_ANSWERS = 1 | |
| MIN_QUESTION_SCORE = 0 | |
| def initialize(question_json) | |
| @json = question_json | |
| @question = OpenStruct.new(@json) | |
| @owner = OpenStruct.new(@json['owner']) | |
| @owner.accept_rate = 0 if @owner.accept_rate.nil? | |
| @owner.reputation = 0 if @owner.reputation.nil? | |
| end | |
| def interesting? | |
| @question.is_answered == false && | |
| @owner.reputation >= MIN_OWNER_REPUTATION && | |
| @owner.accept_rate >= MIN_OWNER_ACCEPT_RATE && | |
| @question.answer_count <= MAX_QUESTION_ANSWERS && | |
| @question.score >= MIN_QUESTION_SCORE | |
| end | |
| def score | |
| # Scores question based on a subjective formula of your device. | |
| # TODO: include comments as factor since a lot of questions get resolved in | |
| # comments before any answer submitted. | |
| score = 0 | |
| score += age.to_f / (24 * HOUR_SECS) * 100 | |
| score += @owner.reputation / 20 | |
| score += @owner.accept_rate | |
| score += @question.score * 20 | |
| score += score / ((@question.answer_count * 10) + 1) | |
| score.to_i | |
| end | |
| def age | |
| # in seconds | |
| Time.now.to_i - @question.creation_date.to_i | |
| end | |
| def display_age | |
| age_in_minutes = age / 60 | |
| age_in_hours = age_in_minutes / 60 | |
| if age_in_hours < 1 | |
| format('%d minute%s', age_in_minutes, age_in_minutes == 1 ? '' : 's') | |
| else | |
| format('%d hour%s', age_in_hours, age_in_hours == 1 ? '' : 's') | |
| end | |
| end | |
| def to_s | |
| [ | |
| @question.title, | |
| @question.tags, | |
| "#{@owner.display_name} (#{@owner.reputation} / #{@owner.accept_rate}%)", | |
| "Score: #{self.score}", | |
| "Answers: #{@question.answer_count}", | |
| "Age: #{self.display_age}", | |
| "Link: #{@question.link}" | |
| ] | |
| end | |
| end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # | |
| # spec/scored_question_spec.rb | |
| # | |
| # Note: this is expected to be in spec subdirectory. | |
| # | |
| # To run: | |
| # cd stackoverflow/ruby | |
| # rspec spec/scored_question_spec.rb | |
| # | |
| # | |
| require 'spec_helper' | |
| require_relative '../stackoverflow' | |
| require_relative '../scored_question' | |
| describe ScoredQuestion do | |
| describe '.new' do | |
| let(:scored_question) { ScoredQuestion.new(@api_search_result) } | |
| it { expect(scored_question.question.tags).to include('foo', 'bar') } | |
| it { expect(scored_question.owner.user_id).to eq 1 } | |
| it { expect(scored_question.owner.display_name).to eq 'foo' } | |
| it { expect(scored_question.question.score).to eq 1 } | |
| it { expect(scored_question.score).to be > 0 } | |
| it { expect([true, false]).to include(scored_question.interesting?) } | |
| end | |
| describe '#interesting?' do | |
| let(:interesting_question) { ScoredQuestion.new(@api_search_result) } | |
| it { expect(interesting_question.question.is_answered).to be false } | |
| it { expect(interesting_question.owner.reputation).to be >= | |
| ScoredQuestion::MIN_OWNER_REPUTATION } | |
| it { expect(interesting_question.owner.accept_rate).to be >= | |
| ScoredQuestion::MIN_OWNER_ACCEPT_RATE } | |
| it { expect(interesting_question.question.answer_count).to be <= | |
| ScoredQuestion::MAX_QUESTION_ANSWERS } | |
| it { expect(interesting_question.question.score).to be >= | |
| ScoredQuestion::MIN_QUESTION_SCORE } | |
| it { expect(interesting_question.interesting?).to be true } | |
| let(:uninteresting_question) do | |
| result = @api_search_result.dup | |
| result['is_answered'] = true | |
| ScoredQuestion.new(result) | |
| end | |
| it { expect(uninteresting_question.interesting?).to be false } | |
| end | |
| describe '#score' do | |
| let(:scored_question) { ScoredQuestion.new(@api_search_result) } | |
| it { expect(scored_question.score).to eq 170 } | |
| end | |
| end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| RSpec.configure do |config| | |
| config.before(:example) { | |
| @api_search_result = { | |
| tags: ['foo', 'bar'], | |
| 'owner' => { | |
| user_id: 1, | |
| display_name: 'foo', | |
| link: 'http://stackoverflow.com/users/1/foo', | |
| user_type: 'registered', | |
| reputation: ScoredQuestion::MIN_OWNER_REPUTATION, | |
| accept_rate: ScoredQuestion::MIN_OWNER_ACCEPT_RATE, | |
| profile_image: 'https://www.gravatar.com/avatar/foo?s=1&d=identicon&r=PG&f=1', | |
| }, | |
| question_id: 1, | |
| link: 'http://stackoverflow.com/questions/100/test-foo', | |
| title: 'Can you foo?', | |
| is_answered: false, | |
| view_count: 10, | |
| answer_count: 0, | |
| score: 1, | |
| creation_date: Time.now.to_i - 3600, | |
| last_activity_date: Time.now.to_i - 60 | |
| } | |
| } | |
| end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # | |
| # stackoverflow.rb | |
| # | |
| # Requests questions from the Stack Overflow API and filters and scores them | |
| # based on criteria defined in script. | |
| # | |
| # For Advanced Search API, see https://api.stackexchange.com/docs/advanced-search | |
| # | |
| # API KEY | |
| # Either paste your key directly below or export to your environment, like so: | |
| # $ export STACKOVERFLOW_API_KEY="my-api-key" | |
| # | |
| # USAGE | |
| # ruby stackoverflow.rb | |
| # > usage | |
| # | |
| require 'pry' | |
| require 'httparty' | |
| require_relative 'scored_question' | |
| # For extra requests, put your API key here | |
| API_KEY = ENV['STACKOVERFLOW_API_KEY'] | |
| # Constants | |
| API_URL = 'https://api.stackexchange.com/2.2' | |
| HOUR_SECS = 60 * 60 | |
| # | |
| # Functions | |
| # | |
| def api_search_request(request_params) | |
| default_params = { | |
| site: 'stackoverflow', | |
| sort: 'activity', | |
| order: 'desc' | |
| } | |
| params = default_params.merge(request_params) | |
| params['key'] = API_KEY if API_KEY | |
| url = format('%s/search/advanced', API_URL) | |
| puts "making request to #{url}" | |
| response = HTTParty.get(url, { query: params }) | |
| response['questions'] = response['items'].map { |question| ScoredQuestion.new(question) } | |
| response | |
| end | |
| def search_recent_open_questions(tag='ruby', exclude_tag='.net') | |
| questions = [] | |
| # Param constants | |
| max_page = 5 | |
| max_age = 72 # hours | |
| page_size = 100 | |
| params = { | |
| tagged: tag, | |
| notttagged: exclude_tag, | |
| closed: 'False', | |
| accepted: 'False', | |
| fromdate: Time.now.to_i - (max_age * HOUR_SECS), | |
| page: 1, | |
| pagesize: page_size | |
| } | |
| while params[:page] <= max_page | |
| response = api_search_request(params) | |
| questions += response['questions'] | |
| params[:page] += 1 | |
| params[:page] = max_age + 1 unless response['has_more'] | |
| end | |
| puts format('Search complete: %s requests remaining on quote', | |
| response.fetch('quota_remaining', '?')) | |
| return questions | |
| end | |
| def filter_interesting_questions(tag='ruby', exclude_tag='.net') | |
| questions = search_recent_open_questions(tag, exclude_tag) | |
| puts "filtering #{questions.length} results" | |
| interesting_questions = questions.keep_if { |question| question.interesting? } | |
| interesting_questions = interesting_questions.sort_by(&:score).reverse | |
| # Return enumerator object: http://ruby-doc.org/core-2.1.1/Enumerator.html | |
| interesting_questions.each | |
| end | |
| # | |
| # Interface | |
| # | |
| def usage | |
| puts <<USAGE | |
| USAGE: | |
| pry(main)> questions = filter_interesting_questions('tag1;tag2') | |
| pry(main)> questions.count | |
| pry(main)> questions.first | |
| pry(main)> filter('tag1;tag2') | |
| pry(main)> $questions.next.to_s | |
| SAMPLE TAGS: | |
| ruby, ruby-on-rails, activerecord, rspec | |
| USAGE | |
| end | |
| # Global here allows us to skip assignment in calling filter. | |
| $questions = nil | |
| def filter(tags, exclude_tags='') | |
| $questions = filter_interesting_questions(tags) | |
| puts format('Filtered %d questions for tags: %s', $questions.count, tags.split(';')) | |
| puts 'To inspect results: $questions.next.to_s' | |
| end | |
| # Pry provides a quasi interactive interface. | |
| # Only interrupts if file called directly | |
| binding.pry if __FILE__ == $0 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # | |
| # spec/stackoverflow_spec.rb | |
| # | |
| # Note: this is expected to be in spec subdirectory. | |
| # | |
| # To run: | |
| # cd stackoverflow/ruby | |
| # rspec spec/stackoverflow_spec.rb | |
| # | |
| # | |
| require 'spec_helper' | |
| require 'json' | |
| require_relative '../stackoverflow' | |
| require_relative '../scored_question' | |
| describe 'Tests stackoverflow script' do | |
| context 'handling API requests' do | |
| context 'when there are more then 5 pages of results' do | |
| before(:each) do | |
| allow(HTTParty).to receive(:get).and_return({ | |
| 'items' => [@api_search_result], | |
| 'has_more' => true | |
| }) | |
| end | |
| it 'stops at 5th page' do | |
| results = search_recent_open_questions('foo') | |
| expect(results.count).to eq 5 | |
| end | |
| end | |
| end | |
| end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment