はじめに
こんにちは!最近はGoを集中的に触っているのですが、Goを使うと簡単にCLIアプリを作れることを知りました。本記事では、cobraとhuhを使い、毎日の最新ニュースを取得し、LINEに通知してストックするCLIアプリをハンズオン形式で作ってみたいと思います。
技術記事だけでなく、ちゃんとニュース記事も読むべし!というモチベーションです。
実装するもの
コードはこちらです。
使用技術
- go 1.21
- cobra v1.8.0
- huh v0.3.0
- News API v2
- LINE Notify API
cobraとは
cobraはCLIアプリケーションを作るためのGo製のライブラリです。k8sやHugoなどの有名OSSのCLIにも採用されています。
Cobra is a library for creating powerful modern CLI applications.
huhとは
huhはCLIアプリケーション上でインタラクティブなTUIを実現するためのライブラリです。テキスト入力やマルチセレクト、スピナーなどを提供しています。なお、Charmというプロジェクトのひとつとして位置づけられています。
A simple, powerful library for building interactive forms and prompts in the terminal.
News APIとは
世界中の現在進行形のニュースを提供してくれるAPIです。例えば、リクエストを送ると次のようなレスポンスを得ることができます。
{
"status": "ok",
"totalResults": 30,
"articles": [
{
"source": {
"id": null,
"name": "Sanspo.com"
},
"author": "サンスポ",
"title": "【速報します】森保ジャパン、決勝トーナメント1回戦でバーレーンと対戦 - サンスポ",
"description": "サッカー・アジア杯決勝トーナメント1回戦(31日、日本-バーレーン、ドーハ)3大会ぶり5度目のアジア王者を目指す世界ランキング17位の日本代表「森保ジャパン」…",
"url": "https://www.sanspo.com/article/20240131-UUL772OVCFEOTHXRW3XQKVSOCY/",
"urlToImage": "https://www.sanspo.com/resizer/vPiJ-YLm4bm-Yp_qgbG1ONOa8SM=/1200x630/filters:focal(1206x672:1216x682):quality(50)/cloudfront-ap-northeast-1.images.arcpublishing.com/sankei/H2YSC3HMDZMD3PUT5RKNAIVOTA.jpg",
"publishedAt": "2024-01-31T08:17:00Z",
"content": null
},
]
}
プロジェクト構成
├── LICENSE
├── api
│ ├── news.go
│ └── news_response.go
├── cmd
│ ├── list.go
│ ├── root.go
│ └── version.go
├── config
│ └── config.go
├── go.mod
├── go.sum
├── main.go
└── model
└── news.go
事前準備
今回は、News APIとLINE Notify APIを利用するので、APIキー・トークンを発行しておきましょう。
1. News APIのAPIキーを発行する
まず、Japan News APIへ飛び、Get API keyをクリックします。
個人情報の入力をして、I am an individual
を選択し、フォームを送信します。
2. Notify APIのトークンを発行する
LINE Notifyへ飛び、右上のログインをクリックしてください。
ログインできたら、自分のアカウント名をクリックし、マイページへと飛びます。アクセストークンを発行しましょう。
トークルームは、私は「1:1でLINE Notifyから通知を受け取る」を選択しました。
これでトークンを発行することができたかと思います。
なお、Line Notifyの公式アカウントを追加していない場合は、こちらに従って追加してください。
実装
1. プロジェクトの準備
go mod initで新しくGoモジュールを初期化します。
$ go mod init mynews
今回は、cobra-cli
というcobraのセットアップを楽にするツールも導入していきます。
$ go install github.com/spf13/cobra-cli@latest
cobra-cli
を使い、プロジェクトを初期化します。
$ cobra-cli init
現時点でこのような構成になっています。
├── LICENSE
├── cmd
│ └── root.go
├── go.mod
├── go.sum
└── main.go
今回はversionコマンドとlistコマンドを実装するので、それらのファイルを追加していきます。
$ cobra-cli add version
$ cobra-cli add list
ここで一度、main.go
を実行してみます。ここではroot.go
の中身が表示されているはずです。
$ go run main.go
A longer description that spans multiple lines and likely contains
examples and usage of using your application. For example:
Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.
Usage:
mynews [command]
Available Commands:
completion Generate the autocompletion script for the specified shell
help Help about any command
list A brief description of your command
version A brief description of your command
Flags:
-h, --help help for mynews
-t, --toggle Help message for toggle
Use "mynews [command] --help" for more information about a command.
root.go
の中身を軽く編集しておきます。
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "mynews",
Short: "mynews allows you to fetch current news articles and send them to your LINE app",
Long: `mynews is a command-line tool that allows you to fetch current news articles and send them to your LINE app. `,
}
それでは、実装を進めていきます。
2. versionコマンドを実装する
手始めにversionコマンドを実装してみます。とはいえ、中身の文字列を編集するだけです。
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print the version number of the mynews CLI tool",
Long: `Print the version number of the mynews CLI tool.`,
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("v1.0.0")
},
}
試しに、versionコマンドを実行してみます。特に問題はないはずです。
$ go run main.go version
v1.0.0
3. listコマンドを実装する ー API周りのロジックの記述
では、まずmodel
というフォルダを新たに作成し、配下にnews.go
を作成してください。そこにnewsの構造体を定義しておきます。
package model
type News struct {
Author string
Title string
Description string
URL string
PublishedAt string
}
次に、APIの取得周りを書いていきます。config
というフォルダを作成し、その配下にconfig.go
を作成します。ここに定数でAPIのエンドポイントや発行したキーを定義しておくことにします。(他の管理方法でも構いません)
package config
const (
NEWS_API_URI= "https://newsapi.org/v2"
NEWS_API_KEY= "あなたの発行したAPIキー"
)
次に、api
というフォルダを新たに作成し、配下にnews_response.go
を作成します。News APIのレスポンスを構造体で定義しておきます。
package api
type NewsResponse struct {
Status string `json:"status"`
TotalResults int `json:"totalResults"`
Articles []struct {
Author string `json:"author"`
Title string `json:"title"`
Description string `json:"description"`
URL string `json:"url"`
PublishedAt string `json:"publishedAt"`
} `json:"articles"`
Code string `json:"code"`
Message string `json:"message"`
}
さらに、api
配下にnews.go
を作成し、データを取得する関数を記述します。
package api
import (
...
)
func Fetch() (NewsResponse, error) {
uri := config.NEWS_API_URI
key := config.NEWS_API_KEY
newsResponse := NewsResponse{}
// Getリクエストを送信する
res, err := http.Get(uri + fmt.Sprintf("/top-headlines?country=jp&apiKey=%s", key))
if err != nil {
return NewsResponse{}, err
}
defer res.Body.Close()
byteArray, err := io.ReadAll(res.Body)
if err != nil {
return NewsResponse{}, err
}
if err := json.Unmarshal(byteArray, &newsResponse); err != nil {
return NewsResponse{}, err
}
if newsResponse.Status != "ok" {
return NewsResponse{}, fmt.Errorf("code: %s, message: %s", newsResponse.Code, newsResponse.Message)
}
return newsResponse, nil
}
ここまででNews APIを用いて、ニュース記事のデータを取得するロジックが実装できました。現時点でのプロジェクト構成は以下のようになります。
├── LICENSE
├── api
│ ├── news.go
│ └── news_response.go
├── cmd
│ ├── list.go
│ ├── root.go
│ └── version.go
├── config
│ └── config.go
├── go.mod
├── go.sum
├── main.go
└── model
└── news.go
3. listコマンドの実装 ー TUIの実装
まずはいつものように中身の文字列を編集しますが、ここでrun
関数を用意しておきます。
var listCmd = &cobra.Command{
Use: "list",
Short: "Fetch and display the latest news articles",
Long: `Fetches and displays the latest news articles.`,
Run: func(cmd *cobra.Command, args []string) {
run()
},
}
func run () {}
では、先ほど実装したapi/news.go
のFetch
関数を利用し、run
関数の中身を記述していきます。
func run() {
newsResponse, err := api.Fetch() // データを取得
if err != nil {
fmt.Println(err)
return
}
newsList := make([]model.News, 0, len(newsResponse.Articles))
for _, v := range newsResponse.Articles {
news := model.News{
Author: v.Author,
Title: v.Title,
Description: v.Description,
PublishedAt: v.PublishedAt,
URL: v.URL,
}
fmt.Printf("> %s\n", news.Title) // 試しにPrintfしてみます。
newsList = append(newsList, news)
}
}
だいぶ実装できてきましたね。listコマンドを実行してみましょう。
$ go run main.go list
> 異次元緩和に効果、「2年で2%」巡り議論=13年下半期・日銀議事録 - ロイター (Reuters Japan)
> 明るい2大惑星・金星と木星。共に観測できるのは2月まで。2024年最小の満月は「スノームーン」 - tenki.jp
> 不正が相次ぐトヨタグループ 広がる不安で中古車市場にも影響が より一層厳しさを増す顧客の目 - CBCニュース【CBCテレビ公式】
> タイ最大野党に違憲判決 不敬罪見直しは「国家転覆」 - 日本経済新聞
以下続く...
ニュースが取得できて、表示されていれば上手くいっています。
ここでhuhの出番です。huhのパッケージをimportに追記しgo mod tidy
を実行します。
import (
"fmt"
"mynews/api"
"mynews/model"
+ "github.com/charmbracelet/huh"
"github.com/spf13/cobra"
)
$ go mod tidy
huhを使って、マルチセレクトを実装していきます。
func multiSelectNews(newsList []model.News) ([]model.News, error) {
options := make([]huh.Option[model.News], 0, len(newsList))
for _, v := range newsList {
options = append(options, huh.NewOption(v.Title, v))
}
selectedNewsList := []model.News{}
form := huh.NewForm(
huh.NewGroup(
huh.NewMultiSelect[model.News]().
Options(options...).
Title("News").
Value(&selectedNewsList).
Validate(validateMultiSelect),
),
)
err := form.Run()
if err != nil {
return []model.News{}, err
}
return selectedNewsList, nil
}
func validateMultiSelect(selectedNewsList []model.News) error {
if len(selectedNewsList) == 0 {
return errors.New("You must select at least 1 article.")
}
return nil
}
cmd/list.go
に処理を追記します。
func run() {
newsResponse, err := api.Fetch()
if err != nil {
fmt.Println(err)
return
}
newsList := make([]model.News, 0, newsResponse.TotalResults)
for _, v := range newsResponse.Articles {
news := model.News{
Author: v.Author,
Title: v.Title,
Description: v.Description,
PublishedAt: v.PublishedAt,
URL: v.URL,
}
- fmt.Printf("> %s\n", news.Title)
newsList = append(newsList, news)
}
+ selectedNewsList, err := multiSelectNews(newsList)
+ if err != nil {
+ fmt.Println(err)
+ return
+ }
+ fmt.Println(selectedNewsList) // // 試しにPrintlnしてみます。
}
では、listコマンドを実行し、複数選択した上でEnterしてみます。
$ go run main.go list
┃ News
┃ > ✓ 森保ジャパンで圧倒的存在感…毎熊晟矢、Jリーグから現れた”ワールドクラス”。バーレーン戦飛躍の舞台裏/レビュー - Goal.com
┃ • 【ロッテ】吉井監督がキャンプインセレモニー出席「『ちむどんどん』するような野球を目指す」 - ニッカンスポーツ
┃ ✓ 名神高速 車に物が当たりフロントガラス割れる 男性が意識不明 | NHK - nhk.or.jp
┃ • 滞る設備投資、人手不足が影 コーセーやアサヒGHD - 日本経済新聞
┃ ✓ 米FRB、金利据え置き:識者はこうみる - ロイター (Reuters Japan)
┃ • 映画「機動戦士ガンダムSEED FREEDOM」冒頭映像6分半が本日2月1日20時よりプレミア公開! 新規カットを含む場面写も到着 - GAME Watch
┃ • ALS嘱託殺人、医師に懲役23年を求刑 - 読売新聞オンライン
┃ • 柿沢未途 前法務副大臣 衆議院に議員辞職願を提出 | NHK - nhk.or.jp
┃ • 中村米吉が入籍を発表「おしゃべりと笑いに満ちた家庭を築いていきたい」 - ステージナタリー
┃ • 長野 宮田村 3年前の殺人未遂容疑で指名手配 暴力団幹部を逮捕 | NHK - nhk.or.jp
┃ • 『ブギウギ』水上恒司“笑顔”のクランクアップの裏側 愛助として最後の名演をCPが称賛 - リアルサウンド
┃ • Co-opシューター『HELLDIVERS 2』民主主義のため、敵も味方も儚く吹き飛ぶ過酷なミッショントレイラー【State of Play速報】 - Game*Spark
何かしらの出力結果が得られていれば、ここまでの実装に問題はありません。
ここまでのcmd/list.go
とapi/news.go
の実装を示します。
package cmd
import (
"errors"
"fmt"
"mynews/api"
"mynews/model"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
)
// listCmd represents the list command
var listCmd = &cobra.Command{
Use: "list",
Short: "Fetch and display the latest news articles",
Long: `Fetches and displays the latest news articles.`,
Run: func(cmd *cobra.Command, args []string) {
run()
},
}
func run() {
newsResponse, err := api.Fetch()
if err != nil {
fmt.Println(err)
return
}
newsList := make([]model.News, 0, len(newsResponse.Articles))
for _, v := range newsResponse.Articles {
news := model.News{
Author: v.Author,
Title: v.Title,
Description: v.Description,
PublishedAt: v.PublishedAt,
URL: v.URL,
}
newsList = append(newsList, news)
}
selectedNewsList, err := multiSelectNews(newsList)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(selectedNewsList)
}
func multiSelectNews(newsList []model.News) ([]model.News, error) {
options := make([]huh.Option[model.News], 0, len(newsList))
for _, v := range newsList {
options = append(options, huh.NewOption(v.Title, v))
}
selectedNewsList := []model.News{}
form := huh.NewForm(
huh.NewGroup(
huh.NewMultiSelect[model.News]().
Options(options...).
Title("News").
Value(&selectedNewsList).
Validate(validateMultiSelect),
),
)
err := form.Run()
if err != nil {
return []model.News{}, err
}
return selectedNewsList, nil
}
func validateMultiSelect(selectedNewsList []model.News) error {
if len(selectedNewsList) == 0 {
return errors.New("You should select at least 1 article.")
}
return nil
}
func init() {
rootCmd.AddCommand(listCmd)
}
package api
import (
"encoding/json"
"fmt"
"io"
"mynews/config"
"net/http"
)
func Fetch() (NewsResponse, error) {
uri := config.NEWS_API_URI
key := config.NEWS_API_KEY
newsResponse := NewsResponse{}
res, err := http.Get(uri + fmt.Sprintf("/top-headlines?country=jp&apiKey=%s", key))
if err != nil {
return NewsResponse{}, err
}
defer res.Body.Close()
byteArray, err := io.ReadAll(res.Body)
if err != nil {
return NewsResponse{}, err
}
if err := json.Unmarshal(byteArray, &newsResponse); err != nil {
return NewsResponse{}, err
}
if newsResponse.Status != "ok" {
return NewsResponse{}, fmt.Errorf("code: %s, message: %s", newsResponse.Code, newsResponse.Message)
}
return newsResponse, nil
}
4. listコマンドの実装 ー LINEに取得したニュース記事を送信する
ここまでお疲れさまです。最後にLINEへ選択したニュース記事をまとめて送信する実装を行います。
まず、config/config.go
にAPIのエンドポイントや発行したトークンを追記します。
package config
const (
NEWS_API_URI = "https://newsapi.org/v2"
NEWS_API_KEY = "あなたの発行したAPIキー"
+ NOTIFY_API_URI = "https://notify-api.line.me/api/notify"
+ NOTIFY_API_TOKEN = "あなたの発行したトークン"
)
続いて、Notify APIのレスポンスの構造体をapi/news_response.go
に追記しておきます。
package api
type NewsResponse struct {
Status string `json:"status"`
TotalResults int `json:"totalResults"`
Articles []struct {
Author string `json:"author"`
Title string `json:"title"`
Description string `json:"description"`
URL string `json:"string"`
PublishedAt string `json:"publishedAt"`
} `json:"articles"`
Code string `json:"code"`
Message string `json:"message"`
}
+ type NotifyResponse struct {
+ Status int `json:"status"`
+}
api/news.go
にLINEへのデータ送信のロジックを追記します。詳しいNotify APIの仕様はドキュメントに記載されていますので各自ご確認ください。
func Notify(newsList []model.News) error {
message := ""
for i, v := range newsList {
content := fmt.Sprintf("\n[ %d ]----------\n%s\n日時: %s\n%s\n--------------\n", i+1, v.Title, v.PublishedAt, v.URL)
message += content
}
form := url.Values{}
form.Add("message", message)
body := strings.NewReader(form.Encode())
// リクエストを作成する
uri := config.NOTIFY_API_URI
req, err := http.NewRequest("POST", uri, body)
if err != nil {
return err
}
// アクセストークンをヘッダにセットする
accessToken := config.NOTIFY_API_TOKEN
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := http.Client{Timeout: 30 * time.Second}
res, err := client.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
byteArray, err := io.ReadAll(res.Body)
if err != nil {
return err
}
response := NotifyResponse{}
err = json.Unmarshal(byteArray, &response)
if err != nil {
return err
}
err = checkStatus(response.Status)
if err != nil {
return err
}
return nil
}
func checkStatus(status int) error {
switch status {
case http.StatusOK:
return nil
case http.StatusBadRequest:
return errors.New("Bad request")
case http.StatusUnauthorized:
return errors.New("Invalid access token")
case http.StatusInternalServerError:
return errors.New("Server-side error occurred")
default:
return errors.New("Unknown status code received")
}
}
先ほど実装したapi/news.goのNotify
関数をcmd/list.go
のrun
関数に追記します。
func run() {
newsResponse, err := api.Fetch()
if err != nil {
fmt.Println(err)
return
}
newsList := make([]model.News, 0, len(newsResponse.Articles))
for _, v := range newsResponse.Articles {
news := model.News{
Author: v.Author,
Title: v.Title,
Description: v.Description,
PublishedAt: v.PublishedAt,
URL: v.URL,
}
newsList = append(newsList, news)
}
selectedNewsList, err := multiSelectNews(newsList)
if err != nil {
fmt.Println(err)
return
}
- fmt.Println(selectedNewsList)
+ err = api.Notify(selectedNewsList)
+ if err != nil {
+ fmt.Println(err)
+ return
+ }
+ fmt.Println("Notify to your app!")
}
では、listコマンドを実行してみましょう!
このようにLINE側に送信できていれば、問題ありません。お疲れさまでした!!!
最後に、cmd/list.go
とapi/news.go
の完成コードを示しておきます。
package cmd
import (
"errors"
"fmt"
"mynews/api"
"mynews/model"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
)
// listCmd represents the list command
var listCmd = &cobra.Command{
Use: "list",
Short: "Fetch and display the latest news articles",
Long: `Fetches and displays the latest news articles.`,
Run: func(cmd *cobra.Command, args []string) {
run()
},
}
func run() {
newsResponse, err := api.Fetch()
if err != nil {
fmt.Println(err)
return
}
newsList := make([]model.News, 0, len(newsResponse.Articles))
for _, v := range newsResponse.Articles {
news := model.News{
Author: v.Author,
Title: v.Title,
Description: v.Description,
PublishedAt: v.PublishedAt,
URL: v.URL,
}
newsList = append(newsList, news)
}
selectedNewsList, err := multiSelectNews(newsList)
if err != nil {
fmt.Println(err)
return
}
err = api.Notify(selectedNewsList)
if err != nil {
fmt.Println(err)
return
}
fmt.Println("Notify to your line app!")
}
func multiSelectNews(newsList []model.News) ([]model.News, error) {
options := make([]huh.Option[model.News], 0, len(newsList))
for _, v := range newsList {
options = append(options, huh.NewOption(v.Title, v))
}
selectedNewsList := []model.News{}
form := huh.NewForm(
huh.NewGroup(
huh.NewMultiSelect[model.News]().
Options(options...).
Title("News").
Value(&selectedNewsList).
Validate(validateMultiSelect),
),
)
err := form.Run()
if err != nil {
return []model.News{}, err
}
return selectedNewsList, nil
}
func validateMultiSelect(selectedNewsList []model.News) error {
if len(selectedNewsList) == 0 {
return errors.New("You should select at least 1 article.")
}
return nil
}
func init() {
rootCmd.AddCommand(listCmd)
}
package api
import (
"encoding/json"
"errors"
"fmt"
"io"
"mynews/config"
"mynews/model"
"net/http"
"net/url"
"strings"
"time"
)
func Fetch() (NewsResponse, error) {
uri := config.NEWS_API_URI
key := config.NEWS_API_KEY
newsResponse := NewsResponse{}
res, err := http.Get(uri + fmt.Sprintf("/top-headlines?country=jp&apiKey=%s", key))
if err != nil {
return NewsResponse{}, err
}
defer res.Body.Close()
byteArray, err := io.ReadAll(res.Body)
if err != nil {
return NewsResponse{}, err
}
if err := json.Unmarshal(byteArray, &newsResponse); err != nil {
return NewsResponse{}, err
}
if newsResponse.Status != "ok" {
return NewsResponse{}, fmt.Errorf("code: %s, message: %s", newsResponse.Code, newsResponse.Message)
}
return newsResponse, nil
}
func Notify(newsList []model.News) error {
message := ""
for i, v := range newsList {
content := fmt.Sprintf("\n[ %d ]----------\n%s\n日時: %s\n%s\n--------------\n", i+1, v.Title, v.PublishedAt, v.URL)
message += content
}
form := url.Values{}
form.Add("message", message)
body := strings.NewReader(form.Encode())
uri := config.NOTIFY_API_URI
req, err := http.NewRequest("POST", uri, body)
if err != nil {
return err
}
accessToken := config.NOTIFY_API_TOKEN
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := http.Client{Timeout: 30 * time.Second}
res, err := client.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
byteArray, err := io.ReadAll(res.Body)
if err != nil {
return err
}
response := NotifyResponse{}
err = json.Unmarshal(byteArray, &response)
if err != nil {
return err
}
err = checkStatus(response.Status)
if err != nil {
return err
}
return nil
}
func checkStatus(status int) error {
switch status {
case http.StatusOK:
return nil
case http.StatusBadRequest:
return errors.New("Bad request")
case http.StatusUnauthorized:
return errors.New("Invalid access token")
case http.StatusInternalServerError:
return errors.New("Server-side error occurred")
default:
return errors.New("Unknown status code received")
}
}
おわりに
本記事では、cobraとhuhを使って毎日のニュースをチェックできるCLIアプリを作ってみました。いかがだったでしょうか。また、筆者が記事を書き慣れておらず拙い文章で、分かりづらい箇所もあったかと思います。何かご指摘等あれば、頂きたいです。ここまで読んでくださった方は、ありがとうございました!