Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationEventAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
31
Help us understand the problem. What is going on with this article?
@kanaxx

PHPで正規表現を使ってHTMLを強引にほじるコツ

手間をかけずスクレイピングもどきのことをやりたい人向けの記事です。

動機

HTMLをほじりたくなることありませんか?
HTMLパーサーなどのライブラリの準備をせず、さくっとHTMLから文字列でぶっこ抜きたくなりませんか?

自分の仕事の中でそんなことが何回か続いたので、環境準備に挫折しないようにできるだけ簡単に、複雑なことができるものをまとめてみました。

使ったもの

  • PHP 7.0.21 (cli) (built: Jul 5 2017 13:31:19) ( ZTS )
  • PCRE Library Version 8.38 2015-11-23
  • 正規表現(preg_match, preg_match_all関数)

やってみる

まずはHTMLを持ってくる

外部ライブラリを使わないでHTMLを取りに行くとなると、file_get_contents関数ですね。
http://php.net/manual/ja/function.file-get-contents.php

$html = file_get_contents('https://qiita.com');

これで$htmlにHTMLが文字列として入る。さすがPHP、簡単すぎます。

正規表現の基本的なコード

PHPで正規表現で文字列をぶっこ抜くときの基本的なコードです。qiitaのトップページの<a>タグのリンク先を抜き出しています。

$html = file_get_contents('https://qiita.com');
$pattern = '/<a href="([^"]*)"/';
if( preg_match($pattern, $html, $result) ){
    var_dump($result);
}else{
    echo 'No match' . PHP_EOL;
}

if( preg_match_all($pattern, $html, $result) ){
    var_dump($result);
}else{
    echo 'No match' . PHP_EOL;
}

難解だと思うので、気を付けるポイントを丁寧に説明していきます。

パターン文字列を囲むリテラル記号をちゃんと選ぶ

$pattern 変数に入れるのはPHPとしての文字列なので、'"で囲む必要があります。厄介なのは、ほじる対象のHTMLが'"を含んでいるので正規表現のパターンの中に"が登場してきます。その場合は、PHPとして成立させるためにダブルクォーテーションを\"をエスケープする必要が出てきます。
今回のサンプルでは、対象とするHTMLが属性をきちんとダブルクォートで囲んでいるので、PHPの両端はエスケープしなくてもよい'シングルクォートにしておきます。HTMLにシングルクオートが出てくるのはいったん置いときましょう。

サンプルコード

//読みにくい
$pattern = "<a href=\"[^\"]*\"";
//上よりは読みやすい
$pattern = '<a href="[^"]*"':

同じことなのに、複雑度が違いますね。

デリミタに気を付ける

PHPのpreg系の関数では、パターン文字列自体を何かで挟む必要があります(デリミタと呼びます)。デリミタはPHPの両端の'とは別です。慣例的にスラッシュで囲むことが多いのでPHPのコードに現れる正規表現のパターン表現は、

  • '/ ここに正規表現 /'
  • "/ ここに正規表現 /"

となりがちです。

HTMLから値を抜き出す正規表現を書こうとすると、パターン自体にスラッシュ(終了タグのスラッシュ)を含むことがよくあります。デリミタのスラッシュと、パターン文字自体のスラッシュが混在することになります。その場合は、パターン内のスラッシュをバックスラッシュ(円マーク)でエスケープし、\/(エンとスラ)する必要があります。エスケープ文字が入ってくると、ただでさえ複雑な正規表現文字がさらに分かりにくくなります。

ネットで見つかるサンプルはデリミタを/にしていることが多いですが、PHPでは、両端が同じ文字で挟まれていればデリミタとして認められるので、わざわざエスケープが必要となる/をデリミタとして選ぶことはないです。他の文字にしたほうが読みやすいです。

// タイトルタグの終了を意味する/が邪魔をする。
$pattern = '/<title>(.*)</title>/';
//                       ↑これ

//バックスラッシュを前に置くと回避できる。Vみたいになって読みにくい
$pattern = '/<title>(.*)<\/title>/';

//@マークで囲むなら/はそのままでよい。読みやすい
$pattern = '@<title>(.*)</title>@';

エラーメッセージ No ending matching delimiter が出るとき
両端がデリミタになっていないときです(つまり、自分がデリミタに選んだ文字が正規表現自体に含まれていることが多いです)。両端の文字列を変えるか、エスケープするか探してみてください。

