├── views ├── index.haml ├── tweets.haml └── layout.haml ├── README.markdown ├── twatcher.rb ├── tweet_store.rb ├── tweet.rb ├── twitter_filter.rb └── public └── stylesheets └── style.css /views/index.haml: -------------------------------------------------------------------------------- 1 | :javascript 2 | function refreshTweets() { 3 | $.get('/latest', {since: window.latestTweet}, function(data) { 4 | $('.tweets').prepend(data); 5 | $('.latest').slideDown('slow'); 6 | $('.tweets li:gt(50)').remove(); 7 | 8 | setTimeout(refreshTweets, 10000); 9 | }); 10 | } 11 | $(function() { 12 | setTimeout(refreshTweets, 10000); 13 | }); 14 | 15 | %h1 Recent LOL Tweets 16 | %ul.tweets 17 | = haml :tweets, :layout => false 18 | -------------------------------------------------------------------------------- /views/tweets.haml: -------------------------------------------------------------------------------- 1 | - @tweets.each do |tweet| 2 | %li.tweet{:class => @tweet_class} 3 | %span.avatar 4 | %a{:href => tweet.user_link} 5 | %img{:src => tweet.profile_image_url, :alt => tweet.username, :height => 48, :width => 48} 6 | %span.main 7 | %span.text= tweet.filtered_text 8 | %span.meta== — #{tweet.name} (@#{tweet.username}) 9 | 10 | - if !@tweets.empty? 11 | :javascript 12 | window.latestTweet = #{@tweets[0].received_at}; -------------------------------------------------------------------------------- /views/layout.haml: -------------------------------------------------------------------------------- 1 | !!! Strict 2 | %html{:xmlns=> "http://www.w3.org/1999/xhtml", 'xml:lang' => "en", :lang => "en"} 3 | %head 4 | %meta{'http-equiv' => "Content-Type", 'content' => "text/html; charset=utf-8"} 5 | %title twatcher 6 | %link{:rel => 'stylesheet', :href => '/stylesheets/style.css', :type => 'text/css'} 7 | %script{:type => 'text/javascript', :src => 'http://ajax.googleapis.com/ajax/libs/jquery/1.3.2/jquery.min.js'} 8 | %body 9 | #container 10 | #content 11 | = yield 12 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | twatcher-lite 2 | ============= 3 | 4 | This is a simplified version of the code that powers [Twatcher](http://twatcher.com). It mainly goes along with [my blog post](http://www.digitalhobbit.com/2009/11/08/building-a-twitter-filter-with-sinatra-redis-and-tweetstream/) and provides an easier way to follow along than manually copying and pasting the code from the blog into the various files. 5 | 6 | Please refer to the blog post for instructions on how to install and run this application. 7 | 8 | I'll eventually publish the full project, which includes configuration options, RSpec specs, etc. Stay tuned. :) -------------------------------------------------------------------------------- /twatcher.rb: -------------------------------------------------------------------------------- 1 | require 'sinatra' 2 | require 'haml' 3 | require File.join(File.dirname(__FILE__), 'tweet_store') 4 | 5 | STORE = TweetStore.new 6 | 7 | get '/' do 8 | @tweets = STORE.tweets 9 | haml :index 10 | end 11 | 12 | get '/latest' do 13 | # We're using a Javascript variable to keep track of the time the latest 14 | # tweet was received, so we can request only newer tweets here. Might want 15 | # to consider using Last-Modified HTTP header as a slightly cleaner 16 | # solution (but requires more jQuery code). 17 | @tweets = STORE.tweets(5, (params[:since] || 0).to_i) 18 | @tweet_class = 'latest' # So we can hide and animate 19 | haml :tweets, :layout => false 20 | end 21 | -------------------------------------------------------------------------------- /tweet_store.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'redis' 3 | require File.join(File.dirname(__FILE__), 'tweet') 4 | 5 | class TweetStore 6 | 7 | REDIS_KEY = 'tweets' 8 | NUM_TWEETS = 20 9 | TRIM_THRESHOLD = 100 10 | 11 | def initialize 12 | @db = Redis.new 13 | @trim_count = 0 14 | end 15 | 16 | # Retrieves the specified number of tweets, but only if they are more recent 17 | # than the specified timestamp. 18 | def tweets(limit=15, since=0) 19 | @db.list_range(REDIS_KEY, 0, limit - 1).collect {|t| 20 | Tweet.new(JSON.parse(t)) 21 | }.reject {|t| t.received_at <= since} # In 1.8.7, should use drop_while instead 22 | end 23 | 24 | def push(data) 25 | @db.push_head(REDIS_KEY, data.to_json) 26 | 27 | @trim_count += 1 28 | if (@trim_count > 100) 29 | # Periodically trim the list so it doesn't grow too large. 30 | @db.list_trim(REDIS_KEY, 0, NUM_TWEETS) 31 | @trim_count = 0 32 | end 33 | end 34 | 35 | end 36 | -------------------------------------------------------------------------------- /tweet.rb: -------------------------------------------------------------------------------- 1 | class Tweet 2 | 3 | def initialize(data) 4 | @data = data 5 | end 6 | 7 | def user_link 8 | "http://twitter.com/#{username}" 9 | end 10 | 11 | # Makes links clickable, highlights LOL, etc. 12 | def filtered_text 13 | filter_lol(filter_urls(text)) 14 | end 15 | 16 | private 17 | 18 | # So we can call tweet.text instead of tweet['text'] 19 | def method_missing(name) 20 | @data[name.to_s] 21 | end 22 | 23 | def filter_lol(text) 24 | # Note that we're using a list of characters rather than just \b to avoid 25 | # replacing LOL inside a URL. 26 | text.gsub(/^(.*[\s\.\,\;])?(lol)(\b)/i, '\1\2\3') 27 | end 28 | 29 | def filter_urls(text) 30 | # The regex could probably still be improved, but this seems to do the 31 | # trick for most cases. 32 | text.gsub(/(https?:\/\/\w+(\.\w+)+(\/[\w\+\-\,\%]+)*(\?[\w\[\]]+(=\w*)?(&\w+(=\w*)?)*)?(#\w+)?)/i, '\1') 33 | end 34 | 35 | end -------------------------------------------------------------------------------- /twitter_filter.rb: -------------------------------------------------------------------------------- 1 | require 'tweetstream' 2 | require File.join(File.dirname(__FILE__), 'tweet_store') 3 | 4 | USERNAME = "my_username" # Replace with your Twitter user 5 | PASSWORD = "my_password" # and your Twitter password 6 | STORE = TweetStore.new 7 | 8 | TweetStream::Client.new(USERNAME, PASSWORD).track('lol') do |status| 9 | # Ignore replies. Probably not relevant in your own filter app, but we want 10 | # to filter out funny tweets that stand on their own, not responses. 11 | if status.text !~ /^@\w+/ 12 | # Yes, we could just store the Status object as-is, since it's actually just a 13 | # subclass of Hash. But Twitter results include lots of fields that we don't 14 | # care about, so let's keep it simple and efficient for the web app. 15 | STORE.push( 16 | 'id' => status[:id], 17 | 'text' => status.text, 18 | 'username' => status.user.screen_name, 19 | 'userid' => status.user[:id], 20 | 'name' => status.user.name, 21 | 'profile_image_url' => status.user.profile_image_url, 22 | 'created_at' => status.created_at, 23 | 'received_at' => Time.new.to_i 24 | ) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /public/stylesheets/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #505; 3 | } 4 | 5 | h1 { 6 | text-align: center; 7 | color: #ee0; 8 | font-size: 300%; 9 | font-family: "Chalkboard", "Comic Sans MS", sans-serif; 10 | } 11 | 12 | #container { 13 | margin: 1em auto; 14 | position: relative; 15 | width: 540px; 16 | text-align: left; 17 | } 18 | 19 | #content { 20 | width: 100%; 21 | margin: 0px; 22 | padding: 0px; 23 | } 24 | 25 | ul.tweets { 26 | margin: 50px 0 0 0; 27 | list-style: none; 28 | background-color: #dfd; 29 | padding: 0px 10px; 30 | } 31 | 32 | li:first-child.tweet { 33 | border-top: none; 34 | } 35 | 36 | li.tweet { 37 | position: relative; 38 | border-top: 1px dashed #aaa; 39 | padding: 0.7em 0px 0.6em 0px; 40 | margin: 0px 0px; 41 | } 42 | 43 | li.latest { 44 | display: none; 45 | } 46 | 47 | .avatar { 48 | display: block; 49 | height: 50px; 50 | width: 50px; 51 | position: absolute; 52 | left: 0px; 53 | margin: 0px 10px 0px 0px; 54 | overflow: hidden; 55 | } 56 | 57 | .avatar a { 58 | text-decoration: none; 59 | } 60 | 61 | .avatar img { 62 | height: 48px; 63 | width: 48px; 64 | border-color: transparent; 65 | border-width: 0px; 66 | } 67 | 68 | .main { 69 | display: block; 70 | margin-left: 60px; 71 | min-height: 50px; 72 | width: 455px; 73 | overflow: hidden; 74 | } 75 | 76 | .text { 77 | margin-top: 0; 78 | padding-top: 0; 79 | } 80 | 81 | .text a { 82 | text-decoration: none; 83 | color: #000; 84 | } 85 | 86 | .text a:hover { 87 | text-decoration: underline; 88 | color: #00f; 89 | } 90 | 91 | .meta { 92 | display: block; 93 | margin: 3px 0px 0px 20px; 94 | color: #444; 95 | font-size: 0.764em; 96 | } 97 | 98 | .meta a { 99 | text-decoration: none; 100 | color: #444; 101 | } 102 | 103 | .meta a:hover { 104 | text-decoration: underline; 105 | color: #00f; 106 | } 107 | 108 | .lol { 109 | font-size: 130%; 110 | font-weight: bold; 111 | color: #f00; 112 | } --------------------------------------------------------------------------------