😀

goでスクレイピングするのにgoquery + bluemonday が最強な件

2022/11/28に公開

goでスクレイピングをしていて、この2つを使用してスクレイピングをしたらとてもとても捗った。

参考にしたサイト

環境

  • MacOSX (Yosemite, ElCapitan)
    • go 1.5.2

goquery

PuerkitoBio/goqueryでも書かれている通り、JQueryと同様の機能をもったライブラリ

  • Selector
  • Attibute
  • manipulation
  • traversal

などJQueryで馴染みのものが一通り使えます。

使い方

まずはいつも通り

go get github.com/PuerkitoBio/goquery

リンクを抽出してみる

/path/to/hoge.go
package main

import (
	"github.com/PuerkitoBio/goquery"
	"fmt"
)

func main() {
	doc, err := goquery.NewDocument("https://github.com/PuerkitoBio/goquery")
	if err != nil {
		fmt.Print("url scarapping failed")
	}
    doc.Find("a").Each(func(_ int, s *goquery.Selection) {
          url, _ := s.Attr("href")
          fmt.Println(url)
    })
}

リポジトリのコードだけのリンクを抽出

jQueryのセレクタがそのまま使える
なのでスクレイピングしたいページにアクセスしてコンソールで

$('table > tbody > tr > td.content > span > a')

とした値と等価なDOM要素が取得できる

/path/to/hoge.go
package main

import (
	"github.com/PuerkitoBio/goquery"
	"fmt"
)

func main() {
	doc, err := goquery.NewDocument("https://github.com/PuerkitoBio/goquery")
	if err != nil {
		fmt.Print("url scarapping failed")
	}
    doc.Find("table > tbody > tr > td.content > span > a").Each(func(_ int, s *goquery.Selection) {
          url, _ := s.Attr("href")
          fmt.Println(url)
    })
}

保存されたHTMLファイルからデータを抽出する

スクレイピングしすぎると相手先に悪いのでファイルからアクセスする。
まずはこれで適当な場所に保存して

/path/to/hoge.go
package main

import (
	"github.com/PuerkitoBio/goquery"
	"fmt"
	"io/ioutil"
	"os"
)

func main() {
	doc, err := goquery.NewDocument("https://github.com/PuerkitoBio/goquery")
	if err != nil {
		fmt.Print("url scarapping failed")
	}
	res, err := doc.Find("body").Html()
	if err != nil {
		fmt.Print("dom get failed")
	}
	ioutil.WriteFile("/path/to/goquery.html", []byte(res), os.ModePerm)
}

保存したファイルから読み込む

/path/to/hoge.go
package main

import (
	"github.com/PuerkitoBio/goquery"
	"fmt"
	"io/ioutil"
	"strings"
)

func main() {
	fileInfos, _ := ioutil.ReadFile("/path/to/goquery.html")
	stringReader := strings.NewReader(string(fileInfos))
	doc, err := goquery.NewDocumentFromReader(stringReader)
	if err != nil {
		fmt.Print("url scarapping failed")
	}
    doc.Find("table > tbody > tr > td.content > span > a").Each(func(_ int, s *goquery.Selection) {
          url, _ := s.Attr("href")
          fmt.Println(url)
    })
}

色々なメソッド

jQueryで使えるメソッドは一通りあるみたいです。(どのメソッドがあるかは直接リポジトリをみたほうが早いです)

  • Next()
    • 指定したDOM要素の次の要素を選択する
s := doc.Find("table > tbody > tr > td.content").Next()
// DOMのまま出力
fmt.Print(s.Html())
  • Parent()
    • 選択したDOMの親要素を取得
s := doc.Find("table > tbody > tr > td.content").Parent()
// DOMの要素の中身を出力
fmt.Print(s.Text())
  • 特定のattibuteを指定してDOMを選択
ret, _ := doc.Find("svg[role=img]").Html()
fmt.Print(ret)

とまあ一通り楽ちんできる機能があります。

さあ、次に取得したHTMLのデータクレンジングが必要ですよね

スクレイピングしたデータをそのまま使うことはほぼ皆無なので、取得したHTMLからデータクレンジングが必要になります。
またHTML等をそのままDBにぶっ込む場合はサニタイズも必要になりますよね。
goqueryだけで頑張ってもいいのですが、もう少し楽にクレンジング作業をしてみましょう。

そこでmicrocosm-cc/bluemondayです。

まずはREADME.mdにかかれている通りの alert('hoge') をサニタイズしてみましょう。

package main

