goquery でアクセシビリティをチェックする2 リンクチェック編

掲載日
更新日

はじめに

JIS達成基準には入らないようですが、閲覧者にはよろしくないのがページのリンク切れです。
リンク切れには以下のようなリスクがあります。

  • 期限切れドメインを使った詐欺サイトに遷移させられる
    特にこれは要注意で、例えば行政の管理する特設サイトが閉鎖後にドメインが取得されて、クリックすると詐欺サイトやR18っぽいサイトに飛ばされるような事例は本当に沢山あります。
    (これに関しては予算の取り方が特殊な都合、ドメインを維持し続けるのが大変な行政にほいほい別ドメイン取らせる業者に罰金食らわせてほしいくらいです。
    特設サイトのためだけに新しいドメイン取らせるとか横行していて本当に酷いです。
    それで中身を見るとサブドメイン、なんならサブディレクトリでも十分でしょみたいな内容だったり…。)
  • シンプルにリンク先で補足したつもりの内容が見れないので、
    閲覧者が内容を理解できなくなるケースもあります。
     

そういう訳で、なるべくリンク切れは防ぎたいです。
今回はGolangでサイトを巡回してリンク切れを検知し、メールでページの修正を促すスクリプトを作成したのでその備忘録です。

作っていく

仕様

  • sitemap.xmlをパースし、サイト全体のURLリストを取得する
  • URLリストを順番にスクレイピングし、imgタグ、aタグを取得。
  • 各タグのURLのリンクチェックを行う。
  • リンクチェックNGならメールを送信する。

実際のコード

GitHubリンク

 package main

import (
	"encoding/xml"
	"fmt"
	"io"
	"linkcheck/model"
	"log"
	"net/http"
	"net/http/httputil"
	"net/smtp"
	"net/url"
	"os"
	"regexp"
	"slices"
	"strings"
	"time"

	"github.com/PuerkitoBio/goquery"
	"github.com/joho/godotenv"
)

var (
	siteUrl        string
	regFragment    *regexp.Regexp
	checkedUrlList []string            // 重複チェック防止
	errUrlList     []model.ErrorUrlMap // リンク切れだったURL
	// メール設定
	hostname string
	port     int
	username string
	password string
)

func main() {
	err := godotenv.Load()
	if err != nil {
		log.Fatal("Error loading .env file")
	}
	sitemapUrl := os.Getenv("SITEMAP_URL")
	siteUrl = os.Getenv("SITE_URL")

	reg, err := regexp.Compile(`^#`)
	if err != nil {
		log.Fatal(err)
		return
	}
	regFragment = reg

	sitemap, err := GetSitemap(sitemapUrl)
	if err != nil {
		log.Fatal("サイトマップの取得に失敗しました。")
		return
	}
	ParseSiteMap(sitemap)

	if len(errUrlList) > 0 {
		SendMail()
	} else {
		log.Println("リンク切れ無し")
	}
}

func GetSitemap(sitemapUrl string) (string, error) {
	req, err := http.NewRequest("GET", sitemapUrl, nil)
	if err != nil {
		log.Fatal("リクエストの作成に失敗しました")
		return "", err
	}
	client := new(http.Client)
	resp, err := client.Do(req)
	if err != nil {
		log.Fatal("リクエストに失敗しました")
		return "", err
	}

	defer resp.Body.Close()
	byteArray, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Fatal("レスポンスの読み取りに失敗しました。")
		return "", err
	}

	return string(byteArray), nil
}

func ParseSiteMap(sitemapData string) {
	sitemap := model.Sitemap{}
	err := xml.Unmarshal([]byte(sitemapData), &sitemap)
	if err != nil {
		log.Fatal("サイトマップをパース出来ませんでした。")
		return
	}

	for _, item := range sitemap.UrlList {
		CheckURL(item.Location)
		time.Sleep(1 * time.Second)
	}
}

func CheckURL(targetUrl string) error {
	log.Println("Target:" + targetUrl)

	req, _ := http.NewRequest("GET", targetUrl, nil)
	client := new(http.Client)
	resp, _ := client.Do(req)
	defer resp.Body.Close()

	doc, err := goquery.NewDocumentFromReader(resp.Body)
	if err != nil {
		log.Fatal("url scraping failed")
		return err
	}

	var urlList []string
	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 := href
		// #はじまりならページ内リンクなので無視
		if regFragment.MatchString(href) {
			return
		}

		// 空なら無視
		if href == "" {
			return
		}

		// ドメイン無なら自サイトなので、絶対パスに変換
		if urlParse.Host == "" {
			absoluteUrl = siteUrl + href
		}

		// すでにチェック済みならスキップ
		if slices.Contains(checkedUrlList, absoluteUrl) {
			return
		}

		// リンク切れチェック
		log.Println("リンクチェック: " + absoluteUrl)

		req, _ := http.NewRequest("GET", absoluteUrl, nil)
		// カスタムヘッダー追加
		// req.Header.Set("Authorization", "Bearer access-token")

		client := new(http.Client)
		resp, err := client.Do(req)

		// 400番台以降はリンク切れとして扱う
		if resp.StatusCode > 400 {
			urlList = append(urlList, absoluteUrl)

			log.Println(
				fmt.Printf("リンク切れ StatusCode: %d", resp.StatusCode),
			)

			dumpResp, _ := httputil.DumpResponse(resp, true)
			fmt.Printf("%s \r\n", dumpResp)
		}

		checkedUrlList = append(checkedUrlList, absoluteUrl)
	})

	if len(urlList) > 0 {
		errUrlList = append(errUrlList, model.ErrorUrlMap{
			OriginUrl: targetUrl,
			UrlList:   urlList,
		})
	}

	return nil
}

