本記事はTVerメンバーによるアドベントカレンダーの15日目の記事です。
はじめに
TVerでバックエンドエンジニアをしている伊藤です。
TVerのバックエンドチームではGoを採用していて、基本的にGoを用いてサービス開発をしています。
そんな中ある日、とある機能の開発でバックエンド側で取得したHTMLを機械的に編集する必要が出てきたので、 golang.org/x/net/html を用いて実現することにしました。
この記事ではHTMLの編集をGoの準標準ライブラリ golang.org/x/net/html で行う方法について簡単に書いていきます。
golang.org/x/net/html について
まずはじめに使うライブラリについて簡単に説明します。
このパッケージはHTML準拠のパーサとトークナイザを提供していて、今回扱うのはパーサの方になります。
func Parse(r io.Reader)
という関数を呼ぶことで HTMLを解析して Node
という形でHTMLを扱うことができます。
この Node
はツリー構造になっていて、自分に紐づく Node
の情報や、TextNode
や DocumentNode
など自分がどういった種類の 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> }
上の例ではFirstChild
に TextNode
を入れることで大雑把に実現していますが、実際には Node
には親子関係を保持するフィールドがいくつかあって、これでは不十分です。本来は FirstChild
だけでなく LastChild
や NextSibling
、 PrevSibling
の値も適切に変更する必要があります。
挿入/削除のために、こういった値を適切に変更してくれる関数が 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
の挿入- 例えば
<link ... />
を挿入したい
- 例えば
- 特定の
Node
の削除- 例えば
<div id="hoge">...</div>
を削除したい
- 例えば
- 特定の値の変更
- 例えば
<title>hoge</title>
を<title>fuga</title>
に変えたい
- 例えば
特定の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 }
上のコードは以下のような処理をコードにしたものです。
func Parse(r io.Reader) (*Node, error)
を使って、取得したHTMLをパースする- パースしたHTMLを上から辿って挿入したい箇所を探す
- 挿入したい箇所にきたら挿入したい
Node
を作成してmapにためる - 最後に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 }
上のコードは以下のような処理をコードにしたものです。
func Parse(r io.Reader) (*Node, error)
を使って、取得したHTMLをパースする- パースしたHTMLを上から辿って削除したい箇所を探す
- 削除したい箇所にきたら削除したい
Node
を作成してmapにためる - 最後にmapにためた
Node
を逐次削除していく
RemoveChild
も AppendChild
と同様に関数を呼び出す Node
のツリー構造を変更するため、 ツリーを辿っている最中に呼び出すと辿れない Node
が出てくることがあり注意が必要です。
今回はそれを回避するために最後にまとめて実行するようにしています。
特定の値の変更
値を変更したい場合は Node
が持つ Node.Data
や Node.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 }
上のコードは以下のような処理をコードにしたものです。
func Parse(r io.Reader) (*Node, error)
を使って、取得したHTMLをパースする- パースしたHTMLを上から辿って変更したい箇所を探す
- 変更したい箇所があれば、
Node.Data
やNode.Attr
を適切に変更する
Node
自体はポインタで保持されているため Node.Data
を置き換えれば変更されますが、 Node.Attr
は []Attribute
であるためループ内で attr
を置き換えても意味がないことに注意が必要です。
Node.Attr[i]
のようにしてスライスが保持している Attribute
自体を置き換えないと変更されません。
このように Node.Data
や Node.Attr
を変更して Render
で再度HTMLを描画すると、実際に置き換わったHTMLが生成されます。
おわりに
今回は golang.org/x/net/html について簡単に紹介させていただきました。
少し挙動に癖がありますが、基本的にHTMLをパースしてNode
に用意されている AppendChild
や RemoveChild
といった関数を呼ぶだけです。非常にシンプルなライブラリです。
他にも goqueryといったHTMLを操作するライブラリというものは存在しますが、簡単な操作であればこの準標準ライブラリで十分事足りるはずです。
Goでこういった処理をする機会はあまりないかもしれませんが、参考になれば幸いです。