1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ジョブカンAdvent Calendar 2024

Day 8

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

Last updated at Posted at 2024-12-07

ジョブカン事業部のアドベントカレンダー 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つになります。

  • HTML5 対応 (RFC)
  • querySelector, querySelectorAll のような現代的なAPIの追加 (RFC)

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

ただ、長く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-parsersymfony/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_NOERROR は以前から文法違反によるエラーなどを抑止するために使われていますが、新しいAPIでも引き続きお世話になりそうですね。

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

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

この記事を執筆した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) "ƒeƒXƒg•¶‘"
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) "テスト文書"

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

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

文字列をHTMLエンティティ化させてから読み込ませるテクニックがまだ有用であることは説明しましたが、PHP 8.2以降は mb_convert_encodingHTML-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&+<>&#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;"

参考

まとめ

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

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

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

お知らせ

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

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

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?