LoginSignup
6
5

More than 5 years have passed since last update.

CakePHP3のバッチ機能とTwitterAPIを使ってTweet収集してみる

Last updated at Posted at 2018-03-04

今回色々調べつつ、実装しました。その為、自分の分からなかった点を整理しつつ、書いていきます。
「準備」「実装」という感じで書きます。

準備

前提

Mac
CakePHP3
Composer
Twitterアカウントの作成
Twitterアプリの作成
Twitter REST API
TwitterOAuth

Composerとは

PHPのパッケージ管理ソフト。
下記記事に詳しく書かれているので参照のこと。
https://qiita.com/atwata/items/d6f1cf95ce96ebe58010

Twitter REST API

Twitterが公式で提供しているAPI。
今回はその中の「GET search/tweets」を使用する。
使用するには、キーが必要となる。
キーは下記で取得する。(要アカウント)
https://apps.twitter.com

TwitterOAuthとは

「Twitter REST API」をPHPから簡単に利用できるようにしたライブラリ。
インストール方法はターミナルでプロジェクトフォルダまで移動して、
composer require Abraham/twitteroauth
を実行。勝手にvenderフォルダ配下にインストールしてくれる。

実装で使うCake周りの機能やら

Shell機能

バッチプログラムをPHPの標準関数やCakePHPのコンポーネントやモデル機能を使用して書ける。
使い方は下記。

  1. Shellファイルを作成する
    ファイル名は「〜Shell.php」とする。
    例えば「SampleShell.php」のようなファイル名。

  2. Shellの中身を書く
    Shellクラスを継承する。
    バッチ実行時の処理はmainに書く。

  3. Shellを実行する
    実行はCakePHPのフォルダまで行き、bin/cake sampleで出来る。
    ※1 実行の際に「〜Shell.php」の部分は不要。
    ※2 HelloWorldShell.phpなど、単語二語以上がキャメルケースとなっているShellの実行は
    bin cake hello_worldというようにShell名をスネークケースで指定する

一応最低限の中身はこんな感じ

SampleShell.php
<?php
namespace App\Shell;

use Cake\Console\Shell;

class SampleShell extends Shell {
    public function main() {
         // ここに処理を書く
        $this->out('Hello Shell');
    }
}

試しに書いた処理の$this->out();の部分はコンソールに引数で与えた値を出力する為の関数。

Component

コンポーネントとは

コンポーネントはコントローラー間で共有されるロジックのパッケージです。
https://book.cakephp.org/3.0/ja/controllers/components.html

ということなので、何か共通化できそうな処理をまとめておけば、
コンポーネントを読み込むだけで、いちいち同じような処理を何度も実装しなくて済むということらしいです。
正直今回は使ってもそんなに嬉しくはないですが、
・バッチ処理ではなく、Webアプリの機能として実装したくなった時も使えそう
・処理を汎用化して、整理することでコードの可読性が上がる
という点を狙って、汎用化できそうな部分はコンポーネントで実装していきたいと思います。

Componentを作るには

  1. 配置場所
    AppRoot/src/Controller/Componentの配下に置く。

  2. ファイル名
    キャメルケースで「〜Component.php」とする。
    例えば、SampleComponent.phpという感じ。

  3. ファイルの中身
    Componentクラスを継承する。
    共通化する処理を実装する。

中身はこんな感じ。

SampleComponent.php
<?php
namespace App\Controller\Component;

/**
 *継承する為のComponentクラスを読み込み
 */
use Cake\Controller\Component;

/**
 * SampleComponent
 */
class SampleComponent extends Component
{
    /**
     * 初期処理
     * @return void
     */
    public function initialize(array $config) {
    }

    /**
     * デバッグログにHelloと出力する
     * @return void
     */
    public function sayHello() {
        $this->log('Hello', 'debug');
    }
}

ShellからComponentを利用する

Componentを読み込む為のShellの中身は下記のような感じ。

