この記事は Zenn に公開した記事の再掲です(原文・最新版: https://zenn.dev/acs_developer/articles/search-console-service-account-site-verification )。
結論
新しく立ち上げた静的サイトについて、Search Console への登録を サービスアカウントだけで API 経由で完結 させました。所有権確認から sitemap 送信までが対象です。
人手で行った操作は、GCP コンソールで Site Verification API を「有効にする」1タップだけです。管理画面をブラウザで自動操作する方法は使わず、API の結果を正としました。
呼び出した順番と、各段で記録した結果は次のとおりです。
| 順 | 処理 | API | 実測 |
|---|---|---|---|
| 1 | FILE方式のトークン取得 | Site Verification POST /token
|
(確認ファイル名を取得) |
| 2 | 確認ファイルを公開ディレクトリへ置いてデプロイ | — | — |
| 3 | 所有権確認 | POST /webResource?verificationMethod=FILE |
HTTP 200 |
| 4 | 人間の Google アカウントを owners に追加 | PUT /webResource/{id} |
— |
| 5 | プロパティ追加 | Webmasters v3 PUT sites/{siteUrl}
|
HTTP 204 |
| 6 | sitemap 送信 | PUT sites/{siteUrl}/sitemaps/{feedpath} |
HTTP 204 |
| 7 | 一覧で確認 | GET sites |
permissionLevel: siteOwner |
本稿では、この呼び出し順と、サービスアカウントが所有者になる仕組みを扱います。あわせて、owners に人間を足す理由と、URL エンコードで注意する点をコード付きで整理します。
前提・環境
- 対象: 静的サイト(本稿では
https://example.com/と表記) - 認証: サービスアカウントの JSON 鍵
- ライブラリ:
google-auth(service_account.CredentialsとAuthorizedSession) - 有効化した API: Site Verification API(Search Console 側の API は、以下のコードで
webmasters/v3エンドポイントとして呼んでいます) - スコープ: 次の2つ
https://www.googleapis.com/auth/siteverificationhttps://www.googleapis.com/auth/webmasters
1本のスクリプトをサブコマンドで切り替え、1段ずつ手で実行して結果を確かめながら進めました。
スクリプト全体
実際に使ったスクリプトから、鍵のパスとドメインだけを置き換えています(全44行)。
# -*- coding: utf-8 -*-
"""Search Console をサービスアカウントで登録・所有権確認する。
使い方: python3 gsc_api.py token → FILE 方式のトークン(ファイル名)を表示
python3 gsc_api.py verify → 所有権確認(トークンファイルが本番にある前提)
python3 gsc_api.py owner <email> → 確認済みサイトの所有者に Google アカウントを追加
python3 gsc_api.py add → webmasters sites.add+sitemap 送信+一覧
"""
import sys, json
from google.oauth2 import service_account
from google.auth.transport.requests import AuthorizedSession
KEY = 'path/to/service-account.json' # 実パスは非公開
SITE = 'https://example.com/'
SCOPES = ['https://www.googleapis.com/auth/siteverification',
'https://www.googleapis.com/auth/webmasters']
creds = service_account.Credentials.from_service_account_file(KEY, scopes=SCOPES)
s = AuthorizedSession(creds)
SV = 'https://www.googleapis.com/siteVerification/v1'
WM = 'https://www.googleapis.com/webmasters/v3'
def show(r):
try: print(r.status_code, json.dumps(r.json(), ensure_ascii=False)[:800])
except Exception: print(r.status_code, r.text[:300])
cmd = sys.argv[1]
if cmd == 'token':
r = s.post(f'{SV}/token', json={'site': {'identifier': SITE, 'type': 'SITE'}, 'verificationMethod': 'FILE'})
show(r)
elif cmd == 'verify':
r = s.post(f'{SV}/webResource?verificationMethod=FILE', json={'site': {'identifier': SITE, 'type': 'SITE'}})
show(r)
elif cmd == 'owner':
email = sys.argv[2]
r = s.get(f'{SV}/webResource'); show(r)
items = r.json().get('items', [])
me = [i for i in items if i['site']['identifier'].rstrip('/') == SITE.rstrip('/')]
if not me: print('not verified yet'); sys.exit(1)
item = me[0]; owners = item.get('owners', [])
if email not in owners: owners.append(email)
r = s.put(f"{SV}/webResource/{item['id']}", json={'id': item['id'], 'site': item['site'], 'owners': owners}); show(r)
elif cmd == 'add':
r = s.put(f'{WM}/sites/{SITE.replace("/", "%2F").replace(":", "%3A")}'); print('sites.add', r.status_code, r.text[:200])
r = s.put(f'{WM}/sites/{SITE.replace("/", "%2F").replace(":", "%3A")}/sitemaps/{(SITE + "sitemap.xml").replace("/", "%2F").replace(":", "%3A")}'); print('sitemap', r.status_code, r.text[:200])
r = s.get(f'{WM}/sites'); show(r)
各段の中身
1. token: FILE方式の確認ファイル名をもらう
POST /token に site.type = 'SITE' と verificationMethod = 'FILE' を渡すと、確認用のトークンが返ります。FILE 方式の場合、このトークンが確認ファイルの名前(google<トークン>.html の形)になります。
このファイルを静的サイトの公開ディレクトリに置き、デプロイしました。トークンの実値は本稿には載せていません。
2. verify: 確認ファイルが公開されてから呼ぶ
POST /webResource?verificationMethod=FILE で所有権確認を行い、HTTP 200 が返りました。
ここで重要なのは、確認を実行したのがサービスアカウント自身だという点です。この API では、確認を実行した主体が、その Web リソースの所有者として登録されます。言い換えると、この時点で所有者になっているのはサービスアカウントであり、人間のアカウントではありません。
verify は Google 側が確認ファイルを取りに来る処理です。そのため、デプロイが反映される前に呼ぶ順番にはしないでください。docstring に「トークンファイルが本番にある前提」と書いているのはこのためです。
3. owner: 人間の Google アカウントを owners に足す
サービスアカウントだけが所有者のままでは、普段使う Google アカウントで Search Console を開いても、このサイトを扱えません。そこで webResource の owners に人間のアカウントを追加しました。
コードで気をつけた点は2つです。
- 既存の owners を取得してから追記し、配列ごと PUT する。 PUT はリソースを置き換える操作なので、追加したいアカウントだけを送るのではなく、取得した既存リストに追記したものを送っています。
-
サイトの照合では末尾スラッシュを揃える。
GET /webResourceの一覧から、identifier.rstrip('/')で比較して対象を選んでいます。
一覧に対象が無い場合は、not verified yet を出して止めます。verify より前に誤って実行しても、何も書き換わりません。
4. add: sites.add と sitemap 送信
Search Console 側には、PUT sites/{siteUrl} でプロパティを追加しました。結果は HTTP 204 です。続けて PUT sites/{siteUrl}/sitemaps/{feedpath} で sitemap.xml を送信し、こちらも HTTP 204 でした。どちらもレスポンスボディは無いので、成否はステータスコードで判断しています。
最後に GET sites を実行し、permissionLevel が siteOwner になっていることを確認して完了としました。
URL エンコードの要点
Webmasters v3 では、siteUrl と feedpath を URL パスの一部として 埋め込みます。どちらも https://example.com/ のように : と / を含むため、そのまま連結すると、パスの区切りとして解釈されてしまいます。
今回のコードでは、この2文字を手で置き換えています。
enc = lambda u: u.replace("/", "%2F").replace(":", "%3A")
# https://example.com/ → https%3A%2F%2Fexample.com%2F
# https://example.com/sitemap.xml → https%3A%2F%2Fexample.com%2Fsitemap.xml
注意点は、feedpath を 相対パス sitemap.xml ではなく、サイト URL を含む完全な URL にしてからエンコードしていることです。
今回のドメインは : と / 以外の記号を含まないため、この置換だけで足りました。クエリ文字列などを含む URL を扱う場合は、urllib.parse.quote(url, safe='') のように、汎用のエンコード関数を使うほうが安全だと考えられます(今回はこちらでは検証していません)。
なお、Site Verification 側の identifier は JSON ボディで渡すため、エンコードは不要です。
まとめ
- 人手で行う必要があったのは、GCP で Site Verification API を有効化する1タップだけ でした。それ以外は、サービスアカウントの鍵とスコープ2つで完結します。
- 呼び出し順は token → 確認ファイルのデプロイ → verify(200) → owners 追加 → sites.add(204) → sitemap 送信(204) → sites.list です。verify はデプロイの反映後に呼びます。
- verify を実行した サービスアカウントが所有者になる ため、人間が管理画面で扱えるよう
ownersに追記します。PUT は置き換えなので、既存リストを取得してから追記します。 - Webmasters v3 の
siteUrlとfeedpathはパスに埋め込むため、:と/をエンコードします。feedpath はサイト URL を含めた完全な形で渡します。 - 確認は管理画面ではなく、
sites.listのpermissionLevel: siteOwnerで行うと、状態をスクリプト内で閉じて確かめられます。
検証の続きや関連ツールは ACS Developer で公開しています。