【PHP】取り込んだHTMLのaタグにDOMDocumentで任意の属性を追加する

DOMDocumentを使い、取り込んだHTMLのaタグに`rel="nofollow"`や`target="_blank"`を付けたかったのですが、文字化けなどに苦戦したので残します。

## 想定対象者
PHPでHTMLのaタグにrel属性やtarget属性を付けたい人、またそれに似た操作をしたい人。

今回の実装コードは以下のように`<html>`や`<body>`を含まないHTMLを取得し、中にあるaタグを処理するコードになっています。
`<html>`タグで囲まれているような**完全なHTMLを想定していない**ので、注意してください。
```HTML
<!-- <html>や<body>を含まないHTML -->
<div>
    <p>
        <a href="https://www.google.co.jp/">Google</a><br>
    </p>
</div>
```
## 環境
- PHP8.2.8
## 実装
```PHP
/**
 * @param $html
 * @return false|string
 */
function addAttributesToLinks($html)
{
    $dom = new DOMDocument();

    // ①HTMLを読み込む
    $dom->loadHTML(
        '<?xml encoding="UTF-8"><section>' . $html . '</section>',
        LIBXML_NOERROR | LIBXML_HTML_NOIMPLIED
    );

    // ②aタグを取得と処理
    $links = $dom->getElementsByTagName('a');
    foreach ($links as $link) {
        $link->setAttribute("target", "_blank");
        $link->setAttribute('rel', 'nofollow noopener noreferrer');
    }

    //　③加工したHTMLの保存
    $newHtml = $dom->saveHTML($dom->documentElement);

    return $newHtml;
}
```

## 詳しい説明
### ①HTMLを読み込む
```PHP
$dom->loadHTML('<?xml encoding="UTF-8"><section>' . $html . '</section>', LIBXML_NOERROR | LIBXML_HTML_NOIMPLIED);
```
この部分で文字列のHTMLを受け取り、HTMLとして読み込んでいます。  

#### `<?xml encoding="UTF-8">`
loadHTMLはHTMLを読み込む際に**XHTML**として読み込むそうなので、xmlの形式で文字コードの宣言をしています。

#### `<section> ... </section>`
XMLを解析する**LIBXML**の仕様で、一番最初にあるタグをルート要素として、その終了タグまでを処理するようになっています。
その為、全てのHTMLを処理してもらうために追加しています。

つまり、
```HTML
<p>ごはん</p>
<p>おもち</p>
```
であれば、`<p>ごはん</p>`内だけを処理し`<p>おもち</p>`の部分は処理しません。

以下のように`<div>`などのルート要素で囲めば、全て処理されます。
```HTML
<div>
    <p>ごはん</p>
    <p>おもち</p>
</div>
```

**完全なHTMLを処理する場合**、この処理の後に追加したルート要素を削除する必要があります。
#### `LIBXML_NOERROR`
loadHTML()のオプション部分です。

DOMDocumentは**HTML4を想定**しており、HTML5からのタグなどが入っていると「HTMLの文法が違うよ！」とエラーを吐きまくります。

