Go言語初心者だけど、はてなブックマークのRSSをブクマ数でフィルタするRSSを作ってみた

Go言語のアドベントカレンダーに登録していたのに記事を完成させるのが遅れてしまい、後から公開しようと思っていたら既に代わりの記事入ってました。Goは層が厚い！

とはいえ、せっかく書いたので公開します。
どっかで書くの辛くなった人がいたらこの記事を代わりにしてほしいです(笑)

僕はフロントエンドのエンジニアなので、普段はGo言語を触ることはありませんが、人気があるのでちょっと触ってみました。

# 作ったもの

はてブのホットエントリーのRSSで、指定したブックマーク数以上のものだけ返してくれるやつです。

https://hatebufilter.herokuapp.com/

これだとホットエントリーのRSSそのままですが、

https://hatebufilter.herokuapp.com/300

のように、 `/` の後に数値を入れると、その数値以上のブックマーク数のものだけにフィルタリングしてくれます。

元々、はてブのRSSには `threshold` というパラメータがあり、これを使うと指定したブックマーク数以上のものだけにフィルタリングしてくれてたのですが、6月くらいにその機能がなくなってしまいました。

それからというもの、RSSで配信される記事数があきらかに増え、日々の情報収集が大変になったので、同じような機能のものを作ろうと思いました。

公開してるので、同じような悩みを持っている人がいたら使ってみてください。

ただ、個人の練習用に作ったものなので、動作保証は致しかねますし、突然消えるかもしれません。
実際、Feedly登録して使ってみてますが、更新されるのがちょっと遅いような感じがします。
Feedly側の取得時間の仕様かもしれませんが、詳しくは調べてません。

以降はソースコードや技術的な詳細、初めてGo言語を触って各工程で学んだことなど、ざっくりとまとめてます。

# 作る前に

