HackerNewsをGo製のCUIで見たい!! (ライブラリ探しと実装)

# 最終的に作るもの
HackerNewsのTUIコマンドである`hn`をGoで作成します。
![hn_preview.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154845/1462762b-f1b5-5377-5cf3-26abfcb27f28.gif)

# 背景
![hacker-news1.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154845/3641ed79-15ab-3bf4-7fa5-4b1256e6c5c9.jpeg)

[HackerNews](https://news.ycombinator.com/news)をわりと良く見るのですが、terminalでも見れないかなーと思っていた所、良い感じなのがありました。

|言語| URL|
|:--:|:--:|
|Python|https://github.com/donnemartin/haxor-news|
|Go|https://github.com/andrewstuart/hn|

最近少し、Goの勉強をしているということでGo製の方を動かしたい！と思っていたら。。。
依存しているライブラリがインストールできない？！[見に行く](https://github.com/rthornton128/goncurses)と2年くらい更新されておらず。。。原因を突き止めるのも面倒だと思い、自分で作ることにしました。

# ライブラリ調査
単なるcli操作でHackNewsの一覧を出すのでは、面白くないと思い。TUI(テキストユーザインタフェース)のライブラリを探しました。
TUIはCUIと違い、GUIのように画面全体を利用します。しかし、GUIとも異なり一般的なテキスト端末で表示できる記号や文字だけで画面を構成します。


こんな感じの見た目になります。↓↓
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154845/b8395647-51e8-e935-3729-09c7c33eb84e.png)
[termuiのREADMEから引用](https://github.com/gizak/termui)

私自身ライブラリを探す時は[awesome-go](https://awesome-go.com/)から見に行き、その後にGithub内を調査しています。今回探したのもawesome-goから見つけました。

以下は自分がHackerNews TUIを作成する際に見つけた候補ライブラリ集です。
それぞれのREADMEにはGIF画像が挿入されているのでよりわかりやすいです。ぜひ見てみてください:bow:
## tui-go
[GitHub - marcusolsson/tui-go: A UI library for terminal applications.](https://github.com/marcusolsson/tui-go)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154845/8b49ecb4-64bc-9eec-ab0d-0179bfc144e5.png)


## term-ui
[GitHub - gizak/termui: Golang terminal dashboard](https://github.com/gizak/termui)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154845/b8395647-51e8-e935-3729-09c7c33eb84e.png)


## termbox-go
[GitHub - nsf/termbox-go: Pure Go termbox implementation](https://github.com/nsf/termbox-go)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154845/42fe7339-a3cf-faa4-e2d7-607ad8facf24.png)

## tview
[GitHub - rivo/tview: Rich interactive widgets for terminal-based UIs written in Go](https://github.com/rivo/tview)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154845/b92fe64b-a7c1-7bc0-6956-acf910afe05b.png)


## termdash
[GitHub - mum4k/termdash: Terminal based dashboard.](https://github.com/mum4k/termdash)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154845/58d19389-3351-216d-8387-3c1d220b5924.png)


# HackerNews CUIの実装
今回は、demo,exampleディレクトリなどの実装を見てしっくりきた[tview](https://github.com/rivo/tview)を利用しました。

## HackerNews APIから記事一覧を取得
HackerNews CUIの実装を見ると、[goquery](https://github.com/PuerkitoBio/goquery)などでスクレイピングをしている実装も多く見られましたが、今回はFirebaseで用意されている[HackerNes API](https://github.com/HackerNews/API)を使用します。

```hn_api.go
package main

import (
	"encoding/json"
	"github.com/otiai10/opengraph"
	"io/ioutil"
	"log"
	"net/http"
	"strconv"
)

type HackerNews struct {
	By          string `json:"by"`
	Score       int    `json:"score"`
	Title       string `json:"title"`
	Type        string `json:"type"`
	Url         string `json:"url"`
	Description string
}

func GetHackerNews(n int) []HackerNews {
	res, err := http.Get("https://hacker-news.firebaseio.com/v0/topstories.json?print=pretty")
	if err != nil {
		log.Fatal(err)
	}

	body, err := ioutil.ReadAll(res.Body)
	if err != nil {
		log.Fatal(err)
	}
	var idHn []int
	json.Unmarshal(body, &idHn)

	var hns []HackerNews
	var hn HackerNews
	cnt := 0
	for _, s := range idHn {
		if cnt > n-1 {
			break
		}
		url := "https://hacker-news.firebaseio.com/v0/item/" + strconv.Itoa(s) + ".json?print=pretty"
		res, _ := http.Get(url)
		body, _ := ioutil.ReadAll(res.Body)
		json.Unmarshal(body, &hn)
		og, err := opengraph.Fetch(hn.Url)
		if err != nil {
			log.Fatal(err)
		}
		hn.Description = og.Description
		hns = append(hns, hn)
		cnt += 1
	}

	return hns
}
```

HackerNews APIのそれぞれの記事の詳細を取得すると以下の様なjsonが返ってくると思います。`hn_api.go`はこれから必要な情報を構造体にぶち込んで行き、その構造体の配列を返します。

```json
{
  "by" : "dhouston",
  "descendants" : 71,
  "id" : 8863,
  "kids" : [ 8952, 9224, 8917, 8884, 8887, 8943, 8869, 8958, 9005, 9671, 8940, 9067, 8908, 9055, 8865, 8881, 8872, 8873, 8955, 10403, 8903, 8928, 9125, 8998, 8901, 8902, 8907, 8894, 8878, 8870, 8980, 8934, 8876 ],
  "score" : 111,
  "time" : 1175714200,
  "title" : "My YC app: Dropbox - Throw away your USB drive",
  "type" : "story",
  "url" : "http://www.getdropbox.com/u/2/screencast.html"
}
```

HackerNewsの記事のタイトルだけでは、内容がよくわからないことがあるので、その記事のURLから`og:description`を取得することにしました。

`og:description`の取得には、[otiai10/opengraph](https://github.com/otiai10/opengraph)というOpen Graph Parserを利用しました。urlを指定するだけで戻り値として構造体が返却され、メソッドチェーンで使用できるため利用しました。

## TUI作成

uiを作成する`ui.go`です。

```ui.go
package main

import (
	"fmt"
	ui "github.com/gizak/termui/v3"
	"github.com/gizak/termui/v3/widgets"
	"log"
	"strconv"
)

type nodeValue string

func (nv nodeValue) String() string {
	return string(nv)
}

func generateTreeNodes(n int) []*widgets.TreeNode {
	hns := GetHackerNews(n)
	var nodes []*widgets.TreeNode
	for _, hn := range hns {
		fmt.Println(hn.Score)
		node := widgets.TreeNode{
			Value: nodeValue(hn.Title),
			Nodes: []*widgets.TreeNode{
				{
					Value: nodeValue("Score: " + strconv.Itoa(hn.Score)),
					Nodes: nil,
				},

				{
					Value: nodeValue("Type: " + hn.Type),
					Nodes: nil,
				},
				{
					Value: nodeValue("Author: " + hn.By),
					Nodes: nil,
				},
				{
					Value: nodeValue("cmd+click →  " + hn.Url),
					Nodes: nil,
				},
				{
					Value: nodeValue("Description"),
					Nodes: []*widgets.TreeNode{
						{
							Value: nodeValue(hn.Description),
							Nodes: nil,
						},
					},
				},
			},
		}
		nodes = append(nodes, &node)
	}

	return nodes
}

func hnUi(n int) {

	if err := ui.Init(); err != nil {
		log.Fatalf("failed to init")
	}
	defer ui.Close()

	nodes := generateTreeNodes(n)

	t := widgets.NewTree()
	t.Title = "Hacker News ClI"
	t.TextStyle = ui.NewStyle(ui.ColorYellow)
	t.WrapText = false
	t.SetNodes(nodes)
	x, y := ui.TerminalDimensions()
	t.SetRect(0, 0, x, y)
	ui.Render(t)
	Keybindings(t)
}
```

tviewの[treeview demo](https://github.com/rivo/tview/blob/master/demos/treeview/main.go)を参考に作成しました。


対応するキーバインドは`keybindings.go`に記述しました。

```keybindings.go
package main

import (
	ui "github.com/gizak/termui/v3"
	"github.com/gizak/termui/v3/widgets"
)

func Keybindings(t *widgets.Tree) {
	uiEvents := ui.PollEvents()
	for {
		e := <-uiEvents
		switch e.ID {
		case "q", "<C-c>":
			return
		case "k":
			t.ScrollUp()
		case "j":
			t.ScrollDown()
		case "E":
			t.ExpandAll()
		case "C":
			t.CollapseAll()
		case "<Enter>":
			t.ToggleExpand()
		}
		ui.Render(t)
	}
}
```

switch文に登録されているキーが入力されると、それに合わせた画面描画処理と再レンダリングが行われます。

## cliのオプションを設定する
最後に、cliのオプションを設定するために[urfave/cli](https://github.com/urfave/cli)を使用します。
urfave/cliはGoでコマンドラインアプリを構築するためのシンプルで高速で楽しいパッケージです。

```hn.go
package main

import (
	"github.com/urfave/cli"
	"os"
)

func main() {
	app := cli.NewApp()
	app.Name = "hn"
	app.Usage = "This is a tool to see 'Hacker News' made with Go"

	app.Flags = []cli.Flag{
		cli.IntFlag{
			Name:  "number, n",
			Value: 10,
			Usage: "option for number of Hacker News acquisitions",
		},
	}

	app.Action = func(c *cli.Context) error {
		hnUi(c.Int("number"))

		return nil
	}
	app.Run(os.Args)
}
```

# 並行処理で高速化
HackerNewsから各記事の詳細を取ってくる部分が遅いのでGoroutineを使って高速化しようと思います。

## Goroutine無
現在のAPIクライアントはこんな感じです。記事のidの配列数文だけgetリクエストを回している。

```measurement/hn_api.go
func GetHackerNewsDetail(ids []int) []HackerNews {
	var hns []HackerNews
	var hn HackerNews
	for _, s := range ids {
		url := "https://hacker-news.firebaseio.com/v0/item/" + strconv.Itoa(s) + ".json?print=pretty"
		res, _ := http.Get(url)
		body, _ := ioutil.ReadAll(res.Body)
		json.Unmarshal(body, &hn)
		og, err := opengraph.Fetch(hn.Url)
		if err != nil {
			log.Fatal(err)
		}
		hn.Description = og.Description
		hns = append(hns, hn)
	}
	fmt.Println()
	return hns
}
```

idの数を20個に設定し、計測してみると

```shell
❯ go run measurement/hn_api.go
18.335145秒
```

## Goroutine有
Gorutineを使って書き換えて見ました。
各Gorutineで各記事の詳細urlのgetリクエストを並行処理させ、HackerNewsの構造体をチャネルで渡します。

```measurement/hn_api_goroutine.go
func GetHackerNewsDetail(ids []int) []HackerNews {
	wg := new(sync.WaitGroup)
	var hns []HackerNews
	var chn = make(chan HackerNews, len(ids))
	for _, s := range ids {
		wg.Add(1)
		url := "https://hacker-news.firebaseio.com/v0/item/" + strconv.Itoa(s) + ".json?print=pretty"
		var hn HackerNews
		go func(url string) {
			res, _ := http.Get(url)
			body, _ := ioutil.ReadAll(res.Body)
			json.Unmarshal(body, &hn)
			if hn.Url != "" {
				og, err := opengraph.Fetch(hn.Url)
				if err != nil {
					log.Fatal(err)
				}
				hn.Description = og.Description
			}
			chn <- hn
			wg.Done()
		}(url)
		hns = append(hns, <-chn)
	}
	defer close(chn)
	wg.Wait()
	fmt.Println()
	return hns
}
```

こちらもidの数を20個に設定し、計測してみると

```
❯ go run measurement/hn_api_goroutine.go
13.051096秒
```

なんと5秒も速くなっている！
Goは簡単に並行処理が実装できて良いですね。

※この速さはgetリクエストということもあり、ネットワーク速度やPCの性能に依存してしまうので環境によって秒数はことなると思います。

# 最後に
作成したものをGitHubにあげ、`go get`をすると使用できるようになります。

```
$ go get github.com/<Account Name>/hn
```


hnの実装リポジトリを載せておきます。
<a href="https://github.com/TaigaMikami/hn"><img src="https://github-link-card.s3.ap-northeast-1.amazonaws.com/TaigaMikami/hn.png" width="460px"></a>