IT女子のラズベリーパイ入門奮闘記

第25回「ラズベリーパイからTwitterへ情報発信!(3)自動投稿編」

raspberrypi25_main
ラズベリーパイで情報発信ツールを作ろう!いよいよ最終回です!
上の写真は完成形。新しい情報をゲットすると、ソファに座っているレゴの紳士がLEDを光らせてお知らせしてくれます。ちなみに土台は第21回記事で作った目覚まし時計です。

今回は最後の仕上げとして、スムーズに自動投稿が行えるように環境を整えます。
まずは、Twitterの投稿制限について調査!分かる範囲のエラーはできるだけ回避しておきましょう。投稿エラーが発生してしまった場合に、エラー内容が確認ができるように、ログファイルに記録できるようにしました。

そして準備が整ったら、自動投稿の設定です。最近使い慣れてきた「cron」には、日時を指定する定時実行だけでなく、ラズベリーパイを起動したときに自動実行してくれる方法があるらしいので挑戦してみました!

 

Twitterの制限

投稿制限については前回、前々回にも少し触れていますが、ここでもう少し詳しく見ていきましょう(2015年7月現在の情報です)。

ツイートの投稿
定義: ツイートには、画像、動画、リンク、最大140文字までのテキストを含めることができます。

おなじみの文字数制限です。API経由で140文字以上の文字列を投稿しようとした場合は、「Status is over 140 characters. 」というエラーが返ってきます。
こちらは前回、縮URLを考慮して140文字になるように、前回対応済みですね!

ツイートの投稿後にエラーメッセージが表示される
ツイートの投稿時に [既に同じツイートを送信済みです。] というエラーメッセージが表示される場合、最近別のツイートでまったく同じテキストを投稿しているため、次のツイートでは新しい内容を投稿することが推奨されることを示しています。
このエラーは、最近のツイートでまったく同じテキストを投稿した場合にのみ表示されます。

23回記事でも紹介しましたが、Twitterには二重投稿を防止する機能があります。1文字でも違っていれば投稿できましたが、全く同じ文字列の場合は数時間あけないと投稿できないようでした。
APIを利用した場合はエラーメッセージとして「Status is a duplicate.」という値が返ってきます。

Twitterリミット (API、ツイート投稿、およびフォロー)
ツイート: 1日に2,400件。1日のツイート投稿リミットは、さらに30分間隔の細かいリミットに分かれます。リツイートもツイートとしてカウントされます。
1日に2,400件のツイート投稿リミットは、さらに30分間隔のリミットに分かれます。アカウント更新/ツイートリミットに達した場合は、リミット期間終了後となる2~3時間後に、もう一度お試しください。

1日に2,400件!思っていたよりも多い!今回の場合は問題無さそうですが、ニュースサイトのような、頻繁に更新されるようなRSSを使いたい場合には注意が必要ですね。
「30分間隔の細かいリミット」の詳細については、今のところ公開されていないようです(2015年7月現在)。

エラーコード一覧
詳しいエラー内容、エラーコードについては、公式サイトで紹介されています。

今回のツールの場合は、文字数制限と重複投稿のエラーを回避しておけば、スムーズに動かす事ができそうですね。ただ、今後、仕様変更があったときの備えとして、投稿した文字列やエラーの内容をログファイルを作成することにしましょう。

 

ログファイルの出力

前回の実行時間を保持しておくログファイルと、エラーが発生したときのためのエラーログファイルの2つを用意することにしました。
記録するデータの内訳は下記のようにしました。

実行ログファイル
(log.csv)
RSSのURL
前回の実行時刻
エラーログファイル
(erroe.csv)
RSSのURL
実行時刻
投稿内容
エラーメッセージ

これらのデータを、CSV形式で管理したいと思います。CSVファイルの入出力は以前に何度かご紹介しましたね。第18回第22回のソースを少しカスタマイズするだけで簡単に実装することができました。

/var/www/news/tweet_rss.php

