PHP に関する投稿を表示しています

PHP の $_REQUEST を Golang でもやりたい

いや要らないだろ…と思いつつもやる必要がうっすらとでてきたのでやってみた。

ようは、クエリパラメータと POST の中身とクッキーから読み取れれば良い。

PHP: $_REQUEST - Manual

やや雑ではあるがお試しするとこんな感じになると思う。

package main

import (
	"fmt"
	"io/ioutil"
	"net/http"
	"net/http/cookiejar"
	"net/url"
	"time"
)

func PHP_REQUEST(r *http.Request, key string) string {
	// Get parameter
	if val, ok := r.URL.Query()[key]; ok {
		return val[0]
	}

	// POST
	tmp := r.PostFormValue(key)
	if tmp != "" {
		return tmp
	}

	// Cookie
	for _, cookie := range r.Cookies() {
		if cookie.Name == key {
			return cookie.Value
		}
	}

	return ""
}

func main() {
	go func() {
		http.HandleFunc("/test", func(w http.ResponseWriter, r *http.Request) {
			w.Write([]byte("hoge = " + PHP_REQUEST(r, "hoge")))
		})
		http.ListenAndServe("0.0.0.0:8000", nil)
	}()

	time.Sleep(time.Millisecond)

	var client *http.Client
	var r *http.Response
	var b []byte

	// nothing
	client = &http.Client{}
	r, _ = client.Get("http://0.0.0.0:8000/test")
	b, _ = ioutil.ReadAll(r.Body)
	fmt.Println(string(b))

	// get
	client = &http.Client{}
	r, _ = client.Get("http://0.0.0.0:8000/test?hoge=get")
	b, _ = ioutil.ReadAll(r.Body)
	fmt.Println(string(b))

	// get / cookie
	client = &http.Client{}
	u, _ := url.Parse("http://0.0.0.0:8000/test")
	client.Jar, _ = cookiejar.New(nil)
	client.Jar.SetCookies(u, []*http.Cookie{
		{
			Name:  "hoge",
			Value: "cookie",
		},
	})
	r, _ = client.Get("http://0.0.0.0:8000/test")
	b, _ = ioutil.ReadAll(r.Body)
	fmt.Println(string(b))

	// post
	client = &http.Client{}
	v := url.Values{}
	v.Add("hoge", "post")
	r, _ = client.PostForm("http://0.0.0.0:8000/test", v)
	b, _ = ioutil.ReadAll(r.Body)
	fmt.Println(string(b))

	time.Sleep(time.Millisecond)
}

日本語の折り返しを正規表現で解決する mikan.js を PHP に書き換えた

できたものがこちらです

作った背景

日本語の折り返しが中途半端になってつらい!機械学習で改善するぞ!という話が過去にありました。
google/budou: Budou is an automatic organizer tool for beautiful line breaking in CJK (Chinese Japanese and Korean).

それからしばらくして、いやいや機械学習じゃなくてもいいのでは?というものが出てきました。
mikan.js : 機械学習なしで、日本語の単語の改行を処理するライブラリを書いた

これって別に JS で表示するときにアレコレしなくても、普段のサーバサイドでいい感じにしてもいいのでは??と思い PHP に移植してみた次第です。

移植する流れ

元のコードを眺めて、同じような処理に書き換えていく簡単なおしごと。
幸いにも、そこまで難しいロジックでは無いので、動作をみながら書き換えていきました。

正規表現のあたりだけ、言語の違いでちょっと詰まったので、ドキュメントを見ながら動作を見ながら随時書き換えていくようなコツコツ作業でした。

言語の書き換え、双方の言語について理解が深まるのでオススメです。

PHP で時間を固定した未来にしたりしたい

PHP で時間が絡むようなテストをしていてコケたりコケなかったりした。

具体的には、登録してから〇〇時間後にあるメソッドが呼ばれたら、ステータスを xx に変える、みたいなもの。

そんなときに使えるアイデアを3つ。

Carbon を使っている場合

Carbon なら setTestNow を使うと良い。
各テストで、好きなように setTestNow を書く。

Carbon::setTestNow(Carbon::parse('ここに時刻'));

特に時刻の希望がなく、現在時刻を固定したいだけならこれで。

Caron::setTestNow(Carbon::now());

それと tearDown で引数なしで呼べばよい(事故防止)

public function tearDown()
{
    parent::tearDown();
    Carbon::setTestNow();
}

Carbon - A simple PHP API extension for DateTime.

Chronos を使っている場合

Chronos にも Carbon と同様に setTestNow が用意されている。助かる〜。

使い方は Carbon のソレと一緒。

Chronos - 3.6

php-timecop を導入する

特に何も導入してなくて DateTime をもりもりやってたり、もはや date や time が乱立してたらこれしかお手軽な手は無いか。
PHP 拡張として動作して PHP が扱う時間をコントロールするツワモノ。

テスト用途だけじゃなくて、例えばあるページが未来ではどのように見えるかの動作確認用とか、プロダクション用途としても使える。(使ったことがある)

remi や brew pecl で配布されているので導入もお手軽。

GitHub - hnw/php-timecop: A PHP extension providing "time travel" capabilities inspired by ruby timecop gem

(余談だけど mac 上でやろうとしたら homebrew の php が core にくっついて拡張の類がなくなったので pecl から入れる必要があった)

ざっくりとはこんな感じ strtotime でもいい。

var_dump(date('Y-m-d H:i:s'));
sleep(3);
var_dump(date('Y-m-d H:i:s'));

timecop_freeze(new DateTime('2018-01-01 12:00:00'));

var_dump(date('Y-m-d H:i:s'));
sleep(3);
var_dump(date('Y-m-d H:i:s'));

