Search Console API で「ページ × 検索語」を自動取得する(Python)— 4ステップと、実運用の3つの罠

Google
B!

自社メディアの運用を自動化するなかで、「どのページが、どの検索語で、何回表示され、何回クリックされ、平均掲載順位はいくつか」を毎日プログラムから取りたくなりました。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ステップ)

  1. STEP 1:サービスアカウントを用意し、プロパティに閲覧権限を付ける(← Google 側の設定作業)
  2. STEP 2:鍵ファイルから「認証済みセッション」を作る(← Python)
  3. STEP 3:searchAnalytics/query で「ページ × 検索語」を取得する(← Python)
  4. STEP 4:25,000 行の上限を超えて全件取り切る(← Python)

まず STEP 1 だけは Google Cloud と Search Console 側の設定作業で、STEP 2 以降がコードです。

STEP 1:サービスアカウントと「閲覧」権限(Google 側の設定)

ユーザー個人の OAuth ではなく、サービスアカウントで読むのがサーバー運用では扱いやすいです(対話ログインが要らない)。

  1. Google Cloud でプロジェクトを作り、サービスアカウントを1つ作成(例:gsc-reader@my-media-project.iam.gserviceaccount.com)
  2. そのサービスアカウントの JSON 鍵をダウンロード(この鍵を STEP 2 で使う)
  3. Search Console 側で、対象プロパティの「設定 → ユーザーと権限」に、上記メールを 「制限付き(閲覧のみ)」 で追加する ← ここを忘れると STEP 3 で 403 になります
  4. 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位)なのに表示は多いページ=伸びしろ」を機械的に洗い出す、といった運用改善に直結します。次回はこのデータを使ったリライト候補の自動抽出について書く予定です。

B!
← 一覧へ戻る