エラーメッセージ Delimiter must not be alphanumeric or backslashが出るとき
デリミタに選んだ文字が悪いときです。英数字はダメ文字で、記号系の文字しか選べません。

使う関数にちゃんと選ぶ

PHPにはpreg_match関数とpreg_match_all関数があります。マッチするものを1つだけ取ってくるか、全部取ってくるかの違いです。$resultの変数の構造も変わるので状況によって使い分けます。たいていの場合はpreg_match_allのほうを使うと思います。

抜き出したいポイントを()で囲む

$pattern = '/<a href="([^"]\*)"/';
                      これと これ

正規表現の中に()を入れると、正規表現にマッチした部分をピンポイントで取り出すことができます。

カッコがない場合
正規表現の全体が$result[0]に配列として入ってきます。

array (
  0 =>
  array (
    0 => '<a href="/about"',
    1 => '<a href="/terms"',
    2 => '<a href="/privacy"',
    3 => '<a href="/api/v2/docs"',
    4 => '<a href="/feedback/new"',
    5 => '<a href="https://help.qiita.com"',
    (省略)
  ),
)

カッコがある場合
正規表現の全体が$result[0][n]の配列として入り、カッコでマッチした部分が$result[1][n]の配列として入ってきます。プログラムで使う場合には、foreach($result[1] as $m) のようにループします。
カッコの数が増えると、$result[2][n], $result[3][n]と増えていきます。

array (
  0 =>
  array (
    0 => '<a href="/about"',
    1 => '<a href="/terms"',
    2 => '<a href="/privacy"',
    3 => '<a href="/api/v2/docs"',
    4 => '<a href="/feedback/new"',
    5 => '<a href="https://help.qiita.com"',
    (省略)
  ),
  1 =>
  array (
    0 => '/about',
    1 => '/terms',
    2 => '/privacy',
    3 => '/api/v2/docs',
    4 => '/feedback/new',
    5 => 'https://help.qiita.com',
    (省略)
  ),
)

preg_match_allのフラグを使う

preg_match_allには、第四引数にフラグを指定することができます。今までの例は、何も指定していなかったのですが、それはPREG_PATTERN_ORDERを指定していたのと同じ動きです。
代わりに、PREG_SET_ORDERを与えると、$resultの形式が変わります。

