Kotaro7750's diary

低レイヤを中心とした技術ブログ、たまに日記

golangで簡単なslackbotを書いてみた

動機

最近インターン先でgolangに触れる機会がありました。(その記事もいつか書きたいですね。)その時は時間的制約でそこまでgolangに触れなかったので、練習がてら書いてみることにしました。ただ、作りたくないものを作るのは嫌なので、以前から気になっていたslackbotを作りました。

どんなBot

話しかけるとinteractive messageを投げ、返答すると対応したレスポンスを返してくるだけの簡単なBotです。ついでにgoでの定期実行も扱ってみました。

f:id:Kotaro7750:20190324131949p:plain f:id:Kotaro7750:20190324131945p:plain

全体像

slackbotを作るのは初めてだったので以下のサイトを参考(基幹部分は真似させてもらいましたが、何をしているのかは把握できたつもりです) tech.mercari.com

ソースコードGitHubにおいてあります。

github.com

大きく分けて2つの処理系統があり、

  • slackのReal Time Messaging Apiを利用して話しかけられるとinteractive messageを返す処理(slack.go内)
  • net/httpモジュールを使って、interactive messageへの回答への対応をハンドリングする処理(handler.go内)

となっています。

他にも、carlescere/schedulerモジュールを使って定期的に実行する処理をハンドリングしていたり、(この処理はgoroutine使って自分で実装してみたい。)WEBページをクロールしてその情報を加工する処理(windパッケージのwind.go)があります。

苦労したところ

goでサーバーアプリ書いたのはほぼ初めてだったので最初は全体像を把握するのが大変でした。その他にも参考にさせてもらったコードがバグっていたので直したり、クロールしたページから必要な情報を抜き出す処理に苦労しました。

クロール処理

// ForecastData is a data of forecast of specific date
type ForecastData struct {
    Date               string
    WindSpeedMidNight  int
    WindSpeedMorning   int
    WindSpeedAfternoon int
    WindSpeedNight     int
}

type ForecastDatas []ForecastData

//MakeForecastData returns array of ForecastData
func MakeForecastData(url string, filePath string) ForecastDatas {
    saveForecastPage(url, filePath)
    formateForecastPage(filePath)

    var forecastDatas = []ForecastData{}

    fp, err := os.Open(filePath)
    defer fp.Close()

    if err != nil {
        fmt.Printf("failes to open %s", filePath)
    }

    scanner := bufio.NewScanner(fp)
    for scanner.Scan() {
        text := scanner.Text()
        forecastDataArray := strings.Fields(text)

        date := forecastDataArray[0]
        var windSpeed = []string{}
        for i := 2; i <= 8; i += 2 {
            windSpeed = append(windSpeed, strings.Split(forecastDataArray[i], "m")[0])
        }

        windSpeedMidNight, _ := strconv.Atoi(windSpeed[0])
        windSpeedMorning, _ := strconv.Atoi(windSpeed[1])
        windSpeedAfternoon, _ := strconv.Atoi(windSpeed[2])
        windSpeedNight, _ := strconv.Atoi(windSpeed[3])

        forecastDatas = append(forecastDatas, ForecastData{
            Date:               date,
            WindSpeedMidNight:  windSpeedMidNight,
            WindSpeedMorning:   windSpeedMorning,
            WindSpeedAfternoon: windSpeedAfternoon,
            WindSpeedNight:     windSpeedNight,
        })
    }

    if err := scanner.Err(); err != nil {
        fmt.Printf("failes to scan %s", filePath)
    }

    return forecastDatas
}

以下のコードはクロール処理部分です。ForecastDataが一日の気象情報を格納する構造体となっていて、MakeForecastDatas()は

  • クロールしてくる(saveForecastPage())
  • 必要情報だけを抜き出す(formatForecastPage())
  • 構造体に格納する

ということを行っています。

func saveForecastPage(url string, filePath string) {
    doc, err := goquery.NewDocument(url)
    if err != nil {
        fmt.Printf("faled to get %s", url)
    }

    body := ""
    doc.Find("body > #main-column > .section-wrap > .forecast-point-10days > tbody").Each(func(i int, s *goquery.Selection) {
        body += s.Text()
    })
    if err != nil {
        fmt.Print("failes to get DOM")
    }

    ioutil.WriteFile(filePath, []byte(body), os.ModePerm)
}

saveForecastPage()では与えられたURLのDOM要素を抜き取ってファイルに保存しています。DOMとかもあまりやったことがないので勉強していきたいですね。 PuerkitoBio/goqueryモジュールを使って取得しています。

func formateForecastPage(filePath string) {
    fp, err := os.Open(filePath)
    defer fp.Close()

    if err != nil {
        fmt.Printf("failes to open %s", filePath)
    }

    scanner := bufio.NewScanner(fp)
    body := ""
    for scanner.Scan() {
        text := scanner.Text()
        text = strings.TrimSpace(text)
        isRequired, linefeed := isRequired(text)
        if isRequired {
            if linefeed {
                text = "\n" + text
            }
            body += text + " "
        }
    }
    body += "\n"
    body = body[1:]

    if err := scanner.Err(); err != nil {
        fmt.Printf("failes to scan %s", filePath)
    }
    ioutil.WriteFile(filePath, []byte(body), os.ModePerm)
}

formatForecastPage()ではページから条件を満たす行(isRequired()内で正規表現を使って判断しています。)だけを残す処理を行っています。

感想

思ったよりも簡単に構築できたので楽しかった。golangはサクッとかけてモジュールも豊富なので良いですね。ただ現状だとどうしてもモジュールに使われている感があるので、しっかりとモジュールの内部コードとかも読んで勉強していきたいです。 内容には関係ないですが、こんな感じの記事でいいんですかね。技術記事は初めてなのでまだまだ分かりづらいところもありますが、成長していきたいです。