Help us understand the problem. What is going on with this article?

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

こんにちは、この記事はGo Advent Calendar 2019 22日目の記事です。

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

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

せっかく作った趣味なので、目標としてTOEFL 100点という目標を置いて
TOEFLテスト英単語3800という単語帳をやってます。

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

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

HackerNews英単語帳を作れないか

ここから派生して、エンジニア界隈で有名なテックニュースサイト HackerNews
英単語帳を作って覚えれば、スラスラ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

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

このエンドポイントで「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はすべて標準パッケージで行えます。

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

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

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

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をやってくれるのはびっくりしました。

単語の集計を行う

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

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

ちょうど同じようなデータ構造を作ってくれている方がいたので、このデータ構造をベースにさせていただきました。

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に吐き出しています。

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ができています。
早速、中身を見てみましょう。こちらからGithub上のものも確認できます。

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の内容によって内容をフィルタリングしていないため、ピリオドやカンマが最頻出単語になってしまってます。(当たり前)
面白い点としては、FacebookGoogleなどが上位に食い込んでいること。やはりFANGはHackerNewsでも話題の種みたいですね。

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

今後の改善ポイント

英語全体で頻出な単語が集計されている

isとかaなどの英語全体での頻出単語が出力されている状態です。
そのため、英語全体での頻出英単語は取り除く処理を入れる必要があります。

幸いにも、Googleをスクレイピングした頻出英単語集はあるので、それをもとに、集計から除外する処理を入れればよいです。

Goルーチンで並行処理化を行う

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

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away