├── .gitignore ├── Capfile ├── config ├── config.yml.example └── deploy.rb ├── public ├── fontawesome-webfont.woff └── font-awesome.min.css ├── VSPL-LICENSE.txt ├── Gemfile ├── oauth_generator.rb ├── keywords_blacklist.txt ├── views ├── tweets.erb └── layout.erb ├── Gemfile.lock ├── bot ├── keywords_whitelist.txt ├── retweeter.rb ├── server.rb ├── tweet_presenter.rb └── README.markdown /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle 2 | .DS_Store 3 | config/config.yml 4 | *.json 5 | stuff 6 | -------------------------------------------------------------------------------- /Capfile: -------------------------------------------------------------------------------- 1 | load 'deploy' 2 | load 'config/deploy' # remove this line to skip loading any of the default tasks 3 | -------------------------------------------------------------------------------- /config/config.yml.example: -------------------------------------------------------------------------------- 1 | consumer_key: xxx 2 | consumer_secret: xxx 3 | oauth_token: xxx 4 | oauth_token_secret: xxx 5 | -------------------------------------------------------------------------------- /public/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mackuba/rails-retweeter-bot/HEAD/public/fontawesome-webfont.woff -------------------------------------------------------------------------------- /VSPL-LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Jakub Suder 2 | 3 | You can modify, distribute and use this software for any purpose without any 4 | restrictions as long as you keep this copyright notice intact. The software is 5 | provided without any warranty. 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # fix for "ArgumentError: invalid byte sequence in US-ASCII" during bundle install (?) 2 | Encoding.default_internal = Encoding.default_external = Encoding::UTF_8 3 | 4 | source "http://rubygems.org" 5 | 6 | # TODO: update to 5.x 7 | gem 'twitter', '~> 4.4' 8 | gem 'oauth' 9 | gem 'multi_json' 10 | 11 | group :development do 12 | gem 'capistrano', '~> 2.13' 13 | gem 'rvm-capistrano' 14 | gem 'sinatra' 15 | gem 'sinatra-reloader' 16 | end 17 | -------------------------------------------------------------------------------- /config/deploy.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/capistrano' 2 | 3 | set :application, "rails_bot" 4 | set :repository, "git@github.com:mackuba/rails-retweeter-bot.git" 5 | set :scm, :git 6 | set :keep_releases, 5 7 | set :use_sudo, false 8 | set :deploy_to, "/var/www/rails_bot" 9 | set :deploy_via, :remote_cache 10 | 11 | server "zermatt", :app, :web, :db, :primary => true 12 | 13 | after 'deploy:update_code', 'deploy:symlink_config' 14 | 15 | namespace :deploy do 16 | task :symlink_config do 17 | run "ln -s #{shared_path}/config/config.yml #{release_path}/config/config.yml" 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /oauth_generator.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'oauth' 3 | require 'yaml' 4 | 5 | config = YAML.load(File.read('config/config.yml')) 6 | 7 | oauth = OAuth::Consumer.new( 8 | config['consumer_key'], 9 | config['consumer_secret'], 10 | :site => 'https://twitter.com', 11 | :request_token_path => '/oauth/request_token', 12 | :access_token_path => '/oauth/access_token', 13 | :authorize_path => '/oauth/authorize' 14 | ) 15 | 16 | rt = oauth.get_request_token 17 | request_token = rt.token 18 | request_secret = rt.secret 19 | 20 | puts "Request token => #{request_token}" 21 | puts "Request secret => #{request_secret}" 22 | puts "Authentication URL => #{rt.authorize_url} [OPEN THIS]" 23 | 24 | print "Provide the PIN that Twitter gave you here: " 25 | pin = gets.chomp 26 | 27 | at = rt.get_access_token(oauth_verifier: pin) 28 | access_token = at.token 29 | access_secret = at.secret 30 | 31 | puts "Access token => #{at.token}" 32 | puts "Access secret => #{at.secret}" 33 | -------------------------------------------------------------------------------- /keywords_blacklist.txt: -------------------------------------------------------------------------------- 1 | abuse 2 | america\w* 3 | anarch\w+ 4 | apple 5 | arrest\w* 6 | asshole 7 | bank\w* 8 | capitalis\w+ 9 | censor\w* 10 | civil\w* 11 | coc 12 | communis\w+ 13 | conduct 14 | cops? 15 | countr(y|ies) 16 | crimes? 17 | DEA 18 | democra\w+ 19 | dick\w* 20 | drones? 21 | drugs? 22 | ecto 23 | elixir(lang)? 24 | employ\w* 25 | erlang 26 | fanatic\w* 27 | fascis\w+ 28 | fbi 29 | femini\w+ 30 | (de)?fraud\w* 31 | fuck\w* 32 | gay 33 | golang 34 | gov\w* 35 | harass\w* 36 | haskell 37 | hex 38 | humanit\w+ 39 | (de)?humani[sz]\w+ 40 | iOS 41 | iPhone 42 | IRS 43 | journ(alis[tm]|o)s? 44 | (in)?justic\w+ 45 | law\w* 46 | (il)?legal\w* 47 | liberal\w* 48 | libert\w+ 49 | (fe)?male 50 | masculin\w+ 51 | meritocr\w+ 52 | misog\w* 53 | nationali\w+ 54 | nazi\w* 55 | NSA 56 | obama 57 | officers? 58 | parent\w* 59 | patriarchy 60 | phoenix 61 | polic\w+ 62 | politic\w+ 63 | power 64 | priviliged? 65 | racis\w+ 66 | rape 67 | regime 68 | religi\w+ 69 | republic\w* 70 | rust 71 | scala 72 | senat\w* 73 | sex\w* 74 | soci\w+ 75 | suprem\w+ 76 | swift 77 | terror\w* 78 | testif\w+ 79 | testim\w+ 80 | tortur\w+ 81 | toxic\w* 82 | trump 83 | TSA 84 | uber 85 | united\sstates 86 | USA? 87 | vim 88 | violen\w+ 89 | war 90 | weapon\w* 91 | wom[ae]n 92 | xcode 93 | xenoph\w+ 94 | -------------------------------------------------------------------------------- /views/tweets.erb: -------------------------------------------------------------------------------- 1 |

