GoutteでCSSセレクタが使えない場合のあれこれ

7月 4th, 2016

phpでスクレイピングといえばGoutte(特に内部で使用しているCssSelector)がちょう便利ですが、とはいえスクレイピング対象のページに都合よくidやclassが振られていなこともままありますね。

今回たまたま某生協の注文サイトから注文状況を取得した際にやったあれこれをいくつかメモ。

パラメータを書き換えてpost

postして遷移するページで、post直前にjsで値を書き換えてからpost、という動作をしている所があった。
細かくコードを追うのは面倒なので、実際にpostされた値に合わせる形でページから値をかき集めてsubmitさせる。
書き換えはsetValues、formからの値の取得はgetValuesで。

$form->disableValidation()->setValues(['osk'=> $prev, 'curosk' => $prev, 'odc' => $form->getValues()['curodc']]);
$this->crawler = $this->client->submit($form);

selectedな要素の次のoptionを選択

「前回の注文情報」というのを取るにあたり、同じURLでpostされたパラメータによって画面遷移するという箇所があったため、「一度現在の注文ページを開き、option要素がselectedになってる要素の次の要素を選択」という条件で取得してみた。

optionの場所が固定なら$crawler->eq(n)で取れるのだが可変なので、順序が保証されているという前提でこんな感じに対応。

<select name="osk">        
  <option value="20160702" >7月2回B週</option>
  <option value="20160701" selected="selected" >7月1回A週</option>
  <option value="20160605" >6月5回D週</option>
</select>

20160605が欲しい

$prev = $this->crawler->filter('.weekOrderSelect select option')->reduce(function($node, $i){
    // 直前のノードがselected
    return (count($node->previousAll()) && $node->previousAll()->first()->attr('selected') == 'selected');
})->attr('value'); 

previousAllが毎回呼ばれるのがイマイチ感がありますがまあこんな感じで。
一個前の取得はpreviousAll->last()ではなくfirst()でとれた。

余計な要素を除外

テーブルからのデータ取得で、よけいなtr(デザイン上の隙間的な何か)を除外。
reduceで頑張る。