preg_match_all($pattern, $html, $result, PREG_SET_ORDER)
array (
  0 =>
  array (
    0 => '<a href="/about"',
    1 => '/about',
  ),
  1 =>
  array (
    0 => '<a href="/terms"',
    1 => '/terms',
  ),
  2 =>
  array (
    0 => '<a href="/privacy"',
    1 => '/privacy',
  ),
  3 =>
  array (
    0 => '<a href="/api/v2/docs"',
    1 => '/api/v2/docs',
  ),
  4 =>
  array (
    0 => '<a href="/feedback/new"',
    1 => '/feedback/new',
  ),
  5 =>
  array (
    0 => '<a href="https://help.qiita.com"',
    1 => 'https://help.qiita.com',
  ),

$result[n][1]に欲しいものが入ってきます。

1回のパターンマッチングで、2つ以上の値を取り出ししたいときはPREG_SET_ORDERのほうが扱いやすいと思います。カッコが2組以上あるときですね。

カッコの使い方に気を付ける

1つの正規表現内にカッコが複数あると$result変数の構造が複雑になります。
丸カッコの内側を?:で始めると、このカッコにマッチした分はpreg_match_all$resultに影響しなくなります。データとして不要な場合には、?:を入れるようにしましょう。

liタグの内側を取り出す正規表現

$html = file_get_contents('https://qiita.com/organizations');
$pattern_a = '@<li(?:[^>]*?)>(.*?)</li>@';
if( preg_match_all($pattern_a, $html, $result) ){
     var_dump($result);
}

//                 ↓ pattern_aとの違いはここ
$pattern_b = '@<li([^>]*?)>(.*?)</li>@';
if( preg_match_all($pattern_b, $html, $result) ){
     var_dump($result);
}

pattern_aの結果
$result[1][0]から値が取れます。
正規表現には、カッコが二組あったけど、最終の結果に出てくるのはカッコが1組の状態と同じになる。

array (size=2)
  0 => 
    array (size=17)
      0 => string '<li><a href="/organizations"><i class="fa fa-fw fa-check"></i> All organizations</a></li>' (length=89)
      1 => string '<li><a href="/organizations?about_only=true"><i class="fa fa-fw"></i> Only organizations with About</a></li>' (length=108)
      2 => string '<li><a href="/organizations?sort=activity_stats_week"><i class="fa fa-fw fa-check"></i> Activity for a week</a></li>' (length=116)
      (省略)
  1 => 
    array (size=17)
      0 => string '<a href="/organizations"><i class="fa fa-fw fa-check"></i> All organizations</a>' (length=80)
      1 => string '<a href="/organizations?about_only=true"><i class="fa fa-fw"></i> Only organizations with About</a>' (length=99)
      2 => string '<a href="/organizations?sort=activity_stats_week"><i class="fa fa-fw fa-check"></i> Activity for a week</a>' (length=107)
      (省略)

pattern_bの結果
取り出すためのカッコが2つあると認識されている状態

$result[1][0]はCSSの一致が入ってしまい、実際に欲しい結果は$result[2][0]に入る。

array (size=3)
  0 => 
    array (size=17)
      0 => string '<li><a href="/organizations"><i class="fa fa-fw fa-check"></i> All organizations</a></li>' (length=89)
      1 => string '<li><a href="/organizations?about_only=true"><i class="fa fa-fw"></i> Only organizations with About</a></li>' (length=108)
      2 => string '<li><a href="/organizations?sort=activity_stats_week"><i class="fa fa-fw fa-check"></i> Activity for a week</a></li>' (length=116)
      3 => string '<li><a href="/organizations?sort=activity_stats_total"><i class="fa fa-fw"></i> Activity for 31 days</a></li>' (length=109)
      4 => string '<li class="footer_link"><a class="footer_copyright" href="http://increments.co.jp">© 2011-2018 Increments Inc.</a></li>' (length=120)
      (省略)
  1 => 
    array (size=17)
      0 => string '' (length=0)
      1 => string '' (length=0)
      2 => string '' (length=0)
      3 => string '' (length=0)
      4 => string ' class="footer_link"' (length=20)
      (省略)
  2 => 
    array (size=17)
      0 => string '<a href="/organizations"><i class="fa fa-fw fa-check"></i> All organizations</a>' (length=80)
      1 => string '<a href="/organizations?about_only=true"><i class="fa fa-fw"></i> Only organizations with About</a>' (length=99)
      2 => string '<a href="/organizations?sort=activity_stats_week"><i class="fa fa-fw fa-check"></i> Activity for a week</a>' (length=107)
      3 => string '<a href="/organizations?sort=activity_stats_total"><i class="fa fa-fw"></i> Activity for 31 days</a>' (length=100)
      4 => string '<a class="footer_copyright" href="http://increments.co.jp">© 2011-2018 Increments Inc.</a>' (length=91)
      (省略)

正規表現のオプションを設定する

いろいろやってもうまく値を抜き出せないとき、オプションを付けたら解決するときもあります。特に改行混じりの複数行をマッチさせたいときに使います。個人的によく使うのは、iオプションsオプションです。オプションは終点デリミタの後ろに置きます。

  • i |大文字と小文字を区別しないオプション(ignore caseです)
  • s |.(ピリオド)が改行にもマッチするようになるので、.*で改行またぎにマッチします。

改行でキレイに整えられているHTMLから<div>タグや<li>タグの開始から終了までをごっそり取り出したいときは、sオプションを使います。

//<li>から</li>までの中身を丸っと取り出す
$pattern = '@<li>(.*)</li>@si';
//                         ↑これ

オプションの一覧はこちらを参照
http://php.net/manual/ja/reference.pcre.pattern.modifiers.php

最短マッチを指定する

正規表現にsオプションを使うと、改行もマッチするようになるので1回のマッチする範囲が長くなります。先ほどの例では、HTML全体の一番最初に出てくる<li>と一番最後に出てくる</il>にマッチしておしまいです。
1個目の<li></li>と2個目の<li></li>の中身を区別して取り出したい場合は、*?を使うと、一番近い</li>にマッチして止まってくれます。

//sオプションを指定しているので改行にもマッチする
//<li>を見つけても、改行で終わらずに</li>を見つけるまで続ける。
//ただし<li>以降の最初の</li>で停止できる
$pattern = '@<li>(.*?)</li>@si';

文字の種類を限定する

何でも(.*)で取ろうとすると、変なところにマッチして狙い通りにならないことがあります。数値な項目、英数字な項目など、抜きたいものに合わせて正規表現を書くときにも制限を掛けましょう。

価格系であれば、[0-9,]*、英数字含むなら[a-zA-Z0-9]*あたりでしょうか。

正規表現に詳しいサイトを参照してください。1 2

どうにもうまくいかないとき

HTMLのページは改行とタブとスペースと、正規表現で扱うのに邪魔になる文字列が含まれていることが多いです。
正規表現があっているようなのに何故かうまくいかないという状況に出くわします。そんなときは、HTMLの文字列からノイズになりそうなものを消し、一行の長~い文字列に変換してから正規表現で料理してみるとうまくいくかもしれません。

//改行とタブを全部取っ払い1行にする
$html = preg_replace('/\r\n|\r|\n|\t/', '', $html);

//2つ以上連続するスペースを1個分のスペースに置き換える
$html = preg_replace('/ {2,}/',' ', $html);

ここまでが基本なテクニックです。これらを使って、強引にHTMLから文字を抜き出すサンプルを書いてみます

実例サンプル集

抜き出したいページのHTML構造を読み取って狙いを定める方法はPHPネイティブのDOMによるスクレイピング入門に詳しく書いてありました 3。F12キー で地味に探します。

まず、頻出パターンを理解する

HTMLを正規表現パターンで抜き出すときによく使うパターンがあります。言葉で説明するのは難しいですが、

  1. 開始の文字があり
  2. ある文字以外の文字の繰り返しがあって
  3. ある文字が出現する

というパターンです。

"([^"]*)" を例に説明します。