2 | <% if user_data %> 3 | Followers count: <%= user_data.followers_count %> | 4 | Tweets count: <%= user_data.statuses_count %> | 5 | Awesomeness threshold: <%= awesomeness_threshold(user_data) %> | 6 | <% end %> 7 | 8 | Matched tweets: <%= tweets.select(&:interesting?).length %>/<%= tweets.length %> | 9 | Sort by: time / score 10 |

11 | 12 |
13 | 14 | <% tweets.each do |t| %> 15 |
19 | 20 |

21 | 22 | <%= t.user.name %> 23 | 24 | 25 |

26 | 27 |

<%= highlight(t.expanded_text) %>

28 | 29 |

30 | <%= t.activity_count %> reaction(s) = 31 | <%= t.retweet_count %> retweet(s) + 32 | <%= t.favorite_count %> favorite(s) / 33 | <%= t.created_at %> / 34 | link 35 | <% if t.retweeted? %> 36 | / 37 | <% end %> 38 |

39 |
40 | <% end %> 41 | -------------------------------------------------------------------------------- /views/layout.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 23 | 24 | 25 |

26 |

27 | <% if live? %> 28 | Home 29 | <% else %> 30 | All 31 | <% end %> 32 | 33 | <% users.each do |u| %> 34 | | <%= u %> 35 | <% end %> 36 | 37 | <% if live? %> 38 | | 39 | <% end %> 40 |
41 |

