4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Posted at

動機

Goの勉強を始め、A Tour of Go を一通り終え、何か作ろうと思っていました。
結局何か作ってみるのが一番身に付きますからね。

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

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

概要

image.png

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

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

通知は画像のようにしてくれます。
スクリーンショット 2021-04-25 20.58.54.png
『記事タイトル』(記事ID)のLGTM数が変化しました。変動前のLGTM数 -> 変動後のLGTM数
というメッセージでLGTM数に変動があったことを教えてくれます。
※自分以外の方の記事データで動作確認していたため、黒塗りしてます。

説明

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

記事についているLGTM数を取得するのに、下記APIを実行します。
GET /api/v2/users/:user_id/itemsレファレンスはこちら

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

実行結果
[
    {
        "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の数★★★★★★★
       ~~ (略)〜〜
    },
    {
        // 記事数分だけ要素繰り返し
    }
]

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

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

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"`
}
設定値を持つ構造体、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で一回に取得できる記事の件数には限りがあるので、全件取得するにはページングする必要があります。
再帰で実現しました。

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

	// 再帰の終了条件、記事の取得件数が0になるまで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)
}

こんなやり方しなくてもこの記載を読んで
API実行結果のレスポンスヘッダのLinkをみれば、ページ分だけAPI実行ができそうなことに後から気づきました。

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

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

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

// 記事情報保持するmapの初期化
func initializeMap(items []qiita.Item) {
	for _, item := range items {
		itemMap[item.ID] = item
	}
}
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レファレンスはこちら

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

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

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

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)を呼び出して更新します。

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は起動時に下記を呼び出して初期化してます。

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)させ、1分間隔でループさせます。

一連の処理を無限ループさせる。
// 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)
			}
		}
	}

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

感想とか

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

ソース

参考

4
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?