M.C.P.C.

―むり・くり―プラスコミュニケーション(更新終了)


| トップページ |

2011年11月 5日 17:09

PerlでTwitter Streaming APIの出力をブラウザ上でニコニコ動画風に表示するスクリプト(再接続未実装)

このエントリーをはてなブックマークに追加 mixiチェック

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/</&lt;/g;
    $text =~ s/>/&gt;/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>

投稿 大野 義貴 [Perl] | |

トラックバック(0)

トラックバックURL: http://blog.dtpwiki.jp/MTOS/mt-tb.cgi/3761

コメントする