42 | 43 |
44 | 45 | <%= yield %> 46 | 47 | 48 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: http://rubygems.org/ 3 | specs: 4 | backports (2.5.1) 5 | capistrano (2.15.9) 6 | highline 7 | net-scp (>= 1.0.0) 8 | net-sftp (>= 2.0.0) 9 | net-ssh (>= 2.0.14) 10 | net-ssh-gateway (>= 1.1.0) 11 | eventmachine (1.0.7) 12 | faraday (0.9.2) 13 | multipart-post (>= 1.2, < 3) 14 | highline (2.0.0) 15 | multi_json (1.11.2) 16 | multipart-post (2.0.0) 17 | net-scp (1.2.1) 18 | net-ssh (>= 2.6.5) 19 | net-sftp (2.1.2) 20 | net-ssh (>= 2.6.5) 21 | net-ssh (5.0.2) 22 | net-ssh-gateway (2.0.0) 23 | net-ssh (>= 4.0.0) 24 | oauth (0.4.7) 25 | rack (1.6.10) 26 | rack-protection (1.5.5) 27 | rack 28 | rack-test (0.6.1) 29 | rack (>= 1.0) 30 | rvm-capistrano (1.2.7) 31 | capistrano (>= 2.0.0) 32 | simple_oauth (0.3.1) 33 | sinatra (1.3.3) 34 | rack (~> 1.3, >= 1.3.6) 35 | rack-protection (~> 1.2) 36 | tilt (~> 1.3, >= 1.3.3) 37 | sinatra-contrib (1.3.1) 38 | backports (>= 2.0) 39 | eventmachine 40 | rack-protection 41 | rack-test 42 | sinatra (~> 1.3.0) 43 | tilt (~> 1.3) 44 | sinatra-reloader (1.0) 45 | sinatra-contrib 46 | tilt (1.3.3) 47 | twitter (4.8.1) 48 | faraday (~> 0.8, < 0.10) 49 | multi_json (~> 1.0) 50 | simple_oauth (~> 0.2) 51 | 52 | PLATFORMS 53 | ruby 54 | 55 | DEPENDENCIES 56 | capistrano (~> 2.13) 57 | multi_json 58 | oauth 59 | rvm-capistrano 60 | sinatra 61 | sinatra-reloader 62 | twitter (~> 4.4) 63 | 64 | BUNDLED WITH 65 | 2.3.7 66 | -------------------------------------------------------------------------------- /bot: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'bundler/setup' 4 | require 'multi_json' 5 | require 'twitter' 6 | require 'yaml' 7 | require_relative 'retweeter' 8 | 9 | config = YAML.load(File.read('config/config.yml')) 10 | twitter = Twitter::Client.new(Hash[config.map { |k, v| [k.to_sym, v] }]) 11 | retweeter = Retweeter.new(twitter) 12 | 13 | command, filename = ARGV 14 | 15 | case command 16 | when nil 17 | puts "Usage:" 18 | puts " bot retweet - retweet matching tweets from bot's timeline" 19 | puts " bot test-retweet - see what would be retweeted" 20 | puts " bot fetch [file] - save last 3 months of tweets from followed users to a json file (or print to stdout)" 21 | puts " bot cached [file] - start web UI with data from a json file or stdin" 22 | puts " bot live - start web UI with data loaded on demand" 23 | 24 | when 'retweet' 25 | retweeter.retweet_new_tweets 26 | 27 | when 'test-retweet' 28 | retweeter.print_retweetable_tweets 29 | 30 | when 'fetch' 31 | json = retweeter.fetch_all_users_json( 32 | :extra_users => ENV['EXTRA_USERS'] && ENV['EXTRA_USERS'].split(','), 33 | :only_users => ENV['ONLY_USERS'] && ENV['ONLY_USERS'].split(','), 34 | :days => ENV['DAYS'] && ENV['DAYS'].to_i 35 | ) 36 | data = MultiJson.dump(json) 37 | filename ? File.write(filename, data) : puts(data) 38 | 39 | when 'cached' 40 | require_relative 'server' 41 | data = filename ? File.read(filename) : STDIN.read 42 | json = MultiJson.load(data, :symbolize_keys => true) 43 | Server.start(retweeter, json) 44 | 45 | when 'live' 46 | require_relative 'server' 47 | Server.start(retweeter) 48 | 49 | else 50 | raise "Unknown command #{command}" 51 | 52 | end 53 | -------------------------------------------------------------------------------- /keywords_whitelist.txt: -------------------------------------------------------------------------------- 1 | action ?(mailer|view|controller|pack|dispatch) 2 | active ?(model|record|support|resource|queue) 3 | architectures? 4 | arrays? 5 | assets? 6 | authenticat\w+ 7 | authlogic 8 | bdd 9 | benchmark 10 | bugs? 11 | bundler? 12 | cach(e|ing) 13 | callbacks? 14 | capistrano 15 | capybara 16 | ci 17 | code 18 | coding 19 | compromised 20 | conferences? 21 | \w+conf 22 | continuous 23 | controllers? 24 | cve 25 | databases? 26 | datamapper 27 | dependenc(y|ies) 28 | deploy\w* 29 | devise 30 | devs? 31 | develop\w* 32 | enumera\w+ 33 | \w+error 34 | factory[ _]girl 35 | frameworks? 36 | function(al|s)? 37 | gems? 38 | gil 39 | git 40 | gsoc 41 | hack(ing|ed|ers?)? 42 | hash(es)? 43 | heroku 44 | inheritance 45 | integration 46 | introducing 47 | irb 48 | (to_|as_)?json\w* 49 | maintain(ing|ers?) 50 | merb 51 | metaprogram\w* 52 | micro ?services? 53 | middleware 54 | models? 55 | modules? 56 | "MRI" 57 | (im)?mutab\w+ 58 | nil|null 59 | objects? 60 | object-oriented 61 | "OOP" 62 | optimi[sz][aei]\w+ 63 | "ORM" 64 | "OSS" 65 | paperclip 66 | passenger 67 | passwords? 68 | patch(ed|es|ing)? 69 | (anti)?patterns? 70 | perf(ormance)? 71 | please.*\brt 72 | pls rt 73 | presentations? 74 | programm\w+ 75 | projects? 76 | /PSA\:/ 77 | queu\w+ 78 | rack 79 | \w*rails\w* 80 | rake 81 | refactor\w* 82 | refinements 83 | releas\w+ 84 | repo(sitor(y|ies))? 85 | "REST" 86 | restful 87 | roda(kase)? 88 | rom 89 | rspec 90 | rubies 91 | rubinius 92 | \w*ruby\w* 93 | rvm 94 | scaling 95 | security 96 | serializ\w+ 97 | sidekiq 98 | sinatra 99 | sourc(e|ed|ing) 100 | syntax 101 | tdd 102 | test\w* 103 | (multi)?thread(s|ed|ing) 104 | (pro ?)?tips? 105 | turbolinks 106 | validations 107 | vms? 108 | vulnerabilit(y|ies) 109 | webapps? 110 | yaml 111 | -------------------------------------------------------------------------------- /retweeter.rb: -------------------------------------------------------------------------------- 1 | require_relative 'tweet_presenter' 2 | 3 | class Retweeter 4 | DAY = 86400 5 | THREE_MONTHS = 90 * DAY 6 | 7 | def initialize(twitter) 8 | @twitter = twitter 9 | end 10 | 11 | def retweet_new_tweets 12 | retweetable_tweets.each { |t| retweet(t) } 13 | end 14 | 15 | def print_retweetable_tweets 16 | puts "./bot retweet would retweet these tweets:" 17 | retweetable_tweets.each { |t| puts "@#{t.user.screen_name}: #{t.expanded_text} (#{t.created_at})" } 18 | end 19 | 20 | def retweetable_tweets 21 | load_home_timeline.select(&:retweetable?).reverse 22 | end 23 | 24 | def fetch_all_users_json(options = {}) 25 | if options[:only_users] 26 | users = options[:only_users] 27 | else 28 | users = followed_users 29 | users |= options[:extra_users] if options[:extra_users] 30 | end 31 | 32 | days = options[:days] 33 | 34 | tweets_json = users.map { |u| load_user_timeline(u, days).map(&:attrs) } 35 | 36 | Hash[users.zip(tweets_json)] 37 | end 38 | 39 | def followed_users 40 | @twitter.following.map(&:screen_name).sort 41 | end 42 | 43 | def load_home_timeline 44 | load_timeline(:home_timeline) 45 | end 46 | 47 | def load_user_timeline(login, days = nil) 48 | interval = days ? (days * DAY) : THREE_MONTHS 49 | starting_date = Time.now - interval 50 | 51 | $stderr.print "@#{login} ." 52 | tweets = load_timeline(:user_timeline, login) 53 | 54 | while tweets.last.created_at > starting_date 55 | $stderr.print '.' 56 | batch = load_timeline(:user_timeline, login, :max_id => tweets.last.id - 1) 57 | break if batch.empty? 58 | tweets.concat(batch) 59 | end 60 | 61 | $stderr.puts 62 | 63 | tweets.reject { |t| t.created_at < starting_date } 64 | end 65 | 66 | def load_timeline(timeline, *args) 67 | options = args.last.is_a?(Hash) ? args.pop : {} 68 | tweets = @twitter.send(timeline, *args, { count: 200, include_rts: false, tweet_mode: 'extended' }.merge(options)) 69 | tweets.map { |t| TweetPresenter.new(t) } 70 | end 71 | 72 | def retweet(tweet) 73 | @twitter.retweet(tweet.id) 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /server.rb: -------------------------------------------------------------------------------- 1 | require 'sinatra/base' 2 | require 'sinatra/reloader' 3 | require_relative 'tweet_presenter' 4 | 5 | class Server < Sinatra::Base 6 | enable :logging, :sessions, :static 7 | set :port, ENV['PORT'] || 3000 8 | 9 | private_class_method :run! 10 | 11 | def self.start(retweeter, data = nil) 12 | @@retweeter = retweeter 13 | @@tweets = {} 14 | @@live = !data 15 | @@users = retweeter.followed_users 16 | 17 | if data 18 | data.each do |login, tweets_json| 19 | @@tweets[login.to_s] = tweets_json.map { |t| TweetPresenter.from_json(t) } 20 | end 21 | end 22 | 23 | run! 24 | end 25 | 26 | helpers do 27 | def live? 28 | @@live 29 | end 30 | 31 | def users 32 | @@users 33 | end 34 | 35 | def highlight(text) 36 | highlighted = text.clone 37 | 38 | TweetPresenter.keywords_whitelist.each do |k| 39 | highlighted.gsub!(k, "\\0") 40 | end 41 | 42 | TweetPresenter.keywords_blacklist.each do |k| 43 | highlighted.gsub!(k, "\\0") 44 | end 45 | 46 | highlighted.gsub!(/\b(https?:\/\/\S*[^,.])(\s|$)/, "\\1\\2") 47 | 48 | highlighted 49 | end 50 | 51 | def awesomeness_threshold(user) 52 | sprintf("%.2f", TweetPresenter.user_awesomeness_threshold(user)) 53 | end 54 | end 55 | 56 | before do 57 | @sort = session[:sort] = params[:sort] || session[:sort] || 'time' 58 | 59 | TweetPresenter.reload_blacklists 60 | end 61 | 62 | get '/' do 63 | @@tweets[nil] ||= @@live ? @@retweeter.load_home_timeline : @@tweets.values.flatten 64 | 65 | index(@@tweets[nil]) 66 | end 67 | 68 | get '/user/:login' do |login| 69 | @@tweets[params[:login]] ||= @@retweeter.load_user_timeline(login) 70 | 71 | index(@@tweets[params[:login]]) 72 | end 73 | 74 | def index(tweets) 75 | selected_tweets = tweets.reject { |t| t.reply? && t.retweet_count == 0 } 76 | 77 | if @sort == 'time' 78 | selected_tweets.sort_by! { |t| -t.created_at.to_i } 79 | else 80 | selected_tweets.sort_by! { |t| -t.activity_count } 81 | end 82 | 83 | erb :tweets, :locals => { 84 | :tweets => selected_tweets, 85 | :user_data => params[:login] && tweets.first ? tweets.first.user : nil 86 | } 87 | end 88 | end -------------------------------------------------------------------------------- /tweet_presenter.rb: -------------------------------------------------------------------------------- 1 | class TweetPresenter 2 | def self.from_json(json) 3 | new(Twitter::Tweet.new(json)) 4 | end 5 | 6 | def self.load_keyword_list(name) 7 | File.readlines(name).map do |pattern| 8 | pattern.strip! 9 | if pattern =~ /^"(.*)"$/ 10 | # double quotes mean don't ignore case 11 | /\b#{$1}\b/ 12 | elsif pattern =~ /^\/(.*)\/$/ 13 | # slashes mean use pattern directly without wrapping in \b 14 | /#{$1}/ 15 | else 16 | /\b#{pattern}\b/i 17 | end 18 | end 19 | end 20 | 21 | def self.reload_blacklists 22 | @whitelist = @blacklist = nil 23 | end 24 | 25 | def self.keywords_whitelist 26 | @whitelist ||= load_keyword_list('keywords_whitelist.txt') 27 | end 28 | 29 | def self.keywords_blacklist 30 | @blacklist ||= load_keyword_list('keywords_blacklist.txt') 31 | end 32 | 33 | def self.user_awesomeness_threshold(user) 34 | # this is a completely non-scientific formula calculated by trial and error 35 | # in order to set the bar higher for users that get retweeted a lot (@dhh, @rails). 36 | # should be around 20 for most people and then raise to ~30 for @rails and 50+ for @dhh. 37 | # the idea is that if you have an army of followers, everything you write gets retweeted and favorited 38 | 39 | 17.5 + (user.followers_count ** 1.25) * 25 / 1_000_000 40 | end 41 | 42 | def initialize(tweet) 43 | @tweet = tweet 44 | end 45 | 46 | [:id, :attrs, :created_at, :retweet_count, :urls, :user].each do |method| 47 | define_method(method) do 48 | @tweet.send(method) 49 | end 50 | end 51 | 52 | def text 53 | @tweet.attrs[:full_text] || @tweet.attrs[:text] 54 | end 55 | 56 | def reply? 57 | text.start_with?('@') 58 | end 59 | 60 | def retweetable? 61 | !retweeted? && interesting? 62 | end 63 | 64 | def interesting? 65 | matches_whitelist? && !matches_blacklist? && above_threshold? 66 | end 67 | 68 | def above_threshold? 69 | (activity_count >= user_awesomeness_threshold) && retweet_count > 0 70 | end 71 | 72 | def retweeted? 73 | @tweet.retweeted 74 | end 75 | 76 | def matches_whitelist? 77 | self.class.keywords_whitelist.any? { |k| expanded_text =~ k } 78 | end 79 | 80 | def matches_blacklist? 81 | return true if user.screen_name == 'sgrif' && expanded_text.downcase =~ /ruby/ # not *that* Ruby 82 | 83 | self.class.keywords_blacklist.any? { |k| expanded_text =~ k } 84 | end 85 | 86 | def activity_count 87 | retweet_count + favorite_count 88 | end 89 | 90 | def favorite_count 91 | # overwrite the alias favorite_count -> @attrs[:favoriters_count] from twitter gem 92 | @tweet.attrs[:favorite_count] 93 | end 94 | 95 | def user_awesomeness_threshold 96 | self.class.user_awesomeness_threshold(user) 97 | end 98 | 99 | def expanded_text 100 | unless @expanded_text 101 | @expanded_text = text.clone 102 | urls.each { |entity| @expanded_text[entity.url] = entity.display_url } 103 | end 104 | 105 | @expanded_text 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | # Rails Retweeter Bot 2 | 3 | This is the source of the bot tweeting at [@rails_bot](https://twitter.com/rails_bot). I'm putting it here in case someone wants to reuse it to make another bot, or offer suggestions to tweak @rails_bot. 4 | 5 | ## The idea 6 | 7 | The are a lot of people in the Ruby/Rails community that I'd like to follow. However, in order to read their interesting tweets I'd also have to agree to read all the other things they tweet, and then I wouldn't do anything else than read tweets all day. 8 | 9 | Basically, I want to read this: 10 | 11 |