SampleShell.php
<?php
namespace App\Shell;

use Cake\Console\Shell;
use Cake\Controller\ComponentRegistry;
use App\Controller\Component\SampleComponent;

class SampleShell extends Shell {
    public function main() {
        // ここに処理を書く
        $this->out('Hello Shell');
        // コンポーネントの読み込み
        $this->Sample = new SampleComponent(new ComponentRegistry());
        // 読み込んだコンポーネントのメソッドを呼ぶ
        $this->Sample->sayHello();
    }
}

実行コマンドはbin/cake sample
上記で作成したSampleComponentを読み込んで、SampleShellからsayHelloメソッドを呼び出している。
sayHelloメソッドはデバッグログにHelloと出力するだけのメソッドなので、デバッグログを確認して出力されていれば上手く読み込めている。

実装

処理の流れ

  1. 特定のキーワードでツイートを検索する
  2. ツイートのアカウントを登録する
  3. ツイートを登録する

テーブル設計

・Twitterアカウントを保存しておくテーブル
・Tweetを保存しておくテーブル
の二つを用意する。

TwitterAccountsテーブル

カラム名 NULL DEFAULT MEMO
id CHAR(36) × Cakeで自動挿入されるid
twitter_account_id VARCHAR(100) × Twitter.user.id
name VARCHAR(100) Twitter.user.name
screen_name VARCHAR(100) × Twittr.user.screen_name
profile_image_url VARCHAR(100) Twitter.user.profile_image_url
created_at DATETIME × Twitter.user.created_at
created DATETIME × Cakeで自動挿入される作成日時
modified DATETIME × Cakeで自動挿入・更新される更新日時

TwitterTweetsテーブル

カラム名 NULL DEFAULT
id CHAR(36) ×
twitter_account_id VARCHAR(100) ×
name VARCHAR(100)
screen_name VARCHAR(100) ×
profile_image_url VARCHAR(100)
created_at DATETIME ×
created DATETIME ×
modified DATETIME ×

コンポーネント実装

TwitterOAuthライブラリを読み込む

処理はコンポーネント内に書いていく。
コンポーネント上部には下記を記載

TwitterComponent.php
<?php
namespace App\Controller\Component;

// vendor配下のライブラリを読み込む
require 'vendor/autoload.php';

use Cake\Controller\Component;
use Cake\ORM\TableRegistry;
use Cake\I18n\Time;

// ライブラリを使用しますよの宣言
use Abraham\TwitterOAuth\TwitterOAuth;

/**
 * Twitterの操作を行う為のコンポーネント
 */