timecop_return();

var_dump(date('Y-m-d H:i:s'));
sleep(3);
var_dump(date('Y-m-d H:i:s'));
string(19) "2018-06-14 12:05:05"
string(19) "2018-06-14 12:05:08"
string(19) "2018-01-01 12:00:00"
string(19) "2018-01-01 12:00:00"
string(19) "2018-06-14 12:05:11"
string(19) "2018-06-14 12:05:14"

そのほか

そもそもの設計として、外からオブジェクトをインジェクション出来るようにするほうが何かと便利っぽい。
とはいえ無理な状況ってあると思うので、そういうときにここで挙げたアイデア使えるんじゃないかな。timecopとかすごい。

モダンな xhprof = tideways

PHP で書かれた、パフォーマンスがクソほど悪い web サイトの様子を見ることになったので、どこに時間使っとんねん!を調べようと思い xhprof 入れるぞ!!と準備しようとしたら PHP7 以降はサポートしないらしい。
FB 社が HHVM に移行したから扱わなくなったのかな?わからん。

調べたら PHP7 対応の更新を独自に追加した xhprof と、 tideways という xhprof 互換のプロファイリングツールがあるらしいので tideways を使ってみる話。

XHProf fork with PHP 7.0, 7.1 and 7.2 support | Tideways

tideways エクステンションの準備

ここからエクステンションがダウンロードできる。

tideways/php-xhprof-extension: Modern XHProf compatible PHP Profiler for PHP 7

ちなみにここでタイトル回収なんですけど、こう説明書きされている。

Modern XHProf compatible PHP Profiler for PHP 7 https://tideways.io

 

Windowsの場合はAppVeyorのアーティファクトからになる。Github からもリンクがあるけど、ここ。

php-profiler-extension master.251 - AppVeyor

ここから自分の PHP バージョンを選んで、CI 結果の中から Artifacts があるので、そこに dll が入っている zip がある。

 

あとは環境を問わず、エクステンションを既存の別のエクステンションと同じディレクトリに入れて php.ini で extension=... を記載を追加して読み込めば良い。

こいつは Windows でやってる。

php -m すると今読み込んでいるエクステンションが確認できるので、このときエラーが出ずに tideways の記載が見えれば準備 OK

注意事項として tideways のツール群を使わない(xhprof相当のことだけローカルでやりたい。tidewaysのインフラを使わない)なら
公式サイトでアナウンスされている yum や apt-get を使ったインストールはしないこと。
そっちだとうまくできないっぽい。

Mission control center for PHP application performance | Tideways

ただ tideways のツール群にのっかると、プロファイリングやアラート通知が出来るよ!と書いてあって非常に便利そう。
ツール群を入れたら自動でプロファイリングして集計して可視化までしてくれる。超便利やん…

tideways の xhprof 互換エクステンションを使ってプロファイリングの実施

Github にも書いてあるが、ざっくりとはこういう形。

tideways_xhprof_enable();

start_my_application();

$data = tideways_xhprof_disable();
file_put_contents(__DIR__ . "/1.xhprof", serialize($data));

これで記載したファイルと同じディレクトリに 1.xhprof というファイルが出来上がる。

Laravel や CakePHP なら一番最初に呼ばれる index.php をまるっと囲ってしまうと何も考えずに「どこのページが重たいかな~~フッフ~ン」と楽できる。
楽できる一方で、全ての処理について出てしまうので「特定のページが重いんだよなあ~~」みたいなときには使いにくい。
そのときは個別にやっていくと良さげ。

プロファイリング結果の確認

出力された結果は xhprof と互換性があるので、xhprof を可視化するツールでそのまま見れる。
例えば xhgui がいろいろできて便利そうに見える。

perftools/xhgui: A graphical interface for XHProf data built on MongoDB

いろいろ必要なのがあるなあとか、変なところで詰まるのもなあ、と思い、今回はとりあえず結果を見たかっただけなので xhprof に付属する xhprof_html を使った。
xhgui の話はまた今度にする。

 

ただ、xhprof_html は xhprof の設定が前提となるので、それそのままだと動かない(主にディレクトリ周り)ので、そのあたりを少しいじった。こちらを使いたい方はどうぞ。

sters/xhprof-html: xhprof (or tideways) visualize html tool from xhprof repo.

これを使うとこんな感じの Web ページが見えるようになるので、これでおしまい。

この画面から、どのメソッドが何回呼ばれてどれくらい時間がかかっているかという、PHP アプリケーションのボトルネックが丸見えになってくるので、パフォーマンスチューニングドンドンやっていける。

余談) PHP7 対応された xhprof

これは Travis でぐるぐる CI も回してる。 PHP 7.0 と 7.1 で CI されてるので、そこは確実に使えるっぽい。

yaoguais/phpng-xhprof: upgrade xhprof extension to PHP7

使い方は README にも書いてあるとおりで xhprof とまったく同じでいい。エクステンション入れて xhprof_enable() したら良い。
結果は xhprof_disable() すると受け取れるので、それを serialize() して保存すると各種ツールで見れる。

Carbon を使って 開始日と終了日を指定した // 単位の日付データ列を生成する

日単位のデータがわっとあって、週とか月とかの単位で集計してーなー!みたいな、そういう事をやりたいときってあると思うんですよ。
そのための日付の範囲を一覧にしてくれるやつ、というイメージで。作ったらあとは SQL とかそれに準ずる何かにポイポイしたらいいと思う。

