テキストから HTML タグを除去する

たとえば

This is a <strong>sample</strong> text
with <a href="#">HTML</a> tags.

というテキストデータが与えられた場合に <strong><a> といった HTML タグを除去することを考える。

正規表現でタグを除去する

単純に <...> の文字列パターンを除去できればいいのであれば正規表現が使える。 こんな感じ。

package striptag

import (
    "html"
    "regexp"
)

// StripTagsRegexp removes all HTML tags from the input string and unescapes any HTML entities.
func StripTagsRegexp(s string) string {
    re := regexp.MustCompile(`<[^>]*>`)
    return html.UnescapeString(re.ReplaceAllString(s, ""))
}

実際に動かしてみよう。 入力テキストを

package example

const (
    Text1 = `This is a <strong>sample</strong> text
with <a href="#">HTML</a> tags.`
)

と定義しておいて,次のように main() 関数を定義する。

//go:build ignore

package main

import (
    "striptag"
    "striptag/example"
)

func main() {
    println(striptag.StripTagsRegexp(example.Text1))
}

これを実行すると

$ go run sample1a.go
This is a sample text
with HTML tags.

と出力される。 ここまでは問題ない。 問題は入力テキストに異物が混ざってた場合。 たとえば

const (
    Text2 = `This is a <strong>sample</strong> text
with <a href="#">HTML</a> tags.<script>alert("XSS")</script>`
)

のような感じ(末尾に <script> タグがある)。 これを StripTagsRegexp() 関数で処理すると

This is a sample text
with HTML tags.alert("XSS")

のように <script> タグの中身が露出してしまう。

Tokenizer を使ってタグを除去する

これを正規表現で対処するのは(やれないことはないだろうが)かなり面倒な気がするので,やり方を変えて golang.org/x/net/html パッケージの Tokenizer を使うことにする。 こんな感じかな。

package striptag

import (
	"errors"
	"html"
	"io"
	"strings"

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

// skipTags defines the HTML tags whose content should be skipped when stripping tags.
var skipTags = map[string]bool{
	"script":   true,
	"style":    true,
	"noscript": true,
	"iframe":   true,
	"object":   true,
	"embed":    true,
	"textarea": true,
	"title":    true,
}

// StripTagsTokenizer removes HTML tags from the input string while skipping
// the content of certain tags defined in skipTags.
func StripTagsTokenizer(s string) (string, error) {
	tokenizer := ghtml.NewTokenizer(strings.NewReader(s))
	var b strings.Builder
	b.Grow(len(s))
	skipDepth := 0

	for {
		tt := tokenizer.Next() // get the next token type
		switch tt {
		case ghtml.ErrorToken: // handle error token
			if errors.Is(tokenizer.Err(), io.EOF) {
				return b.String(), nil // return the accumulated text at the end of input
			}
			return "", tokenizer.Err()
		case ghtml.StartTagToken: // handle start tag token
			t := tokenizer.Token()
			if skipTags[t.Data] {
				skipDepth++ // start skipping content of this tag
			}
		case ghtml.EndTagToken: // handle end tag token
			t := tokenizer.Token()
			if skipDepth > 0 && skipTags[t.Data] {
				skipDepth-- // stop skipping one level of skipped content
			}
		case ghtml.TextToken: // handle text token
			if skipDepth == 0 {
				b.WriteString(html.UnescapeString(string(tokenizer.Text())))
			}
		}
	}
}

これで <script> ... </script> の中身をスキップできる筈。 試してみよう。 main() 関数を

//go:build ignore

package main

import (
	"fmt"
	"os"
	"striptag"
	"striptag/example"
)

func main() {
	out, err := striptag.StripTagsTokenizer(example.Text2)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		return
	}
	fmt.Println(out)
}

と書き直して実行する。

$ go run sample2b.go
This is a sample text
with HTML tags.

おー。 上手く行ったかな。

改行タグを改行コードに変換する

応用として,前節の StripTagsTokenizer() 関数に <br> タグを改行コードに変換する機能を追加する。

