GolangでIndexNowに対応する

掲載日

IndexNowとは

従来の検索エンジンは、クローラーを使って各サイトを巡回してインデックスして検索エンジンにページを掲載してきましたが、そうではなくサイト管理者側がAPIを実行してページの作成・修正・削除などを知らせるのが「IndexNow」です。

Googleは2025年9月9日現在対応していないようですが、Microsoft等が対応しているので、サイトの流入を増やすためにGolangで対応してみます。

APIキーをサイトに配置し、APIを実行する。

IndexNowのスタートガイドが書いてあるスクリーンショット。

IndexNowにアクセスしてスクロールすると、添付のようにスタートガイドが書いてあるので、ガイドに従って設定していきます。

  1. まずはAPIキーをコピー(必要に応じてGenerateしてからコピー)します。
  2. サイトのルートディレクトリに「[コピーしたAPIキー].txt」というファイルを作成して配置します。
    ファイルの内容にはコピーしたAPIキーを貼り付けてください。
    • この時、ルートディレクトリ以外にも配置が可能で、その場合は「Option2」と書いてある方の操作をやればよいみたいです。(実際にはやってないのでここは割愛。)
  3. インデックスさせたいページをAPIで通知します。
    • 最終的にはGolangでページ追加時に自動で通知させますが、試しということで、ガイドを参考に以下のcUrlコマンドでAPIを実行してみます。
$ curl -X POST -H "Content-Type: application/json" -d '{
  "host": "awatana.com",
  "key": "[APIキー]",
  "keyLocation": "https://awatana.com/[APIキー].txt",
  "urlList": [
      "https://awatana.com",
  ]
}' https://api.indexnow.org/IndexNow -v

> (略)
> < HTTP/2 200
> (略)

レスポンスコードの意味するところはガイドの部分に載っていますが、200が出ていればOKです。
(詳しくは以下の通り。TOEIC600点レベルの日本語訳なので間違ってる箇所があったらごめんなさい。)

HTTP CodeResponseReasons
200Ok
URL submitted successfully
400Bad requestInvalid format
403Forbidden
In case of key not valid (e.g. key not found, file found but key not in the file)
422Unprocessable Entity
In case of URLs don’t belong to the host or the key is not matching the schema in the protocol
429Too Many Requests
Too Many Requests (potential Spam)
HTTP CodeResponseReasons
200Ok
URL submitted successfully
400Bad requestInvalid format
403Forbidden
In case of key not valid (e.g. key not found, file found but key not in the file)
422Unprocessable Entity
In case of URLs don’t belong to the host or the key is not matching the schema in the protocol
429Too Many Requests
Too Many Requests (potential Spam)
HTTP CodeResponseReasons
200Ok
URL submitted successfully
400Bad requestInvalid format
403Forbidden
In case of key not valid (e.g. key not found, file found but key not in the file)
422Unprocessable Entity
In case of URLs don’t belong to the host or the key is not matching the schema in the protocol
429Too Many Requests
Too Many Requests (potential Spam)
HTTP CodeResponseReasons
200Ok
URL submitted successfully
400Bad requestInvalid format
403Forbidden
In case of key not valid (e.g. key not found, file found but key not in the file)
422Unprocessable Entity
In case of URLs don’t belong to the host or the key is not matching the schema in the protocol
429Too Many Requests
Too Many Requests (potential Spam)
HTTP CodeResponseReasons
200Ok
URL submitted successfully
400Bad requestInvalid format
403Forbidden
In case of key not valid (e.g. key not found, file found but key not in the file)
422Unprocessable Entity
In case of URLs don’t belong to the host or the key is not matching the schema in the protocol
429Too Many Requests
Too Many Requests (potential Spam)
HTTP CodeResponseReasons
200Ok
URL submitted successfully
400Bad requestInvalid format
403Forbidden
In case of key not valid (e.g. key not found, file found but key not in the file)
422Unprocessable Entity
In case of URLs don’t belong to the host or the key is not matching the schema in the protocol
429Too Many Requests
Too Many Requests (potential Spam)
HTTP CodeResponseReasons
200Ok
URL submitted successfully
400Bad requestInvalid format
403Forbidden
In case of key not valid (e.g. key not found, file found but key not in the file)
422Unprocessable Entity
In case of URLs don’t belong to the host or the key is not matching the schema in the protocol
429Too Many Requests
Too Many Requests (potential Spam)
HTTP CodeResponseReasons
200Ok
URL submitted successfully
400Bad requestInvalid format
403Forbidden
In case of key not valid (e.g. key not found, file found but key not in the file)
422Unprocessable Entity
In case of URLs don’t belong to the host or the key is not matching the schema in the protocol
429Too Many Requests
Too Many Requests (potential Spam)
HTTP CodeResponseReasons
200Ok
URL submitted successfully
400Bad requestInvalid format
403Forbidden
In case of key not valid (e.g. key not found, file found but key not in the file)
422Unprocessable Entity
In case of URLs don’t belong to the host or the key is not matching the schema in the protocol
429Too Many Requests
Too Many Requests (potential Spam)
HTTP Code
200
400
403
422
429
HTTP Code
200
400
403
422
429
HTTP Code
200
400
403
422
429
HTTP Code
200
400
403
422
429
HTTP Code
200
400
403
422
429
HTTP Code
200
400
403
422
429
HTTP Code
200
400
403
422
429

 

