【Go】QiitaでLGTMされたらライン通知する【LINE Notify】

# 動機

Goの勉強を始め、[A Tour of Go](https://go-tour-jp.appspot.com/welcome/1)　を一通り終え、何か作ろうと思っていました。
結局何か作ってみるのが一番身に付きますからね。

- 自分が使うもの
- HTTP通信してみたい
- DB操作もかじりたい

という理由で、LGTMをLINE通知するプログラムを作成することにしました。

# 概要

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/487075/2ea69bb5-1285-29d8-cca9-701c73eb7c8d.png)

ざっくり下記の処理を定期的に繰り返すものをGoで作り、AWSEC2にデプロイしました。

1. Qiita API v2を実行し、記事についてるLGTM数を取得
1. ローカルで保持しているLGTM数と取得したLGTM数を比較
1. 比較した結果差分がある場合は、LINE Notify APIを実行し通知し、DBに保持するLGTM数に更新をかける

通知は画像のようにしてくれます。
![スクリーンショット 2021-04-25 20.58.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/487075/913d79fa-d871-3478-c5a2-23ef3a150982.png)
``『記事タイトル』(記事ID)のLGTM数が変化しました。変動前のLGTM数 -> 変動後のLGTM数``
というメッセージでLGTM数に変動があったことを教えてくれます。
※自分以外の方の記事データで動作確認していたため、黒塗りしてます。

# 説明

## Qiita API v2を実行し、記事についてるLGTM数を取得する

