📝

HTMLRewriterを使う

2024/08/10に公開

Next.jsのApp Routerを使ってサーバサイドでコードを実行させているとたまに「DOMを解析したい」という気持ちになることがある。従来のNode.js系のサーバならJSDOMを使えばいいのだが、エッジサイドコンピューティングで同じようなことをしようとすると通らない

なぜならJSDOMが利用しているパッケージにNode.jsのhttp/httpsのAPIが使われているからである。これはfsのAPIと同様に互換モードでもエッジサイドで動かないAPIである

終わった。と思ったら別の方法があった。HTMLRewriterなるパッケージである

HTMLRewriterとは

Cloudflareが作ったエッジサイド向けのHTMLパーサーである。Workersで動くのでエッジサイドでも動く。ちなみにBun環境でも動くしDeno環境でも動くっぽい(それぞれのドキュメントが出てきた)。どちらもCloudflareのHTMLRewriterのドキュメントを見るようにと書いていたので使い方は同じっぽい

詳しくは自分で調べてくれ

https://developers.cloudflare.com/workers/runtime-apis/html-rewriter/

使う

使い方は簡単

const rewriter = new HTMLRewriter()

rewriter.on('*', {
    element(element: any) {
        // elementの書き換え
    },
    text(text:any){
        // textの書き換え
    },
    comment(comment:any){
        // commentの書き換え
    }
})

こうやってインスタンスにハンドラーと対象を登録し、

async function fetchAndRewrite(){
    const res = await fetch("https://example.com")
    return await rewriter.transform(res).text()
}

こうやってHTMLのfetchデータを流し込んであげると書き直せる

先に言っておく(&知ってる人には教えてほしい)が、ハンドラーの引数の型情報がどこにあるのかわからなかった。入ってるパッケージ知ってる人おしえてください(@cloudflare/workers-types あたりだと思ったんだけど見つからない)

詳細

まずハンドラーの対象の登録

例えば <p> 要素を対象に取りたい場合

rewriter.on('p', {
    element(...

といった感じで書く。この on(selector, handler) の第一引数selectorはだいたいCSSのセレクターと同様の記法

特定のattributeを持つ div だったら div[attr] だし、特定のクラスを持つ要素なら *[class='hoge'] となる

一応ドキュメントにも書いている

https://developers.cloudflare.com/workers/runtime-apis/html-rewriter/#selectors

続いてハンドラーの処理

element(element: any){
    // elementの書き換え
}

今回はElementだけの話になるが、ここで先程の対象になったelementを取り出して操作できる

注意点としてここでハンドラーの引数に渡されているelementの持っているメソッド。ドキュメントにはこの項で書かれている通りで、具体的にできる操作は

  • 要素関係
    • getAttribute(name: string)
    • hasAttribute(name: string)
    • setAttribute(name: string, value string)
    • removeAttirubte(name: string)
  • コンテンツを追加する
    • 前に追加する before(content: string, contentOption: option)
    • 後ろに追加する after(content: string, contentOption: option)
    • 子要素として一番前に追加する prepend(content: string, contentOption: option)
    • 子要素として一番後ろに追加する append(content: string, contentOption: option)
    • 子要素を置換する setInnerContent(content: string, contentOption: option)
  • 自身を取り除く
    • 子要素ごと取り除く remove()
    • 自分だけ取り除く removeAndKeepContent()
    • 置換する replace(content: string, contentOption: option)
  • endタグ周辺を操作する(別途ハンドラーを設置)
    • onEndTag((endtag:any) => {})
      • endタグだけ取り除く endtag.remove()
      • endタグの前に追加する endtag.before(content: string, contentOption: option)
      • endタグの後ろに追加する endtag.after(content: string, contentOption: option)

通常のDOMで見る Element とは違うので注意が必要になる

なお、contentOptionは文字通りオプションである。渡す構造体は {html: boolean} だけで、これをtrue状態で渡すとcontentをDOMとして解釈してくれる

具体例

たとえば img タグの画像をリダイレクトサーバ経由にしてアクセス数を取るみたいなことをする場合

imgsrc.ts
const rewriter = new HTMLRewriter()
rewriter.on('img', {
    element(element:any){
        if(element.hasAttribute("src")){
            const baseSrc = element.getAttribute("src")
            const newSrc = `https://[redirector]/image?src=${baseSrc}`
            element.setAttribute("src", newSrc)
        }
    }
})

export { rewriter }
fetchPage.ts
fetchPage(url: string){
    const res = fetch(url)
    return await rewriter.transform(res)
}

こうすれば取得したページの中身の img の src が書き換わっている

応用

例えばYouTubeのURLが入った場合、それを埋め込みコードに書き換える、なんてこともできる

置き換えるパターンをすべて網羅するのはとちょっと面倒なので、
<p>https://www.youtube.com/watch?v=videoid</p>
という形でHTMLの中に入っている場合を想定し、他の場合をとりあえず意識しない(厳密にやろうとするとちゃんと正規表現等を書いて上げる必要がある)

const youtubeIframeTemplate = '<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/${videoID}" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin">allowfullscreen></iframe>'

rewriter.on('p', {
    text(text:any){
        const rawText = text.text
        if(rawText.includes("https://www.youtube.com/watch?v=") {
            const videoID = rawText.split("?v=")[1]
            const iframe = youtubeIframeTemplate.replace("${videoID}", videoID)
            text.replace(iframe, {html: true})
        }
    }
})

この rewriterを使ってtransformを呼んであげれば置き換えられてYouTubeの引用URLがiframeに置換されて表示される

先に予防線を引いている通り、このコードだとYouTubeのURLが短縮形式だったり文中に存在している場合でも置き換えられてしまうのでちゃんとやるならそれなりにちゃんとやってください(丸投げ

その他

上記の例を見ていると「Elementオブジェクトを作って挿入できないの?」と思うかもしれないが、先にいうとできない。挿入は全部文字列のみである。また、Element.textみたいなこともできないので「このDOMの中にこの文字列があったら……」みたいなのもできない(セレクターを工夫すれば行けるかも)

あくまで「中身の文字列を置換するなら text のハンドラーを、要素を書き換えるなら element のハンドラーを、コメントを書き換えるなら comment のハンドラーを」と割り切っている印象がある

また、今回は fetch の Response をそのまま入れているが、ときにAPIのレスポンスの一部がHTMLタグ入りの文字列でそれを置換したい、みたいなケースもあるだろう。そんなときはこんなふうになる

async function rewrite(rawHTML: string){
    return async rewriter.transform(new Response(rawHTML)).text()
}

これで文字列のHTMLを渡して置換して文字列を返してもらえる

CMSなどリッチテキストのコンテンツを連携するときにもこれが使えるだろう(実際ブログの記事をCMSから取ってきてそれを置換するのに私は使った

あとほかにも iframe の sandbox 属性の追加編集(このあたり引用時に最初から入れてほしいが)、ただのリンクのOGP対応、Twitterの引用など、使い所は多そうである

もしNode.jsではない環境でHTMLの置換をするケースはぜひ

Discussion