20200401のGoに関する記事は1件です。

スクレイピングをやってみる

あなたがこの記事を読んでいるということは私はもうQiitaにはいないのかもしれない。

スクレイピング

今回はGoのサンプルでよくあるようなスクレイピングを行ってみます。
思ったより基本的な処理が多かったので記事にしておきたいと思いました。

goquery,agoutiを使って行います。

goquery

https://github.com/PuerkitoBio/goquery

jQueryライクにDOM内を検索できます。

agouti

https://github.com/sclevine/agouti

WebDriverです。

最初にアクセスするページはJavaScriptが動作するので一度HTMLに展開するために利用します。

今回私はChromeDriverを利用しています。

https://chromedriver.chromium.org/downloads

※お使いの端末のChromeにバージョンをあわせてお使いください

インストール

コマンドとして使う為だけでしたら、

sh$ go install github.com/secondarykey/qo/cmd/qo

でインストールを行うことができます。
※もちろんGoが入っている前提です

さぁ始めましょう

sh$ go mod init {プロジェクトのルートパッケージ}

今回は

https://github.com/secondarykey/qo

で公開しますので

sh$ go mod init github.com/secondarykey/qo

と行います。

Qiitaのユーザの記事を集める

Qiitaは 「https://qiita.com/{ユーザID}」 でユーザの投稿した記事がわかります。
ブラウザの開発ツールなどを使ってDOMを調べてみます。

const (
    QiitaDomain     = "https://qiita.com"
    QiitaItemClass  = "div.AllArticleList__Item-mhtjc8-2"
    QiitaTitleClass = "a.AllArticleList__ItemBodyTitle-mhtjc8-6"
    QiitaTagClass   = "a.AllArticleList__TagListTag-mhtjc8-4"
    QiitaDateClass  = "div.AllArticleList__Timestamp-mhtjc8-8"
)

QiitaItemClassは記事一覧の記事1件を表示しているクラスです。
ここで抽出した記事の情報をItemという構造体にしておきます。

type Item struct {
    Date string
    Name string
    Tags []string
    URL  string
}

直接http.Get()やgoquery.GetDocument()を行ってみるとわかりますが、
ブラウザで見えているものはJavaScriptを実行したもので、そのエレメントを取得することができません。

なのでWebDriverを使って展開する

ここでWebDriverであるagoutilを利用します。
※自分の環境がChromeなのでそのドライバを利用します

func getHTML(url string) (io.Reader, error) {
    options := agouti.ChromeOptions(
        "args", []string{
            "--headless",
            "--disable-gpu",
            "--no-sandbox",
        })

    driver := agouti.ChromeDriver(options)
    defer driver.Stop()
    driver.Start()

    page, err := driver.NewPage()
    if err != nil {
        return nil, xerrors.Errorf("get user item error: %w", err)
    }

    err = page.Navigate(url)
    if err != nil {
        return nil, xerrors.Errorf("get user item error[%s]: %w", url, err)
    }
    html, err := page.HTML()
    if err != nil {
        return nil, xerrors.Errorf("get html: %w", err)
    }

    reader := bytes.NewBufferString(html)
    return reader, nil
}

前述したDriverをダウンロードして、パスを通しておかないと、Navigate()などでエラーが発生します。
また端末とのバージョンをあわせておかないとSesssionIDを確立できなかったりします。

HTMLを検索してみる

用意したHTMLのio.Readerを利用して、goquery.Selectionを準備します。

    html, err := getHTML(url)
    if err != nil {
        return nil, xerrors.Errorf("記事一覧の取得に失敗しました=[%s]: %w", url, err)
    }

    doc, err := goquery.NewDocumentFromReader(html)
    if err != nil {
        return nil, xerrors.Errorf("記事一覧の取得に失敗しました=[%s]: %w", url, err)
    }

検索する

記事を取得するためにItemのクラスを検索します。

    sel := doc.Find(QiitaItemClass)
    if sel.Length() <= 0 {
        return nil, NoLongerError
    }

今回は件数などではなく、記事が存在しなかった場合にNoLongerErrorを返しています。
記事がある場合は1-5件取れるはずです。

FindはEach()関数で件数分処理することができます。
要素がgoquery.Selectionで返ってきますので再検索することで、それぞれの要素を取得できます。

    sel.Each(func(_ int, s *goquery.Selection) {

        item := NewItem()

        title := s.Find(QiitaTitleClass)
        title.Each(func(_ int, a *goquery.Selection) {
            val, ok := a.Attr("href")
            if ok {
                item.URL = val
                item.Name = a.Text()
            }
        })

        tag := s.Find(QiitaTagClass)
        tag.Each(func(_ int, a *goquery.Selection) {
            item.AddTag(a.Text())
        })

        date := s.Find(QiitaDateClass)
        date.Each(func(_ int, a *goquery.Selection) {
            item.Date = a.Text()
        })

        items = append(items, item)
    })

エラーについて

エラーを大域で宣言しておきます。
※後から行う平行処理のために、記事数を取得するようになる為、
 NoLongerError自体は通常のエラーと変わりないものになってしまいます。

var NoLongerError = fmt.Errorf("no longer")

Driverで展開して、要素が0件だった場合にこのエラーが返ってくるのがgetUserItem()という関数になります。

エラーを受けて、NoLongerErrorだった場合は正常なので、
errors.Is()でNoLongerErrorを判定して、記事のループを抜けるようにしておきます。

    for {

        log.Println("Access:" + url)

        wkItems, err := getUserItem(url)
        if err != nil {
            if errors.Is(err, NoLongerError) {
                log.Println("記事の一覧を抽出しました。")
                break
            }
            return nil, xerrors.Errorf("get user item error: %w", err)
        }

        items = append(items, wkItems...)
        page++
        url = fmt.Sprintf(base+"?page=%d", page)
    }

