goでweb漫画のサイトをスクレイピングしてRSSを作成する

# 前説
- もともと PHPで自分の読んでいるweb漫画のサイトをスクレイピングしてRSSを作成するツールを作っていた
    - https://github.com/background-color/webcomic-crawler
- 10年近く経ち、ライブラリも古くなってしまっていたので、Goで作り直すことにしました

# 仕様
- 巡回するURL,読み込むセレクタはDB保持する
- DBからURLを取得し、セレクタの情報を取得する
- 取得した情報は、前回取得した情報と異なればDBに保持する
    - 同じ場合は保持しない
- 取得した最新情報はRSSファイルに出力
    - RSSリーダーで読み込みたいため

# 開発
## スクレイピングライブラリ
### 要件
- クライアントサイドレンダリングのページも読み込めること
- セレクタを指定して要素が取得できること
    - 現行の仕様をそのまま使いまわしたい

というわけで、 [go-rod/rod](https://github.com/go-rod/rod) を使うことに
### コードを書いて動作確認

```go:main.go
package main

import (
	"fmt"
	"github.com/go-rod/rod"
)

func main() {
	browser := rod.New().MustConnect()
	defer browser.MustClose()

	page := browser.MustPage("https://www.yahoo.co.jp/")
	title := page.MustElement("#tabpanelTopics1 > div > div._2jjSS8r_I9Zd6O9NFJtDN- > ul > li:nth-child(1) > article > a > div > div > h1 > span").MustText()
	fmt.Printf("タイトル: %s\n", title)

	page2 := browser.MustPage("https://comic.pixiv.net/works/9314")
	title2 := page2.MustElement("#__next > div.overflow-x-hidden.mt-64 > div > div.mx-auto > div > div.mx-16 > div:nth-child(1) > div ").MustText()
	fmt.Printf("タイトル: %s\n", title2)
}

```

実行結果
```sh
$ go run main.go
タイトル: 40℃に迫る暑さ続出の見通し 警戒
タイトル: 読み込み中…
```

読み込み終了まで待機させたい
```go:main.go
	page2 := browser.MustPage("https://comic.pixiv.net/works/9314")
	el := page2.MustElement("#__next > div.overflow-x-hidden.mt-64 > div > div.mx-auto > div > div.mx-16 > div:nth-child(1) > div")
	el.MustWaitStable()
```

実行結果
```sh
$ go run main.go
タイトル: 40℃に迫る暑さ続出の見通し 警戒
タイトル: 第22話
```

読み込めたのでこれで行けそう


## ログ
Go 1.21より、標準パッケージとしてlog/slogパッケージが追加されたらしい
### コードを書いて動作確認
```go:main.go
package main

import (
	"fmt"
	"log/slog"

	"github.com/go-rod/rod"
)

func main() {
	slog.Info("Hello, World!", "foo", "bar", "hoge", "fuga")
```

ログに出力されること確認
```sh
$ go run main.go
2024/05/26 09:11:24 INFO Hello, World! foo=bar hoge=fuga
```

## DB接続
本番環境では既存のMysqlそのまま使う。 8.0だった
```
$ mysql --version
mysql  Ver 8.0.36-0ubuntu0.22.04.1 for Linux on x86_64 ((Ubuntu))
```

ローカル開発用にdockerのmysql立ち上げておく
```yaml:mysql/docker-compose.yml
version: "3"
services:
  db:
    image: mysql:8.0
    platform: linux/amd64
    command: mysqld --character-set-server=utf8 --collation-server=utf8mb4_bin
    volumes:
      - db-volume:/var/lib/mysql
    environment:
      MYSQL_ROOT_PASSWORD: password
      TZ: "Asia/Tokyo"
    ports:
      - "3306:3306"


volumes:
  db-volume:
```

```sh
$ dc  up -d  
$ dc  exec db mysql -V               
mysql  Ver 8.0.37 for Linux on x86_64 (MySQL Community Server - GPL)
```

### コードを書いて動作確認
```go:models/connect.go
package models

import (
	"database/sql"
	"os"
	"time"

	"github.com/go-sql-driver/mysql"
	"github.com/joho/godotenv"
)

func DBConnect() (*sql.DB, error) {
	var err error

	// .envファイルを読み込む
	err = godotenv.Load()
	if err != nil {
		//logger.Error("Error open .env file")
		return nil, err
	}

	// 環境変数を変数に格納する
	dbName := os.Getenv("DB_NAME")
	dbUser := os.Getenv("DB_USER")
	dbPass := os.Getenv("DB_PASS")
	dbAddr := os.Getenv("DB_ADDRESS")

	// 接続設定
	locale, _ := time.LoadLocation("Asia/Tokyo")
	c := mysql.Config{
		DBName:    dbName,
		User:      dbUser,
		Passwd:    dbPass,
		Addr:      dbAddr,
		Net:       "tcp",
		Collation: "utf8mb4_general_ci",
		ParseTime: true,
		Loc:       locale,
	}

	db, err := sql.Open("mysql", c.FormatDSN())
	if err != nil {
		return nil, err
	}

	// 確認
	pingErr := db.Ping()
	if pingErr != nil {
		return nil, pingErr
	}

	return db, nil
}
```

```go:main.go
package main

import (
	"database/sql"
	"fmt"
	"log/slog"
	"os"
	"time"

	"github.com/background-color/webcomic-crawler-go/models"
	"github.com/go-rod/rod"
)

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
	logger.Debug("---------- start startCrawl()")

	db, err := models.DBConnect()
	if err != nil {
		logger.Error("error", err)
		return
	}
	defer db.Close()

	// SQLクエリの作成
	query := `
select
    c.id, c.name, c.url, c.chk_url,
    s.url_type, s.check_field,
    r.check_text
from comic as c
inner join site as s on c.site_id = s.id
left join (select comic_id, max(id) as id from rss group by comic_id) as r_max on c.id = r_max.comic_id
left join rss as r on r_max.id = r.id
where c.is_disabled = 0
`

	browser := rod.New().MustConnect()
	defer browser.MustClose()

	rows, err := db.Query(query)
	defer rows.Close()
	if err != nil {
		logger.Error("Failed to execute query", slog.Any("error", err))
		return
	}
	defer rows.Close()

	// 登録用
	stmtIns, err := db.Prepare("INSERT INTO rss (`comic_id`, `check_text`) VALUES( ?, ? )")
	if err != nil {
		logger.Error("Failed to execute query", slog.Any("error", err))
		return
	}
	defer stmtIns.Close()

	for rows.Next() {
		var (
			id, name, url, checkField  string
			urlType, chkUrl, checkText sql.NullString
		)
		err := rows.Scan(&id, &name, &url, &chkUrl, &urlType, &checkField, &checkText)
		if err != nil {
			logger.Error("Failed to scan row", slog.Any("error", err))
			return
		}

		fmt.Printf("ID: %v, Name: %s, URL: %s\n", id, name, url)

		page := browser.MustPage(url)
		elText := page.Timeout(10 * time.Second).MustElement(checkField).MustText()
		fmt.Printf("タイトル: %s\n", elText)

		if elText != checkText.String {
			fmt.Printf("更新")
			logger.Info("update: id", id, elText)
			stmtIns.Exec(id, elText)

		}
	}
}

```

スクレイピングして取得した最新情報がDBに保持されること確認

## RSS出力

`gorilla/feeds` を利用する


### コードを書いて動作確認
```go:rss/rss.go
package rss

import (
	"database/sql"
	"fmt"
	"time"

	"github.com/gorilla/feeds"
)

type RSSItem struct {
	Id        int
	CheckText string
	Name      string
	Url       string
	Ins       time.Time
}

func GenerateRSSFeed(db *sql.DB) error {
	rssRows, err := fetchRSSRows(db)
	if err != nil {
		return err
	}
	defer rssRows.Close()

	feed, err := createFeed(rssRows)
	if err != nil {
		return err
	}

	rss, err := feed.ToRss()
	if err != nil {
		return err
	}

	fmt.Println(rss)
	return nil
}

// RSS出力内容取得
func fetchRSSRows(db *sql.DB) (*sql.Rows, error) {
	const rssQuery = `
		SELECT t1.id, t1.check_text, t2.name, t2.url, t1.ins
		FROM rss AS t1 
		INNER JOIN comic AS t2 ON t1.comic_id = t2.id
		ORDER BY t1.id DESC
		LIMIT 30
	`
	return db.Query(rssQuery)
}

// Feed作成
func createFeed(rows *sql.Rows) (*feeds.Feed, error) {
	now := time.Now()
	feed := &feeds.Feed{
		Title:       "ALL RSS",
		Link:        &feeds.Link{Href: "rss.background-color.jp"},
		Description: "WEB COMIC RSS",
		Created:     now,
	}

	var feedItems []*feeds.Item

	for rows.Next() {
		item, err := createFeedItem(rows)
		if err != nil {
			return nil, err
		}
		feedItems = append(feedItems, item)
	}

	feed.Items = feedItems
	return feed, nil
}

// Feedの items作成
func createFeedItem(rows *sql.Rows) (*feeds.Item, error) {
	var rssItem RSSItem

	if err := rows.Scan(&rssItem.Id, &rssItem.CheckText, &rssItem.Name, &rssItem.Url, &rssItem.Ins); err != nil {
		return nil, err
	}

	return &feeds.Item{
		Title:       rssItem.Name,
		Link:        &feeds.Link{Href: rssItem.Url},
		Description: rssItem.CheckText,
		Created:     rssItem.Ins,
	}, nil
}


 ```
 出力された
 ```xml:rss.xml
 <?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>ALL RSS</title>
    <link>rss.background-color.jp</link>
    <description>WEB COMIC RSS</description>
    <pubDate>Tue, 28 May 2024 08:27:41 +0900</pubDate>
    <item>
      <title>オネエさんと女子高生</title>
      <link>https://comic.pixiv.net/works/2963</link>
      <description>第41話&#xA;更新日: 2020年10月8日</description>
      <pubDate>Sun, 26 May 2024 17:20:16 +0900</pubDate>
    </item>
    <item>
      <title>今日、駅で見た可愛い女の子</title>
      <link>https://comic-polaris.jp/ekidemita/</link>
      <description>第37話</description>
      <pubDate>Sun, 26 May 2024 17:19:07 +0900</pubDate>
    </item>
  </channel>
</rss>
```


## その他細かい作業
- RSSファイルを出力
- ログファイルを出力
- logrotate設定
- EC2上でgoを動かす
- EC2にSwapメモリを作成


# 完了
以上を経て、goでweb漫画のサイトをスクレイピングしてRSSを作成するツールが完了しました。

最終的なコードは以下
https://github.com/background-color/webcomic-crawler-go