Go でスクレイピングした mediba+ の記事を Slack に投稿するバッチを作った話

はじめに

はじめまして、2021年4月新卒入社の土屋(@hrktcy)です。バックエンドエンジニアとして6月にテクノロジーセンター Eng6G に所属しました。

背景

Eng6G ではスマプレチャレンジと呼ばれるサービスを、サーバレスなシステムを構築して開発・保守・運用しています。またバックエンドには Go を採用しており、比較的モダンな技術スタックによるプロダクト開発が行われています。

[参考]mediba に入社したらアジャイル志向のチームで最高だった件〜モブワーク無双でテレワークを超越します〜

Go は基本的に暗黙的型変換が認められないため、開発関連のタスクを全てモブで行う弊チームにおいて、ナビゲーターがレビューしやすいというメリットがあります。またモダンな技術を扱うことで、エンジニアとしての市場価値も高まると考えています。

そこで Go 未経験の私がバックエンドエンジニアとしてジョインするにあたり、学習とアウトプットを兼ねて、本稿では Go でスクレイピングした mediba+ の記事を Slack に投稿するバッチを作った話を備忘録として残したいと思います。

開発環境

  • M1 Mac
  • Docker 20.10.7
    • Golang 1.14
  • Terraform 1.0.1
    • AWS provider 3.49.0

事前準備

Slack API トークンを生成しておく

Slack に投稿する App を作成し、予め API トークンを取得しておきます。

  1.  Slack API へアクセス
  2. 「 Create an app 」をクリック
  3. 「 Create New App 」をクリック
  4. 名前」と「ワークスペース」を指定する
  5. アプリを作成した後に表示されたページで「 Permissions 」をクリック
  6. 「 Scopes 」の項目で chat:write 権限を設定
  7. 「 Install App to Workspace 」をクリックし API トークンを取得する

バッチの仕様を固める

Go でスクレイピングした mediba+ の記事を Slack に投稿するにあたり、まずはバッチの仕様を固めることにしました。

スクレイピング方法

スクレイピング系のパッケージ( goquery , etc. )を用いることもできますが、今回は mediba+ の RSS フィードをパースすることでスクレイピングを行います。当たり前ですがスクレイピング先の情報が更新される度にフィードの内容は変更されます。バッチ実行時点の日時と、スクレイピング先の記事日時を参照する必要があります。そこで今回は、バッチ実行時点の日時に更新された記事のみを Slack に投稿するようにします。

同一記事を重複して投稿しない

バッチを実行するたびに同一記事が投稿されるのは避けたいです。これについてはテーブルにスクレイピングした記事情報を格納しておき、 Slack に投稿する文章を生成する前段階で、記事が投稿されたものなのかを判定する必要があります。

バッチの運用方法

バッチプログラムを任意の日時に実行されるような環境を整えたいです。これについては ECS Fargate + CloudWatch Events で任意の日時にバッチが実行されるような環境を構築することで解決します。本稿では以下のシステムを構築しました。本システムは Terraform によって一元管理しています。

image

ソースコード

1. DB へ接続する

Go の OR マッパーとして提供されている gorm を用いて、 env ファイルに記載した DB の情報から接続を行います。

DBTYPE := "mysql"
USER := os.Getenv("USER") // ユーザ名
PASS := os.Getenv("PASS") // パスワード
ENDPOINT := os.Getenv("ENDPOINT") //エンドポイント
DBNAME := os.Getenv("DBNAME") //データベース名

CONNECT := USER+":"+PASS+"@tcp("+ENDPOINT+":3306)/"+DBNAME+"?charset=utf8&parseTime=True&loc=Local"

db, err := gorm.Open(DBTYPE, CONNECT)

if err != nil {
  fmt.Println("DB接続失敗")
  panic(err)
}

fmt.Println("DB接続成功")

今回接続先のテーブル情報は下図の通りです。

image

記事タイトル(title)と URL (link)、投稿済み判定(status)の3つのカラムを定義してあります。

2. mediba+ の記事をスクレイピングする

RSS フィードからバッチ実行時の日時に投稿された記事をスクレイピングしテーブルへ insert します。フィードの取得には gofeed を用います。

# mediba+をスクレイピングする
fp := gofeed.NewParser()
feed, _ := fp.ParseURL("https://koho.mediba.jp/feed/")

変数 feed にはパースされたフィードが格納されており、 feed.Items で投稿記事全てを取得することができます。今回はバッチ実行時点の日時に更新された記事のみを取得するために、要素1つ1つを for 文で回し、各記事の投稿日時とバッチ実行時の日時を比較します。