class TwitterComponent extends Component
{

接続の為のキーを準備する

TwitterComponent.php
// メンバ変数として宣言しておく
private static $CONSUMER_KEY;
private static $CONSUMER_SECRET;
private static $ACCESS_TOKEN;
private static $ACCESS_TOKEN_SECRET;

// 接続キーは外部に設定として持てるように、設定用のメソッドを用意
public function setAuthentication($consumerKey, $consumerSecret, $accessToken, $accessTokenSecret) {
    self::$CONSUMER_KEY = $consumerKey;
    self::$CONSUMER_SECRET = $consumerSecret;
    self::$ACCESS_TOKEN = $accessToken;
    self::$ACCESS_TOKEN_SECRET = $accessTokenSecret;
}

TwitterOAuthオブジェクト作成

$connection = new TwitterOAuth($CONSUMER_KEY, $CONSUMER_SECRET, $ACCESS_TOKEN, $ACCESS_TOKEN_SECRET);

検索オプションを作成する

$searchParams = [
    'q' => '#キーワード AND #キーワード2  exclude:retweets',
    'lang' => 'ja',
    'count' => 100,
    'until' => '2018-02-01'
];

みたいな感じで検索オプションを作る。

検索する

$response = $connection->get('search/tweets', $searchParams);
これだけでTweetを検索できる。めっちゃ簡単。

searsh/tweetsの検索オプションについて

オプションキー 説明 備考
q 検索ワード 「#ワード」とするとハッシュタグを検索できる。ANDで繋ぐとAND検索、ORで繋ぐとOR検索ができる。キーワードの後ろに「 exclude:retweets」をつけるとリツートを除外できる。
lang 検索言語 jaとすると言語が日本語のTweetだけ取得できる。
count 一回の検索で取得する件数 最大100件。TwitterAPIの仕様上、一回のAPIコールで取得できる最大件数が決まっているので、100件以上のTweetを取得したい場合は、何度もAPIをコールする必要がある。
until いつの日付までのTweetを取得するのか 指定した日付までのTweetを取得できる。2018-02-01なら2018-02-01の直前の2018-01-31 23:59:59まで取得。
max_id どこからのTweetを取得するのか これに指定したTweetのId以前のTweetを取得してくる。これをAPIコールの度に更新してやらないと同じ100件のTweetを延々と取得し続ける羽目になってしまう。

Twitterレスポンス

$connection->getLastHttpCode()
でTwitterAPIを呼び出した際のHTTPコードが取得できる。
エラーの場合、検索結果を正常に取得できていないので、適切に処理してやる必要がある。
HTTPコード200なら正常に返ってきており、検索結果の有り、無しで処理を分岐
それ以外のコードの場合は、レスポンスにTwitterAPIのエラーコードが含まれていればそれを返すようにする。
含まれない場合は、原因不明のエラーとして処理。

TwitterComponent.php
// 正常処理
if ($httpCode === 200) {
    if(empty($response->statuses)) {
        // 検索結果無し
        return $result = ['status' => 'empty','response' => []];
    }
    // 検索結果有り
    $tweets = $response->statuses;
    return $result = ['status' => 'success', 'response' => $tweets];    
}
// エラー
else {
    // APIのエラー
    if (isset($response->errors)) {
        $errors = $response->errors;
        return $result = ['status' => 'error', 'response' => $errors];
    }
    //原因不明のエラー
    return $result = ['status' => 'error', 'response' => ['code' => 'unknown', 'unknown error']];
}

DB登録処理の流れ

TwitterComponent.php
// テーブルとエンティティーとデータの準備
$twitterAccountsTable = TableRegistry::get($tableName);
$tableAccount = $twitterAccountsTable
                ->find()
                ->select(['id'])
                ->where(['twitter_account_id' => $twitterAccount->id])
                ->first();      
// UPDATE
if ($tableAccount) {
    $saveData = $twitterAccountsTable->get($tableAccount['id']);
}
// INSERT
else {
    $saveData = $twitterAccountsTable->newEntity();
    $saveData->twitter_account_id = $twitterAccount->id;
}

// エンティティーに情報を詰める
$saveData->name = $twitterAccount->name;
$saveData->screen_name = $twitterAccount->screen_name;
$saveData->profile_image_url = str_replace('http://pbs.twimg.com/', '', $twitterAccount->profile_image_url);
$saveData->created_at = $twitterAccount->created_at;

// DB登録
if ($twitterTweetsTable->save($saveData)) {
    $insertId = $saveData->id;
} else {
    $insertId = null;
}