<?php
require_once("/var/www/news/twitteroauth/autoload.php");
use Abraham\TwitterOAuth\TwitterOAuth;
date_default_timezone_set('Asia/Tokyo');

//認証
$consumerKey       = '★★★★★';
$consumerSecret    = '★★★★★';
$accessToken       = '★★★★★';
$accessTokenSecret = '★★★★★';
$oAuth = new TwitterOAuth($consumerKey, $consumerSecret, $accessToken, $accessTokenSecret);

//RSS一覧
$rss_list = Array();
$rss_list[] = Array('https://www.raspberrypi.org/feed/', 'RaspberryPi公式サイト更新情報');
$rss_list[] = Array('http://deviceplus.jp/feed/', 'DevicePlus更新情報');
$rss_list[] = Array('http://headlines.yahoo.co.jp/rss/all-c_sci.xml', 'Yahoo!ニュースIT・科学の更新情報');

//処理開始
for($j=0;$j<count($rss_list);$j++){
    $rss_url = $rss_list[$j][0];
    $pre_message = $rss_list[$j][1];

    //前回の最終実行日時取得
    $last_date = checkLog($rss_url);

    //メッセージ作成
    $rss_message = createRssMessage($rss_url, $pre_message, $last_date);

    //ツイート投稿
    for($i=0;$i<count($rss_message);$i++){
        tweetMessage($oAuth, $rss_url, $rss_message[$i]);
    }

    //実行ログ出力
    $log = Array($rss_url, date("Y/m/d H:i"));
    writeCSV($log);
}
return;

/* RSSを読み込んでツイート用メッセージを作成 */
function createRssMessage($rss_url, $pre_message='', $last_date=NULL){
    $message = array();
    $xml = @simplexml_load_file($rss_url,'SimpleXMLElement',LIBXML_NOCDATA);

    $max_len = 140 - (mb_strlen($pre_message,'utf-8') + 23 + 2);

    if(isset($xml->channel)){
        $channel = $xml->channel;

        foreach ($channel->item as $value) {
            //日付チェック
            if(isset($value->pubDate) && $last_date!=NULL){
                $pubDate = date("Y/m/d H:i", strtotime($value->pubDate));
                if($pubDate < $last_date){
                    continue;
                }
            }

            //ツイート本文作成
            $str = '';
            if(isset($value->title )){
                $str .= $value->title."\n";
            }
            if(isset($value->description)){
                $str .= strip_tags($value->description);
                $str = html_entity_decode($str,ENT_XHTML)."\n";
            }
            //文字数調整
            if(mb_strlen($str,'utf-8')>$max_len){
                $str = mb_substr($str, 0, $max_len-1, mb_detect_encoding($str)).'…';
            }else{
                $str = mb_substr($str, 0, $max_len, mb_detect_encoding($str));
            }
            if(isset($value->link )){
                $str = $pre_message."\n".$value->link."\n".$str;
            }
            $message[] = $str;
        }
    }
    return $message;
}

/* Twitterに投稿 */
function tweetMessage($oAuth, $rss_url, $message){
    $log = Array();
    //ツイート投稿
    $response = $oAuth->post('statuses/update', array('status' => $message));
    //結果
    if(isset($response->errors)){
        //エラー発生
        $tmp = Array();
        foreach($response->errors as $err){
            $tmp[] = $err->message;
        }
        $log = Array($rss_url, date("Y/m/d H:i"), $message, join($tmp, PHP_EOL));
        //ログ出力
        writeErrorLog($log);
    }else{
        //ツイート成功
        //LED点滅
        blinkLed(15,3);
    }
    return;
}

