🖋️

PHP を使って HTML からテキストを抽出する

2023/11/25に公開

Web サイト上の文章表現について UX ライティングの文脈で設計する際、現在の Web サイト上でどのような単語や文が用いられているか調査したい場合がある。手動でブラウジングしながら調査するのも有益だが、 Web サイトの規模がある程度大きくなると、すべてのページを網羅的に調べるのは骨が折れる。こんなときはプログラムを使って機械的にテキストを抽出できると役に立つかもしれない。

この記事では、ソースコードに含まれる HTML や Laravel の Blade テンプレートからテキストをプログラムで抽出するアイデアについて記す。

なお、サンプルコードは以下のリポジトリで公開している。

https://github.com/hamakou108/php-html-text-extractor

環境

  • PHP 8.2
  • Laravel 10.33.0

HTML の場合

次のようなごく普通の HTML について考えてみる。

resources/index.html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Sample Page</title>
    <script>
      console.log("Hello World!")
    </script>
  </head>
  <body>
    <h1></h1>
    <div>
      <p>This is an <strong>AMAZING</strong> page!</p>
    </div>
    <div>
      Before Apple
      <div>
        Apple
      </div>
      After Apple
    </div>
    <!-- Comment -->
  </body>
</html>

例えば <title> タグの "Sample Page" のようなテキストは抽出されるようにしたい。一方で <script> タグの "Hello World!" のようにライティングとは無関係なテキストは除外されるようにしたい。このように抽出すべきテキストと抽出すべきでないテキストを区別する必要がある。

テキストを抽出する単位についても考慮が必要だ。例えば <p> タグ中にはテキストとテキストの間に <strong> が含まれているが、 <strong> タグがテキストの区切り位置であるとは考えにくい。つまり "This is an AMAZING page!" のように抽出するのが妥当だろう。しかし、その下の <div> タグは通常ブロック要素として振る舞うので、 "Before Apple" / "Apple" / "After Apple" のように開始タグと終了タグの位置でテキストが区切られた方が良いだろう。

このような要件を満たすスクリプトを書いてみる。結果から示すと次のようなコードになる。

src/index.php
<?php

$html = file_get_contents('resources/index.html');

// Remove indentation
$html = preg_replace('/^[ \t]+/m', '', $html);

// Remove line break
$html = str_replace(["\r\n", "\r", "\n"], '', $html);

// Except autonomous custom elements and text
const PHRASING_CONTENT_NAMES = ['a', 'abbr', 'area', 'audio', 'b', 'bdi', 'bdo', 'br', 'button', 'canvas', 'cite', 'code', 'data', 'datalist', 'del', 'dfn', 'em', 'embed', 'i', 'iframe', 'img', 'input', 'ins', 'kbd', 'label', 'link', 'map', 'mark', 'math', 'meta', 'meter', 'noscript', 'object', 'output', 'picture', 'progress', 'q', 'ruby', 's', 'samp', 'script', 'select', 'slot', 'small', 'span', 'strong', 'sub', 'sup', 'svg', 'template', 'textarea', 'time', 'u', 'var', 'video', 'wbr'];

function isText(DOMNode $node): bool {
    return $node->nodeType === XML_TEXT_NODE;
}

function isPhrasingContent(DOMNode $node): bool {
    return in_array($node->nodeName, PHRASING_CONTENT_NAMES, true);
}

function flushTextBuffer(array &$textBuffer, array &$extractedTexts) {
    if (!empty($textBuffer)) {
        $extractedTexts[] = implode(' ', $textBuffer);
        $textBuffer = [];
    }
}

function extractTextFromNodeList(DOMNode $node, array &$textBuffer, array &$extractedTexts) {
    foreach ($node->childNodes as $childNode) {
        if (isText($childNode)) {
            // For text nodes, concatenate and store in buffer
            $textBuffer[] = trim($childNode->nodeValue);
        } elseif (isPhrasingContent($childNode)) {
            // For phrasing content, the next appearing text should be concatenated with the text in buffer, so buffer is not flushed.
            extractTextFromNodeList($childNode, $textBuffer, $extractedTexts);
        } else {
            // For not phrasing content, texts should be separated by the opening tag of the element, so buffer is flushed.
            flushTextBuffer($textBuffer, $extractedTexts);
            extractTextFromNodeList($childNode, $textBuffer, $extractedTexts);
            // Texts should also be separated by the closing tag of the element, so buffer is flushed.
            flushTextBuffer($textBuffer, $extractedTexts);
        }
    }
}

$doc = new DOMDocument();
$doc->loadHTML($html);

$textBuffer = [];
$extractedTexts = [];
extractTextFromNodeList($doc, $textBuffer, $extractedTexts);

flushTextBuffer($textBuffer, $extractedTexts);

foreach ($extractedTexts as $text) {
    echo $text . "\n";
}

最初に file_get_contents() で HTML を読み込んだ後、インデントと改行を削除している。インデントや改行はテキストの一部とみなされるが、抽出時にそれらを敢えて残しておく必要はないからだ。

次に PHRASING_CONTENT_NAMES という定数を宣言し、大量の要素名を持つ配列を値として代入している。フレージングコンテンツは HTML Standard で定義されている。厳密には私は理解していないが、前述の HTML で出てきた <strong> 要素のようにテキストの修飾に用いられる要素であり、これらの多くは要素の前後にあるテキストと連結して意味が成り立つような形でマークアップされる。したがって、ある要素がフレージングコンテンツかそれ以外かに応じて、その前後でテキストを区切るかどうか処理を分岐できるように、フレージングコンテンツに属する要素名をここで定義している。