そこで、[LIBXMLで定義](https://www.php.net/manual/ja/libxml.constants.php)されている**エラー抑制**の定数`LIBXML_NOERROR`を利用します。
#### `LIBXML_HTML_NOIMPLIED`
こちらも[LIBXMLで定義](https://www.php.net/manual/ja/libxml.constants.php)されている`LIBXML_HTML_NOIMPLIED`です。
DOMDocumentでは、`<html>`や`<body>`が無い場合に自動でこれらのタグを追加してくれます。

今回は`<html>`や`<body>`を含まないHTMLを処理を想定しているので、`LIBXML_HTML_NOIMPLIED`オプションを付け、`<html>`や`<body>`の追加を拒否しています。

### ②aタグの取得と処理
```PHP
    $links = $dom->getElementsByTagName('a');
    foreach ($links as $link) {
        $link->setAttribute("target", "_blank");
        $link->setAttribute('rel', 'nofollow noopener noreferrer');
    }
````
今回の実装では、aタグを取得し、それぞれのaタグにrel属性とtarget属性を追加しています。

[PHPの公式ドキュメント](https://www.php.net/manual/ja/class.domdocument.php)を見れば詰まることは無いと思うので、説明は省略します。

### ③加工したHTMLの保存
```PHP
$newHtml = $dom->saveHTML($dom->documentElement);
```
の部分です。

HTMLの保存は`$dom->saveHTML()`でも問題なさそうですが、saveHTML()のみで取得すると**日本語を数値文字参照として取得**してしまいます。

つまり、`<p>執筆中</p>`を`<p>&#22519;&#31558;&#20013;</p>`のように処理してしまいます。

数値文字参照でもブラウザは日本語に自動変換をしてくれますが、テスト時などにややこしいので、私は`$dom->documentElement`で数値文字参照を回避して受け取るようにしています。

また、'$dom->documentElement'で受け取ることで`DOCTYPE`や先に付けた`<?xml encoding="UTF-8">`の部分を省略し、ルート要素の部分のみ受け取れます。

```HTML
<!-- $dom->saveHTML($dom->documentElement)で受け取った場合 -->

<section>
    <div>
        <a href="https://www.google.co.jp/" target="_blank" rel="nofollow noopener noreferrer">Google</a>
    </div>
</section>
```

```HTML
<!-- $dom->saveHTML()で受け取った場合 -->

<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<?xml encoding="UTF-8">
<section>
    <div>
        <a href="https://www.google.co.jp/" target="_blank" rel="nofollow noopener noreferrer">Google</a>
    </div>
</section>
```

## 処理速度
処理速度が気になったので簡易的に検証しました。

検証した際のコードは以下のようになっており、サーバーでなくコマンドラインで実行しています。
```php
<?php

// ファイルを読み込む
$html = file_get_contents("TestCase1.html");
// 10回処理する
for ($i = 1; $i <= 10; $i++){
    // 計測開始
    $startTime = microtime(true);

    // HTML処理
    $newHtml = addAttributesToLinks($html);

    // 計測終了
    $endTime = microtime(true);
    $executionTime = $endTime - $startTime;

    echo "実行時間: {$executionTime} 秒\n";
}

```
読み込むHTMLは以下のように、同じURLが2048個あるものです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3028595/931d040b-d3ff-0a08-fbff-33b01d1589fb.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3028595/5cd09aed-bc42-91af-57c8-68ef177995aa.png)

処理する関数は**実装**とほぼ同じものです
```PHP
function addAttributesToLinks($html)
{
    $dom = new DOMDocument();
    $cnt = 0;

    // ①HTMLを読み込む
    $dom->loadHTML(
        '<?xml encoding="UTF-8"><section>' . $html . '</section>',
        LIBXML_NOERROR | LIBXML_HTML_NOIMPLIED
    );

    // ②aタグを取得と処理
    $links = $dom->getElementsByTagName('a');
    foreach ($links as $link) {
        $link->setAttribute("target", "_blank");
        $link->setAttribute('rel', 'nofollow noopener noreferrer');
    }

    //　③HTMLの保存
    $newHtml = $dom->saveHTML($dom->documentElement);

    echo $cnt."回処理しました。";
    return $newHtml;
}
```

### 結果
パソコンの性能や、状態にもよりますが10回計測したところ以下の通りでした。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3028595/490731db-927a-66f2-9aef-6606c99e1f1d.png)
2048個の場合、0.1秒前後といったところでしょうか。

URL8個の場合もやってみました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3028595/98be8172-31cc-b64b-71d1-483bd6d0e924.png)

URL8192個の場合もやってみました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3028595/dd46704c-495b-e01a-a385-d0a70dfe419b.png)

そんな数の処理はしないと思いますが実用的なところを考えると、この方法の場合**URL2000個ぐらいが限界**そうです。