記事についているLGTM数を取得するのに、下記APIを実行します。
`GET /api/v2/users/:user_id/items`（[レファレンスはこちら](https://qiita.com/api/v2/docs#get-apiv2usersuser_iditems)）


このAPIは`:user_id`に指定したユーザの記事一覧を返却します。
実行結果は下記のように返却されます。likes_countがLGTMの数になります。

```json:実行結果
[
    {
        "rendered_body": "xxx",(値が長くなるのでxxxで表現する）
        "body": "xxxx",(値が長くなるのでxxxで表現する）
        "coediting": false,
        "comments_count": 0,
        "created_at": "2020-07-11T20:33:09+09:00",
        "group": null,
        "id": "3280948b32bc5f1497fd",
        "likes_count": 4, ★★★★★★★likes_countがLGTMの数★★★★★★★
      　~~ （略）〜〜
    },
    {
        // 記事数分だけ要素繰り返し
    }
]

```

コードは下記になります。

```go:QiitaAPIv2を実行し記事一覧を取得するコード
// 引数のConfig、戻り値のItemは自分で作った構造体
// Configは種々の設定値を持つ、ItemはAPIの実行結果を持つ
func GetItems(conf configs.Config, page int) (items []Item, err error) {	
	// 実行するHTTPメソッド、URLの設定。conf.QiitaUserNameに:user_idに指定するIDを持ってる
	req, err := http.NewRequest("GET", "https://qiita.com/api/v2/users/"+conf.QiitaUserName+"/items?page="+strconv.Itoa(page)+"&per_page=20", nil)
	if err != nil {
		return nil, err
	}

	// ヘッダーの設定、conf.QiitaAccessTokenにアクセストークンを持ってる。
	req.Header.Set("Authorization", "Bearer "+conf.QiitaAccessToken)
	client := &http.Client{}
	
	// リクエストの実行
	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}

	// レスポンスボディ読み取り、構造体に変換
	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if err := json.Unmarshal(body, &items); err != nil {
		return nil, err
	}

	return items, nil

```
```go:APIの実行結果を持つ構造体
type Item struct {
	RenderedBody  string    `json:"rendered_body"`
	Body          string    `json:"body"`
	Coediting     bool      `json:"coediting"`
	CommentsCount int       `json:"comments_count"`
	CreatedAt     time.Time `json:"created_at"`
	Group         struct {
		CreatedAt time.Time `json:"created_at"`
		ID        int       `json:"id"`
		Name      string    `json:"name"`
		Private   bool      `json:"private"`
		UpdatedAt time.Time `json:"updated_at"`
		URLName   string    `json:"url_name"`
	} `json:"group"`
	ID             string `json:"id"`
	LikesCount     int    `json:"likes_count"`
	Private        bool   `json:"private"`
	ReactionsCount int    `json:"reactions_count"`
	Tags           []struct {
		Name     string   `json:"name"`
		Versions []string `json:"versions"`
	} `json:"tags"`
	Title     string    `json:"title"`
	UpdatedAt time.Time `json:"updated_at"`
	URL       string    `json:"url"`
	User      struct {
		Description       string `json:"description"`
		FacebookID        string `json:"facebook_id"`
		FolloweesCount    int    `json:"followees_count"`
		FollowersCount    int    `json:"followers_count"`
		GithubLoginName   string `json:"github_login_name"`
		ID                string `json:"id"`
		ItemsCount        int    `json:"items_count"`
		LinkedinID        string `json:"linkedin_id"`
		Location          string `json:"location"`
		Name              string `json:"name"`
		Organization      string `json:"organization"`
		PermanentID       int    `json:"permanent_id"`
		ProfileImageURL   string `json:"profile_image_url"`
		TeamOnly          bool   `json:"team_only"`
		TwitterScreenName string `json:"twitter_screen_name"`
		WebsiteURL        string `json:"website_url"`
	} `json:"user"`
	PageViewsCount int `json:"page_views_count"`
}
```
```go:設定値を持つ構造体、jsonの設定ファイルをこの構造体に変換してます
type Config struct {
	LineAccessToken  string `json:"line_access_token"`
	LineNotifyURL    string `json:"line_notify_url"`
	QiitaUserName    string `json:"qiita_user_name"`
	QiitaAccessToken string `json:"qiita_access_token"`
	DbDataSourceName string `json:"db_data_source_name"`
	LogPath          string `json:"log_path"`
}
```

### ページング
`GET /api/v2/users/:user_id/items`で一回に取得できる記事の件数には限りがあるので、全件取得するにはページングする必要があります。
再帰で実現しました。

```go:ページングして記事全件取得する
func GetAllItems(conf configs.Config, page int, items []Item) ([]Item, error) {
	// 記事を取得する
	temp, err := GetItems(conf, page)
	if err != nil {
		return nil, err
	}

	// 再帰の終了条件、記事の取得件数が０になるまでAPIの実行を繰り返す
	if len(temp) == 0 {
		return items, nil
	}

	// 取得した記事をスライスに入れる
	for _, item := range temp {
		if item.Private {
			// 非公開の記事はスキップ
			continue
		}
		items = append(items, item)
		log.Println(fmt.Sprintf("取得した記事 : %s(%s) LGTM(%d)", item.Title, item.ID, item.LikesCount))
	}

	page++
	return GetAllItems(conf, page, items)
}
```

こんなやり方しなくても[この記載を読んで](https://qiita.com/api/v2/docs#%E3%83%9A%E3%83%BC%E3%82%B8%E3%83%8D%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3)
API実行結果のレスポンスヘッダのLinkをみれば、ページ分だけAPI実行ができそうなことに後から気づきました。

## ローカルで保持しているLGTM数と取得したLGTM数を比較

mapに記事のIDをキーにして、記事情報を保持します。
取得した記事のIDでmapに検索をかけて、LGTM数を比較します。

```go:取得した記事情報保持するmapとmapの初期化
var itemMap = map[string]qiita.Item{}

// 記事情報保持するmapの初期化
func initializeMap(items []qiita.Item) {
	for _, item := range items {
		itemMap[item.ID] = item
	}
}
```
```go:mapにある記事情報と取得した記事情報を比較する
// 記事の取得
items, err := qiita.GetAllItems(conf, 1, []qiita.Item{})

for _, item := range items {
	// 取得した記事のIDでmapに検索をかける
	beforeItem, ok := itemMap[item.ID]
	
	// mapに該当記事のデータ存在しない場合、記事投稿してから初回の取得など
	if !ok {
		itemMap[item.ID] = item
		_, err = db.InsertIntoItem(item)
		if err != nil {
			errorNotifyAndTerminate(conf, err)
		}

		continue
	}

	// LGTMの数が変化した場合　
	if item.LikesCount != beforeItem.LikesCount {
		// 通知
		line.Notify(conf, fmt.Sprintf("『%s』(%s)のLGTM数が変化しました。%d -> %d", item.Title, item.ID, beforeItem.LikesCount, item.LikesCount))
		// ローカルで持ってる記事データの更新
		itemMap[item.ID] = item
		_, err = db.UpdateItem(item)
		if err != nil {
			errorNotifyAndTerminate(conf, err)
		}
	}
}

```

## LINE Notify APIを実行し通知する

通知するには下記APIを実行します。
`POST https://notify-api.line.me/api/notify`（[レファレンスはこちら](https://notify-bot.line.me/doc/ja/)）

リクエストパラメータ`message`に指定した文字列が、通知されます。

```console:API実行例
% curl -X POST -H 'Authorization: Bearer <access_token>' -F 'message=testメッセージ' https://notify-api.line.me/api/notify
{"status":200,"message":"ok"}
```

下記のように通知されます。
![スクリーンショット 2021-04-12 7.31.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/487075/81657108-558c-7732-751f-dd2fefbb7d24.png)

コードは下記になります。

```go:LINENotifyAPIを実行するコード
// 引数のmessageに通知する文章を指定する
func Notify(conf configs.Config, message string) {
	// リクエストパラメータの設定
	form := url.Values{}
	form.Add("message", message)
	body := strings.NewReader(form.Encode())

	//　リクエスト生成、conf.LineNotifyURLにAPIのURLを持たせてる
	req, err := http.NewRequest("POST", conf.LineNotifyURL, body)
	if err != nil {
		log.Fatalln(err)
	}

	// ヘッダの設定、conf.LineAccessTokenにアクセストークンを持たせてる
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.Header.Set("Authorization", "Bearer "+conf.LineAccessToken)
	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		log.Fatalln(err)
	}
	defer resp.Body.Close()
}

```

## DBに更新をかける。

下記のテーブルにLGTMの数を保持しています。

```:テーブル
MySQL [qiita]> select id, likes_count, title from item;
+----------------------+-------------+-------------------------------------------------------------------------------------------------------------------------------+
| id                   | likes_count | title                                                                                                                         |
+----------------------+-------------+-------------------------------------------------------------------------------------------------------------------------------+
| 13bb03d163824fc0f3d0 |           0 | A Tour of GoのExercise: Mapsを解く                                                                                            |
| 13d12f70b42e8b54ff01 |           0 | 今更CSVの形式について知る~RFC4180の2.Definition of the CSV Formatを読む~                                                      |
| 3280948b32bc5f1497fd |           4 | enumの比較は==がつよつよ、equalsはよわよわ【Java】                                                                            |
| 48eb8f4f6ff82477a7d7 |           0 | Go言語に入門する -MacOS * VSCode で開発環境を構築して”Hello World"を出力する-                                                 |
| 70baed0b4711434bd88e |           0 | dockerコンテナからホストにファイルをコピーする（docker cp)                                                                    |
| 90c656397260697dac3a |           0 | 【Spring Boot】独自のプロパティファイルを追加して、env.getProperty()で値を取得したい。                                        |
| bb2e8903ab199b34ac70 |           2 | 【Oracle】ユーザ作ろうとしたら、ORA-65096: invalid common user or role name って怒られたときの解決法                          |
+----------------------+-------------+-------------------------------------------------------------------------------------------------------------------------------+

```

テーブルに記事データが存在しない場合は、`func InsertIntoItem(item qiita.Item) (sql.Result, error)`を呼び出してデータを作成します。
それ以外は、`func UpdateItem(item qiita.Item) (sql.Result, error)`を呼び出して更新します。

```go:INSERT,UPDATE
var db *sql.DB

func InsertIntoItem(item qiita.Item) (sql.Result, error) {
	return db.Exec("INSERT INTO item(id, title, likes_count) values(?, ?, ?)", item.ID, item.Title, item.LikesCount)
}

func UpdateItem(item qiita.Item) (sql.Result, error) {
	return db.Exec("UPDATE item SET likes_count = ? WHERE id = ?", item.LikesCount, item.ID)
}

```

なお、`var db *sql.DB`は起動時に下記を呼び出して初期化してます。

```go:dbの初期化
func Initialize(conf configs.Config, items []qiita.Item) {
	var err error
	db, err = sql.Open("mysql", conf.DbDataSourceName)
	if err != nil {
		log.Fatal(err)
	}

	db.SetMaxIdleConns(10)
	db.SetMaxOpenConns(10)
	db.SetConnMaxLifetime(10 * time.Second)
}
```


## 一連の処理を定期的に繰り返す

LGTM数の取得、比較、通知を繰り返すのに、無限ループさせました。
ループ中の処理の最後に`time.Sleep(time.Second * 60)`させ、１分間隔でループさせます。

```go:一連の処理を無限ループさせる。
// forに後ろに何も書かなければ無限ループになる
for {
	// 記事の取得
	items, err := qiita.GetAllItems(conf, 1, []qiita.Item{})

	for _, item := range items {
		// 記事投稿してから初回の取得、mapに該当記事のデータ存在しない場合
		beforeItem, ok := itemMap[item.ID]
		if !ok {
			itemMap[item.ID] = item
			_, err = db.InsertIntoItem(item)
			if err != nil {
				errorNotifyAndTerminate(conf, err)
			}

			continue
		}

		// LGTMの数が変化した場合
		if item.LikesCount != beforeItem.LikesCount {
			// 通知
			line.Notify(conf, fmt.Sprintf("『%s』(%s)のLGTM数が変化しました。%d -> %d", item.Title, item.ID, beforeItem.LikesCount, item.LikesCount))
			// ローカルで持ってる記事データの更新
			itemMap[item.ID] = item
			_, err = db.UpdateItem(item)
			if err != nil {
				errorNotifyAndTerminate(conf, err)
			}
		}
	}

	// １分間隔でループさせる
	time.Sleep(time.Second * 60)
}
```

# 感想とか

- DB操作してみたいからテーブルに記事データ持たせてみたけど、LGTM数に差分があったら通知するっていうのをやるだけなら特にテーブルは必要なかった。
- 作るだけ作って放置しないように、早めにqiitaに投稿しようと思っていたけど、結局一月くらい放置してた。
 - なにか作ったらすぐ投稿できる大人になりたかった。

# ソース
https://github.com/HiromasaNojima/LGTM_NOTIFIER

# 参考

- [Qiitaでいいねが付くとLINE通知してくれる優しい世界を構築した](https://qiita.com/Sinhalite/items/8fd408071ec7ab9a58e2)
- [Qiita APIを使って「いいね」をLINEに通知するアプリをLambdaで構築した](https://qiita.com/bee2/items/4aaec7206b7deadc01a3) 
- [[超簡単]LINE notify を使ってみる](https://qiita.com/iitenkida7/items/576a8226ba6584864d95)
- [Qiita API v2 documentation - Qiita:Developer](https://qiita.com/api/v2/docs)
- [LINE Notify API Document](https://notify-bot.line.me/doc/ja/)