GoでHTMLを編集する

本記事はTVerメンバーによるアドベントカレンダーの15日目の記事です。

はじめに

TVerでバックエンドエンジニアをしている伊藤です。

TVerのバックエンドチームではGoを採用していて、基本的にGoを用いてサービス開発をしています。

そんな中ある日、とある機能の開発でバックエンド側で取得したHTMLを機械的に編集する必要が出てきたので、 golang.org/x/net/html を用いて実現することにしました。

この記事ではHTMLの編集をGoの準標準ライブラリ golang.org/x/net/html で行う方法について簡単に書いていきます。

golang.org/x/net/html について

まずはじめに使うライブラリについて簡単に説明します。

pkg.go.dev

このパッケージはHTML準拠のパーサとトークナイザを提供していて、今回扱うのはパーサの方になります。

func Parse(r io.Reader) という関数を呼ぶことで HTMLを解析して Node という形でHTMLを扱うことができます。

この Node はツリー構造になっていて、自分に紐づく Node の情報や、TextNodeDocumentNode など自分がどういった種類の Node なのかを示す値、<link> など実際に持っている値などを保持しています。

例えば、 <title>Foo</title>Node で表現して出力すると以下のようになります。

package main

import (
  "fmt"
  "golang.org/x/net/html"
  "os"
)

func main() {
  text := &html.Node{Data: "Foo", Type: html.TextNode}
  elm := &html.Node{Data: "title", Type: html.ElementNode}

  html.Render(os.Stdout, text) // Foo
  fmt.Println()

  html.Render(os.Stdout, elm) // <title></title>
  fmt.Println()

  // textをtitleタグの子供にする
  elm.FirstChild = text
  html.Render(os.Stdout, elm) // <title>Foo</title>

}

上の例ではFirstChildTextNode を入れることで大雑把に実現していますが、実際には Node には親子関係を保持するフィールドがいくつかあって、これでは不十分です。本来は FirstChild だけでなく LastChildNextSiblingPrevSibling の値も適切に変更する必要があります。

挿入/削除のために、こういった値を適切に変更してくれる関数が Node には3つ用意されているのでそれらを使う必要があります。

例えば func (n *Node) AppendChild(c *Node) を使えば、挿入を行うことができます。

package main

import (
  "os"

  "golang.org/x/net/html"
)

func main() {
  text := &html.Node{Data: "Foo", Type: html.TextNode}
  elm := &html.Node{Data: "title", Type: html.ElementNode}
  // textをtitleタグの子供にする
  elm.AppendChild(text)
  html.Render(os.Stdout, elm) // <title>Foo</title>
}

HTMLを解析して生成された Node をこれらの関数用いて変更していけば、意のままにGoでHTMLを編集することができそうです。

実現したい要件

ライブラリを用いたHTMLの編集方法の基本がわかったところで、実現したい要件についてまとめます。 今回機能を開発するにあたって実装する必要のあった操作は以下の3つです

特定のNodeの挿入

golang.org/x/net/html について でも述べましたが、func (n *Node) AppendChild(c *Node) を使って行うことができます。

取得したHTMLをrootから順に辿っていって、挿入したい箇所になったら AppendChild で挿入したい Node を挿入して実現できます。

package main

import (
  "os"
  "strings"

  "golang.org/x/net/html"
)

const source = `
<html>
  <head>
      <meta charset="utf-8">
      <title>テストタイトル</title>
      <meta property="og:title" content="テストタイトル">
      <script src="https://example.com/test.min.js" integrity="sha256-+giwdgktsa=" crossorigin="anonymous"></script>
  </head>
  <body>
      <div class="_welcome"><h2>ようこそ</h2></div>
      <div class="_empty"></div>
  </body>
</html>
`

func main() {
  if err := run(); err != nil {
    panic(err)
  }
}

