🍃このブログは移転しました。
3秒後、自動的に移動します・・・。

html-rewriter-wasmでHTMLをパースする

HTMLファイルをパースして、

  • 特定の文字列を抜き出したり
  • 特定の属性を書き換えたものを書き出したり

ってことをやりたい時、今までは`cheerio`を使うことが個人的には多かった。

GitHub - cheeriojs/cheerio: Fast, flexible, and lean implementation of core jQuery designed specifically for the server.

懐かしい`jQuery`的な記法で操作できる・・とはいえ、もはや`jQuery`のことぜんぜん覚えてなくて、生DOMのAPIばっか使っちゃったり。
かといって、`cheerio`が内部で使ってるHTMLのASTパーサーである`parse5`や`htmlparser2`をそのまま使うのは、ローレベルすぎて乗り気じゃなかったり。

というところで、なんか代用できるものはないかな?って思ってたところで、`HTMLRewriter`のことを思い出したという話。

HTMLRewriter

そもそもは、Cloudflare Workersのランタイムに実装されてるAPI

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

別の所から`fetch()`したHTMLを、書き換えながらもストリーミングでレスポンスできるよっていうなかなかユニークなやつ。

async function handleRequest(req) {
  const res = await fetch(req);

  return new HTMLRewriter()
    .on('div', myDivHandler)
    .transform(res);
}

i18nやリンク切れ検知やらに使えるよっていう触れ込みで紹介されてるけど、個人的にはSSRの何かしらに使えないだろうか・・とか一時期は考えてた。

CDNエッジでSSR、ではなくSSG+αできないか - console.lealog();

まあ実際はこれといった活用法が思いつかなくて、いわば存在を忘れかけてた。

html-rewriter-wasm

で、そんな折に`miniflare`における`HTMLRewriter`の実装として使われるべくポートされたWASMの実装があるのを思い出し。

GitHub - cloudflare/html-rewriter-wasm: WebAssembly version of HTMLRewriter

CFWの`HTMLRewriter`は`Stream`ベースのAPIしかなかったけど、`html-rewriter-wasm`の`HTMLRewriter`は同期で使えそうでいいのでは?冒頭のユースケースに使えるのでは?と思いやってみたところ、バッチリだったよ!という記事でした。

簡単な使い方など

import { HTMLRewriter } from "html-rewriter-wasm";

const htmlString = `<!doctype html>...</html>`;
const encoder = new TextEncoder();

// インスタンス
const rewriter = new HTMLRewriter((chunk) => {});

// やりたいことハンドラ
rewriter
  .on("img[src]", { /* ... */ })
  .on("video > source", { /* ... */ })

// ロード + 終了マーク
await rewriter.write(encoder.encode(htmlString));
await rewriter.end();

// 片付け
rewriter.free();

という感じで、`write()`と`end()`を呼んだら、そのインスタンスは役目を終えて使い回せなくなる。

ハンドラ

やりたいことでは、いわゆるCSSセレクターが書けるので、だいたいのことができる。セレクターの詳細は、CFWのドキュメントがまとまってて楽だった。

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

セレクターと一緒に、`element`と`comments`と`text`の3つに対してやりたいことハンドラを渡す。

import { HTMLRewriter } from "html-rewriter-wasm";

const rewriter = new HTMLRewriter();

rewriter
  .on("h2, h3, h4", {
    element(el) {},
    comments(comment) {},
    text(text) {
      console.log(text);
    },
  })  
  .on(".my-slider img", {
    element(el) {
      const src = el.getAttribute("src");
      if (src !== null) {
        console.log(src);
      }
    },
  });

書き出し

リライトしたあとで、ファイルに書き出したいとき。

import { HTMLRewriter } from "html-rewriter-wasm";

const encoder = new TextEncoder();
const decoder = new TextDecoder();

// ここに貯める
let output = "";
const rewriter = new HTMLRewriter((outputChunk) => {
  output += decoder.decode(outputChunk);
});

rewriter.on("p", {
  element(element) {
    element.setInnerContent("new");
  },
});

try {
  await rewriter.write(encoder.encode("<p>old</p>"));
  await rewriter.end();
  console.log(output); // <p>new</p>
} finally {
  rewriter.free();
}

というようにインスタンスのコールバックでチャンクを貯める。

ディスクからの読み書きは、今までどおり`node:fs`から`readFile()`なり`writeFile()`なりで。

使われてる文字を抽出

const output = new Set();
const [encoder, decoder] = [new TextEncoder(), new TextDecoder()];

const excludeReg = /!doctype html/i;
const rewriter = new HTMLRewriter((chunk) => {
  const text = decoder.decode(chunk).trim();

  if (text !== "" && !excludeReg.test(text)) {
    for (const c of text) output.add(c);
  }
})
  // Not printed
  .on("head, script, style", {
    element(element) {
      element.remove();
    },
  })
  .on("*", {
    element(element) {
      element.removeAndKeepContent();
    },
    comments(comment) {
      comment.remove();
    },
  });

await rewriter.write(encoder.encode(htmlString));
await rewriter.end();

rewriter.free();

まとめ

という感じでなかなか便利かつ、手元で試した限りは`cheerio`よりパフォーマンスも良かったので、これからも使っていきたいところ。