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;