HTTP Code
200
400
403
422
429

HTTP Code
200
400
403
422
429
HTTP Code

(レスポンスコード)

Response

Reasons

200

Ok

URL submitted successfully

URL送信成功(正常にインデックスされる)

400

Bad request

Invalid format

フォーマットが無効(リクエストデータなどが誤ってる可能性あり)

403

Forbidden

In case of key not valid (e.g. key not found, file found but key not in the file)

APIキーが無効。(例:キーが見つからない、ファイルはあるがファイルの内容にキーが含まれていない)

422

Unprocessable Entity

In case of URLs don’t belong to the host or the key is not matching the schema in the protocol

URLがホストに所属していないか、キーがプロトコルのスキーマに一致しない?

(前半はURLのドメインが誤っているという話だと思います。後半はAPIキーのフォーマットがおかしい?ということかなと。)

429

Too Many Requests

Too Many Requests (potential Spam)

リクエストしすぎ。(スパムの可能性あり)

カッコ内の文章的にあまりリクエストを送りすぎるとスパムとみなされるかも。

 

送信して200が返って来たら、Webmaster Toolsで本当にインデックスされたかを確認できます。
 

WebmasterToolsのIndexNowのタブを開き、送信したURLが表示されているのを確認できる画面のスクリーンショット。

赤枠部分に送信したURLが表示されているのが確認できました。

Golangで自動でAPIを実行する。

主導で都度都度APIを実行していると骨が折れるので、Golangでページの追加を検知して自動でAPIを実行するようにします。

自分のサイトは個人サイトで技術ブログのため、頻繁に追加・修正・削除はしないので、1日に1回、昨日から追加・削除されたページがないか探して通知すれば十分と判断し、sitemap.xmlを使って差分を取得し、毎日1回APIを実行するスクリプトを作成していきます。
(修正については後々DBの更新時間などを見てAPIを実行するようにしたい。)

完成したスクリプトのGitHubリンク

sitemap.xmlから昨日追加された・削除されたURLを検知する

まず全体の処理を書きます。
workディレクトリを作成しておき、毎日sitemap.xmlを保存して配置しておきます。
(空の場合エラーになりますが、初回ということなのでエラーハンドリングはせず全URLを通知できるよう空のデータを持たせます。)

workディレクトリ配下のsitemap.xmlと、最新のsitemap.xmlをそれぞれ取得し、URLの一覧を取得します。

func main() {
	err := godotenv.Load()
	if err != nil {
		log.Fatal("Error loading .env file")
	}
	sitemapUrl := os.Getenv("SITEMAP_URL")
	indexNowApiUrl := os.Getenv("INDEXNOW_API_URL")
	host := os.Getenv("HOST")
	key := os.Getenv("KEY")
	keyLocation := os.Getenv("KEY_LOCATION")

	// 1日分前のサイトマップを取得
	oldSitemap, err := GetSitemapByFile("./work/sitemap.xml")

	// 本日分サイトマップを取得
	newSitemap, err := GetSitemapByUrl(sitemapUrl)

	// 本日分情報をworkに退避・上書き
	file, err := os.Create("./work/sitemap.xml")
	file.Write(newSitemap)

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

	oldUrlList := ParseSiteMap(oldSitemap)
	newUrlList := ParseSiteMap(newSitemap)

	// 1日前のデータと本日分を比較
	urlList := GetDiffUrl(oldUrlList, newUrlList)

	if len(urlList) == 0 {
		log.Println("通知が必要なURLはありません。")
		return
	}

	// APIを実行する
	PostIndexNow(urlList, indexNowApiUrl, host, key, keyLocation)
}
  • ローカルのsitemap.xmlを取得する処理
 func GetSitemapByFile(filepath string) ([]byte, error) {
	file, err := os.Open(filepath)
	if err != nil {
		fmt.Println("ファイルを開けませんでした : " + filepath)
		return nil, nil
	}

	data, err := io.ReadAll(file)
	if err != nil {
		fmt.Println("ファイルを読み取れませんでした。 : " + filepath)
		return nil, nil
	}

	file.Close()

	return data, nil
}





  • 最新のsitemap.xmlを取得する処理

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

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

	return byteArray, nil
}
  • xmlファイルを読み込み、URLだけ取得する処理(※sitemap.xmlのフォーマットに合わせた構造体をあらかじめ用意しておく必要があります。)

