【Go言語】新聞記事を自動収集！Goで作る簡単なWebスクレイピングツール

## はじめに
「Go言語を使って新聞記事を自動収集してみたい！」
そんなニーズに応えるべく、今回の記事では、複数の新聞サイトから本文をスクレイピングし、CSVに保存するツールを作成する方法を解説します。

以下の内容を中心に進めます：

- Go言語でのWebスクレイピングの基本
- 複数の新聞サイトに対応した設定の作り方
- 記事データをCSVに保存する方法

初心者にもわかりやすいように、細かいコード解説を交えながら説明していきます！

## 実現したいこと
以下の新聞サイトから記事情報をスクレイピングして、CSVに保存するツールを作ります。

1. 新聞サイトにアクセス
1. 記事タイトルや本文、日付を取得
1. 設定ファイルで複数サイトを切り替えられるようにする
1. -all オプションで全サイトを処理可能

## プロジェクト構成
最初に、プロジェクト構成を確認しておきましょう。

```
newspaper_scraper/
│
├── main.go  // メインの処理
└── go.mod   // Goモジュール設定
```
今回はすべて main.go に記述していきます。

## コード全体
まずは完成版のコードをご覧ください。

```go
package main

import (
	"encoding/csv"
	"flag"
	"fmt"
	"net/http"
	"os"
	"regexp"
	"strings"
	"time"

	"github.com/PuerkitoBio/goquery"
)

// NewspaperConfig 設定情報を保持
type NewspaperConfig struct {
	BaseURL        string // ベースURL
	ListSelector   string // 記事リストを取得するCSSセレクタ
	DetailSelector string // 記事本文を取得するCSSセレクタ
	DateSelector   string // 日付を取得するCSSセレクタ
	DateRegexp     string // 日付抽出用の正規表現
	DateFormat     string // 日付のフォーマット
	CSVFile        string // 出力するCSVファイル名
}

// 新聞の設定情報
var configs = map[string]NewspaperConfig{
	"mainichi": {
		BaseURL:       "https://mainichi.jp/yoroku/",
		ListSelector:  "ul.articlelist li a",
		DetailSelector: "section#articledetail-body p",
		DateSelector:   "span.articletag-date",
		DateRegexp:     `(\d{4}/\d{1,2}/\d{1,2})`, // 例: 2024/12/7
		DateFormat:     "2006/1/2",
		CSVFile:        "mainichi_articles.csv",
	},
	"chugoku": {
		BaseURL:       "https://www.chugoku-np.co.jp/feature/special/%E5%A4%A9%E9%A2%A8%E9%8C%B2",
		ListSelector:  "div.l-articles article.l-articles__item a",
		DetailSelector: "div.article-body p",
		DateSelector:   "div.m-header-info__date",
		DateRegexp:     `(\d{1,2})月(\d{1,2})日`, // 例: 12月7日
		DateFormat:     "2006年1月2日",
		CSVFile:        "chugoku_articles.csv",
	},
}

func main() {
	// CLI引数でモードを指定
	all := flag.Bool("all", false, "Run for all newspapers")
	newspaper := flag.String("newspaper", "", "Specify a single newspaper to process")
	flag.Parse()

	if *all {
		processAllNewspapers()
	} else if *newspaper != "" {
		config, exists := configs[*newspaper]
		if !exists {
			fmt.Printf("Invalid newspaper specified: %s\n", *newspaper)
			return
		}
		processNewspaper(config)
	} else {
		fmt.Println("Please specify a newspaper or use -all to process all newspapers.")
	}
}

func processAllNewspapers() {
	for name, config := range configs {
		fmt.Printf("Processing newspaper: %s\n", name)
		processNewspaper(config)
	}
}

func processNewspaper(config NewspaperConfig) {
	doc, err := fetchDocument(config.BaseURL)
	if err != nil {
		fmt.Printf("Failed to fetch URL: %v\n", err)
		return
	}

	// 記事一覧を取得
	articles := doc.Find(config.ListSelector)
	articles.Each(func(i int, s *goquery.Selection) {
		href, exists := s.Attr("href")
		if !exists {
			return
		}

		fullURL := href
		if !strings.HasPrefix(href, "http") {
			fullURL = config.BaseURL + href
		}

		processArticle(fullURL, config)
	})
}

func processArticle(url string, config NewspaperConfig) {
	doc, err := fetchDocument(url)
	if err != nil {
		fmt.Printf("Failed to fetch article URL: %v\n", err)
		return
	}

	text := extractText(doc, config.DetailSelector)
	date, err := extractDate(doc, config)
	if err != nil {
		fmt.Printf("Failed to extract date: %v\n", err)
		return
	}

	// データフォーマット
	insertText := fmt.Sprintf("%d,%d,%d,%s", date.Year(), int(date.Month()), date.Day(), text)

	// CSVに書き込む
	err = writeToCSV(config.CSVFile, insertText)
	if err != nil {
		fmt.Printf("Failed to write to CSV: %v\n", err)
	}
}

func fetchDocument(url string) (*goquery.Document, error) {
	resp, err := http.Get(url)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
	}

	return goquery.NewDocumentFromReader(resp.Body)
}

func extractText(doc *goquery.Document, selector string) string {
	var textBuilder strings.Builder
	doc.Find(selector).Each(func(i int, s *goquery.Selection) {
		textBuilder.WriteString(strings.TrimSpace(s.Text()))
	})
	return regexp.MustCompile(`\s+`).ReplaceAllString(textBuilder.String(), " ")
}

func extractDate(doc *goquery.Document, config NewspaperConfig) (time.Time, error) {
	dateText := doc.Find(config.DateSelector).Text()
	re := regexp.MustCompile(config.DateRegexp)
	matches := re.FindStringSubmatch(dateText)
	if len(matches) == 0 {
		return time.Time{}, fmt.Errorf("failed to match date")
	}
	return time.Parse(config.DateFormat, matches[0])
}

func writeToCSV(filename, text string) error {
	file, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
	if err != nil {
		return err
	}
	defer file.Close()

	writer := csv.NewWriter(file)
	defer writer.Flush()

	return writer.Write(strings.Split(text, ","))
}
```
## コード解説
### 設定情報の定義
```go
var configs = map[string]NewspaperConfig{
	"mainichi": {
		BaseURL:       "https://mainichi.jp/yoroku/",
		ListSelector:  "ul.articlelist li a",
		DetailSelector: "section#articledetail-body p",
		DateSelector:   "span.articletag-date",
		DateRegexp:     `(\d{4}/\d{1,2}/\d{1,2})`, // 例: 2024/12/7
		DateFormat:     "2006/1/2",
		CSVFile:        "mainichi_articles.csv",
	},
}
```
ここでは、複数の新聞に対応する設定情報を管理しています。それぞれのサイトの構造に合わせて、以下を設定します：

