PHP
スクレイピング
コマンドライン

PHPでコマンドラインツールを作る

コマンドラインツールを作る必要があったので、チャレンジしました。
某WEBサービスをスクレイピングして、入力履歴を表示するコマンドラインツールです。

PHPでコマンドラインツール?

そもそもPHPでコマンドラインツールって作れるの?ってところからスタート。
少し調べたら、コマンドラインでPHPファイルを読み込んで実行することみたいです。

ファイルに書いたphpを実行する
$ php /path/to/php/file.php

コマンドラインに書いたphpを実行する
$ php -r "echo '123';"
123 ← 実行結果

参考)https://www.softel.co.jp/blogs/tech/archives/2077

試しにターミナルを開いて、叩いてみました。

$ php -r "echo 'Hallo World!';"

スクリーンショット 2018-06-01 12.03.58.png

無事表示されました。
Hallo World!を表示するだけのPHPファイルも用意してみました。

hallo.php
<?php
echo 'Hallo World!';
?>

ターミナルで実行。

$ php /path/to/hello.php

スクリーンショット 2018-06-01 12.15.07.png

無事表示されました。

でも、これファイルの置き場が違うとうまく動かなかったりするのかな?
試しにデスクトップに置いて実行してみました。動いた。いいのかな。

file_get_contentsしてみる

スクレイピング自体はちょっと業務でしたことあります。
file_get_contentsして上手に加工するやつでしょ。

でも今回はログインも間にはさみますね。
とりあえずログイン前のトップページを取得できるかやってみます。

index.php
<?php
echo file_get_contents('https://xxxx.xxx/');

ターミナルで実行。

$ php /path/to/hello.php

ダダダダッとページのソースが表示されました。ひとまず成功です。

file_get_contentsをcURLでやってみる

ログインした後のページをスクレイピングするにはどうしたらいいんでしょう?
ちょっとググるとfile_get_contentsではなくcURLを使うといいって出てきますね。
そういえば前に業務でもそれやろうとして、何かがうまくいかなくて諦めたのでした。

とりあえずfile_get_contentsと同じことをcURLで書いてみます。

index.php
<?php
  $url = "https://xxxx.xxx/";
  $conn = curl_init(); // cURLセッションの初期化
  curl_setopt($conn, CURLOPT_URL, $url); // 取得するURLを指定
  curl_setopt($conn, CURLOPT_RETURNTRANSFER, true); // 実行結果を文字列で返す。
  $res =  curl_exec($conn);
  echo $res;
  curl_close($conn); //セッションの終了

  #参考:https://techacademy.jp/magazine/11442 
?>

ターミナルで実行し、file_get_contentsと同じようにダダダッとソースが表示できました。

cURLでログイン

参考サイトのソースを必要最低限いじってみて、ログインできるかテスト。

index.php
<?php
    #ログインに必要なパラメーター
    $params = array( 
        "email" => '自分のログイン用メールアドレス', 
        "password" => '自分のログイン用パスワード', 
        "submit"  => "ログイン" 
    ); 

    #ログインURL
    $login_url = "https://xxxx.xxx/";
    #フォームアクション先URL
    $action_url = $login_url; //今回はログインフォームと同じ
    # ターゲットURL
    $data_url = "https://xxxx.xxx/xxxx";

    #クッキー保存ファイルを作成
    $cookie_file_path = dirname(__FILE__).'/cookie.txt';
    touch($cookie_file_path);

    #ログイン
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $login_url);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
    curl_setopt($ch, CURLOPT_FOLLOWLOCATION, TRUE);
    curl_setopt($ch, CURLOPT_COOKIEFILE, $cookie_file_path);
    curl_setopt($ch, CURLOPT_COOKIEJAR, $cookie_file_path);
    $put = curl_exec($ch) or dir('error ' . curl_error($ch)); 
    curl_close($ch);

    #アクション
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $action_url);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
    curl_setopt($ch, CURLOPT_HEADER, TRUE);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
    curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
    curl_setopt($ch, CURLOPT_COOKIEFILE, $cookie_file_path);
    curl_setopt($ch, CURLOPT_COOKIEJAR, $cookie_file_path);
    curl_setopt($ch, CURLOPT_POST, TRUE);
    curl_setopt($ch, CURLOPT_POSTFIELDS, $params);
    $output = curl_exec($ch) or dir('error ' . curl_error($ch)); 
    curl_close($ch);

    #データ取得
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $data_url);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
    curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
    curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
    curl_setopt($ch, CURLOPT_COOKIEFILE, $cookie_file_path);
    curl_setopt($ch, CURLOPT_COOKIEJAR, $cookie_file_path);
    $output_comp = curl_exec($ch) or dir('error ' . curl_error($ch)); 
    curl_close($ch);

    #取得ページをUTF-8化して表示
    mb_language("Japanese");
    $complete_source = mb_convert_encoding($output_comp, "UTF-8", "auto");
    echo $complete_source;

    #クッキー保存ファイルの削除
    unlink($cookie_file_path);

    #参考:http://web-prog.com/php/curl-login-scraiping/
