├── 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 | }
--------------------------------------------------------------------------------