- BaseURL: 記事一覧ページのURL
- ListSelector: 記事リンクのCSSセレクタ
- DetailSelector: 記事本文のCSSセレクタ
- DateSelector: 日付を取得するCSSセレクタ
- DateRegexp/DateFormat: 日付フォーマットの定義


## 記事のスクレイピング処理
``` go
func processNewspaper(config NewspaperConfig) {
	doc, err := fetchDocument(config.BaseURL)
	if err != nil {
		fmt.Printf("Failed to fetch URL: %v\n", err)
		return
	}

	articles := doc.Find(config.ListSelector)
	articles.Each(func(i int, s *goquery.Selection) {
		href, exists := s.Attr("href")
		if !exists {
			return
		}

		fullURL := href
		if !strings.HasPrefix(href, "http") {
			fullURL = config.BaseURL + href
		}

		processArticle(fullURL, config)
	})
}
```

1. `fetchDocument` を使って、記事一覧ページを取得。
1. `Find` メソッドで記事リンクを抽出。
1. 各リンクに対して `processArticle` 関数を実行。


## 実行例
### 全ての新聞を処理:
```bash
go run main.go -all
```
### 特定の新聞を処理:
```bash
go run main.go -newspaper=mainichi
```

## おわりに
Go言語のシンプルさを活かして、実用的なWebスクレイピングツールを作成しました。新聞のスクレイピングだけでなく、他の用途にも応用可能です。

ぜひ、この記事を参考にして、あなたのプロジェクトにも活用してください！ 🚀