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

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

#準備
##前提
Mac
CakePHP3
Composer
Twitterアカウントの作成
Twitterアプリの作成
[Twitter REST API](http://westplain.sakuraweb.com/translate/twitter/Documentation/REST-APIs/Public-API/GET-search-tweets.cgi)
[TwitterOAuth](https://twitteroauth.com/)

##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名をスネークケースで指定する

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

```php: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クラスを継承する。
共通化する処理を実装する。

中身はこんな感じ。

```php: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の中身は下記のような感じ。

```php: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ライブラリを読み込む
処理はコンポーネント内に書いていく。
コンポーネント上部には下記を記載

```php: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
{
```

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

```php: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のエラーコードが含まれていればそれを返すようにする。
含まれない場合は、原因不明のエラーとして処理。

```php: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登録処理の流れ
```php: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時刻で返ってくるので、必要があれば、日本標準時に直してやる必要がある。
今回は下記のように変換してやる。

```php: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メソッドを用意してやり、そっちで行うことにした。
（結局、変わらない気もするが…）

```php: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 = []`な感じで読み込めないようなので、下記のように読み込む。

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


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

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


```php: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もまた同様。

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

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

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

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

```php: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にして処理を終了する。

```php: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コールの制限に掛かっているだけなので、
制限解除までスリープしてやってから処理再開の方に流す。そうじゃないなら処理終了する。

```php: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;
}
```

検索結果なしも処理終了

```php: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