NHK番組表API叩いてSlackで通知する

## はじめに
なんか面白そうなAPIとか探していたら、NHK番組表APIなるものを発見しました。

NHKっていったら、紅白、高校野球、朝ドラ、大河、天才テレビくんとか忍たま乱太郎とか、昔からそんなイメージ。
最近だと、キングダムとか進撃の巨人なんかも放送してたり、昔のイメージを覆しはじめてますねー。
ちなみに最近見つけた僕のイチオシは「[香川照之の昆虫すごいぜ！](https://www.nhk.or.jp/school/sugoize/)」です。

ちなみに、NHKって不定期放送多くないですかね？
LIFE!とか、見逃しちゃうんですよね〜。
ウッチャンのコント番組見ると、笑う犬思い出しちゃいますよね〜。ミル姉さんとか。

ということで、今回はLambda使ってサーバーレスな感じで、見たい番組・好きなタレントが出ている番組をSlackに通知する、お手軽アプリケーション作ろうかと思います。

言語はGo(勉強中)です。

## 環境など
- AWS Lambda
- Amazon CloudWatch Events（cron式）
- 言語：Go
- NHK番組表API(Program List API)

## NHK番組表APIについて
APIを使用するにあたって、[こちら](http://api-portal.nhk.or.jp/ja)から、ユーザ登録が必要です。
メールアドレスの登録をした後、アプリの登録をしてAPIキーを発行する必要があります。

今回使用するProgram List APIの仕様確認やAPIの試打は、[こちら](http://api-portal.nhk.or.jp/doc_list-v1_con)から。

## Goの実装
### ディレクトリ構成
以下のような構成にしました。

```
% tree
.
└── nhk_api_test
    ├── README.md
    ├── cmd
    │   └── nhk_api_test
    │       └── main.go // main関数
    ├── go.mod
    ├── go.sum
    ├── payload.go      // Slackのwebhookで使う構造体
    ├── proglamList.go  // 番組表APIの構造体
    └── webhook.go      // メインロジック
```
それぞれの実装について、ざっくり説明します。
Go触ってて、学んだところとか。

### main.go
```go:main.go
package main

import (
	"github.com/aws/aws-lambda-go/lambda"
	nhk "github.com/tkl4230/nhk_api_test"
)

func webhook() {
	nhk.Webhook()
}

func main() {
	lambda.Start(webhook)
}
```
特に主だった処理はしていません。

Lambdaの開始と`webhook.go`の`Webhook()`を呼び出しているだけです。
goでは、javaとかみたく`インスタンス.関数()`ではなく`package.関数()`で呼び出します。
外部パッケージの関数を呼び出す時は、その関数の頭文字は大文字で宣言する必要があります。

関数名が小文字の場合は、外部パッケージからは見えなくなり、同一パッケージからの呼び出しのみ可能となります。
同一パッケージ内の関数呼び出しの場合`関数名`で呼び出せます。

あと、importで宣言しているlambdaライブラリを使用するためには以下のコマンド叩いておく必要があります。

`go get github.com/aws/aws-lambda-go/lambda`

### proglamList.go

Program List APIで返ってくるjsonをマッピングするための構造体を宣言してます。

```go:proglamList.go
package nhk_api_test

import "time"

type Program struct {
	List List `json:"list"`
}

type List struct {
	E1 []E1 `json:"e1"`
}

type E1 struct {
	ID        string    `json:"id"`
	EventID   string    `json:"event_id"`
	StartTime time.Time `json:"start_time"`
	EndTime   time.Time `json:"end_time"`
	Area      Area      `json:"area"`
	Service   Service   `json:"service"`
	Title     string    `json:"title"`
	Subtitle  string    `json:"subtitle"`
	Content   string    `json:"content"`
	Act       string    `json:"act"`
	Genres    []string  `json:"genres"`
}

type Area struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

type Service struct {
	ID    string `json:"id"`
	Name  string `json:"name"`
	LogoS Logo   `json:"logo_s"`
	LogoM Logo   `json:"logo_m"`
	LogoL Logo   `json:"logo_l"`
}

type Logo struct {
	URL    string `json:"url"`
	Width  string `json:"width"`
	Height string `json:"height"`
}
```

各フィールドの後ろで`json:"xxx"`と記述することで、jsonを解析した時にjsonのプロパティとgoのフィールドを紐づけてくれます。

### payload.go

Webhookに設定するPayloadの構造体を宣言して、Slackに通知するメッセージ内容の作成と設定をしています。

```go:payload.go
package nhk_api_test

import (
	"bytes"
	"fmt"
	"text/template"
	"time"
)

const timeLayout = "15:04"

// Payload.Text用のテンプレート
var tpl = `
{{ range $key, $val := . -}}
日時： {{ $key }}
{{ range $index, $var := $val }}
番組： {{ $var.Title }}
出演者： {{ $var.Act }}
時間： {{ timeFmt $var.StartTime }} 〜　{{ timeFmt $var.EndTime }}
{{ end }}
------------------------------
{{ end }}
`

type payload struct {
	Channel   string `json:"channel"`
	UserName  string `json:"username,omitempty"`
	Text      string `json:"text"`
	IconUrl   string `json:"icon_url,omitempty"`
	IconEmoji string `json:"icon_emoji,omitempty"`
}

// Payload.Textの作成と設定
func (p *payload) setText(m map[string][]E1) {

	// テンプレート内で使用する関数の設定
	fMap := template.FuncMap{
		"timeFmt": timeFmt,
	}

	// テンプレートの割当
	tpl := template.Must(template.New("tpl").Funcs(fMap).Parse(tpl))

	// テンプレートからデータ出力
	var buffer bytes.Buffer
	if err := tpl.Execute(&buffer, m); err != nil {
		fmt.Println(err)
	}

	// 出力したデータを文字列化
	p.Text = buffer.String()
}

// テンプレートに渡す関数。
// 番組表APIの日時の形式を hh:mm にしてくれる。
func timeFmt(date time.Time) string {
	return date.Format(timeLayout)
}
```

goにはクラスという概念がないため、構造体に属するメソッドを定義する時は、以下のように書き、レシーバ型に構造体を指定します。

`func (レシーバ値 レシーバ型) 関数名()`

上記の書き方で、クラス内のメソッドを宣言するように、構造体に属したメソッドを宣言することになります。

なので、このファイルの`func (p *payload) setText()`はpayload構造体に属したメソッドの宣言になります。

普通の`func 関数名()`は、パッケージに属する感じですかね。

呼び出す時も`パッケージ名.関数()`ではなく、構造体を生成して、`構造体.関数()`になります(具体的には[こちら](#webhookgo)の3-a(詳細はロジック内)を参照)。

あと`*`は、ポインタ渡すかどうかです。Javaでいう、参照渡しに近いかと思います(要勉強)。

### webhook.go
内容としては、主な処理を書いてます。
処理に必要な値は環境変数として外出ししているものもあります。
処理の流れとしては、以下の通りです。

- 現在日取得
    - TZがUTCなので注意です。
    - 取得するjsonがyyyy--mm-dd.jsonなので、そこで使用します。
- 以下の処理はループとなり、現在日を基準に環境変数:TERMに設定した回数、翌日以降のデータ取得を行います。
    - NHK番組表APIをコール
    - 取得したjsonの解析
    - jsonデータの中に環境変数:PERFORMER, PROGRAM_NAMEに部分一致する番組があれば、その番組データをmapに格納。
- Webhookに設定するpayload構造体の生成。
    - mapに番組データがある場合、テンプレートを元にメッセージ生成
- Webhook実行

```go:webhook.go
package nhk_api_test

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"net/url"
	"os"
	"strconv"
	"strings"
	"time"
)

var (
	// 番組表APIのAPIキー
	apiKey = os.Getenv("API_KEY")
	// 探したい出演者(カンマ区切り)
	performer = os.Getenv("PERFORMER")
	// 探したい番組名(カンマ区切り)
	programName = os.Getenv("PROGRAM_NAME")
	// 探したい期間(Max7日)
	term = os.Getenv("TERM")
	// WebhookのURL
	webhookURL = os.Getenv("WEBHOOK_URL")
	// Slackのチャンネル名
	channel = os.Getenv("CHANNEL")
)

const (
	// 日付のフォーマットProgram List APIのjson名で使用
	dateLayout = "2006-01-02"
	// URL
	programListURL = "https://api.nhk.or.jp/v2/pg/list/130/e1/%s.json?key=%s"
)

// Webhook メインロジック
func Webhook() {

	// 今日の日付取得
	// LambdaはUTCなので、9H補正
	now := time.Now()
	now = now.Add(time.Duration(9) * time.Hour)

	// 期間設定を数値に変換
	term, _ := strconv.Atoi(term)

	// 見たい番組表格納用マップ
	// K: 日付、V: E1構造体
	programMap := make(map[string][]E1)
	// API実行(3日先まで)
	for i := 0; i < term; i++ {
		// URIに渡す日付を計算
		d := now.AddDate(0, 0, i)
		date := d.Format(dateLayout)

		// 番組表APIの呼び出し
		reqURL := fmt.Sprintf(programListURL, date, apiKey)
		resp, err := http.Get(reqURL)
		if err != nil {
			log.Println("エラー発生")
			log.Println(fmt.Sprintf("Error:%s", err))
			log.Println(fmt.Sprintf("Request:%s", reqURL))
			return
		}

		if resp.StatusCode != 200 {
			log.Println("ステータスコード異常")
			log.Println(fmt.Sprintf("Status code:%v", resp.StatusCode))
			log.Println(fmt.Sprintf("Request:%s", reqURL))
			return
		}

		defer resp.Body.Close()

		// 番組表APIレスポンスのjson解析
		var p Program
		if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
			log.Println("json解析でエラー発生")
			log.Println(err)
			return
		}

		// 見たい番組名・出演者があれば、pListに格納
		pList := make([]E1, 0)
		for _, e1 := range p.List.E1 {
			if isNoticeTarget(e1) {
				pList = append(pList, e1)
			}
		}

		// 見たい番組名・出演者がなければ、翌日へ。
		l := len(pList)
		if l == 0 {
			log.Printf("%vに、探してる番組・出演者はありませんでした。\n", date)
			continue
		}
		log.Printf("%vに、%v件の番組が見つかりました。\n", date, l)

		// マップに格納
		programMap[date] = pList
	}

	// payloadの生成
	payload := payload{
		Channel:   channel,
		UserName:  "nhk番組取得お知らせ",
		IconEmoji: ":ghost:",
		Text:      "探している番組は見つかりませんでした。",
	}

	// 見たい番組が見つかれば、メッセージ(Text)を更新。
	if len(programMap) > 0 {
		payload.setText(programMap)
	}

	// jsonのエンコード
	jsonByte, err := json.Marshal(payload)
	if err != nil {
		log.Println("jsonのエンコードでエラー発生")
		log.Println(err)
		return
	}

	// Webhookの呼び出し
	_, err = http.PostForm(
		webhookURL,
		url.Values{"payload": {string(jsonByte)}},
	)
	if err != nil {
		log.Println("Webhookの実行でエラー発生")
		log.Println(err)
		return
	}
}

// Webhookで通知する対象か判定
func isNoticeTarget(e1 E1) bool {

	// 出演者のチェック
	performerList := strings.Split(performer, ",")
	for _, v := range performerList {
		if strings.Contains(e1.Act, v) {
			return true
		}
	}

	// 番組のチェック
	programNameList := strings.Split(programName, ",")
	for _, v := range programNameList {
		if strings.Contains(e1.Title, v) {
			return true
		}
	}
	return false
}
```

所々出てくるアンスコのところは戻り値で不要なものを読み捨ててます。
変数宣言のところで`os.Getenv("XXX")`の記載がありますが、こちらで環境変数を読み込んでいます。
## AWSの設定
### デプロイパッケージの作成
こちら、Lambdaの方に設定します。

```
GOOS=linux go build -o webhook main.go
zip webhook.zip webhook
```

### Lambda
<img width="1283" alt="lambda1.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/575194/796945ed-b411-5e5b-aa87-2ebe4ded8911.png">
nhk_program_webhookという関数名にしました。
トリガーにCloudWatch Eventsを設定しています。

<img width="1283" alt="lambda2.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/575194/ce21b506-2ebc-b799-b779-5705c4138afe.png">
ハンドラはwebhookとしています。
先ほど作成したデプロイパッケージはこちらからアップロードします。

<img width="1279" alt="lambda3.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/575194/e80c0cc8-6231-8e4a-e315-bd00e144d847.png">
環境変数6つ。
なんとなく思いついたものを設定してみました。

### CloudWatch Events
<img width="1282" alt="cloudwatch.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/575194/c01e3f83-4345-d124-3691-1d569189f261.png">
画面はLambdaの管理画面ですけど、CloudWatch Eventsの設定です。
毎日22:10(日本で7:10)に動くようにしてます。

## 動かしてみた
<img width="927" alt="slack.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/575194/27247b64-a3c6-1308-77e5-211094e17e3e.png">

CloudWatchではなく、テスト実行ですが、動きました。
(CloudWatchのトリガーからも動くことは確認済みです)

なんと、**昆虫すごいぜ！** 再放送してます！！
すでに4回目っていうことで、乗り遅れてますが、要チェックです！！

ということで、NHK番組表API叩いてSlack通知する、でした。
ありがとうございました。