import (
    "fmt"

    "github.com/microcosm-cc/bluemonday"
)

func main() {
    p := bluemonday.UGCPolicy()
    html := p.Sanitize(
        `<a onblur="alert(secret)" href="http://www.google.com">Google</a>`,
    )

    // Output:
    // <a href="http://www.google.com" rel="nofollow">Google</a>
    fmt.Println(html)
}

おお、素敵。
綺麗にサニタイズしてくれます。

string, byte, io.Readerの型に対応しているみたいです。

p.Sanitize(string) string
p.SanitizeBytes([]byte) []byte
p.SanitizeReader(io.Reader) bytes.Buffer

bluemondayのNewPolicy()がデータクレンジングに最強なんですよ

bluemondayのNewPolicy()メソッドってのがありまして、例えば下記のようなタグ

<ul>
<li class="toclevel-2 tocsection-2"><a href="#.E5.AD.97.E6.BA.90"><span class="tocnumber">1.1</span> <span class="toctext">字源</span></a></li>
<li class="toclevel-2 tocsection-3"><a href="#.E9.9F.B3"><span class="tocnumber">1.2</span> <span class="toctext">音</span></a></li>
<li class="toclevel-2 tocsection-4"><a href="#.E6.84.8F.E7.BE.A9"><span class="tocnumber">1.3</span> <span class="toctext">意義</span></a>
</ul>

うおう・・・リストの構造だけ綺麗にとって他のattibuteいらない・・・・
とかって時に便利なんです。

<ul> <li> だけ抽出する

bluemonday.NewPolicy() の後に
許可するElementだけを AllowElements() に記載するだけです。

package main

import (
    "fmt"

    "github.com/microcosm-cc/bluemonday"
)

func main() {
    p := bluemonday.NewPolicy()

    p.AllowElements("li").AllowElements("ul")

    html := p.Sanitize(
        `<ul>
<li class="toclevel-2 tocsection-2"><a href="#.E5.AD.97.E6.BA.90"><span class="tocnumber">1.1</span> <span class="toctext">字源</span></a></li>
<li class="toclevel-2 tocsection-3"><a href="#.E9.9F.B3"><span class="tocnumber">1.2</span> <span class="toctext">音</span></a></li>
<li class="toclevel-2 tocsection-4"><a href="#.E6.84.8F.E7.BE.A9"><span class="tocnumber">1.3</span> <span class="toctext">意義</span></a>
</ul>
`,
    )

    fmt.Println(html)
}

結果

go run hoge.go
<ul>
<li>1.1 字源</li>
<li>1.2</li>
<li>1.3 意義
</ul>

おお。望み通りになった。

一般的なプロトコルだけ抽出する

例えば scp://wiki.jp/hoge みたいな一般的ではない(scpプロトコルは一般的ですが)hrefが存在したとします。

package main

import (
    "fmt"

    "github.com/microcosm-cc/bluemonday"
)

func main() {
    p := bluemonday.NewPolicy()

    p.AllowElements("li").AllowElements("ul")
    p.AllowStandardURLs()
    p.AllowAttrs("href").OnElements("a")
    html := p.Sanitize(
        `<ul>
<li class="toclevel-2 tocsection-2"><a href="scp://wiki.jp/hoge"><span class="tocnumber">1.1</span> <span class="toctext">字源</span></a></li>
<li class="toclevel-2 tocsection-3"><a href="scp://wiki.jphoge"><span class="tocnumber">1.2</span> <span class="toctext">音</span></a></li>
<li class="toclevel-2 tocsection-4"><a href="https://google.com"><span class="tocnumber">1.3</span> <span class="toctext">意義</span></a></li>
</ul>
`,
    )

    fmt.Println(html)
}

実行してみましょう

go run hoge.go
<ul>
<li>1.1 字源</li>
<li>1.2 音</li>
<li><a href="https://google.com" rel="nofollow">1.3 意義</a></li>
</ul>

おお。httpスキーマだけ綺麗に抽出してくれました。

AllowElements()を使えば、不必要なDOM要素を除去できます。

こんな感じで例えばdivの入れ子がいっぱい複雑でメンドイ・・・とかも上手くAllowElements()を使えば、
綺麗なデータ構造のHTML Nodeを保持できるかと思います。
(元々の用途とは若干違うような気もしますが・・・・)

最後に

一生懸命HTMLNodeを正規表現、またはnet/htmlで頑張ろうと思ったのですが、bluemonday見つけて楽にできて本当に助かった・・・・

Discussion