次に関数の定義が続くが、これらは一旦飛ばして DOMDocument のインスタンスを生成している行を見る。 DOMDocument は HTML や XML を扱いやすくするために PHP に組み込みで提供されているクラスだ。インスタンスを生成した後は loadHTML() で HTML を読み込み、 extractTextFromNodeList() という関数に引数として渡している。

extractTextFromNodeList() では渡された DOMNode (DOMDocument の親クラス) から子ノードを取り出し、ノードの種類に応じて処理を分岐させている。ノードがテキストノードであれば値を取り出して $textBuffer に追加する。フレージングコンテンツに属するノードであればその子ノードを引数として extractTextFromNodeList() を再帰的に呼び出している。それ以外のノードであれば extractTextFromNodeList() の再帰的な呼び出しの前後で flushTextBuffer() 関数を呼び出す。これはフレージングコンテンツではないノードの開始タグと終了タグをテキストの区切り位置とみなし、 $textBuffer の内容をテキスト抽出結果を保持する $extractedTexts に吐き出す。このようにして、すべてのノードを上から下まで順番に辿っていき、テキストを適当な単位で抽出する。

前述した HTML を読み込ませると、意図したとおりに抽出結果が出力されることがわかる。

$ php src/index.php
Sample Page
This is an AMAZING page!
Before Apple
Apple
After Apple

<script> タグの中身は抽出されないのか気になった読者がいるかもしれない。これは <script> タグの中身の nodeTypeXML_CDATA_SECTION_NODE と判定され、テキストノードの場合の XML_TEXT_NODE と区別されるためだ。

Laravel Blade の場合

さて、今の時代に生の HTML ファイルを書く Web 開発者はそれほど多くないと思う。今度は Laravel Blade で開発されたマークアップからテキスト抽出するケースについて考えてみる [1]

次のような Blade テンプレートについて考えてみる。

<?php
/** @var Hamakou108\SampleProject\App\User $user */
?>
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>Laravel Sample Page</title>
    </head>
    <body>
        <h1>Welcome, {{ $user->name }}!</h1>
        @section('content')
            <p>This is my body content.</p>
            @if ($user->isAdmin())
                <p>You are an administrator.</p>
            @else
                <p>You are a normal user.</p>
            @endif
        @endsection
    </body>
</html>

Blade テンプレートには @section などのディレクティブやエコー文 {{ }} が含まれている。今回はテキスト抽出の前にこれらを除去するようにする。実装内容にも依るが、ディレクティブやエコー文に含まれるのは変数の値やメソッドの返り値などが多く、 UX ライティングに関わるテキストはあまり含まれないことが予想されるからだ。

ディレクティブやエコー文の削除範囲の特定はそのままだとやや難しい。このため Laravel が生成する Blade テンプレートのキャッシュファイルを利用する。 php artisan view:cache を実行すると、先程の Blade テンプレートは次のような PHP のコードに変換されてキャッシュされる。

<?php
/** @var Hamakou108\SampleProject\App\User $user */
?>
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>Laravel Sample Page</title>
    </head>
    <body>
        <h1>Welcome, <?php echo e($user->name); ?>!</h1>
        <?php $__env->startSection('content'); ?>
            <p>This is my body content.</p>
            <?php if($user->isAdmin()): ?>
                <p>You are an administrator.</p>
            <?php else: ?>
                <p>You are a normal user.</p>
            <?php endif; ?>
        <?php $__env->stopSection(); ?>
    </body>
</html><?php /**PATH /var/www/example-app/resources/views/index.blade.php ENDPATH**/ ?>

ディレクティブやエコー文は <?php ... ?> のような PHP コードに変換されている。これを正規表現でマッチさせれば容易に削除範囲を特定できる。

スクリプトには次のように1行だけ処理を追加すれば良い。

<?php

...

// Remove line break
$html = str_replace(["\r\n", "\r", "\n"], '', $html);

// Remove PHP code
$html = preg_replace('/<\?php.*?\?>/s', '', $html);

// Except autonomous custom elements and text
const PHRASING_CONTENT_NAMES = ['a', 'abbr', 'area', 'audio', 'b', 'bdi', 'bdo', 'br', 'button', 'canvas', 'cite', 'code', 'data', 'datalist', 'del', 'dfn', 'em', 'embed', 'i', 'iframe', 'img', 'input', 'ins', 'kbd', 'label', 'link', 'map', 'mark', 'math', 'meta', 'meter', 'noscript', 'object', 'output', 'picture', 'progress', 'q', 'ruby', 's', 'samp', 'script', 'select', 'slot', 'small', 'span', 'strong', 'sub', 'sup', 'svg', 'template', 'textarea', 'time', 'u', 'var', 'video', 'wbr'];

...

実行結果は次のようになる。

Laravel Sample Page
Welcome, !
This is my body content.
You are an administrator.
You are a normal user.
脚注
  1. 本当は React や Vue などのフレームワークを取り上げた方が良いのかもしれないが、それならスクリプトも最初から JavaScript で書いた方が自然なので、今回は取り上げない。 ↩︎

Discussion