[PHP]DOM, XPathを使ったスクレイピング(HTMLのタグ内容取得)

サイトから特定のタグを抜き出すには、DOMDocument::loadHTML() を使います。
基本的な手法は以前解説した記事と同じですのでまずはそちらをお読み下さい。

このサンプルはちょっと癖のある HTML ページからいくつかのタグの内容を取得するものです。

<?php
$html = <<<EOD
<html>
<head>
  <title>Page Title</title>
</head>
<body>
  <div id="container">
    <div id="header">
	  <p>this is header</p>
	</div>
	
    <div id="content">
	  <p>&quot;Hello, World!&quot;</p>
	  <p id="sample">Sample <strong>Text</strong></p>
	  
	  <ul>
	    <li>A</li>
	    <li>B</li>
	    <li>C</li>
	  </ul>
	  
	  <DL>
	    <DT>foo</DT>
		<dd>bar</dd>
	  </DL>
    </div>
	
    <div id="footer">
	  <p>Copyright (C) 2014 <a href="https://php-archive.net">php-archive.net</a></p>
	</div>
  </div>
</body>
</html>
EOD;
 
$dom = new DOMDocument('1.0', 'UTF-8');
$html = mb_convert_encoding($html, "HTML-ENTITIES", 'auto');
@$dom->loadHTML($html);
$xpath = new DOMXPath($dom);
$xpath->registerNamespace("php", "http://php.net/xpath");
$xpath->registerPHPFunctions();

// title
$title = $xpath->query('//head/title[1]')->item(0)->nodeValue;
echo $title . "\n";

// id="content"
$content = $xpath->query('//*[@id="content"]')->item(0);

// id="sample"
$sample = $xpath->query('//*[@id="sample"]')->item(0);
$sampleHtml = getInnerHtml($sample);

echo $sample->nodeValue . "\n";
echo $sampleHtml . "\n";

// href
$a = $xpath->query('//div[@id="footer"][1]/p[1]/a[1]')->item(0);
$href = $a->getAttribute('href');
echo $href . "\n";

// li
$liNodes = $xpath->query('.//ul[1]/li', $content);
foreach($liNodes as $li){
	echo $li->nodeValue . "\n";
}

// dl (大文字小文字を無視)
$dl = $xpath->query(".//*[[php:functionString('strcasecmp', name(), 'dl')=0]]", $content)->item(0);

$dlChildren = $dl->childNodes;
foreach($dlChildren as $node){
	if($node->hasChildNodes()){
		$nodeName = strtolower($node->nodeName);
		if($nodeName == 'dt'){
			echo 'dt:' . $node->nodeValue . "\n";
		} else if($nodeName == 'dd') {
			echo 'dd:' . $node->nodeValue . "\n";
		}
	}
}

// HTMLとして取り出す
function getInnerHtml($node){
	$children = $node->childNodes;
	$html = '';
	foreach($children as $child){
		$html .= $node->ownerDocument->saveHTML($child);
	}
	return $html;
}

結果:

Page Title
Sample Text
Sample <strong>Text</strong>
A
B
C
dt:foo
dd:bar

順番に解説していきます。

HTML ファイルを読み込むには XML を読み込むときと同様に DOMDocument を用います。

$dom = new DOMDocument('1.0', 'UTF-8');
$html = mb_convert_encoding($html, "HTML-ENTITIES", 'auto');
@$dom->loadHTML($html);
$xpath = new DOMXPath($dom);
$xpath->registerNamespace("php", "http://php.net/xpath");
$xpath->registerPHPFunctions();

今回は loadXML() の代わりに loadHTML() で読み込みます。ファイルから読み込む場合は loadHTMLFile() にファイルパスを渡します。「@」でエラーを抑制しているのは HTML の記述に誤りがあった場合でもエラーを表示しないようにするためです。例えば HTML ページ中に同じ id 属性が複数存在していた場合 Warning エラーとなります。
文字化けを防ぐ目的で mb_convert_encoding() を利用しています。HTML ページのエンコーディングが明確な場合や「auto」で正しく認識しない場合は直接文字コードを指定してください。

タグの検索には XPath を用いるので DOMXPath() で割り当てます。
その下にある registerNamespace() と registerPHPFunctions() については後述しますが、XPath の検索条件として PHP の関数を利用できるようにするためのもので、単純な検索なら必須ではありません。