func run() error {
  node, err := html.Parse(strings.NewReader(source))
  if err != nil {
    return err
  }

  // AppendChildはnodeに対して副作用のある関数なので、travers実行中のnodeで呼ぶとツリーが破壊的に変更される
  // デバッグが面倒なので後にまとめて実行しておく
  appendTargets := map[*html.Node][]*html.Node{}
  if err := travers(node, func(node *html.Node) error {
    if node.Type != html.ElementNode {
      return nil
    }
    switch node.Data {
    case "head":
      // html.Parseを使って挿入する
      n, err := html.Parse(strings.NewReader(`<meta name="viewport" content="width=device-width">`))
      if err != nil {
        return err
      }
      // html.Parseでnodeを作ると DocumentNodeなどが一緒に作られてしまい <html><head><meta name="viewport" content="width=device-width"/></head><body></body></html><html><head>のようになる
      // 下の処理は余分なものを消すためのおまじない
      cn := n.FirstChild.FirstChild.FirstChild
      cn.Parent.RemoveChild(cn)
      appendTargets[node] = append(appendTargets[node], cn)
    case "div":
      for _, attr := range node.Attr {
        if attr.Key == "class" && attr.Val == "_empty" {
          // html.Nodeを構成して行って挿入する
          p := &html.Node{Data: "p", Type: html.ElementNode}
          p.AppendChild(&html.Node{Data: "aaaa", Type: html.TextNode})
          appendTargets[node] = append(appendTargets[node], p)
        }
      }
    }
    return nil
  }); err != nil {
    return err
  }
  for parent, children := range appendTargets {
    for _, child := range children {
      parent.AppendChild(child)
    }
  }
  if err := html.Render(os.Stdout, node); err != nil {
    return err
  }
  return nil
}

func travers(node *html.Node, f func(*html.Node) error) error {
  if err := f(node); err != nil {
    return err
  }
  for n := node.FirstChild; n != nil; n = n.NextSibling {
    if err := travers(n, f); err != nil {
      return err
    }
  }
  return nil
}

上のコードは以下のような処理をコードにしたものです。

  1. func Parse(r io.Reader) (*Node, error) を使って、取得したHTMLをパースする
  2. パースしたHTMLを上から辿って挿入したい箇所を探す
  3. 挿入したい箇所にきたら挿入したい Nodeを作成してmapにためる
  4. 最後にmapにためた Node を逐次挿入していく

コード上のコメントにも書いてありますがいくつか注意事項があって、まず、 AppendChild が自身に作用する破壊的な関数のため、辿っている最中に呼んでしまうと Node の構造が変わってしまいます。 上の例だと特に気にしなくても良さそうですが、複雑な処理になればなるほど辛くなる可能性があるため、最後にまとめて挿入するようにしています。

また挿入するNode を作る方法には2通りあって、一つは丁寧に Node を作ってそれを挿入する方法で、もう一つは Parse を使って文字列で表現したHTMLをNodeにして挿入する方法です。

複雑なHTMLを挿入したい場合に Parse 使って Node を作成すると楽できるかもしれません。

挿入するNode を作成する際にも注意することがあり、

 if c.Parent != nil || c.PrevSibling != nil || c.NextSibling != nil {
     panic("html: AppendChild called for an attached child Node")
 }

