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"
    "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;

投稿者:

鼻毛スライサー

自分の力と意思だけでインターネットに浮かんでいます

コメントを残す

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