ジョブカン事業部のアドベントカレンダー 8日目です。
はじめに
年末といえば、PHPの新しいバージョンがリリースされる季節です!
今年は11月21日にPHP 8.4がリリースされました。皆様はもうRelease Announcementを確認しましたか?
また一段と便利になったPHPですが、今回はDOM拡張モジュールの進化について紹介していきます。
なお、この記事の内容およびサンプルコードは、執筆時点の最新バージョンであるPHP 8.4.1で検証しています。
DOM拡張モジュールとは
DOM拡張モジュールとは、PHP上でDOM APIを用いてXMLやHTMLを操作するための実装のことです。
DOMはXMLやHTMLのような文書を階層化したデータ構造に落とし込んで読み書きするためのAPI仕様ですが、それをPHPに実装したものとなります。
これを使うと、Webブラウザ上のJavaScriptと同じような感覚でHTMLを操作するプログラムを書くことができます。
主な用途としては、XMLベースのAPIと接続する際のリクエスト生成・レスポンス解析や、Webサイトのスクレイピングが挙げられます。
昔と比べれば活用の機会は減っているかもしれませんが、ある日突然使う必要に迫られたりする。そんなモジュールです。
PHP 8.4で何が変わった?
Release Announcementでも触れられている、特に大きなトピックは以下の2つになります。
「えっ、今?」と思う人もいるかもしれませんね。私もそう思います
ただ、長くPHPを触ってきた人であれば、たったこれだけでどれほど画期的な事であることか分かるのではないでしょうか…!
HTML5対応
PHPのDOM API実装はlibxml2というライブラリを基盤としています。その影響で、従来はlibxml2が対応しているHTML4.01の仕様に基づいたパースしかできませんでした。
HTML自体が比較的ラフなこともあって、これでもある程度は実用できていましたが、HTML5以降で追加された仕様を正しく解釈することはできていませんでした。
PHP 8.4では、HTML5に対応した新しいクラスが導入されました。後述するAPIも、この新しいクラスの上に構築されています。
ちなみに、パースにはLexborというライブラリを用いていて、得られたDOMをlibxml2のデータ構造に変換しているようです。
querySelector
, querySelectorAll
のような現代的なAPIの追加
とうとう querySelector
, querySelectorAll
が導入されました。
ついにXPathから解放され、馴染みのあるCSSセレクターを用いてDOMを抽出することができます。
最近初めて DOMDocument
を触った方は「なぜ querySelector
が無いのだろう」と思いながら渋々とXPathを学ぶか、あるいは kub-at/php-simple-html-dom-parser や symfony/dom-crawler を導入していたのではないでしょうか。
また、他にも classList
のようなWebブラウザではすっかり当たり前なプロパティも追加されています。DOMを編集する時や、条件分岐を書く時に便利そうですね。
HTMLをパースする
今回追加されたHTML5のパースと現代的なDOM操作のAPIは、いずれも Dom\HTMLDocument
(また、そのスーパークラスである Dom\Document
) が起点となるように実装されています。
これは既存の DOMDocument
の挙動を壊さないようにするためです。単に querySelector
が使いたいだけの人にとっては少し面倒ですが、仕方ないですね。
さて、このクラスには Dom\HTMLDocument
のインスタンスを返すstaticメソッドがいくつか定義されています。どこからHTMLを読み込むかの違いなので、ユースケースに合ったものを選びましょう。
<?php
// 文字列から読み込む
$doc = \Dom\HTMLDocument::createFromString("<!doctype html><body></body>");
// ファイルから読み込む
$doc = \Dom\HTMLDocument::createFromFile("/path/to/file");
また、これらのメソッドは第2引数にオプションを渡すことも可能です。渡せるオプションは DOMDocument::loadHTML()
で使用できたオプションの一部と、今回追加されたオプションのみです。
LIBXML_HTML_NOIMPLIED
LIBXML_COMPACT
LIBXML_NOERROR
Dom\HTML_NO_DEFAULT_NS
LIBXML_NOERROR
は以前から文法違反によるエラーなどを抑止するために使われていますが、新しいAPIでも引き続きお世話になりそうですね。
見慣れない Dom\HTML_NO_DEFAULT_NS
については、後で説明します。
インスタンス化した後のメソッドは、ほとんど DOMDocument
と同じです。正直説明することがありません。
それに加えて querySelector
や querySelectorAll
が使えるといったような形になっています。
この記事を執筆した11月末現在では、まだPHP公式サイトのドキュメントの更新が完了していないようです。
ドキュメントが公開されるまでの間は、具体的にどんなメソッドが呼び出せるかはPHPのソースコード内にあるphp_dom.sub.phpを読むと、なんとなく分かります。
<?php
$html = <<<HTML
<!DOCTYPE html>
<head>
<meta charset="utf-8">
</head>
<body>
<h1 class="title red">テスト文書</h1>
<p class="txt-main">これはテスト用のHTMLです。</p>
<p class="txt-main">ああああ。</p>
</body>
HTML;
$doc = \Dom\HTMLDocument::createFromString($html);
var_dump($doc->querySelector(".txt-main")->textContent);
// => string(37) "これはテスト用のHTMLです。"
var_dump($doc->querySelector(".title")->className);
// => string(9) "title red"
var_dump([...$doc->querySelector(".title")->classList]);
// => array(2) {
// [0]=>
// string(5) "title"
// [1]=>
// string(3) "red"
// }
var_dump(array_map(fn($el) => $el->textContent, [...$doc->querySelectorAll(".txt-main")]));
// => array(2) {
// [0]=>
// string(37) "これはテスト用のHTMLです。"
// [1]=>
// string(15) "ああああ。"
// }
HTMLをパースする以外の使い方
今回はあまり掘り下げませんが、HTMLをパースする以外のファクトリメソッドもあるため簡単に紹介しておきます。
Dom\HTMLDocument::createEmpty()
は、何も読み込まず空のHTML文書を作成します。DOM APIを用いてHTMLを作成したい時に使えるかもしれません。
Dom\XMLDocument
はほとんど従来の DOMDocument
と変わりませんが、Dom\HTMLDocument
と同じスーパークラスを持っていること、それによって新しいAPIを使えることが違いとなっています。
<?php
// 空のHTML文書を作成
$doc = \Dom\HTMLDocument::createEmpty();
// 空のXML文書を作成
$xml = \Dom\XMLDocument::createEmpty();
// XMLをパース
$xml = \Dom\XMLDocument::createFromString(<<<XML
<?xml version="1.0" encoding="UTF-8"?>
<Document>
<Node />
<Node />
</Document>
XML);
$xml = \Dom\XMLDocument::createFromFile("/path/to/file");
XPathを使う
CSSセレクターは非常に便利ですが、時にはXPathを使わないと表現できないクエリもあります。
特に、過去にXPathを使って実装したコードをリファクタリングする際には、単純に置き換えることができずに苦労をするかもしれません。
また、単にHTMLパーサーは新しいものにしたいが、XPathを辞める気はないという場合もあるかと思います。
そういう時には Dom\XPath
クラスを使いましょう。既存の DOMXPath
とインターフェースはほとんど同じですので、そちらを使ったことがある人であれば何も説明しなくても使えると思います。
ただし Dom\HTMLDocument
は、既定でHTML要素をXHTMLの名前空間に配置します。
そのため、XPathでクエリする時は名前空間を付けないと結果が得られません。サンプルを示します。
<?php
$html = <<<HTML
<!doctype html>
<body>
<p>aaa</p>
<p>bbb</p>
</body>
HTML;
$document = \Dom\HTMLDocument::createFromString($html);
$xpath = new \Dom\XPath($document);
$xpath->registerNamespace('xhtml', 'http://www.w3.org/1999/xhtml');
var_dump($xpath->query("//p")->length); // => int(0)
var_dump($xpath->query("//xhtml:p")->length); // => int(2)
この点は従来の DOMDocument
の挙動と違うので注意が必要です。
なお Dom\HTMLDocument::createFromString()
のオプションとして Dom\HTML_NO_DEFAULT_NS
を追加すれば、名前空間は付かなくなります。従来通りの挙動を維持したい場合はお試しを。
<?php
$html = <<<HTML
<!doctype html>
<body>
<p>aaa</p>
<p>bbb</p>
</body>
HTML;
$document = \Dom\HTMLDocument::createFromString($html, \Dom\HTML_NO_DEFAULT_NS); // <= オプション追加
$xpath = new \Dom\XPath($document);
var_dump($xpath->query("//p")->length); // => int(2)
参考
UTF-8ではない文書を読み込ませる
charsetの指定が文書内に含まれている場合、最近のバージョンのPHPであれば考慮してくれます。そのため、正しくマークアップされた文書であれば問題なく読み込めることが多いです。
ただ、時にはパースしたい文書にそういったタグが書かれていない場合もあることでしょう。
その時の挙動が新旧APIで若干異なります。実際に見てみましょう。まずは一般的なUTF-8の文書を入力してみます。
<?php
$html1 = <<<HTML
<!DOCTYPE html>
<head>
<meta charset="utf-8">
</head>
<body>
<h1>テスト文書</h1>
<p>これはテスト用のHTMLです。</p>
</body>
HTML;
$html2 = <<<HTML
<!DOCTYPE html>
<head>
</head>
<body>
<h1>テスト文書</h1>
<p>これはテスト用のHTMLです。</p>
</body>
HTML;
function parseLegacy($html)
{
$doc = new DOMDocument();
$doc->loadHTML($html);
$xpath = new DOMXPath($doc);
$node = $xpath->query("//h1")->item(0);
var_dump($node->textContent);
}
function parseModern($html)
{
$doc = \Dom\HTMLDocument::createFromString($html, \Dom\HTML_NO_DEFAULT_NS);
$xpath = new \Dom\XPath($doc);
$node = $xpath->query("//h1")->item(0);
var_dump($node->textContent);
}
echo "charsetあり\n";
parseLegacy($html1);
parseModern($html1);
echo "========\n";
echo "charsetなし\n";
parseLegacy($html2);
parseModern($html2);
charsetあり
string(15) "テスト文書"
string(15) "テスト文書"
========
charsetなし
string(30) "ãã¹ãææ¸"
string(15) "テスト文書"
古いAPIでは文字化けしてしまいましたが、新しいAPIでは文字化けしませんでした。
それでは、今度は入力をShift_JISにして試してみましょう。
<?php
$html1 = <<<HTML
<!DOCTYPE html>
<head>
<meta charset="shift_jis">
</head>
<body>
<h1>テスト文書</h1>
<p>これはテスト用のHTMLです。</p>
</body>
HTML;
$html1 = mb_convert_encoding($html1, 'SJIS-win');
$html2 = <<<HTML
<!DOCTYPE html>
<head>
</head>
<body>
<h1>テスト文書</h1>
<p>これはテスト用のHTMLです。</p>
</body>
HTML;
$html2 = mb_convert_encoding($html2, 'SJIS-win');
function parseLegacy($html)
{
$doc = new DOMDocument();
$doc->loadHTML($html);
$xpath = new DOMXPath($doc);
$node = $xpath->query("//h1")->item(0);
var_dump($node->textContent);
}
function parseModern($html)
{
$doc = \Dom\HTMLDocument::createFromString($html, \Dom\HTML_NO_DEFAULT_NS);
$xpath = new \Dom\XPath($doc);
$node = $xpath->query("//h1")->item(0);
var_dump($node->textContent);
}
echo "charsetあり\n";
parseLegacy($html1);
parseModern($html1);
echo "========\n";
echo "charsetなし\n";
parseLegacy($html2);
parseModern($html2);
charsetあり
string(15) "テスト文書"
string(15) "テスト文書"
========
charsetなし
string(17) "eXg¶"
string(24) "�e�X�g����"
この場合は、charsetが無いとどちらも文字化けしてしまいました。
このように、古いAPIではデフォルトエンコードはISO-8859-1 (HTML4.01時代の標準) だったのですが、新しいAPIではUTF-8 (HTML5時代のデフォルト) となっています。
パースしたい文書にcharsetが含まれていない場合、UTF-8にエンコードしてから読み込むと上手く行きそうですね。
ただ、リアルな用途においてはcharsetが含まれていないことよりも、実際のエンコードとcharsetが一致していない事がしばしばあります。
世間のエンジニアの意思がUTF-8に統一される前、今よりもエンコードの宣言が重要であるにも関わらず、書き手がよく理解しておらず文字化けを頻発させていた時代の話です。そういった時代のHTMLにはよくありました。
こういうケースの対処法として、mb_convert_encoding
の第2引数に HTML-ENTITIES
を指定してHTMLエンティティ化させることで文字化けを回避することがよくありました。
現在でもこのテクニックは有用ですが、Dom\HTMLDocument
の各ファクトリメソッドには、エンコードの自動判定を止めて上書きするオプション引数 $overrideEncoding
が存在します。
これを使うことで、入力のエンコードをUTF-8に統一してからパースさせるアプローチが可能となりました。
従来の HTML-ENTITIES
へのエンコードは、実際何をどう変換しているのかがいまいち分かりにくかったので、こちらのほうが素直に読めてメンテナンスもしやすいように思います。
<?php
$html = <<<HTML
<!DOCTYPE html>
<head>
<meta charset="EUC-JP">
</head>
<body>
<h1>テスト文書</h1>
<p>これはテスト用のHTMLです。EUC-JPと宣言しているのにShift_JISでエンコードされています。</p>
</body>
HTML;
$html = mb_convert_encoding($html, 'SJIS-win');
// そのまま読み込むと当然文字化けする
$doc = \Dom\HTMLDocument::createFromString($html, \Dom\HTML_NO_DEFAULT_NS);
$xpath = new \Dom\XPath($doc);
$node = $xpath->query("//h1")->item(0);
var_dump($node->textContent);
// 一度UTF-8にしてから、第3引数でエンコードを固定
$html = mb_convert_encoding($html, 'UTF-8', 'SJIS-win');
$doc = \Dom\HTMLDocument::createFromString($html, \Dom\HTML_NO_DEFAULT_NS, 'UTF-8');
$xpath = new \Dom\XPath($doc);
$node = $xpath->query("//h1")->item(0);
var_dump($node->textContent);
string(21) "�e�X�g���"
string(15) "テスト文書"
まだ DOMDocument
を使う必要がある人向けのTips
文字列をHTMLエンティティ化させてから読み込ませるテクニックがまだ有用であることは説明しましたが、PHP 8.2以降は mb_convert_encoding
で HTML-ENTITIES
を指定することが非推奨となってしまいました。
DOMDocument
を引き続き使う場合、今後は mb_encode_numericentity
を使ってHTMLエンティティ化するようにしましょう。
<?php
$document = new DOMDocument();
- $document->loadHTML(mb_convert_encoding($input, 'HTML-ENTITIES', 'UTF-8'));
+ $document->loadHTML(mb_encode_numericentity($input, [0x80, 0x10fffff, 0, 0x1fffff]));
ただし、第3引数が正しく入力のエンコードと一致していないと、不適切な結果が出力されるので注意しましょう。
(この引数は省略可能で、省略した場合は mb_internal_encoding
依存です)
一度UTF-8に変換してから mb_encode_numericentity
を通すと楽かもしれません。
<?php
$input = "abcABC&+<>てすとテスト試験";
var_dump(mb_encode_numericentity($input, [0x80, 0x10fffff, 0, 0x1fffff], 'UTF-8'));
$input = mb_convert_encoding($input, 'SJIS-win', 'UTF-8');
var_dump(mb_encode_numericentity($input, [0x80, 0x10fffff, 0, 0x1fffff], 'UTF-8'));
var_dump(mb_encode_numericentity($input, [0x80, 0x10fffff, 0, 0x1fffff], 'SJIS-win'));
string(74) "abcABC&+<>てすとテスト試験"
string(34) "abcABC&+<>?Ă??ƃe?X?g????"
string(74) "abcABC&+<>てすとテスト試験"
参考
まとめ
- PHP 8.4のDOMはHTML5に対応し、
querySelector
やquerySelectorAll
を始めとした現代的なメソッドも使えるようになった - XPathも引き続き使うことができるが、軽微な仕様変更があるので注意が必要
- 新旧APIで文字エンコード回りの処理も若干変わっているが、自動判定を止めて固定するオプションが追加されたので簡単に制御できるようになった
長年不便に思っていた部分に手が入っていて、かなり使いやすいAPIに進化していると感じました。サードパーティライブラリを使わずに済むケースが大幅に増えて嬉しいですね。
個人的には、この記事を書くまでエンコード回りの処理テクニックの根拠をよく理解していなかったのですが、将来的には忘れても良さそうなことが一番嬉しいです。
お知らせ
DONUTSでは、新卒中途を問わず積極的に採用活動を行っています。
我々ジョブカン事業部も、一緒に働くエンジニアを募集しています。よろしければ是非。