2016/02/18(木)Google Calendar のデータをXMLに加工して monthly.js に 流し込む

所属している草野球チームのHP制作を担当していて、「スケジュールを載せたい」という要望が来ました。数週間ぐらい前に「そういえば何かカレンダーをキレイに表示させるJSライブラリがあったよなぁ」と「monthly.js」思い出して、Googleカレンダーとこれを使って表示させることにしました。

練習場所まで全世界に公開するのも気持ちが悪いので、Googleのカレンダーは「非公開」の設定のままで、どうにか情報を取得するすべはないものかと調べると、OAuth2なGoogle Calendar API というAPIを使えばいいと載っていました。( OAuth2じゃないAPIもあるっぽいですが、実装が複雑になるのでOAuth2の方式がいいよみたいな情報が公式ドキュメントに書かれていた。うろ覚え。)

しかし、肝心のPerlの解説がない。(仕事ではRubyがメインですが、草野球のホームページぐらいの規模ならPerlで書きたい。)ライブラリ自体は「Google::API::Client」という名前でCPANに登録されているのでこれを使うことにしました。

client_secret.json みたいなファイルをGoogle APIのページから取得すると(詳細は「celient_secret.json google」でググッて)あとは、以下のコードで、初回時のみ手動で認証してリフレッシュトークンを取得すると、それ以降はよしなにしてくれます。(ずっとアクセスを待ち受ける必要があるWebアプリにも組み込めます。)

#!/usr/bin/env perl

use strict;
use warnings;
use utf8;
use feature qw/say/;
use open qw/:encoding(utf-8) :std/;
use JSON qw//;
use Time::Moment qw//;
use Google::API::Client;
use Google::API::OAuth2::Client;
use XML::Simple ();
use Cache::FileCache;
use DDP; # cpanm Data::Printer してね

my $myemail = 'xxx@gmail.com';
my $client_secrets_file = 'client_secret.json';
my $client = Google::API::Client->new;
my $gcal = $client->build('calendar', 'v3');
my $auth_driver = Google::API::OAuth2::Client->new_from_client_secrets($client_secrets_file, $gcal->{auth_doc});

my $dat_file = 'token.dat';
get_or_restore_token($dat_file, $auth_driver);

my $my_calender_id      = 'primary';
my $holiday_calendar_id = 'ja.japanese#holiday@group.v.calendar.google.com';

my $cache = Cache::FileCache->new({
    cache_root => '/tmp',
    namespace  => 'calendar',
});

my %cache_duration_of = (
    $my_calender_id      => '5s',
    $holiday_calendar_id => '7d',
);

my $now = Time::Moment->now;

my $first_day_of_month = Time::Moment->new(
    year   => $now->year,
    month  => $now->month,
    day    => 1,
    offset => 540, # +09:00(540分)
);

my $req_body = {
    timeMax => $first_day_of_month->plus_months(6)->to_string,
    timeMin => $first_day_of_month->minus_months(6)->to_string,
};

my $schedule = get_or_cache_event_items($my_calender_id);
my $holidays = get_or_cache_event_items($holiday_calendar_id);

my @events = (@{$schedule->{items}}, @{$holidays->{items}});

print make_xml(\@events);

exit;

sub get_or_restore_token
{
    my ($file, $auth_driver) = @_;

    if (-f $file)
    {
        open (my $fh, '<', $file) or die $!;
        local $/;
        my $access_token = JSON::from_json(<$fh>);
        close($fh);

        $auth_driver->token_obj($access_token);
    }
    else
    {
        my $auth_url = $auth_driver->authorize_uri;
        say 'Go to the following link in your browser:';
        say $auth_url;

        say 'Enter verification code:';
        chomp(my $code = <>);
        $auth_driver->exchange($code);
        store_token($file, $auth_driver);
    }
}

sub store_token
{
    my ($file, $auth_driver) = @_;

    my $access_token = $auth_driver->token_obj;

    open (my $fh, '>', $file) or die $!;
    print {$fh} JSON::to_json($access_token);
    close($fh);
}

sub get_or_cache_event_items
{
    my $calendar_id = shift;
    my $items = $cache->get($calendar_id);

    if ( ! $items )
    {
        $items = $gcal->events->list(calendarId => $calendar_id, body => $req_body)->execute({ auth_driver => $auth_driver });
        $cache->set($calendar_id => $items, $cache_duration_of{$calendar_id}) if $items;
    }

    return $items;
}

sub get_date_using_datetime { local $_ = $_[0]; s/(.+)T.+/$1/; $_; }
sub get_time_using_datetime { local $_ = $_[0]; s/.+T([0-9][0-9]:[0-9][0-9]):[0-9][0-9]\+.+/$1/; $_; }