// changeBrToNewline handles the <br> tag by converting it to a newline character in the output.
func changeBrToNewline(t ghtml.Token, b *strings.Builder) {
	if t.Data == "br" {
		// Handle <br> as a line break.
		b.WriteByte('\n')
	}
}

// StripTagsTokenizer removes HTML tags from the input string while skipping
// the content of certain tags defined in skipTags.
func StripTagsTokenizer(s string) (string, error) {
	tokenizer := ghtml.NewTokenizer(strings.NewReader(s))
	var b strings.Builder
	b.Grow(len(s))
	skipDepth := 0

	for {
		tt := tokenizer.Next() // get the next token type
		switch tt {
		case ghtml.ErrorToken: // handle error token
			if errors.Is(tokenizer.Err(), io.EOF) {
				return b.String(), nil // return the accumulated text at the end of input
			}
			return "", tokenizer.Err()
		case ghtml.StartTagToken: // handle start tag token
			t := tokenizer.Token()
			if skipTags[t.Data] {
				skipDepth++ // start skipping content of this tag
			}
			if skipDepth == 0 {
				changeBrToNewline(t, &b)
			}
		case ghtml.SelfClosingTagToken: // handle self-closing tag token
			t := tokenizer.Token()
			if skipDepth == 0 {
				changeBrToNewline(t, &b)
			}
		case ghtml.EndTagToken: // handle end tag token
			t := tokenizer.Token()
			if skipDepth > 0 && skipTags[t.Data] {
				skipDepth-- // stop skipping one level of skipped content
			}
		case ghtml.TextToken: // handle text token
			if skipDepth == 0 {
				b.WriteString(html.UnescapeString(string(tokenizer.Text())))
			}
		}
	}
}

修正した StripTagsTokenizer() 関数を使って以下のテキストを処理する。

const (
	Text3 = `This is a <strong>sample</strong> text<br>with <a href="#">HTML</a> tags.<script>alert("XSS")</script>`
)

処理結果はこんな感じ。

This is a sample text
with HTML tags.

text<br>with<br> タグは改行に変換されていることが確認できた。

ここまできたら <p> のようなブロック要素にも対応したいところだけど,実際にブロック要素か否かはスタイル情報に依存するため,今回は割愛する。

参考図書

thumbs
プログラミング言語Go - 丸善出版 理工・医学・人文社会科学の専門書出版社
明解で効率的なプログラムを描くための書。 柴田 芳樹 訳
 
Release 2016-06-15
評価     

[Comment] 著者のひとりは,あの “K&R” の K のほうである。この本は Go 言語の教科書と言ってもいいだろう。と思ったら絶版状態らしい(2025-01 現在)。復刊を望む!

Powered by linkcard

photo
Go言語 100Tips ありがちなミスを把握し、実装を最適化する impress top gearシリーズ
Teiva Harsanyi (著), 柴田 芳樹 (著)
インプレス 2023-08-18 (Release 2023-08-18)
Kindle版
B0CFL1DK8Q (ASIN)
評価     

[Comment] 版元で PDF 版を購入可能。事実上の Effective Go とも言える充実の内容。オリジナルは敢えてタイトルに “tips” という単語を入れるのを避けたのに邦題が「100 Tips」とかなっていて,原作者がお怒りとの噂(あくまで噂)

reviewed by Spiegel on 2023-08-18 (powered by PA-APIv5)

photo
Go言語で学ぶ並行プログラミング 他言語にも適用できる原則とベストプラクティス impress top gearシリーズ
James Cutajar (著), 柴田 芳樹 (著)
インプレス 2024-12-04 (Release 2024-12-04)
Kindle版
B0DNYMMBBQ (ASIN)
評価     

[Comment] 読書会のために購入。インプレス社の本は Kindle 版より版元で PDF 版を買うのがオススメ。「並行処理」について原理的な解説から丁寧に書かれている。 Go で解説されているが Go 以外の言語でも応用できる。

reviewed by Spiegel on 2025-01-25 (powered by PA-APIv5)