GoとMySQLで作りました。オブジェクト指向じゃなくてすみません。素人が独学で書いたコードなので、変なところがたくさんあると思いますし、そもそも自分(Ubuntu 18.04.2、Go 1.12、MySQL 5.7.25)以外の環境でちゃんと動くかどうか心配です。問題があったら教えてもらえると嬉しいです。
機能
- お好みのRSSフィードURLを設定ファイル(config.yml)に追加して実行すれば、RSSアイテム(=記事)がデータベースにどんどこ登録される。
- RSSアイテムには、URLとタイトルとサマリー(Description、それがなければContent、それがなければTitle)が含まれる。
- RSSアイテムとして、同じurlが再度登録されることはない。
- 30日前より古いRSSアイテムの自動削除
2018.12.15(土)追加:
- itemsデータベースからsummaryコラムを削除してcontentコラムを新設し、そこに本文を直接読み込んでしまうことにした。
実行のために必要な準備
- プロジェクトディレクトリ(またはコンパイルされたバイナリと同じディレクトリ)の中に、設定ファイル:config.ymlを置いてください。config.ymlには、MySQLデータベースへ接続するための設定と、読み込みたいRSSフィードのURLを記入してください。
- MySQLデータベースに、下記のCREATE TABLE構文(「RSSアイテムのテーブル」と「RSSフィードURLのテーブル」)に従って、二つのテーブルを作成してください。照合順序はutf8_general_ciです。
まだやってないこと
- 古いRSSアイテムの自動削除。残しておくのは過去1ヶ月分くらいでいいよね、いくらなんでもね。※やりました@2018.10.29
package main
import (
"bufio"
"bytes"
"context"
"database/sql"
"fmt"
html2 "html"
"io"
"log"
"net/http"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
"golang.org/x/net/html"
"golang.org/x/net/html/charset"
encoding "github.com/mattn/go-encoding"
"github.com/mmcdole/gofeed"
"github.com/spf13/viper"
"github.com/yukihir0/gec"
)
func main() {
// 設定ファイルの読み込み
viper.SetConfigName("config")
viper.AddConfigPath(".")
viper.SetConfigType("yaml")
err := viper.ReadInConfig()
if err != nil {
panic(fmt.Errorf("設定ファイルが読み込めませんでした:%s", err))
}
feedURLs := viper.GetStringSlice("FeedURLs")
cred := viper.GetStringMapString("DBCredentials")
// データベースへの接続
db, err := sql.Open("mysql", cred["user"]+":"+
cred["password"]+
"@tcp("+cred["Server"]+")/"+
cred["database"]+
"?parseTime=true&loc=Asia%2FTokyo")
if err := db.Ping(); err != nil {
panic(fmt.Errorf("データベースに接続できませんでした:%s", err))
}
defer db.Close()
// 各フィードを並列処理で読み込む
ch := make(chan []*gofeed.Item)
for _, url := range feedURLs {
go readFeed(url, ch, db)
}
// 集まってきたitemをまとめる
allItems := make([]*gofeed.Item, 0)
count := len(feedURLs)
for i := 0; i < count; i++ {
items := <-ch
if len(items) == 0 {
fmt.Println("itemがありません")
continue
}
if items[0].Title == "parse error" {
fmt.Println("フィード読み込みエラー:" + items[0].Description)
continue
}
if items[0].Title == "ignore me" {
fmt.Println("新情報なし:" + items[0].Description)
continue
}
allItems = append(allItems, items...)
}
if len(allItems) == 0 {
return
}
// 集まったitemをデータベースに登録
valStrs := make([]string, 0)
params := make([]interface{}, 0)
now := fmt.Sprint(time.Now())
for _, item := range allItems {
valStrs = append(valStrs, "(?, ?, ?, ?, ?, ?)")
desc := item.Description
if desc == "" {
desc = item.Content
}
if desc == "" {
desc = item.Title
}
params = append(params, item.Title, item.Link, now, now, item.Content, desc)
}
valStr := strings.Join(valStrs, ", ")
_, err = db.Exec(`
INSERT IGNORE INTO
items (title, url, created_at, updated_at, content, summary)
VALUES `+valStr,
params...,
)
if err != nil {
panic(fmt.Errorf("itemの登録に失敗しました:%s", err))
}
// 古いitemを捨てる
_, err = db.Exec(`
DELETE FROM items
WHERE
created_at < DATE_SUB(NOW(), INTERVAL 30 DAY)
`)
if err != nil {
panic(fmt.Errorf("itemsテーブルから削除できませんでした:%s", err))
}
// auto_incrementの値を調整
_, err = db.Exec(`
ALTER TABLE items
AUTO_INCREMENT = 1
`)
if err != nil {
panic(fmt.Errorf("itemsテーブルの自動採番値が調整できませんでした:%s", err))
}
_, err = db.Exec(`
ALTER TABLE rss_feeds
AUTO_INCREMENT = 1
`)
if err != nil {
panic(fmt.Errorf("rss_feedsテーブルの自動採番値が調整できませんでした:%s", err))
}
}
// フィードURLからitemを取得してチャンネルに送信
func readFeed(url string, ch chan []*gofeed.Item, db *sql.DB) {
// フィードを読み込む
fp := gofeed.NewParser()
feed, err := fp.ParseURL(url)
if err != nil {
errorSlice := make([]*gofeed.Item, 1)
errorSlice[0] = &gofeed.Item{Title: "parse error", Description: url}
ch <- errorSlice
return
}
addFeedToDB(url, db)
// フィードが前回読み込み時点から更新されていない場合はitemを無視
feedUpd, err := getUpdatedTime(url, feed)
lastUpd := time.Time{}
err = db.QueryRow(`
SELECT last_updated
FROM rss_feeds
WHERE url = ?`,
url).Scan(&lastUpd)
if err != nil {
panic(fmt.Errorf("フィードの前回更新時刻の取得に失敗しました:%s", err))
}
if lastUpd.Equal(*feedUpd) {
errorSlice := make([]*gofeed.Item, 1)
errorSlice[0] = &gofeed.Item{Title: "ignore me", Description: feed.Title}
ch <- errorSlice
return
}
// フィードの最終更新時刻を更新
_, err = db.Exec(`
UPDATE rss_feeds
SET last_updated = ?, updated_at = ?
WHERE url = ?`,
feedUpd,
time.Now(),
url,
)
if err != nil {
panic(fmt.Errorf("フィードの最終更新時刻が更新できませんでした:%s", err))
}
// アイテムの下処理
for _, item := range feed.Items {
url := item.Link
item.Title = html2.UnescapeString(item.Title)
fullContent, _ := getHTMLContent(url)
item.Content = strings.TrimSpace(textContent(fullContent))
if item.Content == "" {
item.Content = item.Description
}
if item.Content == "" {
item.Content = item.Title
}
}
// itemをチャンネルに送る
ch <- feed.Items
}
// フィードが新しかったらデータベースに登録
func addFeedToDB(url string, db *sql.DB) {
now := time.Now()
zeroTime := time.Time{}.Format("2006-01-02 15:04:05")
_, err := db.Exec(`
INSERT INTO
rss_feeds (url, last_updated, created_at, updated_at)
SELECT ?, ?, ?, ?
FROM dual
WHERE NOT EXISTS (
SELECT id
FROM rss_feeds
WHERE url = ?
)`,
url,
zeroTime,
now,
now,
url,
)
if err != nil {
panic(fmt.Errorf("フィードurlの登録に失敗しました:%s", err))
}
}
// フィードの最終更新時刻を取得
func getUpdatedTime(url string, feed *gofeed.Feed) (ud *time.Time, err error) {
if len(feed.Items) > 0 {
ud = feed.Items[0].UpdatedParsed
if ud == nil {
ud = feed.Items[0].PublishedParsed
}
}
if ud == nil {
ud = feed.UpdatedParsed
}
if ud == nil {
ud = feed.PublishedParsed
}
if ud == nil {
ud, err = getLastModified(url)
}
return
}
// ページの最終更新時刻を取得
func getLastModified(url string) (tm *time.Time, err error) {
res, err := http.Get(url)
if err != nil {
log.Printf("%s へのリクエストに失敗しました。%s\n", url, err)
return
}
if code := res.StatusCode; code >= 400 {
err = fmt.Errorf("%s への接続エラーです(%d)。", url, code)
log.Printf("info: %s\n", err)
return
}
lm, ok := res.Header["Last-Modified"]
if !ok {
err = fmt.Errorf("%s のLast-Modifiedヘッダがありませんでした", url)
return
}
tmr, err := time.Parse("Mon, 2 Jan 2006 15:04:05 MST", lm[0])
tm = &tmr
return
}
// ページの素のhtmlを取得
func getHTMLContent(url string) (content string, err error) {
cli := http.DefaultClient
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequest("GET", url, nil)
if err != nil {
log.Printf("%s へのリクエストに失敗しました。%s\n", url, err)
return
}
req = req.WithContext(ctx)
res, err := cli.Do(req)
if err != nil {
fmt.Printf("リクエストがタイムアウトしました:%s", err)
return
}
defer res.Body.Close()
if code := res.StatusCode; code >= 400 {
err = fmt.Errorf("%s への接続エラーです(%d)。", url, code)
log.Printf("info: %s\n", err)
return
}
defer res.Body.Close()
content, err = htmlString(res)
if err != nil {
log.Printf("info: %s\n", err)
return
}
return
}
// htmlStringは、Webページの内容を、どんなテキストエンコーディングであってもutf-8で返す
// (https://mattn.kaoriya.net/software/lang/go/20171205164150.htm)
func htmlString(resp *http.Response) (string, error) {
br := bufio.NewReader(resp.Body)
var r io.Reader = br
if data, err := br.Peek(4096); err == nil {
enc, name, _ := charset.DetermineEncoding(data, resp.Header.Get("content-type"))
if enc != nil {
r = enc.NewDecoder().Reader(br)
} else if name != "" {
if enc := encoding.GetEncoding(name); enc != nil {
r = enc.NewDecoder().Reader(br)
}
}
}
buf := new(bytes.Buffer)
buf.ReadFrom(r)
return buf.String(), nil
}
// untaggedContentは、htmlからテキストを抽出する。
// https://github.com/mattn/go-mastodon/blob/master/cmd/mstdn/main.go より拝借
func untaggedContent(s string) string {
doc, err := html.Parse(strings.NewReader(s))
if err != nil {
return s
}
var buf bytes.Buffer
var extractText func(node *html.Node, w *bytes.Buffer)
extractText = func(node *html.Node, w *bytes.Buffer) {
if node.Type == html.TextNode {
data := strings.Trim(node.Data, "\r\n")
if data != "" {
w.WriteString(data)
}
}
for c := node.FirstChild; c != nil; c = c.NextSibling {
extractText(c, w)
}
if node.Type == html.ElementNode {
name := strings.ToLower(node.Data)
if name == "br" {
w.WriteString("\n")
}
}
}
extractText(doc, &buf)
return buf.String()
}
// textContentは、htmlから本文テキストを抽出する。
func textContent(s string) (content string) {
opt := gec.NewOption()
content, _ = gec.Analyse(s, opt)
return
}
DBCredentials: #データベース接続のための資格情報(環境に応じて要変更)
user: rss
password: ****************
server: localhost:3306
database: rss
FeedURLs: # RSSフィードのurlを列挙(いくらでも)
- https://お好みのurl1
- https://お好みのurl2
CREATE TABLE `items` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `title` varchar(250) NOT NULL DEFAULT '', `url` varchar(191) NOT NULL, `created_at` datetime DEFAULT NULL, `updated_at` datetime DEFAULT NULL, `content` text NOT NULL, `summary` varchar(2000) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `url` (`url`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE TABLE `rss_feeds` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `url` varchar(191) NOT NULL, `last_updated` datetime NOT NULL, `created_at` datetime DEFAULT NULL, `updated_at` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `url` (`url`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;