Laravel の気持ちだったので Carbon だけど Chronos でも同じようにいけると思う。 startOfMonth とかで、自身の日付変わるところは結構驚いた、さすが Carbon やりますねぇ!(

<?php
require_once('vendor/autoload.php');

use Carbon\Carbon;

// 週の最初を日曜日、最後を土曜日に設定
Carbon::setWeekStartsAt(Carbon::SUNDAY); 
Carbon::setWeekEndsAt(Carbon::SATURDAY);

// $type = 1, 2, 3 // day, week, month
function dateRangeIterator($startDate, $endDate, $type=2) {
	$startDate = Carbon::parse($startDate)->startOfDay();
	$endDate = Carbon::parse($endDate)->endOfDay();

	while ($endDate > $startDate) {
		// "この" 始まりと終わりを取得する
		if ($type === 1) {
			$thisStartDay = $startDate->copy();
			$thisEndDay = $thisStartDay->copy()->endOfDay();
		} elseif ($type === 2) {
			$thisStartDay = $startDate->copy();
			$thisEndDay = $thisStartDay->copy()->endOfWeek();
		} elseif ($type === 3) {
			$thisStartDay = $startDate->copy();
			$thisEndDay = $thisStartDay->copy()->endOfMonth();
		}

		// 終わりが指定値を超えないように調整
		if ($thisEndDay > $endDate) {
			$thisEndDay = $endDate;
		}

		// ジェネレーター生成
		yield [$thisStartDay, $thisEndDay];

		// 次の日/週/月にいく
		if ($type === 1) {
			$startDate->startOfDay();
			$startDate->addDays(1);
		} elseif ($type === 2) {
			$startDate->startOfWeek();
			$startDate->addWeeks(1);
		} elseif ($type === 3) {
			$startDate->startOfMonth();
			$startDate->addMonths(1);
		}
	}
}


// 月単位 2/2 ~ 3/2 まで
foreach(dateRangeIterator('2018-02-02', '2018-03-02', 3) as $x) {
	echo "$x[0] - $x[1]\n";
}

echo "------\n";

// 週単位 2/3 ~ 2/18 まで
foreach(dateRangeIterator('2018-02-03', '2018-02-18') as $x) {
	echo "$x[0] - $x[1]\n";
}

echo "------\n";

// 月単位 2/2 ~ 10/12 まで
foreach(dateRangeIterator('2018-02-02', '2018-10-12', 3) as $x) {
	echo "$x[0] - $x[1]\n";
}

/**
$ php hoge.php
2018-02-02 00:00:00 - 2018-02-28 23:59:59
2018-03-01 00:00:00 - 2018-03-02 23:59:59
------
2018-02-03 00:00:00 - 2018-02-03 23:59:59
2018-02-04 00:00:00 - 2018-02-10 23:59:59
2018-02-11 00:00:00 - 2018-02-17 23:59:59
2018-02-18 00:00:00 - 2018-02-18 23:59:59
------
2018-02-02 00:00:00 - 2018-02-28 23:59:59
2018-03-01 00:00:00 - 2018-03-31 23:59:59
2018-04-01 00:00:00 - 2018-04-30 23:59:59
2018-05-01 00:00:00 - 2018-05-31 23:59:59
2018-06-01 00:00:00 - 2018-06-30 23:59:59
2018-07-01 00:00:00 - 2018-07-31 23:59:59
2018-08-01 00:00:00 - 2018-08-31 23:59:59
2018-09-01 00:00:00 - 2018-09-30 23:59:59
2018-10-01 00:00:00 - 2018-10-12 23:59:59
*/

時間とか分とか、単位をもっと細かくしたいんだよね~~ってなっても、同様にして細かい単位のものを実装したらいいと思う。

いじよ。

phpcs の自作ルールを作ってみた

phpcs を使って、他のルールセットにはない、独自のルールを設定する方法を調べた。

存在するルールセットを組み合わせる

そもそも、存在しているルールセットを組み合わせたり、無効にしたりするには、 composer を使ってパッケージを入れるなどして xml ファイルに記載をしていけば OK 。
これは Github の Wiki にもやり方が記載されている。

Coding Standard Tutorial · squizlabs/PHP_CodeSniffer Wiki

xml ファイルの例

<?xml version="1.0"?>
<ruleset name="oreore_ruleset">
    <description>名前空間は無くてもいいよ</description>
    <rule ref="PSR2">
        <exclude name="PSR1.Classes.ClassDeclaration.MissingNamespace" />
    </rule>
</ruleset>

コマンドの例

$ phpcs -n --colors --standard="oreore_ruleset.xml" src/

調べたのはこれではじゃないよ

自分で 1 からルールを定義する

こちらも Github の Wiki 、同じページ(Coding Standard Tutorial)にも書いてある。 Creating the Sniff の項目。

が、ちょっとトラップがあって、 Creating the Sniff の項目だけ読んで進めると、足りないものがいろいろあって NG 。1からルールを定義するときにも xml ファイルは必要だった。

それを読みながら試しに作ってみたのがこれ。

sters/phpcs-myruleset-test: Create phpcs sniffer test repo.

実際に触ってみて、いくつかの制約があるっぽい(というか書いてある)

  • ルールを格納するサブディレクトリを作らないといけない
  • ファイル名の末尾が Sniff.php でないといけない
  • PHP_CodeSniffer\Sniffs\Sniff インタフェースを利用する
  • register と process メソッドを実装する

個別にもう少し詳しく深掘りする。

ルールを格納するサブディレクトリを作らないといけない

こんな感じで掘ると良さそう。
ルールセット名の下に Sniffs という名前にすると、自分で設定しているルールセットだよ~的な意味になるっぽい。

MyRule/Sniffs/HogeSniff.php

phpcs の実装としてはこのあたりにそう書いてあるように見える。
PHP_CodeSniffer/Ruleset.php at master · squizlabs/PHP_CodeSniffer

で、作ったディレクトリと同じ階層に xml ファイルを置く。中身は説明書き程度で、もし他のルールを継承したものを作っていく場合にはここに書き足していくと良いっぽい。

MyRule/rule.xml

<?xml version="1.0"?>
<ruleset name="MyRule">
	<description>MyRule Coding Standards</description>
</ruleset>

アプリケーションと同じところに Sniffer は入れないと思うので(関心の分離的にも別で管理しよう!)、別管理する気持ちで作っていくと良いと思う。

ファイル名の末尾が Sniff.php でないといけない

phpcs が Sniff.php でファイルを探している。実装はこのあたり。

PHP_CodeSniffer/Ruleset.php at master · squizlabs/PHP_CodeSniffer

なので、ルールを書いていくファイルは HogeSniff.php のようなファイル名でないといけない。

MyRule/Sniffs/HogeSniff.php

PHP_CodeSniffer\Sniffs\Sniff インタフェースを利用する

このあたりからは、ぼくの試した実装とにらめっこしてみたほうがいいかもしれない。

これは phpcs の Wiki にも書いてあるものと同じ、ハッシュコメントを警告するための Sniff 。

phpcs-myruleset-test/DisallowHashCommentsSniff.php at master · sters/phpcs-myruleset-test

インタフェースの利用については特にいうこともないので、はい。

register と process メソッドを実装する

引き続き、ハッシュコメントを警告するための Sniff を見ながら。

phpcs では対象となった php ファイル(あるいは他のファイルも指定できる)に対して、トークナイザを実行し、指定されたトークンに対してのみ process メソッドが動くように作られている。
その指定されたトークンを register メソッドで指定する。こういう感じで。

public function register()
{
    return [
        T_COMMENT
    ];
}

トークンの一覧は php.net の方にある。

PHP: パーサトークンの一覧 - Manual

php.net のリストを見ると T_COMMENT は 「 // or #, and /* */ 」にあたると書いてある。
これでコメントのトークンに限定して process メソッドを実行できる。

process メソッドはこんな感じで書いている。

public function process(File $phpcsFile, $stackPtr)
{
    $tokens = $phpcsFile->getTokens();

    if ($tokens[$stackPtr]['content']{0} === '#') {
        $error = 'Hash comments are prohibited; found %s';
        $data  = [trim($tokens[$stackPtr]['content'])];
        $phpcsFile->addError($error, $stackPtr, 'Found', $data);
    }
}

$phpcsFile は PHP_CodeSniffer\Files\File クラスのインスタンスで、今はどのファイルを対象にしているか、そのファイルのトークンは、といった情報が入っている。

$stackPtr はトークンスタックのうち現在の位置を示す int 。 register で指定したトークンが見つかった位置になる。当然のことながら 1 ファイルで複数見つかることもあるので、そのときは毎回 process メソッドが呼ばれるようだ。

$phpcsFile->getTokens() でトークンの一覧を取ることができ、これはトークンの二次元配列になっている。なので $stackPtr で参照することで、現在の位置のトークンが分かる。

トークンは以下のような形式で表現されている。

array(8) {
  ["type"]=>
  string(9) "T_COMMENT"
  ["code"]=>
  int(377)
  ["content"]=>
  string(8) "# echo
"
  ["line"]=>
  int(3)
  ["column"]=>
  int(1)
  ["length"]=>
  int(6)
  ["level"]=>
  int(0)
  ["conditions"]=>
  array(0) {
  }
}

そのトークンが、コード中の何行目にあって~、どのネストレベルで~、とかとか。このうち content に着目すると、そのトークンが持っている中身が見られる。

コメントの場合はコメント自体がまるっとトークンになるので、そのうちの先頭 1 文字をみて # だったらエラーを出すことで、ハッシュコメントを警告する Sniff の出来上がり。

自作のルールも phpcbf で自動でなおるようにしたい

お試したリポジトリには入れていなかったのだが addFixableError と fixer->replaceToken を使うことで出来るっぽい。

PHP_CodeSniffer/ClassDeclarationSniff.php at master · squizlabs/PHP_CodeSniffer

$fix = $phpcsFile->addFixableError($error, $stackPtr, 'SpaceBeforeKeyword', $data);
if ($fix === true) {
    $phpcsFile->fixer->replaceToken(($stackPtr - 1), ' ');
}

複雑なルールを作っていきたい

もしかしたら場合によって、もっと複雑なルールを設定していきたいような場合もあるのでは…?
そんなときどうするんだろうなあと思ったけれど、結局のところは気合で処理を書いていかないといけない(そりゃそうだ)

それは phpcs に含まれる PSR のルールを見てもそうだし Packagist にあるルールセットを見てもみんながんばってるのが分かる。

例えば PSR-2 のクラス定義に関する Sniff 。
PHP_CodeSniffer/ClassDeclarationSniff.php at master · squizlabs/PHP_CodeSniffer

「 1 つ前のトークンが空白で、改行で、その前が abstract か final だったらエラー!」とかそういう。

 

例えば… とおもって Packagist みていたら phpcs でセキュリティチェックしてくれる君があった。
FloeDesignTechnologies/phpcs-security-audit

その中に簡易的に XSS のチェックをしてくれるのがあるが、やっぱり頑張る
phpcs-security-audit/EasyXSSSniff.php at master · FloeDesignTechnologies/phpcs-security-audit

とはいえ、全部書くのもあれなので、 phpcs 側で用意されているユーティリティメソッドだったり、自分で便利に使うものを用意すると Sniff 本体が簡潔に書けて良さそうだ。

 

例えば処理の中や後ろにコメントを書くようなあれを警告する Sniff を作ってみたのがこんな感じ。

phpcs-myruleset-test/DisallowInlineCommentsSniff.php at master · sters/phpcs-myruleset-test

public function process(File $phpcsFile, $stackPtr)
{
    $this->tokens = $phpcsFile->getTokens();
    
    $tokenPrev = $this->findSomethingFirstOnLine($phpcsFile, $stackPtr);
    $tokenNext = $this->findSomethingLastOnLine($phpcsFile, $stackPtr);

    if ($tokenPrev !== false || $tokenNext !== false) {
        $message = "Disallow Inline comments.";
        $errorCode = 'inline_comments';
        $placeholder = [];
        $phpcsFile->addError($message, $stackPtr, $errorCode, $placeholder);
    }
}

findSomething.. は同じ行の前後に、自分以外の別トークンがいるかを探すメソッド。
findFirstOnLine というのが File クラスのメソッドにはあるが、微妙にそういう意図じゃないんだよ!というところだったので、参考にしつつ作ったのがコレ。行の情報があるので、ぐるぐるして、同じ行で別トークンがあるかな?を探しているだけで、特別難しいことは何もしていないとは思う。

おわり

特定のキーワード、特定の処理方法を警告するようなものは簡単に作れそうなので、余力があれば、チーム独自ルールみたいなものを phpcs に掛けられるようにすると、レビューコストが下がったり(?)、変な記述が生まれにくくて、いいなあと思ったとさ。

命名規則のようなちょっとむずかしそうなルールも、頑張ったら実装していくことは出来るので、そういうのを統一していきたいんだよね、みたいな事象のときに活用できそうだ。

PHP で本文抽出したいよね、という

前回の記事: ExtractContent を PHP で書き換えた | ごみばこいん Blog

「今回 PHP に書き換えてみた ExtractContent も含めて、これらの比較は次の記事でやりたい」といいつつ、他のネタが挟まり、ようやくにして比較しました。

比較した結論

お試しにはこちらのリポジトリをどうぞ。
sters/compare-article-extractors: Compare web article extractors.

日本語環境下で難しい話

php-goose 、または php-web-article-extractor では、どちらも内部にストップワード辞書を持っていて、プログラム上で言語を指定、あるいは lang 属性や meta タグから読み取った言語名から、適切なストップワード辞書を選択しています。そうして決まったストップワードの一覧が、文字列にどれくらいあるかを確認して、本文かどうか?といった判断を進めているようです。

ストップワードとはよく使われがちな単語のことで、自然言語処理の前処理として行なうことでデータ量を減らしたり、精度を上げたり出来ます。

自然言語処理における前処理の種類とその威力 - Qiita

例えば英語では「This is a pen.」といったようにスペースで区切られているので「ストップワードの is と a の 2 文字がある!こいつはコンテンツだ!」と簡単に出来るのですが、日本語の場合はそう簡単な話ではありません。日本語はスペース区切りではありませんし、前後にある文字によって、まったく異なる意味合いになったりもしますので、一概に辞書でドバーッと指定することは適切な処理でない可能性が高いです。

じゃあどうするかというと分かち書きの出番なのですが、分かち書きも簡単なものではありません。 Chasen や Mecab といった既存の技術を利用すればお手軽にもできますが php-goose や php-web-article-extractor がそれを利用して日本語対応するかというとちょっと違いそうだなあ、という気持ちです。

php-goose に出したプルリクは諦めてドバーッと処理する形ではありますが、日本語だけ特殊化せざるを得ないので、そうやってみて出してみたものの、なんだかなあと。いやそもそも 元になってる Goose はそういう実装になっていないだろうし…。

そのほかのアプローチ方法

DOM構造や、画面上の位置情報も利用するのはめっちゃ有効的だと思います。
PuppeteerでWebページからメインコンテンツっぽいところを抽出してみる - Qiita

上記の記事の「まとめ」でも出てはいますが、機械学習して取るようにするのもよさそうです。
seomoz/dragnet: Just the facts -- web page content extraction

仕事スクレイピングを楽にした

この記事は Webスクレイピング Advent Calendar 2017 - Adventar の 5 日目の記事です。

お仕事都合による発生するスクレイピングについてと、それつらたん~~~なことになってきたので解消するようにブチアゲした話を書きます。

お仕事都合による発生するスクレイピング

お仕事都合、例えば営業や企画といった人達から「こういうこと出来ないかなあ…?」とかって話が来るとおよそ7,8割の確立でスクレイピングできないっすかというお願いです。それが 1 回だけならいいんですが、異なる人から、異なることを、ちょこちょことお願いが来たりします。
さらに厄介なのが「XX さんに先月お願いしたやつ、今月もよろしく」みたいなやつ。ぼくは XX さんじゃないし、その事があったことを知らないぞ??

そしてその "お願い" の中身としては、例えばこういう感じ。

  • 指定する URL 群から XXX と YYY の情報を集めたい
    • (これがいちばんおおい)
  • 電車案内のサイトから駅名一覧を集めたい
  • 求人系のサイトから職種一覧、募集企業の情報を集めたい
  • IRまとめ系サイトから業種や会社名、連絡先を集めたい
  • 特定のメディアサイトに入っている画像を集めたい
  • 〇〇のメーカーの商品、型番の一覧を集めたい
  • ムフフなコンテンツ集めたい
  • etc...

そしてこういったお願いはかなりの速度感を求められることがあります。

とはいえ、どばーーーーってスクレイピングしたら、対象のサイトに対して、迷惑を掛けることにつながりますし、訴訟リスクも存在します。前提として 1 日目の vaaaaanquish さんが書かれているようなこと( PythonでWebスクレイピングする際の規約回りの読み込み - Stimulator ) を
確認しつつ、良心的な範囲でやらなければいけないので、その点については説明し納得をしてもらっています。

そんなこんなで、これまではまあいっかーという気持ちで、毎回 0 からコードを書いて、ぐるぐるしたりしてやっているのですが、いやいやちょっとそろそろ面倒になってきましたよ、ということでもうちょっとやることにしました。

「コレ使えばおおよそいいよ」セットを作る

毎回コードを書くのはまあいいとして、でも毎回 0 スタートはかなり面倒です。ボイラープレートというか、フレームワークというか、何かそういう「コレ使えばおおよそいいよ」セットを作ります。
このセットがあることによって、コードがざっくりと統一されるので、引き継ぎだったり、並行開発だったり(するか?)、そのあたりの勝手が良くなります(なりました)。

Guzzel 使えばいいじゃん、とかそういう話でもあるんですが、スクレイピングしたときってデータの入出力はもちろん、スクレイピングのやり方によってはキューイングしたり、一時データをもったりするじゃないですか。そういうところ全部扱いたいんですよ。で、後述するんですけど、速度感よくやっていこうとすると、あーだこーだ composer require するってよりは、ファイル 1 個読めばいいじゃん、という環境にしたいのです。

制約とか

お仕事の都合によって、言語の制約は PHP になります。他の言語でもいいのですが、周りの人の取り回しを考えるとこれがベストマッチです。

また composer や PEAR は利用できません。お願いを解決するにあたって速度感よくやるために「コレ使えばおおよそいいよ」セットは、何も考えずにとにかくサクッと使えることが必要です。なんなら PHP の設定も気にせずに使えるように、どこかのサーバにファイルを設置して eval(file_get_contents()) みたいに使えると最高です。

そうして出来たものがこちらです

easy scraping kit
※ CSS セレクタを扱う箇所がざっくり過ぎて正しくないです

コレを使って、例えば「Mapion を利用して、指定する都道府県・市区町村の駅名を集める」ものを書くとこんな用になります。

// プログラム本体
eval(file_get_contents('https://.../scraping_kit.txt'));

HTMLDoc::$waitMin = 5000;
HTMLDoc::$waitMax = 15000;

$baseUrl = 'https://www.mapion.co.jp';

foreach(Console::getListFile('list.csv') as $zip) {
	Console::out("start {$zip[0]}");

	// search pref
	$topDoc = HTMLDoc::loadURL($baseUrl . '/station/');
	$prefLinks = $topDoc->findCSSPath('.section.type-a a');
	foreach($prefLinks as $prefLink) {
		if ($prefLink->textContent !== $zip[0]) {
			continue;
		}
		Console::out("found {$zip[0]}");
		Console::out("start {$zip[1]}");

		// search city
		$prefDoc = HTMLDoc::loadURL($baseUrl . $prefLink->attributes['href']->value);
		$cityLinks = $prefDoc->findCSSPath('.section.type-a a');
		foreach($cityLinks as $cityLink) {
			if (mb_strpos($cityLink->textContent, $zip[1]) === false) {
				continue;
			}

			Console::out("found {$zip[1]}");

			// search station
			$cityDoc = HTMLDoc::loadURL($baseUrl . $cityLink->attributes['href']->value);
			$h1 = $cityDoc->findCSSPath('h1.type-a-ttl');
			$h1 = explode('の', $h1[0]->textContent);
			$h1 = $h1[0];

			$trainLinks = $cityDoc->findCSSPath('table.list-table tr');
			foreach($trainLinks as $train) {
				$item = [
					$h1,
				];
				$tds = $cityDoc->findCSSPath('td', $train);
				foreach($tds as $td) {
					$item[] = str_replace('[MAP]', '', trim($td->textContent));
				}
				Console::outputResult($item);
			}
		}

		Console::out("done {$zip[0]}{$zip[1]}");
	}
}
// list.csv の例
神奈川県,川崎市
神奈川県,横浜市

今ままで 0 から書いて、1,2 時間かかってコードを書いていたのがサクッと終わるようになりました。
また、簡単にではありますが、キューやキャッシュも持てるようになるので、 cron などを使って定期的に回したり、 web サイト A の情報を元に web サイト B の情報を取得するといった複数のステップがあるようなスクレイピングもわりかし簡単にできるようになりました。

ごく簡単なスクレイピングはコードを書かずに済ませたい

「コレ使えばおおよそいいよ」が出来ましたが、そもそもとして、よく発生するお願いは「指定する URL から XXX と YYY の情報を集めたい」です。これってコードを書く必要はほとんどないはずで、入力として URL と CSS セレクタ( もしくは XPath セレクタ)さえあれば、あとはよろしく処理するような仕組みがあれば十分なはずです。

というわけで一旦雑に作って様子を見ています

11 月くらいに作って様子を見ようと思っていたのですが、特に何かあったわけでもないんですが、急にお願いが減っちゃって作らなくていいかなあと思ってきました。とはいえ怠けているとあとが大変になりそうな予感がするので、近いうちにやりたい気持ち。

おわりに

「コレ使えばおおよそいいよ」を作ってお仕事都合のスクレイピングを簡単にしたり、別の開発者が入るときも問題の起きにくい状況になりました。
しかし、もはやこの件についてコードを書くことをなるべくやめたいので、そういう「スクレイピングいい感じにできるツール」みたいなのあったら教えてください。

(Google スプレッドシートで出来ることは知っていますが、時間系のあたりをもうちょっと自由度が効く感じで…)


6 日目は _kjou さんで「何か書きたい」です。こちらもお楽しみに!

Webスクレイピング Advent Calendar 2017 - Adventar

ExtractContent を PHP で書き換えた

いや Ruby でええやん、 Python でええやん、みたいな話だとは思うのだが、やっぱり PHP でやりたいよね、という一定の需要がギョームで発生してしまったので、 Ruby のコードを見ながら PHP に書き換えた。 Packagist にも登録してあるので composer からどうぞ。

sters/extract-content - Packagist

$url = 'http://labs.cybozu.co.jp/blog/nakatani/2007/09/web_1.html';
$extractor = new \ExtractContent\ExtractContent(file_get_contents($url));
$result = $extractor->analyse();
file_put_contents(__DIR__ . '/result', $result);

// // 抽出結果
// Webページの自動カテゴライズ の続き。
// 前回書いたとおり、パストラックで行っている Web ページのカテゴライズでは、Web ページの本文抽出がひとつの鍵になっています。今回はその本文抽出モジュールを公開しつつ、使っている技法をざっくり解説などしてみます。
// 本モジュールの利用は至極簡単。require して analyse メソッドに解析したい html を与えるだけ。文字コードは UTF-8 です。
// ...

ExtractContent は Cybozu の Nakatani Shuyo さんが2009年に作成したもので、正規表現を主として Web 記事上の本文に関するちょっとの知見が加わることで成り立っている。以下の記事へ。

Webページの本文抽出 (nakatani @ cybozu labs)

今回、移す際に参考にしたのは、もともとのものではなく、Ruby 1.9 に対応したソース。というのもこっちの記事を見つけたのは後で、 Github 上で ExtractContent を先に見つけていたので、まあいっか、と。

mono0x/extractcontent: ExtractContent for Ruby 1.9+

Ruby はチョットヨメルので inject とかわからないメソッドだけドキュメント見つつ脳内補完して、同じような感じの処理になるよう PHP へ書き直した。工夫したところはとくになく、クラスで扱うようにした、チョットテスト書いたくらいで、ほぼそのまま移してきた。Wikipedia と Medium、はてなブログあたりで試してみたところで、おおよそうまくいっているように見えたので、たぶん大丈夫。

ただ、試していて、本文っぽいと判断されるのが 2 つ以上あるような 1 記事ページ(例えば記事中に section タグがあってーとか、そいういう)ではうまく取り出せず、スコアが高くなったほうのみ抽出されてしまう。本文しきい値のようなものを設けてそれを超えていたら、結合して出す、とかしないといけないなあと思う。とはいえ、正確に本文が欲しいのか、その記事中の重要な部分にフォーカスするのか、などなど要件にもよるので、とりあえずはいいんじゃないかなの気持ち。

ちなみに PHP による実装もいたのだが、おそらく上記のようなところで、元々のものには無いオプションが増えていたりでチョットわからなかったので、一から書いた次第。

aoiaoi/ExtractContent: extract content from HTML

なお記事解析、本文抽出について Packagist を調べると他にも 3 つのライブラリが出てくる。ざっくり紹介するとこんな感じ。

今回 PHP に書き換えてみた ExtractContent も含めて、これらの比較は次の記事でやりたい。

faker を使ってダミーデータを生成する

これこれ、このライブラリ。

fzaninotto/Faker: Faker is a PHP library that generates fake data for you

ざっくり翻訳しつつ、日本語データでの使い方へ。

faker とは

faker は PHP のライブラリで、偽のデータを提供する。データベースの初期化や XML ドキュメントの生成、ストレステスト、本番データの匿名化、などに活用できる。faker は Perl の Data::Faker と Ruby の Faker に影響を受けている。

faker の使い方

インストール方法

Pakagist にて提供されているので composer を使ってインストールできる。

composer require fzaninotto/faker

基本的な使い方

$faker = \Faker\Factory::create();
// faker オブジェクトの生成

echo $faker->name;
// 名前。例えば 'Lucy Cechtelar'

echo $faker->address;
// 住所。例えば
// "426 Jordy Lodge
// Cartwrightshire, SC 88120-6700"

echo $faker->text;
// 文章…というよりは Lorem 。例えば。
// Dolores sit sint laboriosam dolorem culpa et autem. Beatae nam sunt fugit
// et sit et mollitia sed.
// Fuga deserunt tempora facere magni omnis. Omnis quia temporibus laudantium
// sit minima sint.

faker には嬉しいことに、日本語を含む色々な言語のダミーデータをサポートしている。ここでは日本語での扱いを試してみる。

// 引数に言語を指定すると利用してくれる
$faker = \Faker\Factory::create('ja_JP');

echo $faker->name;
// 例えば '大垣 直子'

echo $faker->address;
// 例えば '2636573  滋賀県浜田市西区笹田町渡辺1-1-10'

echo $faker->realText;
// text は Lorem なので realText がある
// 例えば 'オン燈と、もうすっかりがきの音にすきっと思いなやさで伝つたわ」「なんだ」カムパネルラはこち見ていたの...'

どういうプロパティが用意されているか

英語プロパティは README に全てあって、n - m の範囲で乱数とか、「Dr.」などの敬称を使えるとか、日時とか、まあなんか色々とあるのでそっちを見ると良さそう。

Faker/readme.md at master · fzaninotto/Faker

日本語のだけ、ソースとにらめっこしないと分からないところがあったので、取り上げる。(網羅できてないかも)

country 国名
prefecture 都道府県
city
ward
streetAddress 町以下
postcode 郵便番号
secondaryAddress マンション名
company 会社名
userName ユーザ名
domainName ドメイン名
email メールアドレス
name 名前(姓 + 名)
lastName
firstName
firstNameMale 名(男性)
firstNameFeMale 名(女性)
kanaName 名前カナ
lastKanaName 姓カナ
firstKanaName 名カナ
firstKanaNameMale 名カナ(男性)
firstKanaNameFemale 名カナ(女性)
phoneNumber 電話番号
realText 日本語文章

ORMとの連携

faker は CakePHP や Laravel の ORM と連携することができる(!?)
試してみたところ、連携というよりは ORM を通して、実際に DB に値を格納してくれるっぽい。

例えば CakePHP3 で使うならこんなふうに。

$faker = \Faker\Factory::create('ja_JP');
$populator = new \Faker\ORM\CakePHP\Populator($faker);
$populator->addEntity('Users', 5, [
    'name'       => function() use ($faker) { return $faker->name; },
    'prefecture' => function() use ($faker) { return $faker->prefecture; },
    'created'    => null,
    'modified'   => null,
]);
$inserted = $populator->execute();

$Users = \Cake\ORM\TableRegistry::get('Users');
debug($Users->find('all')->toList());
// 例としてこのような出力になる
// [
//     (int) 0 => object(App\Model\Entity\User) {
//         'id' => (int) 1,
//         'name' => '津田 裕樹',
//         'prefecture' => '栃木県',
//         ....
//         'created' => object(Cake\I18n\FrozenTime) {
//             'time' => '2017-11-20T13:47:32+09:00',
//             'timezone' => 'Asia/Tokyo',
//             'fixedNowTime' => false
//         },
//         'modified' => object(Cake\I18n\FrozenTime) {
//             'time' => '2017-11-20T13:47:32+09:00',
//             'timezone' => 'Asia/Tokyo',
//             'fixedNowTime' => false
//         },
//     },
//     ...
// ] 

特に指定をしなかったカラムについては DB の形を見て、結構いい感じに適当に faker な値で埋めてくれる。指定はクロージャで、 faker を使ってランダムなデータを作ることも出来るし、固定値にすることもできる。また、例としては 1 エンティティしか作らなかったが、addEntity を複数呼んで、 まとめて execute することで複数のエンティティも作ることができる。

シード値を設定する

テストで使うときやっぱりランダムだとつらいよね、というときのためにシード値を設定することができる。

$faker = \Faker\Factory::create();
$faker->seed(1234);

echo $faker->name;
// 何度コードを流しても以下の順番で流れる
// 'Miss Lorna Dibbert'
// 'Litzy Emard'
// 'Odessa Collins'
// ...

ランダムに提供される部分はそのとおりなのだが、日付については引数を指定しないと now() な値が使われるのでシードは同じでも出力される値が変わってしまうので、なんでもいいが毎回同じ値を入れる必要がある。

$faker = \Faker\Factory::create();
$faker->seed(1234);
echo $faker->dateTime();
// ランダムになる

$faker = \Faker\Factory::create();
$faker->seed(1234);
echo $faker->dateTime('2017/01/01 12:00:00');
// 固定パターンになる

faker には乱数を提供する関数もあり、シード値を設定することで、こういったものたちも固定パターン化される。便利ちゃん。

$faker = \Faker\Factory::create();
$faker->seed(1234);
echo $faker->numberBetween(0,100);
// 固定パターンになる
// 81, 50, 62, 18, 56, ...

faker の内部、プロバイダの話

何の気無しに \Faker\Factory::create() を呼び出していたが、実は内部でこんなような処理をしている。

$faker = new \Faker\Generator();
$faker->addProvider(new \Faker\Provider\en_US\Person($faker));
$faker->addProvider(new \Faker\Provider\en_US\Address($faker));
$faker->addProvider(new \Faker\Provider\en_US\PhoneNumber($faker));
$faker->addProvider(new \Faker\Provider\en_US\Company($faker));
$faker->addProvider(new \Faker\Provider\Lorem($faker));
$faker->addProvider(new \Faker\Provider\Internet($faker));

※実際のコードはこのあたり。
Faker/Factory.php at master · fzaninotto/Faker

プロバイダの土台として \Faker\Provider\Base がいるので、これを継承した適当なクラスを作り、 public なメソッドを作って return すれば良い。そのプロバイダクラスを new して addProvider すれば使えるようになる。

class Book extends \Faker\Provider\Base
{
  public function title($nbWords = 5)
  {
    $sentence = $this->generator->sentence($nbWords);
    return substr($sentence, 0, strlen($sentence) - 1);
  }

  public function ISBN()
  {
    return $this->generator->ean13();
  }
}


$faker->addProvider(new Book($faker));

echo $faker->ISBN;

faker を使ってみて

説明にもあるが、テストコードにおいて、いや、そこはなんでも良いんだけど、それっぽい住所や名前をよろしく入力して欲しい、みたいな時。あるいは、DBの初期化時に、多いに力を発揮しそうな感触。

テストコードとして利用する場合、値が毎回ランダムになって使い所がイマイチわかりにくい気もちょっとするが、それで詰まるときってそんなに無いのでは…。値が被って~~~うあああ!!!!みたいなことはあると思う。

話はそれるが最近見かけた property-based testing という方法にはめちゃんこ合っていると思う。

Property-Based Testing for Godly Tests

そもそも PHPUnit なら Data Provider の仕組みがあるし、あるいは CakePHP なら Fixture が、 Laravel には Factory の仕組みがそれぞれにある(きっと他のフレームワークにもあるんじゃないかな、調べてない。。)ので、そういうこともやりやすいと思う。
ちなみに Laravel には faker が既に含まれていて、 Factory の仕組みを使うとガンガンにテストデータを作ってくれる。スゴイ。

Database Testing - Laravel - The PHP Framework For Web Artisans

ORMとの連携はできるとはいえ、既に書いたとおり、各フレームワークがそういう仕組みを提供しているので、それとバッティングするなあという気持ちも。その中で使っていく、みたいなイメージなのかな。。ちょっとわからないや。。

1 2 3