Golangで自分のサイトをスクレイピングしてsitemap.xmlを生成する
- 掲載日
はじめに
sitemap.xmlとは
サイトマップとは、サイト上のページや動画などのファイルについての情報や、各ファイルの関係を伝えるファイルです。
ホームページ上によくあるサイトマップ(HTML)も同じような用途なので、非エンジニアのお客様に説明するとき大変なのですが、以下のような違いがあります。
- サイトマップ
 利用者(人間)にサイト上のページについての情報を伝えるページ
- sitemap.xml
 クローラにサイト上のページについての情報を伝えるファイル
引用サイトにもあるように、サイトの各ページが適切にリンクされていればsitemap.xmlは不要です。
が、「サイトが新しく、外部からのリンクが少ない。」場合は必要になることがあります。
このサイトは個人サイトで新しく、外部からのリンクが少ないのでsitemapを作って配置していきます。
このサイトはフロント側はNuxtで、Nuxtにはサイトマップを自動生成するライブラリもあるのですが、色々設定しないと静的出力(SSG)をしているページのみしか出力できないなどかゆいところに手の届きませんでした。
「Golangで自分のサイトをスクレイピングして、オープンなページを辿ってファイルを自動生成すればいいんじゃない?」と思い立ったのでスクリプトを作成していきます。
作っていく
仕様
- このサイトのトップURL「https://awatana.com」からスクレイピングスタート。
- goqueryを使ってaタグを取得し、相対パスで書かれているリンクを取得。
- リンクをマップに格納し、閲覧したフラグを0にする。
 (この時、同一リンクの別名がある可能性があるので以下のように加工する)- URLの末尾「/」は除去する
