0
0

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

Posted at

前説

  • もともと 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話&#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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0