[ドットインストールのGo言語入門](https://dotinstall.com/lessons/basic_golang)を見て基本的なこと学びました。

最初は書き方がjsっぽいなと思い、あまり抵抗なかったです。
(実際に作り始めたらそんな気持ちは無くなりましたが・・・)

変数宣言などもシンプルで良いなと思いました。

構造体とか出てきたあたりからちょっと難しいと思いましたが、とりあえず作りはじめました。

# RSSを取得

まず、 GETリクエストを送ってRSSを取得してみます。

```go
package main

import (
    "fmt"
    "net/http"
    "io/ioutil"
)

func main() {
    rss := getRSS("http://b.hatena.ne.jp/hotentry/all.rss")
    fmt.Println(rss)
}

func getRSS(url string) string {
    resp, err := http.Get("http://b.hatena.ne.jp/hotentry/all.rss")
    if err != nil {
        // エラーハンドリングを書く
    }
    defer resp.Body.Close()

    // _を使うことでエラーを無視できる
    body, _ := ioutil.ReadAll(resp.Body)

    return string(body)
}
```

`net/http` を使用してGetリクエストを送って、文字列として取得する関数にしてみました。

- `package main` `func main()` はお約束。`func main()` に処理を書いていく
- パッケージをインポートして機能追加していく
    - 標準パッケージだけで色々できるらしいので、今回は標準パッケージだけ使う
    - 使わないパッケージをインポートしてるとエラーになる
- 関数は引数と返り値に型を指定する
- 返り値にerrorを返すので `var, err` のように変数に入れる
    - `err` を使わないと怒られるので `_` で無視できる
    - `error` という型がある

関数の返り値に普通にエラーを返すというのが新鮮でした。
とはいえ、今回は特にエラーハンドリングとか書いてないです。

[【go】golangのエラー処理メモ - ①. errorとError型とカスタムErrorと](http://tweeeety.hateblo.jp/entry/2017/08/17/021040)
[アンダースコア変数について](https://qiita.com/penguin_dream/items/c1df36040b3fc6d42945)

# XMLをパースする

`encoding/xml` を使用してXMLをパースしてみます。
とりあえず、タイトル・リンク・ディスクリプション・日付・ブックマークカウントを出力してみました。

```go
import (
    …
    "encoding/xml"
)

type HatenaFeed struct {
    HatenaBookmarks []struct {
        Title       string `xml:"title"`
        Link        string `xml:"link"`
        Description string `xml:"description"`
        Date        string `xml:"date"`
        Count       int    `xml:"bookmarkcount"`
    } `xml:"item"`
}

func main() {
    rss := getRSS("http://b.hatena.ne.jp/hotentry/all.rss")

    feed := HatenaFeed{}
    err := xml.Unmarshal([]byte(rss), &feed)
    if err != nil {
        fmt.Printf("error: %v", err)
        return
    }

    for _, bookmark := range feed.HatenaBookmarks {
        fmt.Println(bookmark.Title)
        fmt.Println(bookmark.Link)
        fmt.Println(bookmark.Description)
        fmt.Println(bookmark.Date)
        fmt.Println(bookmark.Count)
        fmt.Println("")
    }
}
```

## 出力結果

パースできてます。

```
【復旧】携帯電話サービスにおける通信障害について | モバイル | ソフトバンク
https://www.softbank.jp/mobile/info/personal/important/20181206-14/
掲載日：2018年12月6日 いつもソフトバンクをご利用いただき、誠にありがとうございます。 本日午後1時39分頃より、一部の地域で携帯電話サービスがご利用しづらい状況が発生しており、詳細を確認しております。 ご利用のお客さまには、ご迷惑をお掛けしておりますことをお詫び申し上げます。 以上
351
2018-12-06T05:22:27Z

外国人実習生3年間に69人死亡　法務省「中身把握せず」 - 共同通信 ｜ This kiji is
https://this.kiji.is/443275399432471649
外国人技能実習生が2015～17年の3年間で、計69人死亡していたことが法務省の集計で分かった。実習中を含む事故死や病死のほか、自殺も複数人いた。立憲民主党の有田芳生氏が6日、報道陣の取材に明らかにした。有田氏が同日の参院法務委員会で、集計をまとめた法務省の資料を読み上げ、詳しい原因を明らかにするよう求め...
354
2018-12-06T05:14:05Z
・
・
```

- `type for struct` で構造体の定義ができる(クラスみたいなものという理解)
- 構造体に取得したRSSをマッピングする
- Goのループはfor文だけ
    - rangeを使うと要素分だけ繰り返しできる

[GoでXMLをパースする](https://qiita.com/ytkhs/items/948f516ec82c82eaa882)

# 指定したブックマーク数以上の記事だけ抽出

```go

type Item struct {
	Title string `xml:"title"`
	Link  string `xml:"link"`
	Desc  string `xml:"description"`
	Date  string `xml:"pubDate"`
}

func main() {
    …
    // URLパスを取得して数値に変換
    thresholdCount := 100

    // 取得した閾値以上のはてブ数のスライスを作る
    items := make([]Item, 0)
    for _, item := range feed.HatenaBookmarks {
        if item.Count > thresholdCount {
            items = append(items, Item{item.Title, item.Link, item.Desc, item.Date})
        }
    }
}
```

`thresholdCount` には最終的にはURLで受け取った数値を入れますが一旦固定。

ループで回して閾値以上の記事だけ新しいスライスに追加していきます。
これで指定した値以上の記事だけにすることができました。

- `make([]${type}, 0)` で空のスライスを作成できる
- if文に `()` は不要

# xmlタグに変換

指定した値以上の記事だけにフィルタリングしたデータを作ることができたので、 `xml.MarshalIndent` でxmlに変換します。
はてなブックマークのRSSは1.0ですが書き方がシンプルでわかりやすかったのでRSS2.0の形式にしました。

```go

type RSS2 struct {
	XMLName     xml.Name `xml:"rss"`
	Version     string   `xml:"version,attr"`
	Title       string   `xml:"channel>title"`
	Link        string   `xml:"channel>link"`
	Description string   `xml:"channel>description"`
	ItemList    []Item   `xml:"channel>item"`
}

func main() {
    …
    // RSSの内容を設定 / 取得した記事追加
	newFeed := RSS2{
		Version:     "2.0",
		Title:       "タイトル",
		Link:        "URL",
		Description: "説明文",
	}
	newFeed.ItemList = make([]Item, len(items))
	for i, item := range items {
		newFeed.ItemList[i].Title = item.Title
		newFeed.ItemList[i].Link = item.Link
		newFeed.ItemList[i].Desc = item.Desc
		newFeed.ItemList[i].Date = item.Date
	}

	// XMLに変換
	result, err := xml.MarshalIndent(newFeed, "  ", "    ")
	if err != nil {
		fmt.Printf("error: %v\n", err)
		return
	}
}
```

試行錯誤しながらこうなったけど、この辺はもっと良い書き方がありそう。

# Webページとして処理する

goでサーバーを立ててxmlを返却するようにしてみました。

```go
func main() {
	http.HandleFunc("/", handler)
	http.ListenAndServe(":8080", nil)
}

func handler(w http.ResponseWriter, r *http.Request) {
    ・
    ・
    ・

    // Webページに出力
	fmt.Fprint(w, "<?xml version='1.0' encoding='UTF-8'?>")
	fmt.Fprint(w, string(result))
}
```
`net/http` パッケージを使って簡単にWebサーバー作成できました。
関数を `handler` に移してさっき変換したxmlを出力するように。

xml宣言の出力が強引かと思いましたが、試してみたらうまくいきました。

# Herokuにデプロイ

自分が使うのに、どこかに公開する必要があったので簡単にGoを動かせそうなHerokuを使いました。
詳しいことは省きますが、Goのビルドパックをベースにして、中身だけ今回作ったものに入れ替えたら動いてくれました。

# ソース全体

```go
package main

import (
	"encoding/xml"
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
	"os"
	"strconv"

	_ "github.com/heroku/x/hmetrics/onload"
)

type HatenaFeed struct {
	HatenaBookmarks []struct {
		Title string `xml:"title"`
		Link  string `xml:"link"`
		Desc  string `xml:"description"`
		Date  string `xml:"date"`
		Count int    `xml:"bookmarkcount"`
	} `xml:"item"`
}

type RSS2 struct {
	XMLName     xml.Name `xml:"rss"`
	Version     string   `xml:"version,attr"`
	Title       string   `xml:"channel>title"`
	Link        string   `xml:"channel>link"`
	Description string   `xml:"channel>description"`
	ItemList    []Item   `xml:"channel>item"`
}

type Item struct {
	Title string `xml:"title"`
	Link  string `xml:"link"`
	Desc  string `xml:"description"`
	Date  string `xml:"pubDate"`
}

func main() {
	port := os.Getenv("PORT")

	if port == "" {
		log.Fatal("$PORT must be set")
	}

	http.HandleFunc("/", handler)
	http.ListenAndServe(":"+port, nil)
}

func handler(w http.ResponseWriter, r *http.Request) {
	rss := getRSS("http://b.hatena.ne.jp/hotentry/all.rss")
	feed := HatenaFeed{}
	err := xml.Unmarshal([]byte(rss), &feed)
	if err != nil {
		fmt.Printf("error: %v", err)
		return
	}

	// URLパスを取得して数値に変換
	thresholdCount, _ := strconv.Atoi(r.URL.Path[1:])

	// 取得した閾値以上のはてブ数のスライスを作る
	items := make([]Item, 0)
	for _, item := range feed.HatenaBookmarks {
		if item.Count > thresholdCount {
			items = append(items, Item{item.Title, item.Link, item.Desc, item.Date})
		}
	}

	// RSSの内容を設定 / 取得した記事追加
	newFeed := RSS2{
		Version:     "2.0",
		Title:       "はてブフィルター",
		Link:        "https://hatebufilter.herokuapp.com/",
		Description: "はてブのブックマーク数でフィルタリングできるRSSフィード",
	}
	newFeed.ItemList = make([]Item, len(items))
	for i, item := range items {
		newFeed.ItemList[i].Title = item.Title
		newFeed.ItemList[i].Link = item.Link
		newFeed.ItemList[i].Desc = item.Desc
		newFeed.ItemList[i].Date = item.Date
	}

	// XMLに変換
	result, err := xml.MarshalIndent(newFeed, "  ", "    ")
	if err != nil {
		fmt.Printf("error: %v\n", err)
		return
	}

	// Webページに出力
	fmt.Fprint(w, "<?xml version='1.0' encoding='UTF-8'?>")
	fmt.Fprint(w, string(result))
}

func getRSS(url string) string {
	resp, err := http.Get("http://b.hatena.ne.jp/hotentry/all.rss")
	if err != nil {
		// エラーハンドリングを書く
	}
	defer resp.Body.Close()

	// _を使うことでエラーを無視できる
	body, _ := ioutil.ReadAll(resp.Body)

	return string(body)
}
```

作り始めるまでは軽い気持ちでいたのですが、実際に作り始めたら結構大変で思ったより時間がかかってしまいました。

とはいえ、なんとなく書き方などわかってきたので、機会があればまた何か作ってみようと思います。