/* LED点滅 */
function blinkLed($pin, $count){
    exec('echo '.$pin.' > /sys/class/gpio/export');
    exec('echo out > /sys/class/gpio/gpio'.$pin.'/direction');
    exec('echo 0 > /sys/class/gpio/gpio'.$pin.'/value');
    for($i=0;$i<$count;$i++){
        exec('echo 1 > /sys/class/gpio/gpio'.$pin.'/value');
        usleep(500000);
        exec('echo 0 > /sys/class/gpio/gpio'.$pin.'/value');
        usleep(500000);
    }
    exec('echo  '.$pin.' > /sys/class/gpio/unexport');
}

/* 実行ログファイルの日付チェック */
function checkLog($rss_url){
    $csv_file = '/var/www/news/log.csv';
    $ret = date("Y/m/d H:i", strtotime("-1 day"));
    if(file_exists($csv_file) && ($handle = fopen($csv_file, "r")) !== FALSE) {
        while (($line = fgetcsv($handle, 1000, ",")) !== false) {
            if($line[0]==$rss_url && $line[1]!=''){
                $ret = $line[1];
                break;
            }
        }
        fclose($handle);
    }
    return $ret;
}
/* 実行ログファイル書き込み */
function writeCSV($add_data){
    $csv_file = '/var/www/news/log.csv';
    $csv_data = [];
    $flg = false;
    if(file_exists($csv_file) && ($handle = fopen($csv_file, "r")) !== FALSE) {
        while (($line = fgetcsv($handle, 1000, ",")) !== false) {
            if($line[0]==$add_data[0]){
                $line[1] = $add_data[1];
                $flg = true;
            }
            $csv_data[] = $line;
        }
        fclose($handle);
    }
    if(!$flg){
        //追加
        $csv_data[] = $add_data;
    }
    if(($handle = fopen($csv_file, "w")) !== FALSE) {
        foreach ($csv_data as $data) {
            fputcsv( $handle, $data);
        }
        fclose($handle);
    }
    return;
}
/* エラーログファイル書き込み */
function writeErrorLog($arr){
    setlocale(LC_ALL, 'ja_JP.UTF-8');
    $file_path = '/var/www/news/error.csv';

    if( $handle = fopen( $file_path , 'a' ) ){
        fputcsv( $handle, $arr );
        fclose($handle);
    }
    return;
}

実行コマンド
php /var/www/news/tweet_rss.php

「★★★★★」の部分はTwitter APIの各キー(Consumer key、Consumer secret、Access Token、Access Token Secret)に置き換えてくださいね。

更新日時を基準にして、重複投稿を避するための簡易的なチェックを追加しました。
ツイートを投稿したかどうかに係わらず、処理の最後に実行ログファイル(log.csv)に日時を記録しています。次回実行時は、その日時(初回の場合は前日)以降に更新された情報のみを投稿対象としています。

エラーが発生した場合はエラーログファイル(error.csv)に記録します。自動実行中の不測の事態に備えて……ということで、エラー内容と投稿を試みた文章を保存できるようにしました。

また、複数のRSSをチェックできるように改良しています。URLと、ツイート本文に付加する文字列を配列で管理するようにしました。

実行したら、Twitterを確認してみましょう!更新情報が投稿されていれば成功です!

raspberrypi25_img01

図1

LEDの点滅時間を、スリープとして利用しているので、3~5秒間隔程度で投稿されていきます。

それに合わせてログファイルを確認。Excelで開くと文字化けが発生してしまう事があるので、その場合は一度テキストエディタで開き、文字コードをSHIFT-JISに変換してからファイルを開きましょう。

raspberrypi25_img02

図2

こちらは実行ログの画像です。URLと日時がきちんと記録されているでしょうか?

raspberrypi25_img03

図3

こちらはエラーログ。
そして、このログファイルのおかげで発見できた実際のエラーです(上記のプログラムは修正後のものになります)。
4列目に「Status is over 140 characters. 」というエラーが記録されています。文字数は調整しているはずなのになぜ?と思って、投稿を試みた文字列を、Twitterの投稿ボックスに貼り付けてみると、

raspberrypi25_img04

図4