  1. TableRegistryを使って、テーブルオブジェクトを取得
  2. オブジェクトのfindメソッドを使って、アカウント検索。first()で最初の1行だけ取ってくるかつ$queryオブジェクトの実行。
  3. テーブルにTwitter上のidを持っておき、それを使って検索することで、登録済みかどうか判断。DBに存在していれば登録済みとなる。
  4. 登録済みの場合はUPDATEしてやる。(プロフィール画像やscreen_nameは変更可能な為)
  5. エンティティに登録データを詰める。
  6. DBへ登録

上記はTwitterアカウントの登録処理だが、Tweetの場合も基本的に同じ。

Twitterレスポンスのcreated_adなどの時刻について

UTC時刻で返ってくるので、必要があれば、日本標準時に直してやる必要がある。
今回は下記のように変換してやる。

TwitterComponent.php
// 日付形式の調整
$tweet->created_at = new Time($tweet->created_at);
$tweet->created_at->timezone = 'Asia/Tokyo';
$tweet->created_at = $tweet->created_at->i18nFormat('yyyy-MM-dd HH:mm:ss');

ログの出力

処理件数が多く、情報量が多いのでログを出力してやらないとイマイチ処理が上手くいっているのかがわからない。
バッチ処理なので、処理が一連の流れで行われてしまうし。
ということでログはきちんと出してやることにする。
下記はTwitterアカウント情報についてログに出力してる箇所。
その他の箇所でもログは出すようにする。

// TwitterAccount情報
$this->log('Twitter user.id: '.$twitterAccount->id, 'info');
$this->log('Twitter user.created_at: '.$twitterAccount->created_at, 'info');
$this->log('Twitter user.name: '.$twitterAccount->name, 'info');
$this->log('Twitter user.screen_name: '.$twitterAccount->screen_name, 'info');
$this->log('Twitter user.profile_image_url: '.str_replace('http://pbs.twimg.com/', '', $twitterAccount->profile_image_url), 'info');

バッチ実装

main処理

main処理はあんまり汚したくないなと思い、大元の処理は
searchTweetsメソッドを用意してやり、そっちで行うことにした。
(結局、変わらない気もするが…)

SearchTweetsShell.php#main
public function main() {
    $this->log('-----------Shell Start>>>>>>>>>>>>>', 'info');
    // コンポーネント準備
    $this->loadComponents();
    // Twitter検索処理
    $this->searchTweets();
    $this->log('>>>>>>>>>>>Shell End---------------', 'info');
}

コンポーネントの読み込み

作成したコンポーネントの読み込みを行う。
Shell内ではpublic static $components = []な感じで読み込めないようなので、下記のように読み込む。

SearchTweetsShell.php
use Cake\Controller\ComponentRegistry;
use App\Controller\Component\TwitterComponent;


private function loadComponents() {
    $this->Twitter = new TwitterComponent(new ComponentRegistry());
}

Tweet検索処理

いよいよメインとなる検索&取ってきたTweetの登録処理。
まずはコンポーネント経由で接続する為のTwitterOAuthオブジェクトを作成してやる。

SearchTweetsShell.php#searchTweets
// 認証情報の設定
$this->Twitter->setAuthentication(self::CONSUMER_KEY, self::CONSUMER_SECRET, self::ACCESS_TOKEN, self::ACCESS_TOKEN_SECRET);

変数を用意。
重要なのは、$continueFlg$maxId
検索処理終了時に、$continueFlgはfalseにしてやる。(必要件数取ってきたら、処理終了したいので)
$maxIdについては、TwitterSearchAPIの特性上、一回の検索で取ってこれるTweetが100件の為、
次のAPIコール前に(今回取ってきた一番最後のTweetのId - 1)してやることで、今回の検索結果と同じTweetを取ってこないようにする役割がある。
※Tweetには一意となる固有のIDがそれぞれに振られている。TwitterのUserもまた同様。

SearchTweetsShell.php#searchTweets
$continueFlg = true;    // 検索処理の続行フラグ
$searchCount = 0;       // 取得した検索結果件数
$insertDbCount = 0;     // DBにinsertしたTweet件数
$maxId = null;          // 次回検索時の対象となる一番新しいツイートID

いよいよ検索処理。
とは言っても、コンポーネント内でほとんど実装してやったので、あとは呼ぶだけ。

SearchTweetsShell.php#searchTweets
// TwitterSearchAPIをコール
$results = $this->Twitter->searchTweets(self::$SEARCH_OPTIONS);

返ってきたレスポンスを処理する。
大まかに下記のような感じ。

SearchTweetsShell.php#searchTweets
$status = $results['status'];

// 成功時
if ($status === 'success') {...
foreach($tweets as $tweet) {...
    //DB登録処理
}

// 失敗時
if ($status === 'error') {...
    //継続可能なエラーならスリープ or 継続不可ならエラー内容をログに出し、処理終了
}

// 結果なし
if ($status === 'empty') {
    // これ以上検索してもしょうがないので処理終了
}

成功時は取ってきたTweetをぐるぐる回して、登録する。
アカウントだけ取れてもしょうがないので、Tweetの登録まで成功しなければ、
DBをロールバックするトランザクション処理を入れている。
Tweet登録まで成功したらコミット。
(設定ファイル移すべきだろうが、)検索処理件数をShell内に定義しといたので、
規定の件数まで達したら$continueFlgをfalseにして処理を終了する。

SearchTweetsShell.php#searchTweets
foreach($tweets as $tweet) {
    // ツイートを目標数処理したら処理を終了
    if ($searchCount === self::SEARCH_COUNT) {
        $this->log("検索結果が{$searchCount}件に達しました。", 'info');
        $this->log('Tweet検索処理完了', 'info');
        $continueFlg = false;
        break;
    }

    $this->log('- Tweet Info ---------------------------------------------------------------------', 'info');
    // トランザクション開始
    $connection = ConnectionManager::get('default');
    $connection->begin();

    // アカウント・ツイートのDB登録
    $insertAccountId = $this->Twitter->saveTwitterAccount(self::USE_TABLE['accounts'], $tweet->user);
    if (!empty($insertAccountId)) {
        $insertTweetId = $this->Twitter->saveTweet(self::USE_TABLE['tweets'], $insertAccountId, $tweet);
        if(!empty($insertTweetId)) {
            $connection->commit();
            $insertDbCount++;
        } else {
            $connection->rollback();
        }
    } else {
        $connection->rollback();
    }
    $this->log('---------------------------------------------------------------------------------', 'info');
    $searchCount++;
}

失敗時はエラーコード判断して、88ならAPIコールの制限に掛かっているだけなので、
制限解除までスリープしてやってから処理再開の方に流す。そうじゃないなら処理終了する。

SearchTweetsShell.php#searchTweets
$errors = $results['response'];
// 複数エラー:処理終了
if (count($errors) > 1) {
    foreach($errors as $error) {
        $this->log("Twitter.error.code: {$error->code}. Twitter.error.message: {$error->message}", 'error');
    }
    $continueFlg = false;
}
// 単一エラー
else {
    // Code:88の場合:スリープ後、処理を継続
    $error0 = $errors[0];
    $this->log("Twitter.error.code: {$error0->code}. Twitter.error.message: {$error0->message}", 'error');
    if ($error0->code === self::ERROR_CODE_RATE_LIMIT_EXCEEDED) {
        $this->log('APIコール制限の為、'.self::API_CALL_SLEEP_TIME.'秒間のスリープ後、処理を再開します。', 'info');
        sleep(self::API_CALL_SLEEP_TIME);
        continue;
    }
    // その他Codeの場合:処理終了
    $continueFlg = false;
}

検索結果なしも処理終了

SearchTweetsShell.php#searchTweets
$this->log('Twitter Response is empty.', 'info');
$this->log('Tweet検索処理完了', 'info');
$continueFlg = false;

最後に

色々ツッコミどころはあるかと思いますが、
TwitterSearchAPIを使って、なかなか処理まで実装しているのをお目にかからなかった気がしたので、上げてみました。
下記にGistに上げたソースを置いておきます。
https://gist.github.com/trewa-nek9585/0078066ab64aaeed74d6f6a78cf2b5a8
https://gist.github.com/trewa-nek9585/690dcaaf06d085167ffa05e791623d8c

6
5
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
5