sub make_xml
{
    my $items = shift;

    my $data = {};
    $data->{event} = ();

    my $i = 0;

    for my $item (@{$items})
    {
        my $event = {};

        my $startdate = $item->{start}{date} ? $item->{start}{date} : get_date_using_datetime($item->{start}{dateTime});
        my $enddate   = $item->{end}{date}   ? $item->{end}{date}   : get_date_using_datetime($item->{end}{dateTime});

        $event->{id}        = ++$i;
        $event->{name}      = $item->{summary};
        $event->{startdate} = $startdate;
        $event->{enddate}   = $enddate;

        $event->{color} = '#ffb128';
        $event->{color} = '#ddeeff' if $item->{organizer}{email} ne $myemail;

        $event->{starttime} = get_time_using_datetime($item->{start}{dateTime}) if $item->{start}{dateTime};
        $event->{endtime}   = get_time_using_datetime($item->{end}{dateTime})   if $item->{end}{dateTime};

        push(@{$data->{event}}, $event);
    }

    return XML::Simple::XMLout($data, RootName => 'monthly', XMLDecl => '<?xml version="1.0"?>', NoAttr => 1);
}

以下のようにXMLが出力されます。

<?xml version="1.0"?>
<monthly>
  <event>
    <name>練習(雨天中止)</name>
    <color>#ffb128</color>
    <enddate>2016-02-14</enddate>
    <endtime>15:30</endtime>
    <id>1</id>
    <startdate>2016-02-14</startdate>
    <starttime>11:00</starttime>
  </event>
  <event>
    <name>試合!</name>
    <color>#ffb128</color>
    <enddate>2016-03-26</enddate>
    <endtime>16:00</endtime>
    <id>2</id>
    <startdate>2016-03-26</startdate>
    <starttime>14:00</starttime>
  </event>
  <event>
    <name>グランド練習</name>
    <color>#ffb128</color>
    <enddate>2016-02-20</enddate>
    <endtime>16:00</endtime>
    <id>3</id>
    <startdate>2016-02-20</startdate>
    <starttime>14:00</starttime>
  </event>
  <event>
    <name>練習</name>
    <color>#ffb128</color>
    <enddate>2016-02-28</enddate>
    <endtime>15:00</endtime>
    <id>4</id>
    <startdate>2016-02-28</startdate>
    <starttime>10:00</starttime>
  </event>
  <event>
    <name>練習</name>
    <color>#ffb128</color>
    <enddate>2016-03-06</enddate>
    <endtime>15:00</endtime>
    <id>5</id>
    <startdate>2016-03-06</startdate>
    <starttime>10:00</starttime>
  </event>
  <event>
    <name>練習</name>
    <color>#ffb128</color>
    <enddate>2016-03-13</enddate>
    <endtime>15:00</endtime>
    <id>6</id>
    <startdate>2016-03-13</startdate>
    <starttime>10:00</starttime>
  </event>
  <event>
    <name>練習</name>
    <color>#ffb128</color>
    <enddate>2016-04-03</enddate>
    <endtime>15:00</endtime>
    <id>7</id>
    <startdate>2016-04-03</startdate>
    <starttime>10:00</starttime>
  </event>
  <event>
    <name>練習</name>
    <color>#ffb128</color>
    <enddate>2016-03-20</enddate>
    <endtime>15:00</endtime>
    <id>8</id>
    <startdate>2016-03-20</startdate>
    <starttime>10:00</starttime>
  </event>
  <event>
    <name>敬老の日</name>
    <color>#ddeeff</color>
    <enddate>2015-09-22</enddate>
    <id>9</id>
    <startdate>2015-09-21</startdate>
  </event>
  <event>
    <name>国民の休日</name>
    <color>#ddeeff</color>
    <enddate>2015-09-23</enddate>
    <id>10</id>
    <startdate>2015-09-22</startdate>
  </event>
  <event>
    <name>秋分の日</name>
    <color>#ddeeff</color>
    <enddate>2015-09-24</enddate>
    <id>11</id>
    <startdate>2015-09-23</startdate>
  </event>
  <event>
    <name>文化の日</name>
    <color>#ddeeff</color>
    <enddate>2015-11-04</enddate>
    <id>12</id>
    <startdate>2015-11-03</startdate>
  </event>
  <event>
    <name>元日</name>
    <color>#ddeeff</color>
    <enddate>2016-01-02</enddate>
    <id>13</id>
    <startdate>2016-01-01</startdate>
  </event>
  <event>
    <name>昭和の日</name>
    <color>#ddeeff</color>
    <enddate>2016-04-30</enddate>
    <id>14</id>
    <startdate>2016-04-29</startdate>
  </event>
  <event>
    <name>憲法記念日</name>
    <color>#ddeeff</color>
    <enddate>2016-05-04</enddate>
    <id>15</id>
    <startdate>2016-05-03</startdate>
  </event>
  <event>
    <name>体育の日</name>
    <color>#ddeeff</color>
    <enddate>2015-10-13</enddate>
    <id>16</id>
    <startdate>2015-10-12</startdate>
  </event>
  <event>
    <name>みどりの日</name>
    <color>#ddeeff</color>
    <enddate>2016-05-05</enddate>
    <id>17</id>
    <startdate>2016-05-04</startdate>
  </event>
  <event>
    <name>海の日</name>
    <color>#ddeeff</color>
    <enddate>2016-07-19</enddate>
    <id>18</id>
    <startdate>2016-07-18</startdate>
  </event>
  <event>
    <name>勤労感謝の日</name>
    <color>#ddeeff</color>
    <enddate>2015-11-24</enddate>
    <id>19</id>
    <startdate>2015-11-23</startdate>
  </event>
  <event>
    <name>天皇誕生日</name>
    <color>#ddeeff</color>
    <enddate>2015-12-24</enddate>
    <id>20</id>
    <startdate>2015-12-23</startdate>
  </event>
  <event>
    <name>春分の日</name>
    <color>#ddeeff</color>
    <enddate>2016-03-21</enddate>
    <id>21</id>
    <startdate>2016-03-20</startdate>
  </event>
  <event>
    <name>春分の日 振替休日</name>
    <color>#ddeeff</color>
    <enddate>2016-03-22</enddate>
    <id>22</id>
    <startdate>2016-03-21</startdate>
  </event>
  <event>
    <name>こどもの日</name>
    <color>#ddeeff</color>
    <enddate>2016-05-06</enddate>
    <id>23</id>
    <startdate>2016-05-05</startdate>
  </event>
  <event>
    <name>成人の日</name>
    <color>#ddeeff</color>
    <enddate>2016-01-12</enddate>
    <id>24</id>
    <startdate>2016-01-11</startdate>
  </event>
  <event>
    <name>建国記念の日</name>
    <color>#ddeeff</color>>
    <enddate>2016-02-12</enddate>
    <id>25</id>
    <startdate>2016-02-11</startdate>
  </event>