?>

ブラウザで実行すると、一瞬「認証完了」って出て成功したように見える。
けど、すぐログイン画面に戻っちゃう。
参考サイトのコードをコピペしてちょっと加工しただけだから、
いらなさそうなコードをコメントアウトしたり出したりしてみたけどうまくいかない。

ブラウザのcookieが邪魔してる気がして消してみたせいか、
むしろ「認証完了」画面にすら飛ばなくなった。なんだなんだ?

cURLでログイン再チャレンジ

試しに手作業でゆっくり動作確認して、情報整理。
ログアウトした状態でターゲットのURLを直接ブラウザのアドレスバーに入力して
アクセスすると、ログインURLにリダイレクトされる。
アイパスを入力・送信すると、一瞬「認証完了」ページが出た後、ターゲットURLにリダイレクトされる。

単純にターゲットページにアイパスをPOSTするだけではダメで、
認証完了ページを1枚挟んでからのリダイレクト処理が必要なんですな。

問題のリダイレクト処理は、こんな感じ↓にJSで書かれていました。

setTimeout(function(){ location.href = "
    https://xxxx.xxx/user_session/callback
    ?oauth_token=とーくん
    &oauth_verifier=べりふぁいあ"; 
}, 500);
// ※実際の引数はランダムな長い英数字

このURLをスクレイピングしてアクセスすれば、ターゲットページに行きつけそうです!
手順としては、こんな感じかな。

  1. ログイン画面にアイパスをPOST
  2. 認証完了画面のJSからURLを抜き出してリダイレクト
  3. ターゲットページにアクセス
<?php

class Log {

    private $ch;
    private $login_url  = 'https://xxxx.xxxx.xxx/';
    private $target_url = 'https://xxxx.xxx/xxxxx';
    private $log_source;

    //TODO あとでコマンドラインから受け取る
    private $email      = 'メールアドレス';
    private $password   = 'パスワード';
    private $month      = '201806'; //年月を指定(2018年5月は201805)

    /**
     * ログインして入力履歴ページを取得
     */
    public function __construct() {
        $this->ch = curl_init(); //cURLセッションを初期化
        curl_setopt_array($this->ch, array(
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_COOKIEFILE => '',
            CURLOPT_COOKIEJAR => '',
        ));
        $this->login();
        $this->get_log();
    }

    /**
     * POST送信
     * 
     * @access private
     * @param string $url
     * @param array $params
     * @return string 送信後の画面
     */
    private function post($url, $params) {
        curl_setopt_array($this->ch, array(
            CURLOPT_URL => $url,
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $params,
        ));
        return curl_exec($this->ch);
    }

    /**
     * GET送信
     * 
     * @access private
     * @param string $url
     * @return string 送信後の画面
     */
    private function get($url) {
        curl_setopt_array($this->ch, array(
            CURLOPT_URL => $url,
            CURLOPT_HTTPGET => true,
        ));
        return curl_exec($this->ch);
    }