比較するにあたり、パースされた PubDate(UTC) は string 型なので time 型と比較することができず、スクレイピング先によってはフォーマットも違う可能性があるため合わせてあげる必要があります。また PubDate を JST にする必要もあります。

こちらについては RFC1123Z フォーマットで統一しました。まず PubDate を time.Parse で変換します。さらにそれを RFC1123Z のフォーマットに変換し、タイムゾーンを JST にすることで、投稿日時とバッチ実行時の日時を比較します。公式フォーマットはこちらから参照できます。

// RSS構造体
type Article struct {
  link string
  published string
}

// RSSのPubDateを文字列→日時の型に変換、それをRFC1123ZのFormatに変換し、タイムゾーンをJSTにする
for _, item := range feed.Items {
  m := make(map[string]Article)

  if item == nil {
    break
  }

  var timeParse = time.Time{}
  timeParse, _  = time.Parse(time.RFC1123Z, item.Published)
  pubDateJST := timeParse.In(time.FixedZone("Asia/Tokyo", 9*60*60)).Format(time.RFC1123Z)

  // 以下Insert処理
  ...
}

投稿日時とバッチ実行時の日時が同じ時、構造体 m に記事の情報を格納し、 gorm を用いてテーブルに insert すれば OK です。このとき、 status カラムにはデフォルト値として false を入れておきます。

3. Slack に投稿する

Slack に投稿する処理は以下の通りです。

// envファイルに記載したAPIトークンからクライアントを生成する
tkn := os.Getenv("TOKEN")
c := slack.New(tkn)

_, _, err := c.PostMessage("#チャンネル名", slack.MsgOptionText("テキスト", true))

if err != nil {
  panic(err)
} else {
  fmt.Println("投稿完了")
}

テーブルに格納されている、投稿日時がバッチ実行時の日時と同じ且つ status が false の記事のタイトルとリンクを取得し、 slack.MsgOptionText の第一引数に代入します。記事1つ1つを連投すると Slack の通知が多くなり煩わしく感じるので、 string 配列の中に取得した記事とタイトルを append していき、 strings パッケージの strings.Join を用いて、各要素を結合してから投稿処理を行うようにしました。

投稿処理が完了したら gorm を用いて status カラムの値を true に update することで、バッチを実行し直しても同じ記事を再度 Slack に投稿しないようにします。また全てのクエリ操作はトランザクション内で行うようにし、返ってきた err が nil かどうかを見てロールバック、コミットを判断するようにします。

実行結果

無事バッチが動作することを確認できました。

image

バッチの実行時間は大体1秒でした。

image

つまづいたところ

Dockerfile の設計

バッチプログラムでは Go Modules という外部パッケージ管理システムを用いています( Go Modules を使用する場合、 Go のバージョンは1.11以上である必要があります)が、 Docker イメージをビルドする際に毎回 Go Modules のダウンロードが走るため、バッチの実行時間が長くなってしまうという問題がありました。また M1 mac でビルドした Go イメージを ECR に Push すると、自動的に Go イメージが linux/arm 版になってしまうようでした(記事執筆時点)。こちらについては、 Docker Buildx で linux/amd64 でビルドし ECR に Push することで対応できましたが、実行時間問題は解決していません。

そこで Multistage Build を採用することにしました。開発環境用のイメージの中でビルドを行い、生成されたシングルバイナリを本番環境用の Alpine イメージに移すことで、大幅なメモリ削減ができるだけでなく実行時間も短縮することが可能です。

FROM amd64/golang:1.15-alpine AS builder
WORKDIR /go/src/tsuchiya
COPY . /go/src/tsuchiya
ENV GO111MODULE=on
RUN CGO_ENABLED=0 GOOS=linux go build -o api main.go

FROM alpine:latest
WORKDIR /root/
COPY --from=builder /go/src/tsuchiya/api .
CMD ["./api"]

コンソールから ECR のプライベートリポジトリを覗いてみます。

image

イメージサイズを確認すると100 MB 以上削減されていることが分かりました。

おわりに

スクレイピングした mediba+ の最新記事を Slack に投稿するバッチを作りました。静的型付け言語に苦手意識を持っていた私ですが、 Go は構文もシンプルなため、とても楽しく実装まで取り組むことができました。

なおご存知だとは思いますが、スクレイピングは相手先のサーバにアクセスするため短時間で大量のアクセスを行うのは NG です。迷惑がかからないよう十分配慮をしましょう。

最後に、 mediba では一緒にモノづくりができるエンジニアを募集しています。
少しでもご興味がありましたらこちらからどうぞ。