自社メディアの運用を自動化するなかで、「どのページが、どの検索語で、何回表示され、何回クリックされ、平均掲載順位はいくつか」を毎日プログラムから取りたくなりました。Search Console の管理画面でも見られますが、サイト数が増えると手作業では回りません。
そこで Search Console API(Search Analytics) をサービスアカウントで叩き、ページ × 検索語 のデータを丸ごと取得する仕組みを作りました。本記事では、その手順を 4ステップに分けて説明し、最後に実運用ではじめてわかった3つの罠(データ遅延・25,000行の上限・プロパティ形式)をまとめます。
この記事で使うもの(言語・環境)
コードは すべて Python です。追加ライブラリは2つだけ。
- 言語:Python 3.10 以上
- ライブラリ:
requests(HTTP)/google-auth(サービスアカウント認証) - 認証方式:サービスアカウント(読み取り専用)
必要ライブラリ(requirements.txt):
requests==2.34.2
google-auth==2.59.0
サンプルのサイト名・サービスアカウント・鍵のパスはすべてダミーです。ご自身の値に読み替えてください。
全体の流れ(4ステップ)
- STEP 1:サービスアカウントを用意し、プロパティに閲覧権限を付ける(← Google 側の設定作業)
- STEP 2:鍵ファイルから「認証済みセッション」を作る(← Python)
- STEP 3:
searchAnalytics/queryで「ページ × 検索語」を取得する(← Python) - STEP 4:25,000 行の上限を超えて全件取り切る(← Python)
まず STEP 1 だけは Google Cloud と Search Console 側の設定作業で、STEP 2 以降がコードです。
STEP 1:サービスアカウントと「閲覧」権限(Google 側の設定)
ユーザー個人の OAuth ではなく、サービスアカウントで読むのがサーバー運用では扱いやすいです(対話ログインが要らない)。
- Google Cloud でプロジェクトを作り、サービスアカウントを1つ作成(例:
gsc-reader@my-media-project.iam.gserviceaccount.com) - そのサービスアカウントの JSON 鍵をダウンロード(この鍵を STEP 2 で使う)
- Search Console 側で、対象プロパティの「設定 → ユーザーと権限」に、上記メールを 「制限付き(閲覧のみ)」 で追加する ← ここを忘れると STEP 3 で 403 になります
- Google Cloud で「Search Console API」を有効化
権限スコープは読み取り専用にしておきます(余計な権限を持たせない)。
https://www.googleapis.com/auth/webmasters.readonly
STEP 2:認証(Python)— 鍵から「認証済みセッション」を作る
ここから Python です。google-auth の AuthorizedSession を使うと、requests とほぼ同じ使い勝手で、アクセストークンの取得・更新を自動でやってくれます。
# 認証:サービスアカウントの鍵から、認証済みの HTTP セッションを作る
from google.auth.transport.requests import AuthorizedSession
from google.oauth2 import service_account
SCOPE = "https://www.googleapis.com/auth/webmasters.readonly"
def make_session(key_file: str) -> AuthorizedSession:
cred = service_account.Credentials.from_service_account_file(
key_file, scopes=[SCOPE]
)
return AuthorizedSession(cred)
鍵ファイルの場所は環境変数で指定できるようにし、無ければ既定パスにフォールバックします。鍵は Git 管理下に置かない・ログに出さないのが鉄則です。
# 鍵の場所を決める(環境変数 GSC_SA_KEY が優先。無ければ既定パス)
import os
from pathlib import Path
def key_path() -> Path:
p = os.environ.get("GSC_SA_KEY", "").strip()
path = Path(p).expanduser() if p else Path.home() / "app" / "gsc-service-account.json"
if not path.is_file():
raise RuntimeError(f"鍵が見つかりません: {path}(GSC_SA_KEY で場所を指定できます)")
return path
STEP 3:データを取得する(Python)— searchAnalytics/query
エンドポイントは POST /webmasters/v3/sites/{プロパティ}/searchAnalytics/query です。dimensions に ["page", "query"] を指定すると、ページとその検索語の組み合わせごとに、表示回数・クリック・CTR・平均掲載順位が返ります。
なお 直近2〜3日のデータはまだ揃っていないため、取得する期間の終了日を数日手前にずらすのがコツです(詳しくは後半の罠①)。ここでは3日ずらしています。
# 取得:直近28日ぶんの「ページ × 検索語」を1ページだけ取る
from datetime import date, timedelta
from urllib.parse import quote
API = "https://www.googleapis.com/webmasters/v3"
LAG_DAYS = 3 # 直近3日はまだ確定していないので終端をずらす
def fetch(session, prop: str, days: int = 28) -> list[dict]:
end = date.today() - timedelta(days=LAG_DAYS)
start = end - timedelta(days=days - 1)
# プロパティ名は必ず URL エンコードする(後述の罠③)
url = f"{API}/sites/{quote(prop, safe='')}/searchAnalytics/query"
body = {
"startDate": str(start),
"endDate": str(end),
"dimensions": ["page", "query"],
"rowLimit": 25000,
"startRow": 0,
}
r = session.post(url, json=body, timeout=60)
if r.status_code != 200:
raise RuntimeError(f"Search Console が {r.status_code}: {r.text[:200]}")
return r.json().get("rows", [])
返ってくる1行はこんな形です(keys に dimensions と同じ順で値が入る)。これは JSON レスポンスです。
{
"keys": ["https://media.example.com/how-to-fold-crane/", "折り紙 鶴 折り方"],
"clicks": 12,
"impressions": 340,
"ctr": 0.0353,
"position": 8.4
}
パース(Python)はこれだけです。
# パース:1行を取り出して使う
for row in rows:
page, query = row["keys"]
impressions = int(row["impressions"])
clicks = int(row["clicks"])
position = float(row["position"])
# ここで DB に入れる / 集計する など
STEP 4:全件取り切る(Python)— startRow でページング
1回のリクエストで返るのは 最大 25,000 行です。行数が多いプロパティでは1回では足りないので、startRow をずらして全部取り切るまでループします。
# 全件取得:25,000行ずつ、無くなるまで startRow を進める
PAGE_ROWS = 25000
def fetch_all(session, prop: str, start: str, end: str) -> list[dict]:
all_rows: list[dict] = []
start_row = 0
while True:
body = {
"startDate": start, "endDate": end,
"dimensions": ["page", "query"],
"rowLimit": PAGE_ROWS, "startRow": start_row,
}
r = session.post(
f"{API}/sites/{quote(prop, safe='')}/searchAnalytics/query",
json=body, timeout=60,
)
if r.status_code != 200:
raise RuntimeError(f"Search Console が {r.status_code}: {r.text[:200]}")
rows = r.json().get("rows", [])
all_rows.extend(rows)
if len(rows) < PAGE_ROWS: # 上限未満なら、それが最後のページ
break
start_row += PAGE_ROWS
return all_rows
ポイントは終了条件で、「返ってきた行数 < rowLimit なら最後のページ」と判定します。ちょうど 25,000 行返ってきたら、続きがあるとみなして次を取りに行きます。
実運用でハマった3つの罠
4ステップで動きますが、実際に運用してみて初めて分かった落とし穴が3つありました。
罠①:直近2〜3日分は「まだ揃っていない」
Search Console のデータは確定まで数日かかります。「今日から28日前まで」で取ると、直近1〜3日は数字がスカスカだったり、翌日見ると増えていたりして、前日比がブレます。対策は STEP 3 のとおり、終了日を数日だけ手前にずらすこと(LAG_DAYS = 3)。
罠②:プロパティの「形式」と URL エンコード
{プロパティ} に渡す文字列は、プロパティの種類で変わります。
| プロパティの種類 | API に渡す文字列(例) |
|---|---|
| URL プレフィックス | https://media.example.com/(末尾スラッシュまで含める) |
| ドメイン | sc-domain:example.com |
どちらも : や / を含むので、URL に埋め込む前に必ずエンコードします(quote(prop, safe=''))。素で連結すると 404 になったり、別プロパティを指してしまいます。
from urllib.parse import quote
quote("https://media.example.com/", safe="") # -> https%3A%2F%2Fmedia.example.com%2F
quote("sc-domain:example.com", safe="") # -> sc-domain%3Aexample.com
罠③:403 と 404 の意味を知っておく
実運用で出るエラーはだいたいこの2つです。握りつぶさず、プロパティ名を添えて例外にすると原因究明が早いです。
- 403(権限なし):サービスアカウントが、そのプロパティにユーザー追加されていない(→ STEP 1 の手順3を確認)
- 404(見つからない):プロパティ文字列の形式ミス(末尾スラッシュ・
sc-domain:の付け忘れ)や、エンコード漏れ(→ 罠②)
if r.status_code != 200:
# r.text の先頭にエラー理由が入っている。プロパティ名も一緒に出す
raise RuntimeError(f"Search Console が {r.status_code}({prop}): {r.text[:200]}")
まとめ:全体(Python)
ここまでを1つにまとめると、コアは驚くほど短くなります。
# 全体:鍵 -> 認証 -> ページ×検索語を全件取得(Python)
from datetime import date, timedelta
from urllib.parse import quote
from google.auth.transport.requests import AuthorizedSession
from google.oauth2 import service_account
API = "https://www.googleapis.com/webmasters/v3"
SCOPE = "https://www.googleapis.com/auth/webmasters.readonly"
LAG_DAYS, PAGE_ROWS = 3, 25000
def session_from_key(key_file: str) -> AuthorizedSession:
cred = service_account.Credentials.from_service_account_file(key_file, scopes=[SCOPE])
return AuthorizedSession(cred)
def page_queries(session, prop: str, days: int = 28) -> dict[str, list[dict]]:
end = date.today() - timedelta(days=LAG_DAYS)
start = end - timedelta(days=days - 1)
url = f"{API}/sites/{quote(prop, safe='')}/searchAnalytics/query"
out: dict[str, list[dict]] = {}
start_row = 0
while True:
body = {"startDate": str(start), "endDate": str(end),
"dimensions": ["page", "query"], "rowLimit": PAGE_ROWS, "startRow": start_row}
r = session.post(url, json=body, timeout=60)
if r.status_code != 200:
raise RuntimeError(f"Search Console が {r.status_code}({prop}): {r.text[:200]}")
rows = r.json().get("rows", [])
for x in rows:
page, query = x["keys"]
out.setdefault(page, []).append(
{"query": query, "impressions": int(x["impressions"]),
"clicks": int(x["clicks"]), "position": float(x["position"])})
if len(rows) < PAGE_ROWS:
break
start_row += PAGE_ROWS
for rows in out.values():
rows.sort(key=lambda q: -q["impressions"])
return out
呼び出し(Python)はこれだけです。
s = session_from_key("/path/to/gsc-service-account.json")
data = page_queries(s, "https://media.example.com/")
for page, queries in list(data.items())[:3]:
print(page)
for q in queries[:5]:
print(f" {q['query']}: 表示{q['impressions']} クリック{q['clicks']} 順位{q['position']:.1f}")
押さえどころ(再掲)
- コードは Python、認証はサービスアカウント+読み取り専用スコープ。鍵は Git 外・ログに出さない
- STEP 1 でプロパティにサービスアカウントを追加(忘れると 403)
- STEP 3:直近数日は未確定 → 終了日を数日ずらす(罠①)
- STEP 4:25,000 行上限 →
startRowで全件取得 - プロパティ文字列は2種類あり、必ず URL エンコード(罠②)
ページ × 検索語 が手に入ると、「あと少しで上位(掲載順位11〜20位)なのに表示は多いページ=伸びしろ」を機械的に洗い出す、といった運用改善に直結します。次回はこのデータを使ったリライト候補の自動抽出について書く予定です。
Analyzegear