(https://cs.opensource.google/go/x/net/+/refs/tags/v0.4.0:html/node.go;l=88-90)

AppendChildには上のような処理が入っていて、既にどこかの Node にアタッチされている Node を挿入しようとすると panic になります。

特に Parse を使う場合は、DocumentNode 等の余分な Node が欲しい Node の親に自動的に作成されているので、その親 Node から切り離してあげる必要があります。

特定のNodeの削除

特定のNode の削除は func (n *Node) RemoveChild(c *Node) を使って行うことができます。

挿入と同様に、rootから順に辿って、該当の Node に辿り着いたらそのNodeの親Nodeから RemoveChild を呼ぶことで削除することができます。

package main

import (
    "os"
    "strings"

    "golang.org/x/net/html"
)

const source = `
<html>
  <head>
      <meta name="viewport" content="width=device-width"/>
      <meta charset="utf-8">
      <title>テストタイトル</title>
      <meta property="og:title" content="テストタイトル">
      <script src="https://example.com/test.min.js" integrity="sha256-+giwdgktsa=" crossorigin="anonymous"></script>
  </head>
  <body>
      <div class="_welcome"><h2>ようこそ</h2><h2>アドベントカレンダーへ</h2></div>
      <div class="_empty"></div>
  </body>
</html>
`

func main() {
    if err := run(); err != nil {
        panic(err)
    }
}

func run() error {
    node, err := html.Parse(strings.NewReader(source))
    if err != nil {
        return err
    }

    // RemoveChildは自身のNodeに対して破壊的なので最後にまとめて実行する
    // 特にnode.Parentに対して複数のChildをRemoveしたい時に問題になる
    deleteTargets := map[*html.Node][]*html.Node{}
    if err := travers(node, func(node *html.Node) error {
        if node.Type != html.ElementNode {
            return nil
        }
        switch node.Data {
        case "script":
            deleteTargets[node.Parent] = append(deleteTargets[node.Parent], node)
        case "h2":
            deleteTargets[node.Parent] = append(deleteTargets[node.Parent], node)

        }
        return nil
    }); err != nil {
        return err
    }
    for parent, children := range deleteTargets {
        for _, child := range children {
            parent.RemoveChild(child)
        }
    }
    if err := html.Render(os.Stdout, node); err != nil {
        return err
    }
    return nil
}

func travers(node *html.Node, f func(*html.Node) error) error {
    if err := f(node); err != nil {
        return err
    }
    for n := node.FirstChild; n != nil; n = n.NextSibling {
        if err := travers(n, f); err != nil {
            return err
        }
    }
    return nil
}

上のコードは以下のような処理をコードにしたものです。

  1. func Parse(r io.Reader) (*Node, error) を使って、取得したHTMLをパースする
  2. パースしたHTMLを上から辿って削除したい箇所を探す
  3. 削除したい箇所にきたら削除したい Nodeを作成してmapにためる
  4. 最後にmapにためた Node を逐次削除していく

RemoveChildAppendChild と同様に関数を呼び出す Node のツリー構造を変更するため、 ツリーを辿っている最中に呼び出すと辿れない Node が出てくることがあり注意が必要です。 今回はそれを回避するために最後にまとめて実行するようにしています。

特定の値の変更

値を変更したい場合は Node が持つ Node.DataNode.Attr を適切に置き換えていく必要があります。

  • Node 自身の値を変更したい場合は Node.Data の値を変更する
  • Node が持つ class などの属性値を追加/変更したい場合は Node.Attr の該当の値を追加/変更する
package main

import (
    "os"
    "strings"

    "golang.org/x/net/html"
)

const source = `
<html>
  <head>
      <meta name="viewport" content="width=device-width"/>
      <meta charset="utf-8">
      <title>テストタイトル</title>
      <meta property="og:title" content="テストタイトル">
      <script src="https://example.com/test.min.js" integrity="sha256-+giwdgktsa=" crossorigin="anonymous"></script>
  </head>
  <body>
      <div class="_welcome"><h2>ようこそ</h2><h2>アドベントカレンダーへ</h2></div>
      <div class="_empty"></div>
  </body>
</html>
`

func main() {
    if err := run(); err != nil {
        panic(err)
    }
}

func run() error {
    node, err := html.Parse(strings.NewReader(source))
    if err != nil {
        return err
    }
    if err := travers(node, func(node *html.Node) error {
        if node.Type != html.ElementNode {
            return nil
        }
        switch node.Data {
        case "title":
            // 属性追加
            node.Attr = append(node.Attr, html.Attribute{Key: "id", Val: "title"})
            // Dataを置き換えてテキストを変更する
            node.FirstChild.Data = "MerryXmas!!!"
        case "div":
            // Nodeの属性の変更
            // Attributeはポインタでないのでindexでsliceの一部を置き換える
            for i, attr := range node.Attr {
                if attr.Key == "class" {
                    node.Attr[i] = html.Attribute{
                        Key: attr.Key,
                        Val: "replace_class",
                    }
                }
            }
        case "h2":
            // Dataを置き換えてタグを変更する
            node.Data = "h1"
        }
        return nil
    }); err != nil {
        return err
    }
    if err := html.Render(os.Stdout, node); err != nil {
        return err
    }
    return nil
}

func travers(node *html.Node, f func(*html.Node) error) error {
    if err := f(node); err != nil {
        return err
    }
    for n := node.FirstChild; n != nil; n = n.NextSibling {
        if err := travers(n, f); err != nil {
            return err
        }
    }
    return nil
}

上のコードは以下のような処理をコードにしたものです。

  1. func Parse(r io.Reader) (*Node, error) を使って、取得したHTMLをパースする
  2. パースしたHTMLを上から辿って変更したい箇所を探す
  3. 変更したい箇所があれば、Node.DataNode.Attr を適切に変更する

Node 自体はポインタで保持されているため Node.Data を置き換えれば変更されますが、 Node.Attr[]Attribute であるためループ内で attr を置き換えても意味がないことに注意が必要です。 Node.Attr[i] のようにしてスライスが保持している Attribute自体を置き換えないと変更されません。

このように Node.DataNode.Attr を変更して Render で再度HTMLを描画すると、実際に置き換わったHTMLが生成されます。

おわりに

今回は golang.org/x/net/html について簡単に紹介させていただきました。

少し挙動に癖がありますが、基本的にHTMLをパースしてNode に用意されている AppendChildRemoveChild といった関数を呼ぶだけです。非常にシンプルなライブラリです。

他にも goqueryといったHTMLを操作するライブラリというものは存在しますが、簡単な操作であればこの準標準ライブラリで十分事足りるはずです。

Goでこういった処理をする機会はあまりないかもしれませんが、参考になれば幸いです。