GoでRSSフィードを読み込み、データベースに登録する

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"
	"database/sql"
	"fmt"
	_ "github.com/go-sql-driver/mysql"
	"golang.org/x/net/html"
	"golang.org/x/net/html/charset"
    html2 "html"
	"io"
	"log"
	"net/http"
	"strings"
	"time"

	"github.com/mmcdole/gofeed"
	"github.com/spf13/viper"
	encoding "github.com/mattn/go-encoding"
	"github.com/yukihir0/gec"
)

func main() {
	// 設定ファイルの読み込み
	viper.SetConfigName("config")
	viper.AddConfigPath(".")
	viper.SetConfigType("yaml")
	err := viper.ReadInConfig()
	if err != nil {
		panic(fmt.Errorf("設定ファイルが読み込めませんでした:%s \n", 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 \n", 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 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 \n", 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 \n", err))
	}

	// auto_incrementの値を調整
	_, err = db.Exec(`
		ALTER TABLE items
		AUTO_INCREMENT = 1
	`)
	if err != nil {
		panic(fmt.Errorf("itemsテーブルの自動採番値が調整できませんでした:%s \n", err))
	}
	_, err = db.Exec(`
		ALTER TABLE rss_feeds
		AUTO_INCREMENT = 1
	`)
	if err != nil {
		panic(fmt.Errorf("rss_feedsテーブルの自動採番値が調整できませんでした:%s \n", 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 \n", 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 \n", 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 \n", err))
	}
}

// フィードの最終更新時刻を取得
func getUpdatedTime(url string, feed *gofeed.Feed) (ud *time.Time, err error) {
	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) {
	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
	}
	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;

投稿者:

鼻毛スライサー

狂新世界の管理人です。LTLでうんことおしりと神と導師を飼い、花粉を爆発させています。 https://mastodon.crazynewworld.net/@hanage999

「GoでRSSフィードを読み込み、データベースに登録する」への2件のフィードバック

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です