$this->crawler->filter("table#wecpwa0010_{$parent} tbody tr")->reduce(function($node) {
            $item = $node->filter('tr.cartSubs');
            return count($item) === 0;
        })->each(function($node) use ($previous) {
            $item = [
                'name' => '',
                'price' => '',
                'quantity' => '',
            ];
/// 後続処理

reduceやeachで無名関数を渡すときはuseで外部変数も渡せるのでなかなかベンリですね。

毎度参考にしている諸々

WebスクレイピングライブラリGoutteで遊んでみる
http://d.hatena.ne.jp/hnw/20120115
hnwさんの2012年の記事ですがわかりやすい

The DomCrawler Component
http://symfony.com/doc/current/components/dom_crawler.html#node-filtering
公式のDomCrawlerドキュメント


phpでurldecodeとかを標準入力で受け取ってさくっと処理したい

12月 1st, 2015

$echo %3D | php -r 'echo(urldecode(file_get_contents("php://stdin")));'
=

file_get_contents(“php://stdin”)で標準入力を受け取れるのでこんな感じで。
(nkf入ってないけどphpあったよ、などの時にたまに役立つかもしれない)


Redmine APIによるデータ取得とjqで捗る話

8月 13th, 2014

RedmineのREST APIとjqコマンドで今更ながら色々捗るなーという話。

Redmine API

Redmine exposes some of its data through a REST API. This API provides access and basic CRUD operations (create, update, delete) for the resources described below. The API supports both XML and JSON formats.

jq

jq is like sed for JSON data – you can use it to slice and filter and map and transform structured data with the same ease that sed, awk, grep and friends let you play with text.

環境

jq version 1.3
Redmine version 2.5.2.stable

こんなチケットを作ってみて動かします。

チケット一覧画面

データを取ってくる

RedmineAPIはリクエストにAPI KEY(ユーザー管理画面より発行)を含めることで認証出来ます。

passed in as a “key” parameter
passed in as a username with a random password via HTTP Basic authentication
passed in as a “X-Redmine-API-Key” HTTP header (added in Redmine 1.1.0)

とドキュメントにあるように、三通りの方法があります。こんなに対応せんでもええんやないか、と思います。curlで叩いてみます。

keyパラメータを使用

curl -s http://redmine.mydomain.com/issues.json?key={myapikey}

Basic認証でユーザー名として使用(randome passwordって書いてああるけど無視されるので空でもよい)

curl -s http://{myapikey}@redmine.mydomain.com/issues.json

or

curl -s -u {myapikey}: http://redmine.mydomain.com/issues.json

X-Redmine-API-Key認証ヘッダを使用

curl -H "X-Redmine-API-Key:{myapikey}" -s http://redmine.mydomain.com/issues.json

認証に成功するとissuesのJSONがごちゃっと返ってきます。
そのままでは見づらいのでとりあえずjqに食わせてみます。

jqに渡してissuesの1件目を表示

curl -s  http://{myapikey}@redmine.mydomain.com/issues.json? | jq '.issues[0]'
{
  "updated_on": "2014-08-08T10:22:32Z",
  "created_on": "2014-08-08T10:19:40Z",
  "estimated_hours": 5,
  "done_ratio": 0,
  "start_date": "2014-08-08",
  "description": "機能追加タスク",
  "id": 4,
  "project": {
    "name": "myproject",
    "id": 1
  },
  "tracker": {
    "name": "機能",
    "id": 2
  },
  "status": {
    "name": "新規",
    "id": 1
  },
  "priority": {
    "name": "通常",
    "id": 2
  },
  "author": {
    "name": "Redmine Admin",
    "id": 1
  },
  "assigned_to": {
    "name": "Redmine Admin",
    "id": 1
  },
  "subject": "機能追加"
}

見やすいですね。

絞り込みとか加工とか

データが取れたので色々加工してみます。

自分のタスク情報を取得

自分にアサインされたissuesを取得してjqで表示項目を絞り込んでみる。

curl -s  http://{myapikey}@redmine.mydomain.com/issues.json?assigned_to_id=me | jq '.issues[] | {subject, id, assigned_to, estimated_hours}'
{
  "estimated_hours": 3,
  "assigned_to": {
    "name": "egmc .com",
    "id": 3
  },
  "id": 2,
  "subject": "機能追加だよ"
}
{
  "estimated_hours": 1.5,
  "assigned_to": {
    "name": "egmc .com",
    "id": 3
  },
  "id": 1,
  "subject": "バグだよ1"
}

文字列を整形してリストっぽくデータを取ってみる

jqは文字列内に取得したデータを入れ込んだり、各種演算子も使えたりするのでこんな出力も得られます。

curl -s http://{myapikey}@redmine.mydomain.com/issues.json?assigned_to_id=me | jq -r '.issues[] | "・#\(.id) \(.subject)[担当:\(.assigned_to.name)](\(.estimated_hours)h)"'
・#2 機能追加だよ[担当:egmc .com](3h)
・#1 バグだよ1[担当:egmc .com](1.5h)

作成済のカスタムクエリの条件を適用してみる

redmine側で作成した既存のカスタムクエリの出力をjsonで得ることも出来ます。
issuesのAPIにはないのですが、下記のように画面表示と同じURL構造にして/projects/{プロジェクト名}/issues.json?query_id={クエリID}というリクエストを送るとカスタムクエリの条件が適用されます。
というか単純に画面でリストを表示して、拡張子を.jsonとか.xmlにすることによってその形式で出力してくれるようです(なのでこちらは厳密にはAPIで提供されてるわけではないっぽい)

「担当が自分で見積もり時間が2以上かかるタスク」という条件のカスタムクエリで試してみます。

カスタムクエリ

curl -s http://{myapikey}@redmine.mydomain.com/projects/myproject/issues.json?query_id=3 | jq '.issues[] | {subject, id, assigned_to, estimated_hours}'
{
  "estimated_hours": 3,
  "assigned_to": {
    "name": "egmc .com",
    "id": 3
  },
  "id": 2,
  "subject": "機能追加だよ"
}

コマンドを組み合わせて集計とか

パイプでawkなどに渡すことによって簡単な集計処理も出来ます。

見積もり時間の合計を取得

curl -s http://{myapikey}@redmine.mydomain.com/issues.json?assigned_to_id=me | jq '.issues[].estimated_hours' | awk '{m+=$1} END{print m}'
4.5

特定の期間内のチケットの集計とか工夫次第で色々出来そうですね。

まとめ

RedmineAPIとjqの組み合わせでコマンドラインから色々出来るのでベンリだよ!という事を言いたかった。
とはいえ各種コマンドとの組み合わせでもうちょっとベンリに使える気がするのでシェル力アップのためにもうちょっと勉強しようと思います。