動機
Goの勉強を始め、A Tour of Go を一通り終え、何か作ろうと思っていました。
結局何か作ってみるのが一番身に付きますからね。
- 自分が使うもの
- HTTP通信してみたい
- DB操作もかじりたい
という理由で、LGTMをLINE通知するプログラムを作成することにしました。
概要
ざっくり下記の処理を定期的に繰り返すものをGoで作り、AWSEC2にデプロイしました。
- Qiita API v2を実行し、記事についてるLGTM数を取得
- ローカルで保持しているLGTM数と取得したLGTM数を比較
- 比較した結果差分がある場合は、LINE Notify APIを実行し通知し、DBに保持するLGTM数に更新をかける
通知は画像のようにしてくれます。
『記事タイトル』(記事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の数★★★★★★★
~~ (略)〜〜
},
{
// 記事数分だけ要素繰り返し
}
]
コードは下記になります。
// 引数の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
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"`
}
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数を比較します。
var itemMap = map[string]qiita.Item{}
// 記事情報保持するmapの初期化
func initializeMap(items []qiita.Item) {
for _, item := range items {
itemMap[item.ID] = item
}
}
// 記事の取得
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
に指定した文字列が、通知されます。
% curl -X POST -H 'Authorization: Bearer <access_token>' -F 'message=testメッセージ' https://notify-api.line.me/api/notify
{"status":200,"message":"ok"}
コードは下記になります。
// 引数の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)
を呼び出して更新します。
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
は起動時に下記を呼び出して初期化してます。
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に投稿しようと思っていたけど、結局一月くらい放置してた。
- なにか作ったらすぐ投稿できる大人になりたかった。
ソース