世界中のツイートを地図上で表示するサイト"Tweets in the World"を作りました。
具体的には、位置情報を含むツイートをマーカーでプロットし、2秒に一度、適当にツイートの中身を表示しています。Photo Filterをオンにすると、写真を含むツイートに限定します。
旅行先の情報収集とかに使えないかアイデア検討中です。
開発備忘録
サーバー
Sinatraを使いました。こういう規模のウェブアプリケーションにちょうどいいですね。
Twitter Streaming APIは認証が必要で、しかも認証1つに対して1つしかストリーム接続できません。なので接続を1つ張り取り込んだツイートを各クライアントに配信する必要があります。このためには、
1. Twitter Streaming用にスレッドを1つ起こす必要がある。
2. クライアントとはlong pollなりweb socketなりで接続する必要がある。
1に関して苦労しました。
require 'sinatra' get '/' do # process end Thread.new do # twitter streaming end
とすると、Sinatraが起動直後に終了してしまいます。
単にスレッド起こすだけなら終了しません。スレッド内でEventMachineを使うと終了します。使用したライブラリtweetstreamがEventMachineを使って実装されていました。なぜ終了するのかよくわかりません。
(EveentMachineというライブラリを初めて知りました。Node.jsの前にこういうものがあったとは。)
結局、以下を参考にrunの初期化で2つのプロセスを起動するようにしたら、なんとなくうまくいきました。
2はSinatraにstream helperという処理中に順次出力するヘルパーがあったのでこれを利用しました。httpのロングポールになります。
# -*- coding: utf-8 -*- # # Tweets in the World # Author: ICHIKAWA, Yuji # Copyright (C) 2012 ICHIKAWA, Yuji (New 3 Rs) All rights reserved. require "sinatra/base" require 'thin' require 'tweetstream' TweetStream.configure do |config| config.consumer_key = ENV['CONSUMER_KEY'] config.consumer_secret = ENV['CONSUMER_SECRET'] config.oauth_token = ENV['ACCESS_TOKEN'] config.oauth_token_secret = ENV['ACCESS_TOKEN_SECRET'] config.auth_method = :oauth end class App < Sinatra::Base @@outs = [] def App.outs @@outs end get '/' do redirect '/index.html' end get '/tweets' do stream(:keep_open) do |out| @@outs.push out out.callback { @@outs.delete out } out.errback { @@outs.delete out } end end end EM.run do buffer = [] dumping = false # true if process to dump tweets exists EM.defer do TweetStream::Client.new.filter({locations: '-180,-90,180,90'}) do |status| next unless status.geo # There are statuses without geo even if filtering by location. buffer.unshift status buffer = buffer[0, 10] unless dumping dumping = true callback = proc { |result| dumping = false } EM.defer(nil, callback) do while status = buffer.shift App.outs.each do |out| out << JSON.generate({ screen_name: status.user.screen_name, profile_image_url: status.user.profile_image_url, text: status.text, geo: status.geo.coordinates, media_url: status.media.empty? ? nil : status.media[0].media_url }) + "\n" end end end end end end Thin::Server.start App, '0.0.0.0', ENV['PORT'] end
ひたすらTwitter streamingをFIFOに読むプロセスを作り、その中で別途FIFOの内容をクライアントに吐き出すプロセスをdeferします。(Twitter streamingを待たせてしまうとTwitter streamingがストールします。)
ウェブサーバーは所定のURLにアクセスがあると、sinatra streamを上記のクライアントに吐き出すプロセスが扱うようにするだけ。
クライアント側
ロングポールをJavaScriptで扱うのに、XMLHTTPRequestのprogressイベントを使いました。
httpの接続時間はブラウザ側に制限が設けられているので、切断したら再度接続するようにします。
# Tweets in the World # Copyright (C) 2012 ICHIKAWA, Yuji (New 3 Rs) lifeTime = 2000 photoFilter = false startReceiving = -> $.ajax url: "/tweets", xhr: -> xhr = new window.XMLHttpRequest() progressHandler = (event) -> now = new Date().getTime() # delete last incomplete line, cut previous part. difference = xhr.responseText.replace(/\r?\n.*?$/, '').slice(progressHandler.lastLength) lines = difference.split /\r?\n/ lines = lines.filter (e) -> e isnt '' # There are empty lines. try tweets = lines.map (e) -> JSON.parse e bounds = map.getBounds() for e in tweets continue if photoFilter and not e.media_url? latLng = new google.maps.LatLng e.geo[0], e.geo[1] if bounds.contains latLng marker = new google.maps.Marker icon: if photoFilter and e.profile_image_url? then { url: e.profile_image_url } else null map: map position: latLng if now - progressHandler.lastTime > lifeTime infoWindow = new google.maps.InfoWindow content: (if e.media_url? then "<img height=\"75\" class=\"info-content\" src=\"#{e.media_url}\">" else '') + "<p>#{e.text}</p>" disableAutoPan: true infoWindow.open map, marker progressHandler.lastTime = now else infoWindow = null setTimeout ((marker, infoWindow) -> -> infoWindow.close() if infoWindow? marker.setMap null )(marker, infoWindow), lifeTime catch error console.log error progressHandler.lastLength = xhr.responseText.replace(/\r?\n.+?$/, '').length progressHandler.lastLength = 0 progressHandler.lastTime = new Date().getTime() xhr.addEventListener 'progress', progressHandler xhr success: startReceiving error: (xhr, status, error) -> alert 'connection error. try to reload.' map = new google.maps.Map $('#map')[0], center: new google.maps.LatLng(37.77663, -122.417108) mapTypeId: google.maps.MapTypeId.ROADMAP zoom: 2 $('input[name="photo-filter"]:radio').change -> photoFilter = $(this).val() is 'true' startReceiving()