    /**
     * ログイン処理
     * 
     * @access private
     */
    private function login() {
        $params = array(
            'data[User][email]' => $this->email,
            'data[User][password]' => $this->password,
            'submit'  => 'ログイン',
        );
        $source = $this->post($this->login_url, $params);
        preg_match('/https:\/\/zaim\.net\/user_session\/callback\?oauth_token=(\w+)&oauth_verifier=(\w+)/',
            $source, $matches
        );
        $this->get($matches[0]);
    }

    /**
     * 指定された月の入力履歴を取得
     * 
     * @access private
     */
    private function get_log() {
        $param = $this->month ? '?month='.$this->month : '';
        $this->log_source = $this->get($this->target_url.$param);
    }

    /**
     * 入力履歴を整形
     * 
     * @access public
     */
    public function scraping() {
        //TODO 加工すること
        echo $this->log_source;
    }
}

$log = new Log();
$log->scraping();

#参考:http://www.tsubasa-note.blog/entry/php-curl-login/
#参考:https://qiita.com/mpyw/items/c65fb4ec4cef80909a47
?>

無事ブラウザで期待通りに動きました。
クラス名をどうするか、どこまでを__constructに含めるかに悩んだ…

綺麗に加工する

取得したソースを綺麗に加工していきますよ。
あとはひとすら正規表現だから、ソースは割愛します。

対話型にする

せっかくコマンドラインで実行するから、対話型にしたい!
調べてみたら案外簡単にできました。どういうやりとりにするかとか考えるほうが大変だった。
とはいえいちいち対話型で入力するのも面倒だし、引数でも渡せるようにしました。

一応ブラウザでも確認できます。
が、引数渡せないのでテストアカウントの当月表示のみです。

/**
 * アカウント情報を設定
 * 
 * @access private
 */
private function set_account() {
    if(empty($this->argv)) { #引数が空の場合はブラウザで実行されている
        echo <<<EOM
            <div style='margin: 20px; padding: 20px; background-color: #fff;'>
                <h1 style='text-align: center; line-height: 2;'>--+。*.゚:。ようこそ!+。*.゚:。 --</h1>
                <p style='text-align: center; line-height: 1.5;'>
                    ほしさきひとみの今月の入力履歴を表示しています。<br>
                    コマンドラインから実行していただくと、ご自身のアカウントの履歴を確認いただけます。
                </p>
            </div>
EOM;
        $this->email = 'メールアドレス';
        $this->password = 'パスワード';

    } else {
        print ("\n───────── +。*.゚:。ようこそ!+。*.゚:。 ─────────\n");
        print ("入力履歴を確認できるコマンドラインツールです。\n"); 
        $this->input_account();
    }
}

/**
 * アカウント情報を入力してもらう
 * 
 * @access private
 */
private function input_account($error = null) {
    if($this->argv[1] AND !$error) {
        $this->email = $this->argv[1];
    } else {
        ob_end_clean();
        print ("\n▼ログイン用メールアドレスを入力してください(半角英数字)\n"); 
        $this->email = trim(fgets(STDIN));
    }

    if($this->argv[2] AND !$error) {
        $this->password = $this->argv[2];
    } else {
        print ("\n▼ログイン用パスワードを入力してください(半角英数字)\n"); 
        $this->password = trim(fgets(STDIN));
    }
}

/**
 * ログイン処理
 * 
 * @access private
 */
private function login() {
    $params = array(
        'data[User][email]' => $this->email,
        'data[User][password]' => $this->password,
        'submit'  => 'ログイン',
    );
    $source = $this->post($this->login_url, $params);
    preg_match('/https:\/\/zaim\.net\/user_session\/callback\?oauth_token=(\w+)&oauth_verifier=(\w+)/',
        $source, $matches
    );
    if(empty($matches[0])) {
        print ("\n[!]メールアドレスかパスワードが間違っています。再度入力してください。\n"); 
        $this->input_account(1);
    } else {
        $this->get($matches[0]);
    }
}

#参考:http://uxmilk.jp/15067
#参考:http://www.phppro.jp/phptips/archives/vol37/2

image.png

うん、いい感じ!な気がする!