PHP 8.4の新しいDOM APIはいいぞ！

ジョブカン事業部のアドベントカレンダー 8日目です。

https://qiita.com/advent-calendar/2024/jobcan

# はじめに

年末といえば、PHPの新しいバージョンがリリースされる季節です！

今年は11月21日にPHP 8.4がリリースされました。皆様はもうRelease Announcementを確認しましたか？

https://www.php.net/releases/8.4/ja.php

また一段と便利になった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でも触れられている](https://www.php.net/releases/8.4/ja.php#dom_additions_html5)、特に大きなトピックは以下の2つになります。

- HTML5 対応 ([RFC](https://wiki.php.net/rfc/domdocument_html5_parser))
- `querySelector`, `querySelectorAll` のような現代的なAPIの追加 ([RFC](https://wiki.php.net/rfc/dom_additions_84))

「えっ、今？」と思う人もいるかもしれませんね。~~私もそう思います~~

ただ、長くPHPを触ってきた人であれば、たったこれだけでどれほど画期的な事であることか分かるのではないでしょうか…！

## HTML5対応

PHPのDOM API実装はlibxml2というライブラリを基盤としています。その影響で、従来はlibxml2が対応しているHTML4.01の仕様に基づいたパースしかできませんでした。

HTML自体が比較的ラフなこともあって、これでもある程度は実用できていましたが、HTML5以降で追加された仕様を正しく解釈することはできていませんでした。

PHP 8.4では、HTML5に対応した新しいクラスが導入されました。後述するAPIも、この新しいクラスの上に構築されています。

ちなみに、パースには[Lexbor](https://github.com/lexbor/lexbor)というライブラリを用いていて、得られたDOMをlibxml2のデータ構造に変換しているようです。

## `querySelector`, `querySelectorAll` のような現代的なAPIの追加

とうとう `querySelector`, `querySelectorAll` が導入されました。

ついにXPathから解放され、馴染みのあるCSSセレクターを用いてDOMを抽出することができます。

最近初めて `DOMDocument` を触った方は「なぜ `querySelector` が無いのだろう」と思いながら渋々とXPathを学ぶか、あるいは [kub-at/php-simple-html-dom-parser](https://packagist.org/packages/kub-at/php-simple-html-dom-parser) や [symfony/dom-crawler](https://packagist.org/packages/symfony/dom-crawler) を導入していたのではないでしょうか。

また、他にも `classList` のようなWebブラウザではすっかり当たり前なプロパティも追加されています。DOMを編集する時や、条件分岐を書く時に便利そうですね。

# HTMLをパースする

今回追加されたHTML5のパースと現代的なDOM操作のAPIは、いずれも `Dom\HTMLDocument` (また、そのスーパークラスである `Dom\Document`) が起点となるように実装されています。

これは既存の `DOMDocument` の挙動を壊さないようにするためです。単に `querySelector` が使いたいだけの人にとっては少し面倒ですが、仕方ないですね。

さて、このクラスには `Dom\HTMLDocument` のインスタンスを返すstaticメソッドがいくつか定義されています。どこからHTMLを読み込むかの違いなので、ユースケースに合ったものを選びましょう。

```php
<?php
// 文字列から読み込む
$doc = \Dom\HTMLDocument::createFromString("<!doctype html><body></body>");
// ファイルから読み込む
$doc = \Dom\HTMLDocument::createFromFile("/path/to/file");
```

また、これらのメソッドは第2引数にオプションを渡すことも可能です。渡せるオプションは `DOMDocument::loadHTML()` で使用できたオプションの一部と、今回追加されたオプションのみです。

- [`LIBXML_HTML_NOIMPLIED`](https://www.php.net/manual/ja/libxml.constants.php#constant.libxml-html-noimplied)
- [`LIBXML_COMPACT`](https://www.php.net/manual/ja/libxml.constants.php#constant.libxml-compact)
- [`LIBXML_NOERROR`](https://www.php.net/manual/ja/libxml.constants.php#constant.libxml-noerror)
- `Dom\HTML_NO_DEFAULT_NS`

`LIBXML_NOERROR` は以前から文法違反によるエラーなどを抑止するために使われていますが、新しいAPIでも引き続きお世話になりそうですね。

見慣れない `Dom\HTML_NO_DEFAULT_NS` については、後で説明します。

インスタンス化した後のメソッドは、ほとんど `DOMDocument` と同じです。正直説明することがありません。
それに加えて `querySelector` や `querySelectorAll` が使えるといったような形になっています。

:::note warn
この記事を執筆した11月末現在では、まだPHP公式サイトのドキュメントの更新が完了していないようです。
ドキュメントが公開されるまでの間は、具体的にどんなメソッドが呼び出せるかはPHPのソースコード内にある[php_dom.sub.php](https://github.com/php/php-src/blob/php-8.4.1/ext/dom/php_dom.stub.php#L1928)を読むと、なんとなく分かります。
:::

```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) "ああああ。"
// }
```

https://3v4l.org/1M9X0#v8.4.1

# HTMLをパースする以外の使い方

今回はあまり掘り下げませんが、HTMLをパースする以外のファクトリメソッドもあるため簡単に紹介しておきます。

`Dom\HTMLDocument::createEmpty()` は、何も読み込まず空のHTML文書を作成します。DOM APIを用いてHTMLを作成したい時に使えるかもしれません。

`Dom\XMLDocument` はほとんど従来の `DOMDocument` と変わりませんが、`Dom\HTMLDocument` と同じスーパークラスを持っていること、それによって新しいAPIを使えることが違いとなっています。

```php
<?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
<?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)
```

https://3v4l.org/EBUqZ#v8.4.1

この点は従来の `DOMDocument` の挙動と違うので注意が必要です。

なお `Dom\HTMLDocument::createFromString()` のオプションとして `Dom\HTML_NO_DEFAULT_NS` を追加すれば、名前空間は付かなくなります。従来通りの挙動を維持したい場合はお試しを。

```php
<?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)
```

https://3v4l.org/ftEmM#v8.4.1

### 参考

https://github.com/php/php-src/issues/13737

# UTF-8ではない文書を読み込ませる

charsetの指定が文書内に含まれている場合、最近のバージョンのPHPであれば考慮してくれます。そのため、正しくマークアップされた文書であれば問題なく読み込めることが多いです。

ただ、時にはパースしたい文書にそういったタグが書かれていない場合もあることでしょう。

その時の挙動が新旧APIで若干異なります。実際に見てみましょう。まずは一般的なUTF-8の文書を入力してみます。

```php
<?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) "テスト文書"
```

https://3v4l.org/F2uVA

古いAPIでは文字化けしてしまいましたが、新しいAPIでは文字化けしませんでした。

それでは、今度は入力をShift_JISにして試してみましょう。

```php
<?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) "eXg¶"
string(24) "�e�X�g����"
```

https://3v4l.org/3poKT

この場合は、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
<?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) "テスト文書"
```

[https://3v4l.org/iio1m#v8.4.1](https://3v4l.org/iio1m#v8.4.1)

## まだ `DOMDocument` を使う必要がある人向けのTips

文字列をHTMLエンティティ化させてから読み込ませるテクニックがまだ有用であることは説明しましたが、PHP 8.2以降は `mb_convert_encoding` で `HTML-ENTITIES` を指定することが非推奨となってしまいました。

`DOMDocument` を引き続き使う場合、今後は `mb_encode_numericentity` を使ってHTMLエンティティ化するようにしましょう。

```diff_php
<?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
<?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&+<>&#12390;&#12377;&#12392;&#12486;&#12473;&#12488;&#35430;&#39443;"
string(34) "abcABC&+<>?&#258;??&#387;e?X?g????"
string(74) "abcABC&+<>&#12390;&#12377;&#12392;&#12486;&#12473;&#12488;&#35430;&#39443;"
```

https://3v4l.org/qjQg3

### 参考

https://qiita.com/takaram/items/79bf513694025f737d07

https://github.com/php/php-src/pull/7177#issuecomment-1317296767

# まとめ

- PHP 8.4のDOMはHTML5に対応し、`querySelector` や `querySelectorAll` を始めとした現代的なメソッドも使えるようになった
- XPathも引き続き使うことができるが、軽微な仕様変更があるので注意が必要
- 新旧APIで文字エンコード回りの処理も若干変わっているが、自動判定を止めて固定するオプションが追加されたので簡単に制御できるようになった

長年不便に思っていた部分に手が入っていて、かなり使いやすいAPIに進化していると感じました。サードパーティライブラリを使わずに済むケースが大幅に増えて嬉しいですね。

個人的には、この記事を書くまでエンコード回りの処理テクニックの根拠をよく理解していなかったのですが、将来的には忘れても良さそうなことが一番嬉しいです。

# お知らせ

DONUTSでは、新卒中途を問わず積極的に採用活動を行っています。

我々ジョブカン事業部も、一緒に働くエンジニアを募集しています。よろしければ是非。

https://recruit.jobcan.jp/donuts/show/B001/1396297