Thanks @steveklabnik for reminding me about this article. Every programmer should read it: kalzumeus.com/2010/06/17/fal…

— Aaron Patterson (@tenderlove) August 31, 2012
12 | 13 | 14 | But not this: 15 | 16 |

Just saw a poo that looked like a starfish.

— Aaron Patterson (@tenderlove) October 15, 2012
17 | 18 | (sorry, Aaron...) 19 | 20 | What I need is someone that would follow all these people, read all their tweets and retweet only what seems important. This bot is my attempt at creating such filter. 21 | 22 | ## How it works 23 | 24 | The basic idea was that the best tweets get retweeted a lot, so I made the bot select tweets with a high number of retweets. Adding favorites improved things further, because a lot of tweets get many favorites but not many retweets (especially some useful but not funny tweets from [@ruby_news](https://twitter.com/ruby_news) or [@rubyflow](https://twitter.com/rubyflow) - the funny ones get retweeted the most). I've ignored the tweets of people from outside the selected group retweeted by people in the group, because almost all of them were off topic. 25 | 26 | Now I had most of the interesting tweets marked to be retweeted, but most of the top tweets were still not relevant - funny tweets about random things, tweets about politics, current news, Apple, Microsoft, startups, religion, etc. So then I've added a keyword whitelist - I went through the top tweets and I've prepared a list of keywords that would only match the tweets I'd like to see retweeted. Later I've also added a blacklist in order to ignore tweets that matched some "good" words, but also included some "bad" words too (a blacklist match trumps a whitelist match). (Note: I'm using the words good/bad/whitelist/blacklist only to refer to being relevant or irrelevant to what I want to see the bot tweet about - I realize that a lot of blacklisted topics are important, I simply want this bot to focus only on purely technical content, since that's easiest to judge algorithmically.) 27 | 28 | I've also made the minimum number of retweets+favorites depend on the author - those with a high number of followers get much more retweets on average, so a post with 30 retweets by [@spastorino](https://twitter.com/spastorino) (3871 followers) will usually be more interesting than a post with 30 retweets by [@dhh](https://twitter.com/dhh) (72141 followers). 29 | 30 | The end result is that even though some good tweets are ignored and some off topic tweets get retweeted, the filter works surprisingly well in most cases. It should retweet about 4 tweets per day on average, which sounds like an acceptable number. 31 | 32 | ## How to use 33 | 34 | First, you need a Twitter account to tweet from. You'll want a fresh account only for that purpose. The bot selects the tweets to retweet from what it sees on its home timeline, so the list of people to be observed and retweeted is simply the bot account's "following" list. Log in as the bot's account and follow any people that you'd like to see retweeted. 35 | 36 | Next, clone the repository and run `bundle install`. Create a `config.yml` file based on the example file. Go to Twitter's developer site and register an application there (it doesn't technically have to be owned by the bot), then copy the first two keys to the config. Then use the `oauth_generator.rb` script to get the other two keys (here you need to sign in with the bot's account!). 37 | 38 | Then you can use any of these: 39 | 40 | ### ./bot retweet 41 | 42 | Loads 200 last tweets from the timeline (ignoring retweets) and retweets the ones that match the filter and weren't retweeted yet. 200 last tweets includes tweets from the last 2 days on average in @rails_bot's case (I think most tweets will get most of their retweets and favorites in the first 2 days anyway). 43 | 44 | ### ./bot fetch data.json 45 | 46 | Downloads last 3 months of tweets from each of the followed people and saves them as a JSON file. 47 | 48 | ### ./bot cached data.json 49 | 50 | Starts a web UI that you can use to tweak the filter and see which tweets get marked. Uses the JSON file created above. This is useful for tweaking whitelists, blacklists, followed user list etc. - you can easily test which tweets are matched and which aren't without having to redownload the data constantly. This is important because there are pretty strict usage limits in Twitter's API, and if you load too much within an hour, you will be blocked until the next hour. 51 | 52 | ### ./bot live 53 | 54 | Starts a web UI, downloading tweets on demand. This is useful for checking new profiles to see if it's worth adding them to the list. 55 | 56 | ## How to customize the bot 57 | 58 | To make the bot useful for you, apart from the followed user list you will probably need to tweak some of its parameters like keywords and threshold values to make it focus on the specific content you're interested in. 59 | 60 | ### Edit keywords that will not be retweeted in 'keywords_blacklist.txt' 61 | 62 | These are the words that you DO NOT want included in any of your retweets. If any of those words and expressions match, the tweet will not be retweeted regardless of what else is in it, who tweeted it or how popular it is. Use the original list as an example of how the patterns should look and prepare a list that will work for you. 63 | 64 | The file is basically a list of Ruby regular expression patterns, each on a separate line, without any opening and closing symbols. As you can see from the original list, it can include things like alternative or optional parts, wildcards etc. Also, a word break symbol is automatically added at the beginning and end of the pattern, so they only match full words, not parts of words. 65 | 66 | Two additional exceptions: a pattern in quotes (`"FOO"`) means that the pattern is case-sensitive, and a pattern in slashes (`/foo/`) means that it will be used as is without adding the word break symbols. 67 | 68 | ### Edit words that will be retweeted in 'keywords_whitelist.txt' 69 | 70 | The list of words in this text file are the words that you DO want included in your retweets. Matching a word from this list is a requirement, i.e. a tweet will only be considered at all if it matches something on this list. Technically the list is built in the same way as the blacklist. Again, use the example list from @rails_bot to prepare a list of words that are relevant for you and that you want included in your retweets. 71 | 72 | ### Change retweet threshold in 'tweet_presenter.rb' 73 | 74 | If you look in `tweet_presenter.rb` and find the `user_awesomeness_threshold` function, you can customize the threshold values that determine which retweets get retweeted. The way the threshold works is that if the number of retweets + the number of favorites on a given tweet is greater than or equal to a threshold (which depends on the number of followers its author has), then that tweet will be retweeted. 75 | 76 | The function currently uses a pretty strange expression that was simply created by trial and error to fit the specific user list that @rails_bot follows, but it might not work for other lists, so you might need to change not only the specific values, but also the whole function. E.g. you could use a simpler function like `user.followers_count / 1000 + 3` - then for a person with 6000 followers their tweets will need to have at least 9 retweets+favorites to be considered for retweeting (as long as they match the whitelist and don't match the blacklist), and a person with almost no followers will still need at least 3. Do some experiments and see what works for you - make sure that both popular and relatively unknown people don't get retweeted regardless of what they say or not retweeted at all. 77 | 78 | ## Credits 79 | 80 | Created by [Kuba Suder](http://mackuba.eu) ([@kuba_suder](https://twitter.com/kuba_suder)), licensed under VSPL ([Very Simple Public License](https://github.com/mackuba/rails-retweeter-bot/blob/master/VSPL-LICENSE.txt)). 81 | -------------------------------------------------------------------------------- /public/font-awesome.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome 3.0.2 3 | * the iconic font designed for use with Twitter Bootstrap 4 | * ------------------------------------------------------- 5 | * The full suite of pictographic icons, examples, and documentation 6 | * can be found at: http://fortawesome.github.com/Font-Awesome/ 7 | * 8 | * License 9 | * ------------------------------------------------------- 10 | * - The Font Awesome font is licensed under the SIL Open Font License - http://scripts.sil.org/OFL 11 | * - Font Awesome CSS, LESS, and SASS files are licensed under the MIT License - 12 | * http://opensource.org/licenses/mit-license.html 13 | * - The Font Awesome pictograms are licensed under the CC BY 3.0 License - http://creativecommons.org/licenses/by/3.0/ 14 | * - Attribution is no longer required in Font Awesome 3.0, but much appreciated: 15 | * "Font Awesome by Dave Gandy - http://fortawesome.github.com/Font-Awesome" 16 | 17 | * Contact 18 | * ------------------------------------------------------- 19 | * Email: dave@davegandy.com 20 | * Twitter: http://twitter.com/fortaweso_me 21 | * Work: Lead Product Designer @ http://kyruus.com 22 | */ 23 | 24 | @font-face{ 25 | font-family: 'FontAwesome'; 26 | src: url('/fontawesome-webfont.woff?v=3.0.1') format('woff'); 27 | font-weight:normal; 28 | font-style:normal; 29 | } 30 | 31 | [class^="icon-"],[class*=" icon-"]{font-family:FontAwesome;font-weight:normal;font-style:normal;text-decoration:inherit;-webkit-font-smoothing:antialiased;display:inline;width:auto;height:auto;line-height:normal;vertical-align:baseline;background-image:none;background-position:0 0;background-repeat:repeat;margin-top:0}.icon-white,.nav-pills>.active>a>[class^="icon-"],.nav-pills>.active>a>[class*=" icon-"],.nav-list>.active>a>[class^="icon-"],.nav-list>.active>a>[class*=" icon-"],.navbar-inverse .nav>.active>a>[class^="icon-"],.navbar-inverse .nav>.active>a>[class*=" icon-"],.dropdown-menu>li>a:hover>[class^="icon-"],.dropdown-menu>li>a:hover>[class*=" icon-"],.dropdown-menu>.active>a>[class^="icon-"],.dropdown-menu>.active>a>[class*=" icon-"],.dropdown-submenu:hover>a>[class^="icon-"],.dropdown-submenu:hover>a>[class*=" icon-"]{background-image:none}[class^="icon-"]:before,[class*=" icon-"]:before{text-decoration:inherit;display:inline-block;speak:none}a [class^="icon-"],a [class*=" icon-"]{display:inline-block}.icon-large:before{vertical-align:-10%;font-size:1.3333333333333333em}.btn [class^="icon-"],.nav [class^="icon-"],.btn [class*=" icon-"],.nav [class*=" icon-"]{display:inline}.btn [class^="icon-"].icon-large,.nav [class^="icon-"].icon-large,.btn [class*=" icon-"].icon-large,.nav [class*=" icon-"].icon-large{line-height:.9em}.btn [class^="icon-"].icon-spin,.nav [class^="icon-"].icon-spin,.btn [class*=" icon-"].icon-spin,.nav [class*=" icon-"].icon-spin{display:inline-block}.nav-tabs [class^="icon-"],.nav-pills [class^="icon-"],.nav-tabs [class*=" icon-"],.nav-pills [class*=" icon-"],.nav-tabs [class^="icon-"].icon-large,.nav-pills [class^="icon-"].icon-large,.nav-tabs [class*=" icon-"].icon-large,.nav-pills [class*=" icon-"].icon-large{line-height:.9em}li [class^="icon-"],.nav li [class^="icon-"],li [class*=" icon-"],.nav li [class*=" icon-"]{display:inline-block;width:1.25em;text-align:center}li [class^="icon-"].icon-large,.nav li [class^="icon-"].icon-large,li [class*=" icon-"].icon-large,.nav li [class*=" icon-"].icon-large{width:1.5625em}ul.icons{list-style-type:none;text-indent:-0.75em}ul.icons li [class^="icon-"],ul.icons li [class*=" icon-"]{width:.75em}.icon-muted{color:#eee}.icon-border{border:solid 1px #eee;padding:.2em .25em .15em;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px}.icon-2x{font-size:2em}.icon-2x.icon-border{border-width:2px;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px}.icon-3x{font-size:3em}.icon-3x.icon-border{border-width:3px;-webkit-border-radius:5px;-moz-border-radius:5px;border-radius:5px}.icon-4x{font-size:4em}.icon-4x.icon-border{border-width:4px;-webkit-border-radius:6px;-moz-border-radius:6px;border-radius:6px}.pull-right{float:right}.pull-left{float:left}[class^="icon-"].pull-left,[class*=" icon-"].pull-left{margin-right:.3em}[class^="icon-"].pull-right,[class*=" icon-"].pull-right{margin-left:.3em}.btn [class^="icon-"].pull-left.icon-2x,.btn [class*=" icon-"].pull-left.icon-2x,.btn [class^="icon-"].pull-right.icon-2x,.btn [class*=" icon-"].pull-right.icon-2x{margin-top:.18em}.btn [class^="icon-"].icon-spin.icon-large,.btn [class*=" icon-"].icon-spin.icon-large{line-height:.8em}.btn.btn-small [class^="icon-"].pull-left.icon-2x,.btn.btn-small [class*=" icon-"].pull-left.icon-2x,.btn.btn-small [class^="icon-"].pull-right.icon-2x,.btn.btn-small [class*=" icon-"].pull-right.icon-2x{margin-top:.25em}.btn.btn-large [class^="icon-"],.btn.btn-large [class*=" icon-"]{margin-top:0}.btn.btn-large [class^="icon-"].pull-left.icon-2x,.btn.btn-large [class*=" icon-"].pull-left.icon-2x,.btn.btn-large [class^="icon-"].pull-right.icon-2x,.btn.btn-large [class*=" icon-"].pull-right.icon-2x{margin-top:.05em}.btn.btn-large [class^="icon-"].pull-left.icon-2x,.btn.btn-large [class*=" icon-"].pull-left.icon-2x{margin-right:.2em}.btn.btn-large [class^="icon-"].pull-right.icon-2x,.btn.btn-large [class*=" icon-"].pull-right.icon-2x{margin-left:.2em}.icon-spin{display:inline-block;-moz-animation:spin 2s infinite linear;-o-animation:spin 2s infinite linear;-webkit-animation:spin 2s infinite linear;animation:spin 2s infinite linear}@-moz-keyframes spin{0%{-moz-transform:rotate(0deg)}100%{-moz-transform:rotate(359deg)}}@-webkit-keyframes spin{0%{-webkit-transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg)}}@-o-keyframes spin{0%{-o-transform:rotate(0deg)}100%{-o-transform:rotate(359deg)}}@-ms-keyframes spin{0%{-ms-transform:rotate(0deg)}100%{-ms-transform:rotate(359deg)}}@keyframes spin{0%{transform:rotate(0deg)}100%{transform:rotate(359deg)}}@-moz-document url-prefix(){.icon-spin{height:.9em}.btn .icon-spin{height:auto}.icon-spin.icon-large{height:1.25em}.btn .icon-spin.icon-large{height:.75em}}.icon-glass:before{content:"\f000"}.icon-music:before{content:"\f001"}.icon-search:before{content:"\f002"}.icon-envelope:before{content:"\f003"}.icon-heart:before{content:"\f004"}.icon-star:before{content:"\f005"}.icon-star-empty:before{content:"\f006"}.icon-user:before{content:"\f007"}.icon-film:before{content:"\f008"}.icon-th-large:before{content:"\f009"}.icon-th:before{content:"\f00a"}.icon-th-list:before{content:"\f00b"}.icon-ok:before{content:"\f00c"}.icon-remove:before{content:"\f00d"}.icon-zoom-in:before{content:"\f00e"}.icon-zoom-out:before{content:"\f010"}.icon-off:before{content:"\f011"}.icon-signal:before{content:"\f012"}.icon-cog:before{content:"\f013"}.icon-trash:before{content:"\f014"}.icon-home:before{content:"\f015"}.icon-file:before{content:"\f016"}.icon-time:before{content:"\f017"}.icon-road:before{content:"\f018"}.icon-download-alt:before{content:"\f019"}.icon-download:before{content:"\f01a"}.icon-upload:before{content:"\f01b"}.icon-inbox:before{content:"\f01c"}.icon-play-circle:before{content:"\f01d"}.icon-repeat:before{content:"\f01e"}.icon-refresh:before{content:"\f021"}.icon-list-alt:before{content:"\f022"}.icon-lock:before{content:"\f023"}.icon-flag:before{content:"\f024"}.icon-headphones:before{content:"\f025"}.icon-volume-off:before{content:"\f026"}.icon-volume-down:before{content:"\f027"}.icon-volume-up:before{content:"\f028"}.icon-qrcode:before{content:"\f029"}.icon-barcode:before{content:"\f02a"}.icon-tag:before{content:"\f02b"}.icon-tags:before{content:"\f02c"}.icon-book:before{content:"\f02d"}.icon-bookmark:before{content:"\f02e"}.icon-print:before{content:"\f02f"}.icon-camera:before{content:"\f030"}.icon-font:before{content:"\f031"}.icon-bold:before{content:"\f032"}.icon-italic:before{content:"\f033"}.icon-text-height:before{content:"\f034"}.icon-text-width:before{content:"\f035"}.icon-align-left:before{content:"\f036"}.icon-align-center:before{content:"\f037"}.icon-align-right:before{content:"\f038"}.icon-align-justify:before{content:"\f039"}.icon-list:before{content:"\f03a"}.icon-indent-left:before{content:"\f03b"}.icon-indent-right:before{content:"\f03c"}.icon-facetime-video:before{content:"\f03d"}.icon-picture:before{content:"\f03e"}.icon-pencil:before{content:"\f040"}.icon-map-marker:before{content:"\f041"}.icon-adjust:before{content:"\f042"}.icon-tint:before{content:"\f043"}.icon-edit:before{content:"\f044"}.icon-share:before{content:"\f045"}.icon-check:before{content:"\f046"}.icon-move:before{content:"\f047"}.icon-step-backward:before{content:"\f048"}.icon-fast-backward:before{content:"\f049"}.icon-backward:before{content:"\f04a"}.icon-play:before{content:"\f04b"}.icon-pause:before{content:"\f04c"}.icon-stop:before{content:"\f04d"}.icon-forward:before{content:"\f04e"}.icon-fast-forward:before{content:"\f050"}.icon-step-forward:before{content:"\f051"}.icon-eject:before{content:"\f052"}.icon-chevron-left:before{content:"\f053"}.icon-chevron-right:before{content:"\f054"}.icon-plus-sign:before{content:"\f055"}.icon-minus-sign:before{content:"\f056"}.icon-remove-sign:before{content:"\f057"}.icon-ok-sign:before{content:"\f058"}.icon-question-sign:before{content:"\f059"}.icon-info-sign:before{content:"\f05a"}.icon-screenshot:before{content:"\f05b"}.icon-remove-circle:before{content:"\f05c"}.icon-ok-circle:before{content:"\f05d"}.icon-ban-circle:before{content:"\f05e"}.icon-arrow-left:before{content:"\f060"}.icon-arrow-right:before{content:"\f061"}.icon-arrow-up:before{content:"\f062"}.icon-arrow-down:before{content:"\f063"}.icon-share-alt:before{content:"\f064"}.icon-resize-full:before{content:"\f065"}.icon-resize-small:before{content:"\f066"}.icon-plus:before{content:"\f067"}.icon-minus:before{content:"\f068"}.icon-asterisk:before{content:"\f069"}.icon-exclamation-sign:before{content:"\f06a"}.icon-gift:before{content:"\f06b"}.icon-leaf:before{content:"\f06c"}.icon-fire:before{content:"\f06d"}.icon-eye-open:before{content:"\f06e"}.icon-eye-close:before{content:"\f070"}.icon-warning-sign:before{content:"\f071"}.icon-plane:before{content:"\f072"}.icon-calendar:before{content:"\f073"}.icon-random:before{content:"\f074"}.icon-comment:before{content:"\f075"}.icon-magnet:before{content:"\f076"}.icon-chevron-up:before{content:"\f077"}.icon-chevron-down:before{content:"\f078"}.icon-retweet:before{content:"\f079"}.icon-shopping-cart:before{content:"\f07a"}.icon-folder-close:before{content:"\f07b"}.icon-folder-open:before{content:"\f07c"}.icon-resize-vertical:before{content:"\f07d"}.icon-resize-horizontal:before{content:"\f07e"}.icon-bar-chart:before{content:"\f080"}.icon-twitter-sign:before{content:"\f081"}.icon-facebook-sign:before{content:"\f082"}.icon-camera-retro:before{content:"\f083"}.icon-key:before{content:"\f084"}.icon-cogs:before{content:"\f085"}.icon-comments:before{content:"\f086"}.icon-thumbs-up:before{content:"\f087"}.icon-thumbs-down:before{content:"\f088"}.icon-star-half:before{content:"\f089"}.icon-heart-empty:before{content:"\f08a"}.icon-signout:before{content:"\f08b"}.icon-linkedin-sign:before{content:"\f08c"}.icon-pushpin:before{content:"\f08d"}.icon-external-link:before{content:"\f08e"}.icon-signin:before{content:"\f090"}.icon-trophy:before{content:"\f091"}.icon-github-sign:before{content:"\f092"}.icon-upload-alt:before{content:"\f093"}.icon-lemon:before{content:"\f094"}.icon-phone:before{content:"\f095"}.icon-check-empty:before{content:"\f096"}.icon-bookmark-empty:before{content:"\f097"}.icon-phone-sign:before{content:"\f098"}.icon-twitter:before{content:"\f099"}.icon-facebook:before{content:"\f09a"}.icon-github:before{content:"\f09b"}.icon-unlock:before{content:"\f09c"}.icon-credit-card:before{content:"\f09d"}.icon-rss:before{content:"\f09e"}.icon-hdd:before{content:"\f0a0"}.icon-bullhorn:before{content:"\f0a1"}.icon-bell:before{content:"\f0a2"}.icon-certificate:before{content:"\f0a3"}.icon-hand-right:before{content:"\f0a4"}.icon-hand-left:before{content:"\f0a5"}.icon-hand-up:before{content:"\f0a6"}.icon-hand-down:before{content:"\f0a7"}.icon-circle-arrow-left:before{content:"\f0a8"}.icon-circle-arrow-right:before{content:"\f0a9"}.icon-circle-arrow-up:before{content:"\f0aa"}.icon-circle-arrow-down:before{content:"\f0ab"}.icon-globe:before{content:"\f0ac"}.icon-wrench:before{content:"\f0ad"}.icon-tasks:before{content:"\f0ae"}.icon-filter:before{content:"\f0b0"}.icon-briefcase:before{content:"\f0b1"}.icon-fullscreen:before{content:"\f0b2"}.icon-group:before{content:"\f0c0"}.icon-link:before{content:"\f0c1"}.icon-cloud:before{content:"\f0c2"}.icon-beaker:before{content:"\f0c3"}.icon-cut:before{content:"\f0c4"}.icon-copy:before{content:"\f0c5"}.icon-paper-clip:before{content:"\f0c6"}.icon-save:before{content:"\f0c7"}.icon-sign-blank:before{content:"\f0c8"}.icon-reorder:before{content:"\f0c9"}.icon-list-ul:before{content:"\f0ca"}.icon-list-ol:before{content:"\f0cb"}.icon-strikethrough:before{content:"\f0cc"}.icon-underline:before{content:"\f0cd"}.icon-table:before{content:"\f0ce"}.icon-magic:before{content:"\f0d0"}.icon-truck:before{content:"\f0d1"}.icon-pinterest:before{content:"\f0d2"}.icon-pinterest-sign:before{content:"\f0d3"}.icon-google-plus-sign:before{content:"\f0d4"}.icon-google-plus:before{content:"\f0d5"}.icon-money:before{content:"\f0d6"}.icon-caret-down:before{content:"\f0d7"}.icon-caret-up:before{content:"\f0d8"}.icon-caret-left:before{content:"\f0d9"}.icon-caret-right:before{content:"\f0da"}.icon-columns:before{content:"\f0db"}.icon-sort:before{content:"\f0dc"}.icon-sort-down:before{content:"\f0dd"}.icon-sort-up:before{content:"\f0de"}.icon-envelope-alt:before{content:"\f0e0"}.icon-linkedin:before{content:"\f0e1"}.icon-undo:before{content:"\f0e2"}.icon-legal:before{content:"\f0e3"}.icon-dashboard:before{content:"\f0e4"}.icon-comment-alt:before{content:"\f0e5"}.icon-comments-alt:before{content:"\f0e6"}.icon-bolt:before{content:"\f0e7"}.icon-sitemap:before{content:"\f0e8"}.icon-umbrella:before{content:"\f0e9"}.icon-paste:before{content:"\f0ea"}.icon-lightbulb:before{content:"\f0eb"}.icon-exchange:before{content:"\f0ec"}.icon-cloud-download:before{content:"\f0ed"}.icon-cloud-upload:before{content:"\f0ee"}.icon-user-md:before{content:"\f0f0"}.icon-stethoscope:before{content:"\f0f1"}.icon-suitcase:before{content:"\f0f2"}.icon-bell-alt:before{content:"\f0f3"}.icon-coffee:before{content:"\f0f4"}.icon-food:before{content:"\f0f5"}.icon-file-alt:before{content:"\f0f6"}.icon-building:before{content:"\f0f7"}.icon-hospital:before{content:"\f0f8"}.icon-ambulance:before{content:"\f0f9"}.icon-medkit:before{content:"\f0fa"}.icon-fighter-jet:before{content:"\f0fb"}.icon-beer:before{content:"\f0fc"}.icon-h-sign:before{content:"\f0fd"}.icon-plus-sign-alt:before{content:"\f0fe"}.icon-double-angle-left:before{content:"\f100"}.icon-double-angle-right:before{content:"\f101"}.icon-double-angle-up:before{content:"\f102"}.icon-double-angle-down:before{content:"\f103"}.icon-angle-left:before{content:"\f104"}.icon-angle-right:before{content:"\f105"}.icon-angle-up:before{content:"\f106"}.icon-angle-down:before{content:"\f107"}.icon-desktop:before{content:"\f108"}.icon-laptop:before{content:"\f109"}.icon-tablet:before{content:"\f10a"}.icon-mobile-phone:before{content:"\f10b"}.icon-circle-blank:before{content:"\f10c"}.icon-quote-left:before{content:"\f10d"}.icon-quote-right:before{content:"\f10e"}.icon-spinner:before{content:"\f110"}.icon-circle:before{content:"\f111"}.icon-reply:before{content:"\f112"}.icon-github-alt:before{content:"\f113"}.icon-folder-close-alt:before{content:"\f114"}.icon-folder-open-alt:before{content:"\f115"} --------------------------------------------------------------------------------