URLに「page=2」などをクエリで設定して上げるとページングできますので、
ループ内に記述しておきます。

CSVを書き込む

作ったItemに対して、CSVを作成しておきます。
日付、記事名、タグ、URLでCSVで残しておきます。
※記事内にタイトルやタグは存在するので、日付以外いらないかもしれません。

記事一覧である[]*Itemを元にCSVを作成していきますが、Goには"encoding/csv"があります。

func generateCSV(id string, items []*Item) error {

    log.Println(fmt.Sprintf("記事数:%d", len(items)))

    name := filepath.Join(id, id) + ".csv"

    f, err := os.Create(name)
    if err != nil {
        return xerrors.Errorf("get user item error: %w", err)
    }
    defer f.Close()

    writer := csv.NewWriter(f)

    for _, item := range items {
        err = writer.Write(item.Slice())
        if err != nil {
            return xerrors.Errorf("csv write error: %w", err)
        }
    }

    writer.Flush()

    log.Println(fmt.Sprintf("%s に一覧を出力しました", name))

    return nil
}

ここではos.Fileを利用してWriterを作成しています。
Write()に渡しているitem.Slice()は[]stringになります。

構造体にメソッドを追加して、簡単に取得できるようにしておくとよいでしょう。

func (i *Item) Slice() []string {
    line := make([]string, 4)
    line[0] = i.Date
    line[1] = i.Name
    buf := ""
    for idx, tag := range i.Tags {
        if idx != 0 {
            buf += ";"
        }
        buf += tag
    }
    line[2] = buf

    line[3] = i.URL
    return line
}

記事にカンマあったりするとずれる可能性がありますが、
CSV自体は「保存用」の用途が強いので、ここでは不問にしておきましょう。
※encoding/csvにはダブルコーテを入れるなどのオプションが存在します。

記事データのダウンロード

記事のURLはわかったので、今度は記事にアクセスします。

Qiitaは「/{user_id}/items/{id}」で記事を持っていますが”.md”をつけると元のデータを取得できます。
Item.URLからアクセスするURLを作成し、http.Get()を行って取得してきます。
昨今、話題になっているQiitaのコピー記事はここを利用していると思います。

ファイルとしてはItemのIDを取得して”{id}.md”として保存しています。

func generateItem(id string, item *Item) error {

    url := QiitaDomain + item.URL + ".md"

    log.Println("Access:" + url)
    resp, err := http.Get(url)
    if err != nil {
        return xerrors.Errorf("item request[%s]: %w", url, err)
    }
    defer resp.Body.Close()

    idSlc := strings.Split(item.URL, "/")
    itemId := idSlc[3]

    name := filepath.Join(id, itemId) + ".md"

    f, err := os.Create(name)
    if err != nil {
        return xerrors.Errorf("create item file: %w", err)
    }
    defer f.Close()
    _, err = io.Copy(f, resp.Body)
    if err != nil {
        return xerrors.Errorf("response copy : %w", err)
    }

    log.Println(fmt.Sprintf("    -> %s", name))

    return nil
}

これで記事のマークダウンの保存完了です。

並行処理

以下は14記事を処理した時のログです

2020/03/30 07:14:59 記事数の取得を行います
・・・
2020/03/30 07:15:19 Success

約20秒かかってます。(Windows)

一覧抜き出しの1ページにつき約9-7秒、10記事に2秒くらいで処理しています。

この処理に並列処理(groutine)を入れてあげます。
(windows10 core i7-6700 3.40GHz core 8)

並行処理を入れてみる

単純にいうと理論値になりました。

2020/03/30 06:32:45 記事数の取得を行います
・・・
2020/03/30 06:32:52 Success

リクエスト間隔について

並行処理について書きましたが、
実際アクセスするサイトは他人のサービスなので並行処理は行わず、
記事のダウンロード時に1アクセスにつき2秒入れています。
※一覧時は展開に3,4秒かかっているので、入れていません。

コマンド実行時に

sh$ qo -r 10 secondarykey

とすると1記事に対して10秒待ち受けます。

var dur *int
func init() {
    dur = flag.Int("r", 2, "request duration")
}

と初期化を行って、実行時にflag.Parse()を行うと、この変数に展開されます。
※30秒は長すぎるかな?と思ってチェックしてます。

    flag.Parse()
    args := flag.Args()
    if len(args) < 1 {
        return fmt.Errorf("引数にユーザIDが必要です")
    }

    if *dur <= 0 {
        return fmt.Errorf("リクエスト区間が短いです")
    }

    if *dur > 30 {
        return fmt.Errorf("リクエスト区間が長すぎやしませんか?")
    }

記事のダウロードのところで待受を行います。
※一覧の処理はドライバへの展開で十分に遅い(7-9秒)ので行いません

        time.Sleep(time.Duration(*dur) * time.Second)

ご利用上の注意点

お気づきだと思いますが、認証が入ってないので
プログラムは他の人のIDの全記事もダウンロード可能ですので節度を持ってご利用ください。

https://github.com/secondarykey/qo

並行処理バージョンは推奨してないので技術的に(自分のサービスで)お使いください。
※一回バグって数秒の間に1万回アクセスした時に「あーQiitaっていいサービスだな」って思いました。

なのに退会して申し訳ないです。それはgoroutineブランチにおいておきました。

あとがき

当方は退会する為に書きましたが、思ったよりGoの基本的な処理があったので
勉強用の資料にしようかな?と思って記事にしました。

移行先はGoの記事はShizuoka.goのブログにしようかな?と思っています。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む