Goで「HackerNews頻出英単語 1200」をつくる

こんにちは、この記事は[Go Advent Calendar 2019 22日目](https://qiita.com/advent-calendar/2019/go)の記事です。

## 英語の勉強における英単語

いきなりですが、最近、趣味で英語の勉強をしています。

せっかく作った趣味なので、目標として**TOEFL 100点**という目標を置いて
[TOEFLテスト英単語3800](https://www.amazon.co.jp/dp/B071Y335LW/)という単語帳をやってます。

この単語帳 TOEFL界隈では非常に有名な単語帳でして、これさえやれば**TOEFLの点数 10点上がる**と言われています。（ほんまかいな）

実際にこの単語帳をやって、TOEFLの試験をやると**おぉ、めっちゃ単語出てくる！**と言った感じで
単語の意味がわかると、難解で有名なTOEFLの文章も読めるようになります。

## HackerNews英単語帳を作れないか

ここから派生して、エンジニア界隈で有名なテックニュースサイト [HackerNews](https://news.ycombinator.com/)も
英単語帳を作って覚えれば、スラスラHackerNewsのサイトが読めるのでは？というアイデアが湧いてきました。

「TOEFLの英単語帳覚えちゃえばHackerNewsもスラスラ読めるようになるんじゃないの？」と思ったあなた、さすがですね。
自分も最初はそう思ってました。

しかし、TOEFLはあくまで大学・大学院の留学用の試験のため、さまざまな学術的な単語が出てきます。
例えば、**platypus(カモノハシ)**とか。カモノハシは実際に、TOEFL英単語にも載っています。

HackerNewsには、そういった学術的な単語というより、技術的な用語が多いです。そのため、TOEFLの単語を覚えてもHackerNewsは読めるようにはなりません。
HackerNewsを読めるようになるには、技術的英単語を覚える必要があります。

そこで、今回はGoを用いて、HackerNewsの頻出英単語帳を作ってみたいと思います。

幸いにも、Goには自然言語処理やスクレイピングのライブラリがたくさんあるので、簡単に作ることができます。

## ソースコード
ソースコードは以下です。
https://github.com/pco2699/hackernews1200

## 処理の流れ・プロジェクト構成

処理の流れは次の通りです。

1. HackerNews APIからHTMLを取得する（fetch）
2. HTMLからタグを取り除きTextだけにする（extract）
3. TextをToken化・Taggingする（tokenize）
4. 単語の集計を行う（count）

プロジェクトの構成も上記の処理の流れに沿っています。

```
.
├── LICENSE
├── README.md
├── cmd
│   ├── fetcher.go    # 1. HackerNews APIからHTMLを取得する
│   ├── extractor.go  # 2. HTMLからタグを取り除きTextだけにする
│   ├── tokenizer.go  # 3. TextをToken化・Taggingする
│   └── counter.go    # 4. 単語の集計を行う
├── collections
│   └── counter.go    # 集計用のデータ構造
├── go.mod
├── go.sum
└── main.go
```


## HackerNews APIからHTMLを取得する
実はHackerNewsはAPIが公開されており、自由に記事を取得することができます。
こちらのGithubにAPIの仕様が載っています。

[HackerNews/API](https://github.com/HackerNews/API)

### HackerNews APIの仕様
今回は以下の2つエンドポイントを叩いてみます。

#### New, Top and Best Stories

>Up to 500 top and new stories are at /v0/topstories (also contains jobs) and /v0/newstories. Best stories are at /v0/beststories.
>Example: [https://hacker-news.firebaseio.com/v0/topstories.json?print=pretty](https://hacker-news.firebaseio.com/v0/topstories.json?print=pretty)

このエンドポイントで「New or Top or Best」な記事 500コが取得できます。
取得すると、以下のようなStoryのIDのArrayが取れます。

```
[ 9129911, 9129199, 9127761, 9128141, 9128264, 9127792, 9129248, 9127092, 9128367, ..., 9038733 ]
```

今回はこの中でも「Top」のエンドポイントを利用してみます。
（TopとBestの違いがあまりわかっていない。）

#### A story
> a story: https://hacker-news.firebaseio.com/v0/item/8863.json?print=pretty

上記でとってきた500のTopStoriesの詳細を、Story APIで取得します。

```
{
  "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"
}
```

この中でも必要なのは`url`のみなので、URLのみフィルタリングします。

### GoでHackerNews APIにアクセスしてみる
まずは、`fetcher.go`で、HackerNews APIへアクセスし、HTMLを`goquery.Document`形式のArrayで取得します。
(goqueryは後ほど、詳しく説明します。)

GoでAPIへのアクセス、JSONのMarshall, Unmarshallはすべて標準パッケージで行えます。


```go:fetcher.go
package cmd

import (
	"encoding/json"
	"fmt"
	"io/ioutil"
	"net/http"
	"strconv"
	"strings"

	"github.com/PuerkitoBio/goquery"
)

const (
	TopStories = "https://hacker-news.firebaseio.com/v0/topstories.json"
	Story      = "https://hacker-news.firebaseio.com/v0/item/{{number}}.json"
)

func Fetch() ([]*goquery.Document, error) {
    // TopStoriesを取得する
	stories, err := fetchTopStories()
	if err != nil {
		return nil, err
	}
    // TopStoriesからそれぞれの記事情報を取得
	articles, err := fetchArticles(stories)
	if err != nil {
		return nil, err
	}
    // 記事情報のURLからHTMLを取得し、goqueryのarrayに
	docs := fetchDocuments(articles)

	return docs, nil
}

// TopStoriesを取得する関数
func fetchTopStories() ([]int, error) {
	if stories, err := fetch(TopStories); err == nil {
		return stories, nil
	} else {
		return nil, err
	}
}

// TopStoriesへアクセスする実態の関数
func fetch(url string) ([]int, error) {
	// httpで該当のURLへアクセス
	resp, err := http.Get(url)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	// レスポンスのbodyをすべてbytes[]型で取得
	bytes, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}

	// json.Unmarshalでjsonをint[]のarrayにマッピングする
	var array []int
	err = json.Unmarshal(bytes, &array)
	if err != nil {
		return nil, err
	}

	return array, nil
}
// 以降の処理は省略
```

## HTMLからタグを取り除きTextだけにする
HackerNewsから記事を取得したので、`goquery`を使って、不要なタグを除去してテキストのみにします。

`goquery`はjQueryライクにHTMLの内容抽出を行えるライブラリです。
Goを用いた、スクレイピングの際に非常に便利です。

[PuerkitoBio/goquery](https://github.com/PuerkitoBio/goquery)

`extractor.go`の処理は非常にシンプルです。
`script`タグや`style`タグなど、HTMLタグ中で単語と集計して困るテキストをすべて除去し
最後にbodyタグ内のテキストを抽出しています。


```go:extractor.go
package cmd

import (
	"github.com/PuerkitoBio/goquery"
)

func Extract(docs []*goquery.Document) ([]string, error) {
	var texts []string
	for _, doc := range docs {
		text := extract(doc)
		texts = append(texts, text)
	}
	return texts, nil
}

func extract(doc *goquery.Document) string {
	doc.Find("script").Each(func(i int, el *goquery.Selection) {
		el.Remove()
	})
	doc.Find("style").Each(func(i int, el *goquery.Selection) {
		el.Remove()
	})
	doc.Find("noscript").Each(func(i int, el *goquery.Selection) {
		el.Remove()
	})

	return doc.Find("body").Text()
}
```

## TextをToken化・Taggingする
HTMLタグをテキストにした後は、TextのToken化・Taggingを行います。

Token化は、スペース・改行などを余計な情報を取り除き、単語の集計をしやすくします。
Taggingは、それぞれの単語が何なのかの情報を付与します。
たとえば「write -> 現在形の動詞」といった形です。

この一連の処理は、`prose`というライブラリですべて行うことができます。

[jdkato/prose](https://github.com/jdkato/prose)

```go:tokenizer.go
func Tokenize(texts []string) ([]prose.Token, error) {
	var tokens []prose.Token
	for _, text := range texts {
		if t, err := tokenize(text); err == nil {
			tokens = append(tokens, t...)
		} else {
			return nil, err
		}
	}
	return tokens, nil
}

func tokenize(text string) ([]prose.Token, error) {
	doc, err := prose.NewDocument(text)
	if err != nil {
		return nil, err
	}
	return doc.Tokens(), nil
}
```

`prose.NewDocument`でテキスト情報をほおりこむだけでToken化、Taggingをやってくれるのはびっくりしました。

## 単語の集計を行う
最後に単語の集計を行います。
集計には、以下の複数のデータ構造を組み合わせたものを用います。


| データ構造       |用途 |
|:-----------------|:------------------|
| ハッシュマップ    |すでに出ている単語の管理 |
| 優先度付きキュー（ヒープ）|出てきた頻度の集計・頻度が高いものを取り出し |

ちょうど[同じようなデータ構造](https://github.com/tizz98/go-playground/blob/6aa1e2ab8002609e2530f93f3ab564f65749473c/collections/counter.go)を作ってくれている方がいたので、このデータ構造をベースにさせていただきました。

```go:counter.go
package cmd

import (
	"github.com/pco2699/hackernews1200/collections"
	"gopkg.in/jdkato/prose.v2"
)

func Count(tokens []prose.Token) []collections.CounterItem {
	counter := collections.NewCounter()
	for _, token := range tokens {
		counter.AddItems(token.Text)
	}

	return counter.MostCommon(1200)
}

```

こちらで一通り、処理ができたので、`main.go`でそれぞれを呼び出します。
`main.go`の最後では、できた単語を`hackernews1200.txt`に吐き出しています。

```go:main.go
package main

import (
	"bufio"
	"fmt"
	"log"
	"os"

	"github.com/pco2699/hackernews1200/cmd"
)

func main() {
	fmt.Println("Fetching HackerNews API...")
	docs, err := cmd.Fetch()
	if err != nil {
		fmt.Println(err.Error())
	}
	fmt.Println("Extracting HTML...")
	texts, err := cmd.Extract(docs)
	if err != nil {
		fmt.Println(err.Error())
	}
	fmt.Println("Tokenize HTMLs...")
	tokens, err := cmd.Tokenize(texts)
	if err != nil {
		fmt.Println(err.Error())
	}
	fmt.Println("Counting tokens...")
	items := cmd.Count(tokens)

	file, err := os.OpenFile("hackernews1200.txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
	if err != nil {
		log.Fatalf("failed creating file: %s", err)
	}

	datawriter := bufio.NewWriter(file)

	for _, item := range items {
		fmt.Fprintf(datawriter, "Text: %v Count: %v\n", item.Value, item.Count)
	}

	datawriter.Flush()
	file.Close()

}
```

## 動かしてみよう
動かして10分ほど待つと、`hackernews1200.txt`ができています。
早速、中身を見てみましょう。[こちら](https://github.com/pco2699/hackernews1200/blob/master/hackernews1200.txt)からGithub上のものも確認できます。

```text:hackernews1200.txt
Text: , Count: 41753
Text: . Count: 33799
Text: the Count: 33142
Text: to Count: 20756
Text: of Count: 17875
Text: and Count: 16858
Text: a Count: 16147
Text: " Count: 13158
Text: in Count: 11258
Text: that Count: 9044
Text: is Count: 8931
Text: ) Count: 7260
Text: : Count: 7008
Text: for Count: 6881
Text: ( Count: 6475
Text: 's Count: 6306
Text: it Count: 5769
Text: on Count: 5663
Text: with Count: 5120
Text: The Count: 4674
Text: I Count: 4657
Text: you Count: 4597
Text: are Count: 3981
Text: as Count: 3775
Text: be Count: 3675
Text: this Count: 3549
Text: by Count: 3519
Text: at Count: 3306
Text: was Count: 3151
Text: from Count: 3056
Text: or Count: 3037
Text: have Count: 2962
Text: an Count: 2837
Text: can Count: 2717
Text: not Count: 2706
Text: we Count: 2601
Text: n't Count: 2387
Text: your Count: 2208
```

現状、Taggingの内容によって内容をフィルタリングしていないため、ピリオドやカンマが最頻出単語になってしまってます。（当たり前）
面白い点としては、`Facebook`や`Google`などが上位に食い込んでいること。やはりFANGはHackerNewsでも話題の種みたいですね。

実装をUPDATE次第、こちらの単語帳も更新していきたいと思います。

## 今後の改善ポイント
### 英語全体で頻出な単語が集計されている
`is`とか`a`などの英語全体での頻出単語が出力されている状態です。
そのため、英語全体での頻出英単語は取り除く処理を入れる必要があります。

幸いにも、[Googleをスクレイピングした頻出英単語集](https://github.com/first20hours/google-10000-english)はあるので、それをもとに、集計から除外する処理を入れればよいです。

### Goルーチンで並行処理化を行う
現状、一切、Goルーチンを使ってません。
TopStoriesが取れれば、それ以降は処理を並列化できます。そのため、処理を並列化してより処理の高速化を図りたいと思います。