Archive for the ‘php’ Category

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あったよ、などの時にたまに役立つかもしれない)


CakePHP1.3.20、2.5.3で修正されたデータが全件更新されるやつを調べてみた

金曜日, 8月 8th, 2014

http://bakery.cakephp.org/articles/markstory/2014/07/21/cakephp_2_5_3_and_1_3_20_released

7/21に久々に1.3系を含むCakeのアップデートがリリースされました。
1.3系も更新されるやつは大体やばいやつ、ということで今回も何やらやばそうな事が書いてあります。

The 1.3.20 release contains an important fix to address a potential race condition in Model::save() that can cause data loss when records are deleted during concurrent updates.

data lossとはどういうことか、実際に試して確認してみました。

結論

save(update)処理の最中に対象レコードが削除されると、〜where1=1というえらいクエリが発行されて更新しようとした内容で全件updateが発生します。
速やかにバージョンアップしましょう。1.3系は他に変更点はなさそうです。

環境

PHP5.4.31
CakePHP1.3.19(再現環境)

試してみる

system_logsというテーブルに対してID指定で更新をかける下記のようなコードを実行。

$this->SystemLog->id = 100;
$updatedata = [];
$updatedata['SystemLog']['user_id'] = 1;
$this->SystemLog->save($updatedata);

id=100のレコードが存在する場合

下記のようなクエリが実行される。


SELECT COUNT(*) AS `count` FROM `system_logs` AS `SystemLog`   WHERE `SystemLog`.`id` = 100

SELECT COUNT(*) AS `count` FROM `system_logs` AS `SystemLog`   WHERE `SystemLog`.`id` = 100

UPDATE `system_logs` SET `user_id` = 1  WHERE `system_logs`.`id` = 100

カウントが二回実行されてますがどちらも存在チェック(model::exits())で呼ばれてます。
普通にアップデートされてますね。

id=100のレコードが存在しない場合

SELECT COUNT(*) AS `count` FROM `system_logs` AS `SystemLog`   WHERE `SystemLog`.`id` = 100

INSERT INTO `system_logs` (`user_id`, `created`) VALUES (1, '2014-08-07 19:00:29')

最初の存在チェックでレコードが存在しないため、insertが実行されました。

id=100のレコードが更新途中で削除された場合

初回の存在チェックと、二回目の存在チェック(DboSource::defaultConditions)より前にsleepを入れてレコード削除。

SELECT COUNT(*) AS `count` FROM `system_logs` AS `SystemLog`   WHERE `SystemLog`.`id` = 100

SELECT COUNT(*) AS `count` FROM `system_logs` AS `SystemLog`   WHERE `SystemLog`.`id` = 100

UPDATE `system_logs` SET `user_id` = 1  WHERE 1 = 1

WHERE1=1というやばそうなUPDATEが実行されました。

何が起きているのか?

ID指定ありで、Modell::save内の初回の存在チェックが通るとupdateとして処理が行われるのですが、その際のcondition(WHERE句)作成ロジック内で再度対象レコードの存在チェックが行われ、対象レコードが存在しないとdefaultCondition=nullとして扱われ、WHERE 1 = 1というSQLが発行されるようです。

dbo_source.phpの1877行あたり

   function defaultConditions(&$model, $conditions, $useAlias = true) {
        if (!empty($conditions)) {
            return $conditions;
        }
        $exists = $model->exists();
        if (!$exists && $conditions !== null) {
            return false;
        } elseif (!$exists) {
            return null; // 存在チェックに引っかかるとnullが返り、結果的にwhere1=1となる
        }
        $alias = $model->alias;

        if (!$useAlias) {
            $alias = $this->fullTableName($model, false);
        }
        return array("{$alias}.{$model->primaryKey}" => $model->getID()); //通常はこっち
    }

アップデート版の挙動

以下のように修正されてます。
$model->__safeUpdateModeはsaveによる更新時はtrueとなります。

    function defaultConditions(&$model, $conditions, $useAlias = true) {
        if (!empty($conditions)) {
            return $conditions;
        }
        $exists = $model->exists();
        if (!$exists && ($conditions !== null || !empty($model->__safeUpdateMode))) {
            return false;
        } elseif (!$exists) {
            return null;
        }
        $alias = $model->alias;

        if (!$useAlias) {
            $alias = $this->fullTableName($model, false);
        }
        return array("{$alias}.{$model->primaryKey}" => $model->getID());
    }

存在チェックに引っかかった場合はnullではなくfalseが返るようになります。
defaultConditionsでfalseが返された場合、実行されるSQLは以下のようになります。

UPDATE `system_logs` SET `user_id` = 1  WHERE 0 = 1

お、おう、という感じはしますが結果的に更新はかからなくなります。

影響は?

同一レコードに対する更新、削除が頻繁に行われるようなシステムでなければ(例えば特定のユーザーに紐付いたデータ等であれば)そんなに起こることはなさそうですが、バッチによる一斉更新とユーザー操作がかぶったりした場合に運悪いと遭遇しそうです。
速やかにアップデートしましょう。

最後に

nullとかfalseとかarrayとかが混在して戻ってくるやつをアレコレするの見てて辛い。