文字数オーバー!?
よく見ると、3行目の「Raspi.TV」が青文字になっているので、URLとして認識されているようです。
URLが2つ以上出てくる文章を投稿すると、1つ目だけがURLとして扱われ、2つ目以降のURLは文字列として処理されてしまうようですね。短縮URLとして22文字になるはずだった文字列がそのままの文字数で投稿され、文字数オーバーのエラーが発生……という流れで発生したエラーでした。
この他にもエラーログファイルのおかげで気付くことのできたエラーがいくつかありました。APIの仕様が変更になった場合などに役に立つかも……と思って取り入れてみたエラーログファイルでしたが、テスト段階で早速活躍してくれました!

 

cronで自動投稿設定!

自動実行といえば、cron!この連載でもおなじみになってきたcron設定ですが、たとえば、1時間間隔で動作させるのであれば、次のような手順で記述します。

crontab -e

まずはLX Terminalからcronの設定画面を呼び出して……

00 * * * * php /var/www/news/tweet_rss.php

このように記載!
[Ctrl]+[O]で保存、[Ctrl]+[X]で設定を終了し、元の画面に戻ります。

先程のプログラムでは、Yahoo!ニュースの「IT・科学」カテゴリのRSSを使用しましたが、数分間隔でどんどん更新されていくため、更新スピードに追いつけずに、ツイートする前にRSSから無くなってしまった……という事態が発生。ニュースサイトなど、頻繁に更新されるようなRSSでは、もう少し短い間隔で動作させたほうが良いですね。cronの設定前に、大まかな更新間隔を確認しておきましょう。
1日に数件の更新であれば、1時間おきの自動実行でも十分でした。

 

ラズベリーパイ起動時に自動投稿!(ちょっと失敗…)

自動投稿の設定も完了して、自動投稿ツール完成!……の前に、cronには、指定時間に実行させるだけでなく、「ラズベリーパイ起動時」という条件でも自動実行させる方法があるらしいので、挑戦してみることにしました。

結論から言うと、今回のように「時刻」を取り扱う場合は、このままの状態では正しく動作してくれないのですが……どうしてダメなのか、原因について調べたことも含めてご紹介します!

まず、テストのために用意したプログラムはこちらです。

/var/www/news/tweet_start.php

<?php
require_once("/var/www/news/twitteroauth/autoload.php");
require_once("/var/www/news/twitter_config.php");
use Abraham\TwitterOAuth\TwitterOAuth;
date_default_timezone_set('Asia/Tokyo');

//日付を取得
$today = date("Y年m月d日 H:i");

for($i=0;$i<5;$i++){
    //メッセージ作成
    $message = $today.' ラズベリーパイ起動!('.($i+1).')';

    //認証
    $consumerKey       = '★★★★★';
    $consumerSecret    = '★★★★★';
    $accessToken       = '★★★★★';
    $accessTokenSecret = '★★★★★';
    $oAuth = new TwitterOAuth($consumerKey, $consumerSecret, $accessToken, $accessTokenSecret);
    //投稿
    $response = $oAuth->post('statuses/update', array('status' => $message));
    if(isset($response->errors)){
        //エラー発生
        sleep(15);
    }else{
        //ツイート成功
        break;
    }
}

実行コマンド
php /var/www/news/tweet_start.php

重複投稿のエラーを避けるために、時刻を一緒につぶやくようにしました。また、ラズベリーパイ起動直後に動かすため、ネットワーク接続が完了していない場合を考慮して、間隔をあけて5回トライできるようにしています。実際にやってみたところ、1~2回目で投稿できることが多かったです。

このプログラムを、ラズベリーパイ起動時に実行させてみたいと思います。
cronを使った起動時の自動実行の情報は、Wikipediaからリンクが貼られている、英語のマニュアルページにありました。

Crontab : Scheduling Tasks – math-linux.com

こちらのページの下の方にまとめられています。cronには、日時を設定する方法以外に、下記のように文字列を指定する記述方法があるそうです。

There are also special strings of characters :

