前説
- もともと PHPで自分の読んでいるweb漫画のサイトをスクレイピングしてRSSを作成するツールを作っていた
- 10年近く経ち、ライブラリも古くなってしまっていたので、Goで作り直すことにしました
仕様
- 巡回するURL,読み込むセレクタはDB保持する
- DBからURLを取得し、セレクタの情報を取得する
- 取得した情報は、前回取得した情報と異なればDBに保持する
- 同じ場合は保持しない
- 取得した最新情報はRSSファイルに出力
- RSSリーダーで読み込みたいため
開発
スクレイピングライブラリ
要件
- クライアントサイドレンダリングのページも読み込めること
- セレクタを指定して要素が取得できること
- 現行の仕様をそのまま使いまわしたい
というわけで、 go-rod/rod を使うことに
コードを書いて動作確認
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)
}
実行結果
$ go run main.go
タイトル: 40℃に迫る暑さ続出の見通し 警戒
タイトル: 読み込み中…
読み込み終了まで待機させたい
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()
実行結果
$ go run main.go
タイトル: 40℃に迫る暑さ続出の見通し 警戒
タイトル: 第22話
読み込めたのでこれで行けそう
ログ
Go 1.21より、標準パッケージとしてlog/slogパッケージが追加されたらしい
コードを書いて動作確認
main.go
package main
import (
"fmt"
"log/slog"
"github.com/go-rod/rod"
)
func main() {
slog.Info("Hello, World!", "foo", "bar", "hoge", "fuga")
ログに出力されること確認
$ 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立ち上げておく
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:
$ dc up -d
$ dc exec db mysql -V
mysql Ver 8.0.37 for Linux on x86_64 (MySQL Community Server - GPL)
コードを書いて動作確認
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
}
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
を利用する
コードを書いて動作確認
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
}
出力された
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話
更新日: 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