- URLのパラメータ(?以降)やフラグメント(#以降)は除去する
 
- 1つのページのaタグを辿り終わったら、閲覧フラグが0のURLをスクレイピングし、閲覧フラグを1にする。
- 2の手順に戻り、閲覧フラグがすべて1になるまで続ける。
使用したパッケージ
- encoding/xml
 Goの構造体をXML文書に変換(Marshal)、またはその逆(Unmarshal)が出来るパッケージ
 sitemap.xmlを作成する用途
- net/url
 URLパーサー
 同一リンクの別名がそのうち出て来そうなのでURLを正規化する用途
- github.com/PuerkitoBio/goquery
 DOMをjQueryみたいに扱えるライブラリ
 aタグを取得して辿るために使用
- github.com/joho/godotenv
 環境変数の管理を出来るライブラリ
実装
package main
import (
	"encoding/xml"
	"fmt"
	"log"
	"net/url"
	"os"
	"sitemap/creator/model"
	"strings"
	"time"
	"github.com/PuerkitoBio/goquery"
	"github.com/joho/godotenv"
)
var checkList = make(map[string]*model.CheckItem)
var siteUrl = ""
var xmlns = "http://www.sitemaps.org/schemas/sitemap/0.9"
func main() {
	err := godotenv.Load()
	if err != nil {
		log.Fatal("Error loading .env file")
	}
	formatUrl, err := formatURL(os.Getenv("URL"))
	siteUrl = formatUrl
	log.Println("Target サイト: " + formatUrl)
	// ルートURLは最初に追加しておく
	checkList[formatUrl] = model.NewCheckItem(formatUrl)
	nestUrl(formatUrl)
	createSiteMap()
}
// urlを辿る
func nestUrl(targetUrl string) error {
	// 閲覧済みフラグをON
	checkList[targetUrl].Checked = true
	log.Println("Target:" + targetUrl)
	time.Sleep(1 * time.Second)
	doc, err := goquery.NewDocument(targetUrl)
	if err != nil {
		log.Fatal("url scraping failed")
		return err
	}
	siteUrlParse, err := url.Parse(siteUrl)
	doc.Find("a").Each(func(_ int, s *goquery.Selection) {
		href, _ := s.Attr("href")
		urlParse, err := url.Parse(href)
		if err != nil {
			log.Fatal(err)
		}
		absoluteUrl := ""
		// ドメイン無なら自サイトなので、絶対パスに変換
		if urlParse.Host == "" {
			absoluteUrl = siteUrl + href
		} else {
			// ドメイン有ならチェック
			if urlParse.Host == siteUrlParse.Host {
				// 自サイトドメインなら追加
				absoluteUrl = href
			} else {
				// 他サイトドメインならスキップ
				return
			}
		}
		formatUrl, err := formatURL(absoluteUrl)
		if err != nil {
			log.Fatal(err)
		}
		_, hasUrl := checkList[formatUrl] // 存在チェック
		if !hasUrl {                      // 存在しない物のみ追加
			checkList[formatUrl] = model.NewCheckItem(formatUrl)
		}
	})
	// チェックリストが全てOKになったら抜ける
	allOk := true
	for _, checkItem := range checkList {
		if !checkItem.Checked {
			allOk = false
		}
	}
	if allOk {
		return nil
	}
	for checkUrl, checkItem := range checkList {
		// 既に閲覧済みならスキップ
		if checkItem.Checked {
			continue
		} else {
			nestUrl(checkUrl)
		}
	}
	return nil
}
// サイトマップ作製
func createSiteMap() {
	sitemap := &model.Sitemap{
		Xmlns: xmlns,
	}
	for _, item := range checkList {
		sitemap.UrlList =
			append(
				sitemap.UrlList,
				model.UrlItem{
					Location: item.URL,
				},
			)
	}
	output, err := xml.MarshalIndent(sitemap, "", "    ")
	if err != nil {
		fmt.Printf("error: %v\n", err)
	}
	f, err := os.Create("output/sitemap.xml")
	f.Write([]byte(xml.Header))
	f.Write(output)
}
// URL整形
func formatURL(absoluteUrl string) (string, error) {
	// 末尾パラメータ除去
	parseUrl, err := url.Parse(absoluteUrl)
	if err != nil {
		return "", err
	}
	// 末尾「/」除去
	formatUrl := parseUrl.Scheme + "://" + parseUrl.Hostname() + parseUrl.Path
	formatUrl = strings.TrimSuffix(formatUrl, "/")
	return formatUrl, nil
}
ある程度汎用的に使えるように、サイトURLは環境変数にしてあります。
- ルートのURLをマップに格納してスタート。
- 指定のURLをスクレイピング。
- 閲覧したので閲覧フラグをtrue(閲覧した)にする。
- goqueryを使ってページ内のaタグを取得し、href属性の内容を取り出す。- この時、表記ゆれのあるURLの可能性があるので正規化する。(formatURL関数)
 
- aタグのhref属性の内容が自分のサイトのURLと判断できれば、- マップに重複が無ければ、閲覧フラグをfalse(閲覧してない)で追加。
- マップに重複があればスキップ。
 
- マップ内の閲覧済みでないリンクを2の手順からやり直す。(nestUrl関数)
- マップ内が全て閲覧済みになったら処理を停止する。
- 1~7で作成したマップを基にsitemap.xmlを出力する。(createSiteMap関数)
1~7の工程はなんかもう少しうまく出来そうですが、とりあえずこれで良しとします。
ShellScriptで実行とsitemap.xmlの移動
上記スクリプトを実行すると、outputディレクトリ配下にsitemap.xmlが生成されるので、以下のようなスクリプトを作成して公開できる位置にサイトマップを移動させます。
#!/bin/sh
cd /[任意のスクリプト配置パス]/tools/001_sitemap/
if ./main >> /var/log/tools/create_sitemap.log; then
        mv output/sitemap.xml /[任意のsitemap.xml配置ディレクトリパス]
else
        echo "Create Sitemap Failed!" | sendmail -f [メールのFROMアドレス] -t [メールのTOアドレス] -s "[warning]CreateSitemap failed!"
fi地味に詰まったのですが、このサイトはNuxtなので、「.output/public配下にsitemap.xmlを配置すれば見れるでしょ」という雑な考えでmvしたのですが、buildしてないのでそう事は単純ではありませんでした。
仕方ないので、この手の後から追加・修正したいファイルを詰め込む用途のディレクトリを切ってそこに配置し、8080ポートでアクセスできるように → ポートフォワーディングで/sitemap.xmlでアクセス時に飛ばすというなんだかとても回りくどいことをしました。(もっといい方法があるような気がしてなりません。)
# httpd.confに追記
Listen 8080
<VirtualHost *:8080>
    DocumentRoot "/[任意のsitemap.xml配置ディレクトリパス]/"
    ServerName awatana.com:8080
    ServerAdmin admin@example.com
    ErrorLog "/etc/httpd/logs/static-error.log"
    TransferLog "/etc/httpd/logs/static-access.log"
    <Directory "/[任意のsitemap.xml配置ディレクトリパス]/">
        Require all granted
    </Directory>
</VirtualHost># ssl.confに追記
ProxyPass /sitemap.xml http://localhost:8080/sitemap.xml
ProxyPassReverse /sitemap.xml http://localhost:8080/sitemap.xmlとはいえ、これで awatana.com/sitemap.xmlにアクセスするとサイトマップが見れるようになりました。
後はサーチコンソールでサイトマップを登録すれば完了です。
おわりに
こんな面倒なことをするくらいならライブラリの設定頑張った方がよかったかもしれない。

この記事を書いた人
- A.W.
- 茨城県在住Webエンジニアです。 PHPなどを業務で使用しています。 趣味ではGoやNuxt、Flutterをやってます。