func ParseSiteMap(sitemapData []byte) []string {
	var urlList []string
	sitemap := model.Sitemap{}

	if sitemapData == nil {
		return urlList
	}

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

	for _, item := range sitemap.UrlList {
		// URLリスト追加
		urlList = append(urlList, item.Location)
	}

	return urlList
}
  • 二つの配列から差分を取得する処理
    差分用にMapを作成し、KeyにURL、Valueに値1を入れておき、古い方と新しい方で重複があれば値を加算しておきます。
    値が1のものは差分になるので、追加もしくは削除されたURLと判断して通知するURLリストに追加します。
func GetDiffUrl(oldUrlList []string, newUrlList []string) []string {
	var diffUrlList []string
	diffMap := map[string]int{}

	for _, oldUrl := range oldUrlList {
		diffMap[oldUrl] = 1
	}

	for _, newUrl := range newUrlList {
		_, ok := diffMap[newUrl]

		if ok {
			diffMap[newUrl]++
		} else {
			diffMap[newUrl] = 1
		}
	}

	for url, cnt := range diffMap {
		if cnt == 1 {
			diffUrlList = append(diffUrlList, url)
		}
	}

	return diffUrlList
}

APIを実行する

追加・削除されたURLの一覧を取得できたので、今回の本題IndexNowのAPIを実行します。

JSON形式でデータを送る必要があるので、構造体を用意しておきます。


type IndexNowData struct {
	Host        string   `json:"host"`
	Key         string   `json:"key"`
	KeyLocation string   `json:"keyLocation"`
	UrlList     []string `json:"UrlList"`
}

構造体に必要なデータを格納し、json.Marshal関数でJSON形式にします。

http.NewRequest("METHOD", "URL", "DATA")とすることで、指定のAPIのURLに対してポスト形式でデータを送信できます。

ちなみにAPIのURLはBingに紹介されている「https://api.indexnow.org/IndexNow」ではなくてもよさそうです。
IndexNowのFAQページに書いてあるURLを使えばよさそう。

後はヘッダーを「"Content-Type: application/json"」にセットしてAPIを実行すればOKです。

自分はログを出して終わってますが、エラーが出たらメールで通知するなどするとなお良いと思います。

func PostIndexNow(
	urlList []string,
	apiUrl string,
	host string,
	key string,
	keyLocation string,
) error {
	postData := model.IndexNowData{
		Host:        host,
		Key:         key,
		KeyLocation: keyLocation,
		UrlList:     urlList,
	}
	jsonData, err := json.Marshal(postData)
	if err != nil {
		log.Fatal("リクエストデータの作成に失敗しました")
		return err
	}

	log.Println("URL:" + apiUrl)
	log.Println("DATA:" + string(jsonData))

	req, err := http.NewRequest("POST", apiUrl, bytes.NewBuffer(jsonData))
	if err != nil {
		log.Fatal("リクエストの作成に失敗しました")
		return err
	}
	req.Header.Set("Content-Type", "application/json")

	client := new(http.Client)
	resp, err := client.Do(req)
	if err != nil {
		log.Fatal("リクエストに失敗しました")
		return err
	}

	defer resp.Body.Close()

	// 成功したら抜ける
	if resp.Status == "200 OK" {
		log.Println("リクエストに成功しました。")
		return nil
	}

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

	var response model.Response
	if err := json.Unmarshal(byteArray, &response); err != nil {
		log.Println(string(byteArray))
		log.Fatal("レスポンスのJson化に失敗しました")
		return err
	}

	// エラーの詳細をログに出力しておく
	log.Println(response.Code)
	log.Println(response.Message)

	for _, detail := range response.Details {
		log.Println(detail.Target)
		log.Println(detail.Message)
	}

	return nil
}

おわりに

後は作ったスクリプトをサーバーに配置し、crontabで定期的に実行すればOKです。
最初の実行後はWebmaster Toolsの方で正常に登録されたか確認しておきましょう。

 

Webmaster ToolsでIndexNowの登録が正常に行われたかを確認しているスクリーンショット。

 

なお、リクエストの送り過ぎはスパム判定される可能性があるので注意が必要です。
(天気予報のような、高頻度で情報が更新されているページであっても更新の都度送らなくていいということがIndexNowのFAQにも書いてあるので、修正と通知のタイミングや頻度に迷った際はFAQを一読しておくことをお勧めします。)

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

この記事を書いた人

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

Comment