まずはページのタイトルタグから取得します。XPath のクエリは「//head/title[1]」で、head 要素から1番目の title 要素を取得しています。query() は一致した全ての要素をリストの形で返すので item(0) として最初の要素を指定する必要があります。これはクエリの title[1] で1つに絞り込まれていることが明らかでも省略できません。
XPath クエリのインデックス番号は [1] から始まり、item(n) は 0 から始まることに注意して下さい。

// title
$title = $xpath->query('//head/title[1]')->item(0)->nodeValue;
echo $title . "\n";

// id="content"
$content = $xpath->query('//*[@id="content"]')->item(0);

次は属性 id が content であるものを取得します。属性(Attribute)は「@」で表され、「@id="content"」のとき id が content であるものを指します。 「*」は要素名のワイルドカードで、すべての要素が対象になります。<div> であることがあらかじめわかっているなら「//div[@id="content"]」としてしまっても構いません。

クラス名を指定して取得する際などに「class="foo bar"」のように複数の属性を持つ要素を選択する場合、*[@class="foo"] と書くだけでは class が foo のみの要素という意味になってしまうため取得できません。完全一致で *[@class="foo bar"] と書くか、クラスに foo が含まれるものという意味で *[contains(@class, "foo")] としなければなりません。

同じように属性 id が sample であるものを取得し、内容を表示します。

$sample = $xpath->query('//*[@id="sample"]')->item(0);
$sampleHtml = getInnerHtml($sample);

echo $sample->nodeValue . "\n";
echo $sampleHtml . "\n";
[nodeValue]
Sample Text

[innerHtml]
Sample <strong>Text</strong>

ここでは2つの異なる方法で表示しています。ひとつは nodeValue() でもう一つは独自に定義した getInnerHtml です。
nodeValue の結果は「Sample Text」で、本来あるはずの <strong> が消えています。また、「&quot;」などの文字参照も「"」等に置き換えられた状態で取得されます。
これを防ぐには getInnerHtml() として定義したように saveHTML() や C14N() で HTML 文字列として取得します。

フッターにある <a>タグからリンクアドレス(href)を取得するには次のようにします。

// href
$a = $xpath->query('//div[@id="footer"][1]//a[1]')->item(0);
$href = $a->getAttribute('href');
echo $href . "\n";

id に "footer" を持つ <div> 以下の <a>を取得しています。「//a」とすることで <p> の有無にかかわらず <div id="footer"> 以下のすべての階層を取得対象にしています。
属性を取得するには getAttribute() を用います。指定した属性が存在しない場合は空の文字列が返されます。

画像タグ <img> からファイルパスの属性 src を取得する場合でも同様です。body 内の全ての画像のパスを得るなら「//body//img」で全てを取得しておき、getAttribute() で src を抜き出すだけです。

$imgs = $xpath->query('//body//img');

foreach($imgs as $img){
	echo $img->getAttribute('src') . "\n";
}

<ul> のようなリストタグから子要素を取得する場合は「.//ul[1]/li」のようにします。

// li
$liNodes = $xpath->query('.//ul[1]/li', $content);
foreach($liNodes as $li){
	echo $li->nodeValue . "\n";
}

query() の第二引数に抽出元となるノード(コンテキストノード)を指定しているので「.//」とした場合そのコンテキストノード以下から検索されます。この場合 id="content" の要素以下から1番目の <ul> を探し、それ以下の <li> 要素を全て抜き出すことになります。

最後は <dt> の抽出ですが、少し厄介な問題があります。
たいていのサイトはタグを小文字で統一しているので大丈夫なのですが、中には大文字と小文字が混在するサイトも存在します。XPath のクエリは大文字と小文字を区別するので「DT」と「dt」は一致しません。XPath 2.0 であれば lower-case() で変換するというのもありますが現在 PHP に標準搭載されているものは XPath 1.0 なので、PHP 側の関数 strcasecmp() をクエリ内で使えるようにします。
その準備として registerNamespace() で名前空間を登録し、registerPHPFunctions() で使用する関数を登録します。本来は registerPHPFunctions("strcasecmp") のように使うものだけ登録しますが、引数なしの場合制限がなくなり、全ての関数が使えるようになります。使う際には「名前空間:functionString("関数名", 引数…)」のようにします。

$xpath->registerNamespace("php", "http://php.net/xpath");
$xpath->registerPHPFunctions();

$dl = $xpath->query(".//*[php:functionString('strcasecmp', name(), 'dl')=0]", $content)->item(0);