[^"]
この4文字で表現しているのは、ダブルクォーテーション以外の文字という意味の正規表現です。
[^"]*
それに*を付けて、ダブルクォーテーション以外の文字の0回以上の繰り返し
[^"]*"
*の後ろにダブルクォーテーションを置いて、ダブルクォーテーション以外の文字の0回以上の繰り返しが見つかったあとに、ダブルクォーテーションが見つかった場合となり
"[^"]*"
さらに先頭にダブルクォーテーションを置くことで、

1. まずダブルクォーテーションが1つ出現し、
2. ダブルクォーテーション以外の文字の0回以上の繰り返しが見つかったあとに、
3. ダブルクォーテーションが見つかった場合

となります。日本語で簡潔に言うなら、ダブルクォートに挟まれた0文字以上の文字となります
"([^"]*)"
最後に、先頭のダブルクォーテーションと最後のダブルクォーテーションを丸カッコで挟みます。これで、ダブルクォートの間の文字を抜き出す正規表現が完成します。

類似ケースだと、
<[^ ]* <の後にスペース以外が並んで、スペースが来るまで →タグ名が取れる
<[^>]*> <の後に>以外が並んで、>が来るまで →タグの中身が全部取れる

というのに応用できます。〇以外の繰り返しのあとに〇が来るというのは、便利に使えます。

1. タグに挟まれた部分を取り出す

タグ名が分かっているときに使えるパターンですね。

//1行で探すとき
$pattern = '@<title>(.*)</title>@';

//複数行またいで探すときに
$pattern = '@<div>(.*?)</div>@s';

//liタグにcss系の属性が付いている場合の中身だけ取り出す
$pattern = '@<li(?:.*?)>(.*?)</li>@s';

最後のやつは?:を使って$resultに入ってこないように抑止しています。

2. タグの中の属性の値を取り出す

aタグのhrefや、imgタグのsrcで使うことが多いパターンです。

//キレイなAタグなら簡単
$pattern = '@<a href="([^"]*)">@';
//                            ↑これ

//hrefだけ抜くなら最後の>は要らない
$pattern = '@<a href="([^"]*)"@';

//srcの位置がimgの直後に来ないことがある場合
$pattern = '@<img(?:.*?) src="([^"]*)"@';

上のサンプルだと、Aタグの直後にhrefが来ないとうまくマッチしません。また<a href=''>のようにシングルクオートで囲まれてしまうとマッチしません。サイトの特性を見てパターンを考えましょう。2個以上のカッコを使う場合は、(?:)を使うべきか考えましょう。