// リンク切れをメールで通知
func SendMail() {
	hostname := os.Getenv("MAIL_HOST")
	port := os.Getenv("MAIL_PORT")

	from := os.Getenv("MAIL_FROM_ADDRESS")
	to := os.Getenv("MAIL_TO_ADDRESS")

	str := ""
	for _, errUrl := range errUrlList {
		str = str + "[エラーリンクのあるページURL]\n" + errUrl.OriginUrl + "\n[リンク切れURL]\n"
		for _, url := range errUrl.UrlList {
			str = str + "・" + url + "\n"
		}
	}

	msg := []byte(
		strings.ReplaceAll(
			fmt.Sprintf(
				"To: %s\nSubject: [Link Check]Error!\n\nリンク切れがあります。\n以下のページを修正してください。\n%s",
				to,
				str,
			),
			"\n",
			"\r\n",
		),
	)

	if err := smtp.SendMail(
		fmt.Sprintf("%s:%s", hostname, port),
		nil,
		from,
		[]string{to},
		msg,
	); err != nil {
		fmt.Fprintln(os.Stderr, err)
	}
}

サイト内の全ページを見たいので、こっちの記事で作成したsitemap.xmlを使ってリンクを取得します。
xmlをパースしてリンクを取得し、順番にスクレイピング。(大したページ数じゃないですが、一応負荷を考慮して1URLごとに1秒待機)

URLが入ってそうなタグ(とりあえずaタグ)をgoqueryを使って取得し、リンクチェックを行います。
あとはNGならメールを送信すればOKです。
※gmail等を使っている場合、DMARCなどを設定しておかないと迷惑メール扱いされるかもしれません。

tlsエラー

上記コードをサーバーに上げて初回実行時、以下のようなエラーが表示されました。

tls: failed to verify certificate: x509: certificate is not valid for any names, but wanted to match localhost

postfixを使ってメールを送信しているのですが、暗号化通信の設定になっているにもかかわらず証明書の設定が無い?ためエラーが出てしまっている模様。
大事なメールを送る場合はちゃんと暗号化した方が良いと思いますが、今回はシステムチェックメールを自分宛に送るだけなので暗号化せず、以下の設定をして回避しています。

vi /etc/postfix/main.cf
 # 以下の値をnoneに修正
 smtpd_tls_security_level = none

一部サイトはリンクが生きていてもブロックされる。

リンク先ページが存在していてもブロックされることがあります。(Cloudflare使ってる場合等。スクレイピングに対して厳しいのは仕方ないので先方は悪くないです。)
 404の時は404で返して、存在するページへのアクセスは403で返して無いかな?(それなら判定時に403はスルーすればいいので)と思ったのですがそうはなってないようで、404ページだろうがなんだろうがスクレイパーのアクセスには403で返却するようです。

curl -v https://stackoverflow.com/questions/hogehoge.html

...(中略)...
< HTTP/2 403
< date: Fri, 25 Jul 2025 02:57:16 GMT
< content-type: text/html; charset=UTF-8
< content-length: 7033

Body取得が駄目なんだろうしHEADリクエストならいけないか?と思ったのですがやっぱり403になりました。

curl -I https://stackoverflow.com/questions/hogehoge.html
HTTP/2 403
date: Fri, 25 Jul 2025 02:54:54 GMT
...(中略)...
server: cloudflare
cf-ray: 96486014bcf0d541-NRT

うーん、タダ乗り的な行為や、お行儀の悪い(DDOS攻撃じみた)スクレイピングを防ぎたいのは分かるし、Cloudflareがやってることは利用者を守るまっとうな対応とも思うんですが、リンクチェックのためにせめて404の時は404出してほしい。

こういったサイトは除外リストを作ってチェックしないようにするか、メールを送って都度人力確認になってしまいますね。
試したサイトはstackoverflowなので、ドメインが手放されて詐欺サイトに変わってしまうことは早々なさそうなのは幸いです。

おわりに

リンクチェックは閲覧者のためという、アクセシビリティ的な側面もありますが、期限切れドメインのリスクは元サイト自体の信頼も損なう可能性があるので早めに対策しておきたい部分でした。

バックナンバー

 

記事の作成者のA.W.のアイコン

この記事を書いた人

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

Comment