</monthly>

Googleカレンダーが終了時刻それ自体はイベントの期間に含めない(不等号に「=(イコール)」が付くかどうかの違いのようなもの) のに対して、monthly.js は終了時刻それ自体をイベントの期間に含めるという違いがあるため、endtime(enddateじゃなくて)がない場合は、enddate を一日早めるという面倒な処理が追加で必要になります。(上記のコードではそれが追加されていないので注意。)

上記の面倒な処理を終えて「monthly.js」のマニュアルを読んでWebアプリに組み込むと、以下のように美しく表示できるようになります。ちなみに、高速で「月」を切り替えるとイベントが移動したり増殖したりする「monthly.js」のバグを作り終えてから発見しました。(ぎりぎりクリティカルなバグでもないかというあんばいですが…)

スクリーンショット 2016-02-18 0.34.09

スクリーンショット 2016-02-18 0.34.24

お疲れ様です。( ´Д`)=3

2014/02/05(水)Web Speech API でツイート

Web Speech APIのためにマイク買ったので、音声でツイートできるAjaxなCGIを適当に書いてみました。(CGIモジュールなんて使うのもクソ久しぶり)

よく使うような単語やフレーズの認識精度はすげーです。

Web Speech APIの仕様は、「Web Speech API Specification」を参照。

完成したやつ↓(隣の部屋の人に聞こえないようマイクに近づいて小声でしゃべっています)

「index.html」

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>こえでついーと!</title>
  <link rel="stylesheet" href="voice_tweet.css">
  <script src="http://ajax.googleapis.com/ajax/libs/jquery/2.1.0/jquery.min.js"></script>
  <script src="voice_tweet.js"></script>
</head>

<body>
  <h1>こえでついーと!</h1>
  <div id="status">状態:<span id="desc"></span></div>
  <div id="recog_result"></div>
  <button>音声認識開始</button>
</body>

</html>

「voice_tweet.css」

@charset "UTF-8";                                                                                                                                                       

#recog_result
{
    width: 600px;
    height: 100px;
    border: 1px solid #666;
    padding: 10px;
    margin-top: 20px;
}

button
{
    margin-top: 10px;
}

「voice_tweet.js」

"use strict";

$(function()
{
    var script_url     = "voice_tweet.cgi";
    var api_recog      = $("#recog_result");
    var api_status     = $("#status #desc");
    var api_button     = $("button");
    var is_recognizing = false; // 音声認識中か否か
    var interim_result = "";

    var error_en2ja = { "no-speech": "何か話して" };

    var recognition = new webkitSpeechRecognition();
    recognition.continuous     = true; // 複数の連続した認識を有効にする
    recognition.interimResults = true; // 途中結果を返す
    recognition.lang           = 'ja'; // 指定しない場合はドキュメントルートのlangが使われる(BCP 47 を参照)
    recognition.start();

    recognition.onresult = function(event)
    {
        for (var i = 0; i < event.results.length; i++)
        {
            api_recog.text(interim_result + " " + event.results[i][0].transcript);
        }

        var result = event.results[event.results.length - 1];

        if (result.isFinal)
        {
            var last_spoken = result[0].transcript.trim();

            console.log("音声認識結果:" + last_spoken);

            if (last_spoken === "ストップ" || last_spoken === "stop")
            {
                recognition.stop();
            }
            else if (last_spoken === "クリア")
            {
                api_recog.text("");
            }
            else if (last_spoken === "削除")
            {
                api_recog.text( remove_trailing_word( remove_trailing_word( api_recog.text() ) ) );
            }
            else if (last_spoken === "ツイート" || last_spoken === "ツイード")
            {
                $.ajax
                ({
                    dataType: "text",
                    data:
                    {
                        "tweet_text": remove_trailing_word( api_recog.text() ),
                    },
                    url: script_url,
                    timeout: 10000, // 10秒
                    type: "POST",
                    success: function(res)
                    {
                        console.log(res);
                    },
                    error: function(XMLHttpRequest, textStatus, errorThrown)
                    {
                        console.log("ツイート失敗");
                        //console.log(XMLHttpRequest);
                        //console.log(XMLHttpRequest.status);
                        //console.log(textStatus);
                        //console.log(errorThrown);
                    }
                });

                api_recog.text("");
            }

            interim_result = api_recog.text();
        }
    };

    recognition.onstart = function()
    {
        is_recognizing = true;
        interim_result = "";
        api_button.text("音声認識停止");
        console.log("音声認識スタート!");
        api_status.text("音声入力待ち");
    };

    recognition.onerror = function(event)
    {
        var error = error_en2ja[event.error] || event.error;
        console.log("音声認識エラー:" + error);
        api_status.text(error);
    };

    recognition.onend = function()
    {
        is_recognizing = false;
        api_button.text("音声認識再開");
        console.log("音声認識が終了しました");
        api_status.text("音声認識が終了しました");
    };

    api_button.click(function()
    {
        is_recognizing ? recognition.stop() : recognition.start();
    });

    function remove_trailing_word(text)
    {
        var splited_text = text.split(/s+/);
        splited_text.pop();
        return splited_text.join(" ");
    }
});

「voice_tweet.cgi」

#!/usr/bin/env perl

use strict;
use warnings;
use utf8;
use open OUT => qw/:utf8 :std/;
use Encode qw/decode_utf8/;
use CGI;
#use CGI::Carp qw/fatalsToBrowser/;
use Net::Twitter::Lite::WithAPIv1_1;
use Text::Truncate;
use Try::Tiny;

my $MAX_TWEET_LEN = 140;
my @IP_WHITE_LIST = qw/150.65.110.57/;

my %WHITE_IP;
@WHITE_IP{ @IP_WHITE_LIST } = ();

my $q = CGI->new;

open(my $fh, '>', 'log.txt') or die $!; # 直前のリクエスト分のログを取る

if ($q->request_method eq 'POST')
{
    my $ip         = $q->remote_addr;
    my $tweet_text = decode_utf8( $q->param('tweet_text') );

    if (exists $WHITE_IP{$ip} && length $tweet_text)
    {
        print {$fh} "$tweet_text\n";

        my $nt = Net::Twitter::Lite::WithAPIv1_1->new(
            consumer_key        => '***',
            consumer_secret     => '***',
            access_token        => '***',
            access_token_secret => '***',
            ssl                 => 1,
        );

        my $appendix = " (音声認識ツイート)";
        $tweet_text = truncstr($tweet_text, $MAX_TWEET_LEN - length $appendix);

        print "Content-Type: text/plain; charset=UTF-8\n\n";

        try {
            $nt->update("$tweet_text$appendix");
            print "ツイート成功![$ip]";
        }
        catch {
            print "ツイート失敗![$ip]";
            print {$fh} $_;
        };
    }
}