パターンの最後に>/>(スペースとスラッシュと>)を入れるべきかどうかは悩むところですが、属性値を抜き出すケースでは要らないので、書かないほうがよいかもしれません。

3. タグの中身を全部取り出す

No2と考え方は同じです。<があり、>以外の繰り返しがあり、>が出てくるまで。

//タグなら何でも
$pattern = '@<([^>]*)>@';
//Aタグ限定で
$pattern = '@<a ([^>]*)>@';

これでタグの中身を取り出したあとであれば、href="([^]*)"に当てやすくなります。無理して1つの正規表現で抜き出すのではなく、1回目で取り出したものに対して2回目を当てていくほうが簡単かもしれません。

$html = file_get_contents('https://qiita.com/organizations');
$pattern_atag = '@<a ([^>]*)>@';
$pattern_href = '@href="([^"]*)"@';

if( preg_match_all($pattern_atag, $html, $result_a) ){
    var_dump($result_a);
    foreach($result_a[1] as $i=>$atag){
        if( preg_match_all($pattern_href, $atag, $result_h) ){
            var_dump($result_h[1][0]);
        }
    }
}else{
    echo 'No match' . PHP_EOL;
}

4. JavasSriptの文字列を抜き出す

HTML内部に<script>タグが埋まっていて、変数定義がされている場合は、HTMLな文字列から抜き出すより、JavaScriptの変数やjsonっぽい文字列から必要なもの探したほうが効率がよいこともあります。

$html = file_get_contents('https://qiita.com/organizations');
$pattern = '@window.Qiita =(.*);@';

if( preg_match_all($pattern, $html, $result) ){
    //var_dump($result);
    var_dump(json_decode($result[1][0]));
}else{
    echo 'No match' . PHP_EOL;
}
object(stdClass)[1]
  public 'asset_host' => string 'cdn.qiita.com' (length=13)
  public 'TLD' => string 'com' (length=3)
  public 'controller_path' => string 'public/organizations' (length=20)
  public 'controller_action' => string 'public/organizations#index' (length=26)
  public 'controller' => string 'organizations' (length=13)
  public 'action' => string 'index' (length=5)
  public 'action_path' => string 'public/organizations#index' (length=26)
 / public 'env' => string 'production' (length=10)
  public 'flash' => 
    object(stdClass)[2]
  public 'is_team_page' => boolean false
  public 'request_parameters' => 
    object(stdClass)[3]
      public 'controller' => string 'public/organizations' (length=20)
      public 'action' => string 'index' (length=5)
  public 'root_domain' => string 'qiita.com' (length=9)
  public 'variant' => null
  public 'config' => 
    object(stdClass)[5]
      public 'mixpanel' => 
        object(stdClass)[4]
          public 'per_team' => string 'xxx' (length=32)
          public 'public' => string 'xxx' (length=32)
          public 'team' => string 'xxx' (length=32)
      public 'default_locale' => string 'en' (length=2)
      public 'locale' => string 'en' (length=2)
  public 'team' => null
  public 'user' => null
  public 'GIT_BRANCH' => null
  public 'DEBUG' => boolean false

まとめ

PHPに標準で備わっている機能だけで、ウェブページから値を抜き出す方法をまとめました。社内のイントラシステムから社員番号で社員の名前を抜き出したり、インターネットサービスから定期的に値を調べたりできるようになるはずです。
正規表現は奥が深い技術なので、理解できるようにできるだけ簡単に書いてみました。誰かの役に立てばよいかなと思います。

注意事項

ウェブページをスクレイピングするのには、お行儀よく節度を持ってやりましょう。サイトに対する攻撃とみなされる場合もあります。利用者の責任でお願いします。

参考にした資料

正規表現あれこれ
https://qiita.com/mpyw/items/c0312271819baee09132

【5分でまるっと理解】PHP正規表現の使い方まとめ
https://eng-entrance.com/php-regularex

PHPネイティブのDOMによるスクレイピング入門
https://qiita.com/ikmiyabi/items/12d1127056cdf4f0eea5

Webスクレイピングする際のルールとPythonによる規約の読み込み
https://vaaaaaanquish.hatenablog.com/entry/2017/12/01/064227

31
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
31
Help us understand the problem. What is going on with this article?