$dlChildren = $dl->childNodes;
foreach($dlChildren as $node){
	if($node->hasChildNodes()){
		$nodeName = strtolower($node->nodeName);
		if($nodeName == 'dt'){
			echo 'dt:' . $node->nodeValue . "\n";
		} else if($nodeName == 'dd') {
			echo 'dd:' . $node->nodeValue . "\n";
		}
	}
}

要素名は「DL」か「dl」なのか未知なので「.//*」とし、XPath の関数 name() で得られた要素名と「dl」を strcasecmp() で比較して一致していれば true という条件にします。
あとは得られた <dl> の子要素 childNodes を foreach() で回して <dt> の時と <dd> の時で処理を分けています。この際に hasChildNodes() でチェックしておかないと改行や空白文字などが混じってしまいます。

functionString() で使うことのできる関数は PHP 標準のものだけでなく、独自に定義した関数でも動作します。
例えば後方一致させる関数 endsWith() を作っておくと、指定した文字で終わるものだけを選択できます。
これを使えば img 要素のうち、src が「.jpg」で終わるものだけを抽出することができます。

// 部分一致
$partial = $xpath->query('//body//img[contains(@src, "sample")]');

// 前方一致
$prefix = $xpath->query('//body//img[starts-with(@src, "picture")]');

// 後方一致
$suffix = $xpath->query('//body//img[php:function("endsWith", @src, ".jpg")]');

function endsWith($node, $needle){
	$length = mb_strlen($needle);
	if(mb_substr($node[0]->nodeValue, - $length) === $needle){
		return true;
	} else {
		return false;
	}
}

"sample" を含むもの、"picture" で始まるもの、".jpg" で終わるものを選択しています。
部分一致を表す contains() や 前方一致を表す starts-with() は XPath の関数としてあらかじめ用意されているので自分で作る必要はありません。

内容の複雑さゆえ、やや長文になってしまいましたが解説は以上です。
形式の整った XML に比べて HTML は不規則であることが多いため、パースする際は正しくないデータが送られてくる前提で柔軟な設計を取るのが良さそうです。

参考:
http://php.net/manual/ja/class.domxpath.php

PHP で XML の内容を取得する(DOM, XPath)

前回、基本的なファイルの読み書きに関してまとめたので、今回は XML の要素を取得したり、検索する方法についてのメモです。

要素を名前から取得するには、getElementsByTagName(“要素名”) を使います。 <data> ノードの中にある最初の <sample> ノードの内容を表示するサンプルがこちらです。

sample.xml
<?xml version="1.0" encoding="utf-8"?>
<data>
  <sample id="1">red</sample>
  <sample id="2">green</sample>
  <sample id="3">test</sample>
  <sample id="4">blue</sample>
  <sample id="5">white</sample>
  <sample id="6">yellow</sample>
</data>
PHP
<?php
$dom = new DOMDocument('1.0', 'UTF-8');
$dom->preserveWhiteSpace = false;
$dom->formatOutput = true;
$dom->load("sample.xml");

$root       = $dom->getElementsByTagName("data")->item(0);
$sampleNode = $root->getElementsByTagName("sample")->item(0);

echo $sampleNode->nodeValue;
結果
red

getElementsByTagName() を使うと、その親ノードより深いすべてのノードが配列として取得されます。 一番最初(0番目)のアイテムを選択する場合は「item(0)」のように指定します。
ノードから値を取り出す時は「nodeValue」を使えば取得できます。ノードの内容を書き換える際も直接 nodeValue 書き換えます。

$sampleNode->nodeValue = 'New Text';

上の例ではルートノード <data> を getElementsByTagName() で取得しましたが、 documentElement を使うとルートノードの名前がわからない場合でも取得できます。
ルートノードとはドキュメント内で最も浅い親ノードのことで、この場合は <data> です。 よって下記のように記述しても同じ結果を得ることができます。

$root   = $dom->documentElement;
$sample = $root->getElementsByTagName("sample")->item(0)->nodeValue;

ノードの検索

複数ある <sample> 要素の内、内容が「test」であるノードを取得してみます。

<?php
$dom = new DOMDocument('1.0', 'UTF-8');
$dom->preserveWhiteSpace = false;
$dom->formatOutput = true;
$dom->load("sample.xml");
$root = $dom->documentElement;

$search = "test";
$result = null;

$sampleNodeList = $root->getElementsByTagName("sample");
foreach($sampleNodeList as $sampleNode){
  if($sampleNode->nodeValue === $search){
    $result = $sampleNode;
    break;
  }
}

if( !is_null($result) ) echo $result->getAttribute("id");
結果
3

