PerlでTwitter Streaming APIの出力をブラウザ上でニコニコ動画風に表示するスクリプト(再接続未実装)
スポンサードリンク
Twitter Streaming API接続を使い、ニコニコ動画風にブラウザ上で字幕を流すMojoliciousウェブアプリケーションの最新版です。
なにかイベントあった時にサクッとAmazon EC2を立ち上げて、ソースをコピー&ペーストしてすぐ設置できるぐらいのカジュアルさを目指しました。
■動作確認環境
- CentOS 5.6 / Perl 5.8.8 / Mojolicious 1.97
- Amazon Linux AMI / Perl 5.10.1 /Mojolicious 2.19(2.20~2.24は不可)
~~~
起動するには、事前に取得しておいたconsumer keyをconsumer_keys.yamlに、access tokenを~/.pit/defalt.yamlに設定しておき、
perl twitter-jimaku.pl daemon -listen http://*:3000
とサーバで実行したあと、ウェブブラウザ(Firefox7、Google Chrome 15)で
http://www.example.com:3000
に接続すると画面に字幕が流れる、というものになります。
~~~
現状わかっている不具合として、
「Twitter Streaming APIの接続が切れた時の再接続ができない」
というものがあります。いろいろ調べて、AnyEventのイベントループだけで廻っている場合は策があるのですが、Mojoliciousのイベントループとの複合技になると途端に制御が難しくなってしまいます。だれか、解決策をご存じの方にご指南をいただきたく、現状のソースを公開します。
Firelaneme: twitter-jimaku.pl
#!/usr/bin/perl use AnyEvent::Twitter::Stream; use Config::Pit; use EV; use File::Spec; use FindBin::Real; use Mojolicious::Lite; use Mojo::JSON; use utf8; use version; use YAML; binmode STDOUT => ':utf8'; my $keys = YAML::LoadFile( File::Spec->catdir( FindBin::Real::Bin(), '..', 'consumer_keys.yaml' ) ); my $pit = pit_get( 'twitter.com@CLCLCL' ); # Mojoliciousサーバに接続されたクライアント my $clients = {}; my $listener = AnyEvent::Twitter::Stream->new( consumer_key => $keys->{consumer_key}, consumer_secret => $keys->{consumer_key_secret}, token => $pit->{access_token}, token_secret => $pit->{access_token_secret}, method => 'sample', #method => 'userstream', #method => 'filter', #track => 'JAPAN', on_tweet => sub { my $tweet = shift; # $tweetはflagged utf8 return if rand(1) < 0.8; # sample streamは流量多いので2割だけ採用 my $user = $tweet->{user}{screen_name}; my $text = $tweet->{text} || ''; my $lang = $tweet->{user}{lang} || ''; return if $lang ne 'ja'; # 日本語のみ return unless $user && $text; # たまに空のTweetが流れるのを防ぐ $text =~ s/</</g; $text =~ s/>/>/g; $text =~ s/[\x00-\x1f\x7f]//g; # コントロールコード除去 # 伏せ字処理 my $fuseji = sub { # ユーザー名を伏せ字 $user =~ s/./*/g; # ツイート内容の名前らしきところを伏せ字&撹乱 $text =~ s/\@[^\s:]+/@****/g; $text =~ s/[^\s]+さん/****ちゃん/g; $text =~ s/[^\s]+ちゃん/****さん/g; $text =~ s/[^\s]+くん/****クン/g; # URLを伏せ字 $text =~ s{http://t.co/[\w\-]+}{http://t.co/******}g; }; $fuseji->(); # 伏せ字しないときはコメントアウトすること print "$user : $text\n"; # コンソールに表示 my $json = Mojo::JSON->new; # JSONに変換 my $str = $json->encode({ user => $user, text => $text, }); # Mojo::JSONするとutf8 fragged落ちるのでutf8 fraggedにする utf8::decode( $str ); # Mojoliciousサーバに接続中の全てのクライアントに送信 for (keys %$clients) { $clients->{$_}->send_message( $str ); } }, on_error => sub { my $error = shift; warn "ERROR: $error"; }, on_eof => sub { }, ); get '/' => 'index'; websocket '/stream' => sub { my $self = shift; # ログに接続記録 app->log->debug(sprintf 'Client connected: %s', $self->tx); # $clientsにWebSocketクライアントを追加 my $id = sprintf "%s", $self->tx; $clients->{$id} = $self->tx; # WebSocketコネクション切断時処理(共通) my $on_finish = sub { # ログに切断記録 app->log->debug('Client disconnected'); # $clientsからWebSocketクライアントを削除 delete $clients->{$id}; }; # イベント設定 if ( $Mojolicious::VERSION < qv("v2.0") ) { # Mojolicious 1.xの時の処理 $self->on_message(); $self->on_finish( $on_finish ); } else { # Mojolicious 2.xの時の処理 $self->on( message => sub {}, finish => $on_finish, ); } }; # Mojolicious::Liteのイベントループ開始 app->start; __DATA__ @@ index.html.ep <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Mojolicious + WebSocket + Twitter Streaming API</title> <meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0" /> %= javascript '/js/jquery.js'; <script type="text/javascript"> // WebSocketの書き方の違いを吸収するオブジェクト WS = function(f) { if (typeof MozWebSocket != 'undefined' ) return new MozWebSocket(f); if (typeof WebSocket != 'undefined' ) return new WebSocket(f); return; } // onload $(function () { // WebSocketオブジェクト作成 var ws = WS('<%= url_for('stream')->to_abs %>'); // connectingを表示 var html = '' + '<p class="description">' + '<img src="data:image/gif;base64,' + 'R0lGODlhEAAQAPfgAP////39/erq6uvr6+jo6P' + 'n5+dPT0/v7+/X19efn5/Pz8/j4+Pf39/r6+vz8' + '/MzMzO/v7/b29svLy/7+/unp6e7u7kJCQtnZ2f' + 'Hx8a+vr4mJid7e3s/PzyYmJrOzs/Dw8NLS0vT0' + '9Le3t9ra2tvb25CQkKOjo2tra9DQ0KysrM3Nza' + '2traurq729vezs7M7OzuHh4fLy8rq6und3d6Cg' + 'oIGBgYCAgGRkZGJiYsPDw8fHx4eHh+Dg4J+fn6' + 'KiooiIiG9vb6enp9fX18DAwOXl5d3d3e3t7WBg' + 'YJmZmZOTk9/f30VFRebm5jQ0NBUVFQQEBNjY2I' + 'SEhOTk5K6urtzc3D8/P2dnZ8LCwpubm8jIyLm5' + 'uZqamiEhIcTExC0tLbCwsIyMjNXV1dHR0VxcXO' + 'Pj40lJSTw8PGxsbExMTCwsLF9fXxAQEMnJyRYW' + 'FpSUlCIiIhsbGwgICAsLC11dXVhYWJGRkba2tr' + 'y8vMbGxr+/v7i4uDs7O76+vmFhYYaGho2NjbW1' + 'tZeXl4qKiiQkJKmpqYODg0ZGRk9PT3Z2dgkJCT' + 'o6OkFBQY+Pjx8fH3l5eRMTEw8PDyoqKrGxsWho' + 'aHNzcwcHB7KysqGhoYKCgkpKSmVlZXFxcaioqE' + '1NTeLi4p2dnaampqSkpJ6ensXFxVNTU7S0tFZW' + 'VjExMVlZWaWlpVRUVDAwMCgoKFBQUKqqqg0NDU' + 'NDQxkZGT09PUdHR3p6ehISEgICAsHBwURERDU1' + 'NZKSkm1tbTk5OWlpaRwcHFJSUtTU1DMzMyAgIH' + '5+fiMjI3JycnR0dA4ODkhISMrKynx8fJiYmAYG' + 'BnV1dU5OTgMDA4WFhR4eHgoKCpycnC8vL1paWm' + 'NjYzc3N7u7u4uLiycnJ3t7e15eXhoaGjY2NkBA' + 'QP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAACH/C05FVFNDQVBFMi4wAwEA' + 'AAAh+QQFAADgACwAAAAAEAAQAAAIpQDBCRxIsG' + 'DBF1FwOQEQwEEAg+B6XJMT5wmAAwwiFCjo480j' + 'TVOYAJhQAEMFBgPFLOomyCADAQI2gqvDBQhEcB' + 'VgVBA4p4OImyFIeBIoy4uAmwcMhBFoocmAmw0k' + 'cBB4Yk+emwJyGBDYw8KPmyhkbBB4wUonTgYNTB' + 'nyYaCeMaiQqMCg4EILGimKFLzj6MYZRDY0JGFx' + 'AaISD0lqaEil4+jNxwIDAgAh+QQFAADgACwBAA' + 'EADgAOAAAImwDBCTRQx1SkDmj8qBDIkIUzbVzg' + 'OFkj59QWhhmqrJohggKBLzgqrQEADsocRRcZCq' + 'wBIMAEHxaiqFQZoMCBGWWuzGQYAAGDOa0q7BQ4' + '4cOHG3QgDAUXQMCAHUckLEVAZoClSTSWJqBSAc' + 'YOY3d2EhFThAE4HTVsWBqBIAKTMKNeuGD4AAkY' + 'N5+CfNGSjMDMBDokgVqRY0QMhgEBACH5BAUAAO' + 'AALAEAAQAOAA4AAAiZAMEJHOEDCDILOJKAEMhQ' + 'xpkyFvY08dLBkAmGfPqo+nPFxQAtlBp1oAGOhz' + 'I1KRgy/NOG1wtAk6apVGnlGDQ3QDjMZJgh0RJM' + 'M2LsFJjgSRsNNhQMBQegaaofUJYGOOAATwkZSx' + 'dEOECBExYUOxFUUBAAnBBQQSQkKNAAgwAiAxYw' + 'JCHDg4wcEgyQYIJgJoQRKrJwKOJCrsCAACH5BA' + 'UAAOAALAEAAQAOAA4AAAiZAMEJhOFBg5UjtExA' + 'Ecgwy48TN8aoQrNETQaGDwrNMKECQoUufsx8Yw' + 'EuwZYafBgyxHLqkAEdYDyoVDmjQ50MSUbMZChC' + 'mCkTWBDsFEghFitCJiIMBUfg0aA8LKQszfAqkx' + 'APKJYeiRPlw6gWPHZOsOXlATgieLLwwOAgQIMC' + 'DQIsY0ghDIgLPBIYUbAgwEwEAqSQoYChL8OAAC' + 'H5BAUAAOAALAEAAQAOAA4AAAiZAMEJFMDGFSMN' + 'SPTAEMjwwopAJX7YmAGkxhCGRVJcykNCgQIQlz' + 'RZuQPuQ4sUBhgyzIAKCAkqdl6oVFkCTSgOLQjM' + 'ZJhjySY2XQrsFOjCTBkOEhoMBTegiQUqIDAs1Z' + 'Kmz4ALOoduGqRrARkYMXYKggMLBLgQCQSEODAB' + 'wAprtd74YMjgA4YIBwA8SeStx0wHBQrktVBIBc' + 'OAACH5BAUAAOAALAEAAQAOAA4AAAibAMEJjEFF' + 'R6kVIh5QEMiQwIMWdjIE6RHIBwqGLl7gEUKAQQ' + 'Ql2MCAeQCOAQkURBgyzGGjBBkjF1KqZEiIkggC' + 'GxTMZIjixJ8EUhzsFPgBx4kBAgIMBQeBzo0YEB' + 'os7XJo24IQBZb6MRQqQIECE3Zu2aMGCrgAAQBw' + 'm5KAAKBm1KpkYAggDTNpkJz4ItaJxcwHhWZx6U' + 'CqhAGGAQEAIfkEBQAA4AAsAQABAA4ADgAACJkA' + 'wQksYAQGMA4GlGAQyBABgQ0XQEjo0uKKEoYLBj' + 'BxoeBAgwEGPEgiAc5BDCMIGDIUEuTLgAYhIqhU' + 'eQWLhAYMHMxkWCQJCwcHAOwUGEJDCQBIh4JTYE' + 'PDoicplIpBhARTHBxKRZ0RoSIYpB87UxwZxgOc' + 'qEZtdtkRMGBItl99+DCkUSXaoDRNzCzpJWOmmB' + 'JjzFg4QWMEw4AAIfkEBQAA4AAsAQABAA4ADgAA' + 'CJkAwQmc0AABhAEDICwQyHCCAwYhIAiQsmFDBY' + 'ZIAAQ44GBCgAgUwhgQAO6Bl2cAGDIkIIGDgiiV' + 'jqhUOWLIhjJypsxkSEFLljdrEuwUuOALoA5OCA' + 'wFFyHIClJwSi3d8EkEIy7FlupxIwFEpkiBdg7Z' + '0UMpIUW5atwyAuGBCUc7XjBcUa2KoUN0cJwQxG' + 'amEBqIxtzY4cETw4AAOw==' + '" width="16" height="16" style="vertical-align: middle;" />' + 'CONNECTING</p>' + '<p>Google Chrome 14以上、Mozilla Firefox 7以上で接続できます</p>'; connecting = $('<div id="connecting">') .html(html) .appendTo('body'); // テロップ準備 var jimaku = new Stage('#stage'); // WebSocket接続時 ws.onopen = function () { // connectingを消す connecting.fadeOut(1500); // Welcomeを表示・自動で消える var html = '' + '<p class="description">' + 'WELCOME TO<br />TEST</p>' + '<p class="icons">' + '<img class="icon_left" alt="test" />' + '<img class="icon_right" ' + 'src="' + 'data:image/png;base64,' + 'iVBORw0KGgoAAAANSUhEUgAAAFAAAABQCAYAAA' + 'COEfKtAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJ' + 'bWFnZVJlYWR5ccllPAAAA5RJREFUeNrsW+tx2k' + 'AQlpj8lzsQqcBKBaIDkQpEKjAdQCqwUwFxBeAK' + 'pFSAUwGkApEKlFtmRYSQ0aF7i/1mdhjbMrf6tL' + 'cv7XkegUAgEAgEAoFAIBAIBOdRlmXAJGWyYvJI' + 'jHQT9shkyyRG0irA70ImCZMF/J3YuiQPyCmQsK' + 'I8x67xc0aMnZOXlvwAclNi7Xzb8pC2RqIDFXr4' + 'jpIXso93Jg9XLtvgNTl8+r7/15an/ow3YFKPbX' + 'k7tiotkVfx2LRCGE1FYM4XYkrQppCWfIvT7/Ei' + 'M2KNLanBxTZRuHZWykWhPdHm3EIFXhdKXDcp1U' + 'AviVgmFTcouJJBZIflyyAx1Eli0tPnxD3XS0v1' + '2Oreys8Czju2yPrqWNjoD4WIbKROOhDqJjG50S' + 'e2ERle+f61ZgJXWko5jFwRkzGTCUpfHJjMWbn1' + '2lKy7Q2kvGOmyx9XEtoPn76AnxXFkw4LLDqK+b' + '6ABsAEin+Fa3TqwNb/wnPhSGCRn4qUB7eQoxWY' + 'IO+oA0+ZBztRxAID9E+mblI1wCf/aPHJEfr7KR' + 'iRL+gLE+y73SOO21xkC3vsC97wSRzujbwq6xiJ' + 'fhOSGCn0iTYBDGUJlld1uKW29NFHzNEqxwMhbY' + '8WB65q03w1MJJIXoDERQMir9qu70jkKTpjVyr2' + 'JZEHVUk+4Ij8YSonywKXd0ZeVXp+k2WBPK8Zh+' + 'IPIVi+SA8iA8wJc5RTIGGk/ZZWC1/xhZuBBJEp' + 'pmhqmgkd0XiGEjlM4APPNIPS0Q4kM0JxKdBAvv' + 'eV58JPKrXAlhRs5xfX0hPue1SticGeXu9Iyx78' + 'Z96LR4rJix1MbZY37TINPvAwVOtTboEYxWYOET' + 'i3Uit8t7su7ca6l5Ec+/otGbZCMm1sPICbGfeZ' + 'YoUtPNM1/o+jbzbWzFOhEWCc61soJC5onN+wCU' + '+ybnCHEsu2Oo3DQUrHODp9U23GJROZNK0dtdpZ' + 'HDRWShx8Y1Comn9Ou6Y48X9TB6KtVPL8HlFy75' + '0P/Iwda1/BW7XvygisVRC54+2otlTlYgJMFKMr' + 'FcTEG8673mpg6VX7yhDmBQcoTWNh/PHhAZvMMe' + 'Iy6w5bO5CaVGeEE6sdCg6X20ak0lNSKi1yazqn' + 'M3mUX+ZoB/T9dAwVHTDFah32cZLAFjInKDIGjQ' + '7e/4PTOSPsl027UMdLpaBGZEVm1FLpVETVqx07' + 'TpoTCAQCgUAgEAgEAoFAOOGfAAMAIv2Ryl/695' + 'gAAAAASUVORK5CYII=' + '" alt="Twitter" /></p>'; $('<div id="welcome">') .html(html) .hide() .delay(1000) .fadeIn(500) .delay(5000) .fadeOut(1500) .appendTo('body'); // デバッグコンソールに接続完了を表示 console.log('Connection opened'); }; // WebSocketサーバからメッセージ受信 ws.onmessage = function (msg) { // サーバからメッセージ受け取る var res = JSON.parse(msg.data); // テロップ流す var d = jimaku.create(res.text); }; }); // テロップオブジェクト(流れる1本の字幕) var Telop = function( stage, msg, y ) { this.stage = stage; this.msg = msg; this.position = {}; this.position.x = 0; this.position.y = y; this.screen = {}; this.screen.width = $( this.stage ).width() || 100; this.die = false; // checkで変化 this.width = 0; // createで決定 this.speed = 0; // createで決定 this.create(); }; Telop.prototype = { slice: 17, // 流れる速さ 17msec time: 5000, // 画面を流れ切る時間 5000msec create : function() { this.object = $('<span>') .css('visiblity','hidden') .html(this.msg) .appendTo( this.stage ); this.position.x = this.screen.width - 100; this.width = this.object.width() + 1; this.speed = ( this.screen.width + this.width ) / (this.time / this.slice ); this.show(); }, do_: function() { // 1フレーム分動かす処理 // doにするとOperaとSafariがdo{}whire()と勘違いしてパースエラー this.move(); this.show(); this.check(); }, // move: 1フレーム分の動き方指定 move: function() { this.position.x -= this.speed; }, // show: オブジェクトを表示/再表示 show: function() { this.object.css({ visiblity: 'visible', display : 'block', width : this.width + 'px', left : this.position.x + 'px', top : this.position.y + 'px' }); }, // check: 1フレーム分動いた後の生死判定 check: function() { if ( this.position.x < this.width * -1) { this.die = true; this.object.remove(); } }, // isDie: メンバプロパティisDie(生死状態)アクセサ isDie: function() { return this.die; }, // getPosition: メンバプロパティPosition(位置)アクセサ getPosition: function() { return this.position; }, // getPosition: メンバプロパティwidth(幅)アクセサ getWidth: function() { return this.width; } }; // ステージオブジェクト(テロップを一元管理) var Stage = function( stage ) { var self = this; // setIntervarl用 this.stage = stage; // Telopオブジェクトが入る要素指定 this.objects = []; // Telopオブジェクト格納用 this.width = $( this.stage ).width() || 1024; // ステージ幅 // タイマーobj(1つでステージ上のobj全部に動かす指示を出す) this.id = setInterval( function(){ self.move(); }, 17); } Stage.prototype = { // create: ステージにtelopオブジェクトを追加 // WebSocketでデータ受信したら呼ばれる create: function( mes ) { var h = {}; // ステージ上の空き位置探索用捨てハッシュ var ny = 0; // 表示位置 // ステージ上のobjのy位置をキーにして一番右のtelopの位置記録 if ( this.objects.length != 0) { for ( var i = 0; i < this.objects.length; i++) { var item = this.objects[i]; h[ item.getPosition()['y'] ] = item.getPosition()['x'] + item.getWidth() + 100; // 100は経験値で追加 } // 全ての段を上から見てステージの空き位置を探索 for ( var y = 0; y <30*40; y += 30 ) { // 30px刻みで 40段分探索 if ( (typeof h[y] == 'undefined' ) || ( h[ y ] < this.width ) ) { ny = y; break; } } } // ステージにテロップを挿入 this.objects.push( new Telop( this.stage, mes, ny ) ); }, // move: ステージ上のobjを全部動かす:タイマーで呼ばれる move: function() { // ステージ上のobjを順番に選択する for ( var i = this.objects.length - 1; i > -1; i--) { var item = this.objects[i]; item.do_(); // objに設定された動きをさせる // ステージ上の死んだobjを取り除く if ( item.isDie() ) { // objは死んでいるか? // objを詰める(forを逆順にする必要あり) this.objects.splice(i, 1); } } } } </script> <style type="text/css"> html, body { height: 98%; } h1 { font-size: 14px; } span { position: absolute; font-size: 24px; font-family: 'A-OTF 新ゴ Pro M'; } body { overflow: hidden; } #welcome { position: absolute; border: 0px; text-align: center; width: 440px; height: 267px; padding-top: 64px; -webkit-border-radius: 16px; -moz-border-radius: 16px; border-radius: 16px; -webkit-box-shadow: #666 6px 6px 12px; -moz-box-shadow: #666 6px 6px 12px; box-shadow: #666 6px 6px 12px; background: #66aaaa; background: -webkit-gradient(linear, 0 0, 0 bottom, from(#66aaaa), to(#4D8080)); background: -webkit-linear-gradient(#66aaaa, #4D8080); background: -moz-linear-gradient(#66aaaa, #4D8080); background: -ms-linear-gradient(#66aaaa, #4D8080); background: -o-linear-gradient(#66aaaa, #4D8080); background: linear-gradient(#66aaaa, #4D8080); left: 50%; top: 50%; margin-left: -220px; margin-top: -133px; } #welcome p.description { width: 316px; height: 120px; background-color: black; color: white; margin: 0px auto 0px; text-align: center; font-size: 42px; line-height: 52px; padding-top: 16px; font-family: Alial; font-weight: bold; text-shadow: 3px 3px 0px #888; } #welcome p.icons { width: 385px; height: 80px; margin: 16px auto 0px; text-align: center; } #welcome .icon_left { width: 80px; height: 80px; float: left; } #welcome .icon_right { width: 80px; height: 80px; float: right; } </style> </head> <body> <header> <h1>Mojolicious + WebSocket + Twitter Streaming API</h1> </header> <article id="stage"> </article> </body> </html>
スポンサードリンク
トラックバック(0)
トラックバックURL: http://blog.dtpwiki.jp/MTOS/mt-tb.cgi/3761
コメントする