String Action
@reboot execution at boot
@yearly execution once a year, “0 0 1 1 *”
@annually execution once a year, “0 0 1 1 *”
@monthly execution onnce a month, “0 0 1 * *”
@weekly execution once a week, “0 0 * * 0”
@daily execution once a day, “0 0 * * *”
@midnight execution once a day, “0 0 * * *”
@hourly execution once an hour, “0 * * * *”

表の1行目「@reboot」は、「execution at boot(=起動時に実行) 」とされています。他にも、各単位ごとに「一度」実行するための文字列が用意されています。「@reboot」以外は、いつものように数値指定で表現できるので、あまり登場する機会は無さそうですね。

それでは実際にテストしてみましょう!
先程と同様に、コマンドラインから「crontab -e」を入力し、cronの設定画面を呼び出します。そして、「@reboot」を使用した命令文を登録。

@reboot php /var/www/news/tweet_start.php

設定を保存してラズベリーパイを再起動!同じタイミングで、別のパソコンからTwitterを確認してみましょう。

raspberrypi25_05

図5

このように投稿されていれば成功です!
……と、言いたいところですが、実はこれではダメなんです。

raspberrypi25_06

図6

理由ははこちら。よく見ると、ツイート本文の日時と実際の日時がかなり違っています。

しばらく電源を切ってから再度起動すると分かりやすいのですが、前回のシャットダウン時間が投稿されてしまうという不具合が発生してしまいました。わたしの場合は、連休明けの月曜日にラズベリーパイを起動してみて、ようやく気付くことができました。

調べてみると、ラズベリーパイには内部電源がないことが原因でした。電源を落としてしまうとラズベリーパイ内部の時計も止まってしまうため、再起動後、時計が再設定されるまではシャットダウン時刻を基準に動いてしまうようです。
普段、LANケーブルを挿しっぱなしで使っていたので、全然気付かなかったのですが、ネットワークを切った状態で起動すると、時計が再設定されず、ずれたままの状態で動いてしまいました。

先程の結果からすると、cronの「@reboot」のタイミングは、時計合わせは間に合わないようですね。そして、RSS情報の自動投稿ツールでは、現在時刻を基準として投稿する項目を判定しているため、このままではうまく動作してくれません。

解決方法としては、ntpを再起動や、ntpdateパッケージの利用などがあります。実際に試してみたところ、数分の誤差が発生したり、「ラズベリーパイ起動時」には間に合わなかったり……確実に動かすのはなかなかむずかしかったです。

とはいえ、今回のツールは、起動時の自動実行が必須ではないので、わたしのラズベリーパイでは「60秒のスリープを入れてから処理をスタートさせる」というやや強引な方法で動いてもらっています。Wi-Fi接続でも問題なく動いているので、とりあえず目標とする機能を実装することができました!

ちなみに、「RTC(real-time clock)モジュール」という部品を取り付けると、ラズベリーパイの時計を止めずに保つことができるそうです。

リアルタイムクロック
リアルタイムクロック(real-time clock、RTCと略記)は、コンピュータの時計であり、コンピュータの電源が切られていても現在時刻を刻み続ける機能のことを指す。また、その機能を持つ集積回路のことを指す場合もある。

 

まとめ

RSS情報のTwitter自動投稿ツール完成!
自動実行については少し未完成な部分もありますが……とりあえず元気に動いてくれているので良しとしましょう!こういう試行錯誤も電子工作の醍醐味ですね!

次回は、久々にスマートフォンと連携!
以前、「MPD」アプリを使って音楽を聴く工作をやりましたが、次回は「VNC」アプリを使ってラズベリーパイのデスクトップ画面そのものを触れるようにしてみたいと思います!
これができれば、意外と面倒なディスプレイやキーボード、マウスの接続から解放されるかも!?

出場ロボット解剖計画
円山ナカノ

プロフィール:プログラミング暦通算4年、最近IT業界に舞い戻ってきたプログラマーです。女子です。