サンプルの $sampleNodes の中には getElementsByTagName() で見つかった全ての sample ノードを格納されています。 foreach でループさせ、値が見つかったら break でループを抜けます。 見つかった要素の属性名「id」の内容を取得するには getAttribute(“属性名”) を使います。


XPath を使ったノード探索

上記の方法が間違っているというわけでは有りませんが、ループで検索するのは原始的すぎると思うのであれば、XPath を使うと綺麗にまとまります。

<?php
$dom = new DOMDocument('1.0', 'UTF-8');
$dom->preserveWhiteSpace = false;
$dom->formatOutput = true;
$dom->load("sample.xml");

$xpath = new DOMXPath($dom);

$result = $xpath->query("/data/sample[text()='test']")->item(0);
echo $result->nodeValue;

随分すっきりしました。
基本的には query() に検索条件を指定して抽出します。 サンプルでは、値「test」を持つ sample ノードを取得しています。 「/」は階層を表します。「/data/sample」の時、<data> 要素が持つ子ノードの内、<sample> という名前の要素を全て選択します。
ディレクトリパスの書き方と同様に「.」は現在のノードを表し、「..」と書けばその親を指定することも出来ます。
条件は [] の中に記述します。テキスト内容は text() で得られるので、「text()=’test’」とするとテキストが「test」である場合という条件になります。
n 番目に見つかったノードという条件であれば「/data/sample[1]」のように指定できます。数値は 0 ではなく 1 から始まるため、この場合最初に見つかった sample ノードという意味になります。「/data/sample[position()=1]」と書いても同じです。最後に見つかったノードであれば「/data/sample[last()]」という書き方ができます。

id などの属性(Attribute)からノードを選択する場合は属性名の前に @ をつけて指定します。
ドキュメント内の全ての <sample> のうち、id が 3 であるものを取得してみます。

<?php
$dom = new DOMDocument('1.0', 'UTF-8');
$dom->preserveWhiteSpace = false;
$dom->formatOutput = true;
$dom->load("sample.xml");

$xpath = new DOMXPath($dom);
$result = $xpath->query('//sample[@id = "3"]')->item(0);
echo $result->nodeValue;

条件は「and」「or」を使って複数指定することもできます。

[@id="3" or @class="sample"]

この場合 id が 3 又は クラスが sample であるという条件になります。

XPathクエリの「/」は子ノード(一つ下の階層)までを検索対象にしますが、クエリに「//」をつかうとそのノード以下の子孫ノード(すべての階層のノード)が対象になります。「/data//sample」のように探索することも出来ます。この場合は <data> 以下の全ての深さの階層から <sample> という名前のノードを選択することになります。

条件は and や or で組み合わせることができます。

$result = $xpath->query('//sample[text()="green" or text()="red"]');
foreach($result as $node){
  echo $node->nodeValue . "<br />";
}

「red green」と表示されます。 red か green の値を持つすべての sample ノードが抽出されているのがわかります。

XPath の query() によって取得した DOMElement から、さらに query を使って絞り込む場合、query() の第2引数として絞込元となるノード(コンテキストノード)を渡します。そのさいクエリを「//title」のようにしてしまうとコンテキストノードを指定する意味がなくなってしまうので、「.//title」などの相対的な書き方にします。

<?php
$xml = <<<EOD
<?xml version="1.0" encoding="utf-8"?>
<data>
  <article>
    <title>sample1</title>
  </article>
 
  <article>
    <title>sample2</title>
  </article>
</data>
EOD;
 
$dom = new DOMDocument('1.0', 'UTF-8');
$dom->preserveWhiteSpace = false;
$dom->formatOutput = true;
$dom->loadXml($xml);
 
$xpath = new DOMXPath($dom);
 
$article = $xpath->query('//article')->item(0);
$title = $xpath->query('.//title', $article)->item(0);
echo $title->nodeValue;
結果: sample1

XPath の文法の詳細については、こちらのサイト(http://www.techscore.com/tech/XML/XPath/xpath02.html/)が わかりやすくまとめてくださっています。


子ノードの削除

親ノードから子ノードを削除するには removeChild() を使います。

$xpath  = new DOMXPath($dom);
$result = $xpath->query('/data/sample[text()="test"]')->item(0);
$result->parentNode->removeChild($result);

$root->removeChild($result);

子ノードの削除は親ノードから行います。親ノードの参照は parentNode に格納されているのでそれを利用します。あるいは '/data/sample/..' のようにしてパスを遡って親ノードを取得する方法もあります。