📝 この記事は自社ブログの戦略レポート(CodeQuest.work)を、エンジニア向けに「計測実装」へ振り直して再構成したものです。戦略の背景に興味がある方は原文をどうぞ。
結論(先に)
「順位は維持されているのに CTR が落ちている」クエリは、Search Console API の query × date 集計で機械的に検出できます。 これは広告主優遇の陰謀ではなく、SERP(検索結果ページ)という限られた面積を、広告枠・AI Overviews・リッチリザルトが奪う 「面積の地盤沈下」 が原因です。順位レポートだけ見ていると見落とすので、本記事では Python で「同順位 CTR 低下クエリ」を抽出するコードを実装します。
対象読者は、GA4 や Search Console を見て「最近オーガニックが弱い気がする」と感じているが、それを数字で言語化できていない個人開発者・Web 制作者です。
なぜ順位レポートでは見えないのか
順位(平均掲載順位)は「あなたのページが検索結果の何番目に出たか」しか表しません。しかし実際のユーザーが見る画面では、同じ「1位」でも前後の状況が年々変わっています。
| 圧力要因 | SERP 面積への影響 |
|---|---|
| 広告枠の拡大 | 最上部の広告数・面積が増え、純粋なオーガニックが下へ押し下げられる |
| AI Overviews(AIO) | 冒頭にAI回答が大きく表示され、リンク到達前に答えが得られる |
| リッチリザルト・自社プロパティ | 強調スニペット・地図・ショッピング・動画枠が面積を占有する |
結果、こういう現象が起きます。
- 「10位以内なのに、ファーストビューに映らない」
- 「1位なのに、CTR が以前より低い」
これが 面積の地盤沈下 です。順位(rank)は同じでも、その順位が置かれている“地盤”そのものが沈む。だから 順位ではなく「順位 × CTR の時系列」で見ないと検出できません。
興味深いことに、順位と AI 引用は単純な代替関係ではありません。
- Seer Interactive の分析:AIO 引用の約 55% がページ上部30%から抽出される
- Ahrefs の調査:AI 引用の 62% がオーガニックトップ10圏外から来ている
「Google順位が低くても AI には引用される」逆転も起きており、単一指標の「順位」はもう現実を説明しきれません。だからこそ自分のデータで実測します。
実装:Search Console API で「同順位 CTR 低下クエリ」を抽出する
1. 準備
Google Cloud でサービスアカウントを作り、Search Console の対象プロパティに「制限付き」ユーザーとして当該サービスアカウントのメールを追加します。あとはライブラリを入れるだけです。
pip install google-api-python-client google-auth
2. 期間を2分割して取得するクライアント
「先月 vs 今月」のように2期間を比較し、順位がほぼ同じなのに CTR が落ちたクエリを炙り出すのが狙いです。Search Console API は dimensions に query を指定すると、クエリごとの clicks / impressions / ctr / position を返します。
from googleapiclient.discovery import build
from google.oauth2 import service_account
SCOPES = ["https://www.googleapis.com/auth/webmasters.readonly"]
SITE_URL = "sc-domain:example.com" # ドメインプロパティの場合
KEY_FILE = "service-account.json"
def build_client():
"""Search Console API クライアントを生成する"""
credentials = service_account.Credentials.from_service_account_file(
KEY_FILE, scopes=SCOPES
)
return build("searchconsole", "v1", credentials=credentials)
def fetch_query_stats(service, start_date: str, end_date: str) -> dict:
"""指定期間のクエリ別 clicks/impressions/ctr/position を取得する
戻り値: { query: {"clicks", "impressions", "ctr", "position"} }
"""
rows_by_query: dict[str, dict] = {}
start_row = 0
while True:
request = {
"startDate": start_date,
"endDate": end_date,
"dimensions": ["query"],
"rowLimit": 25000,
"startRow": start_row,
}
response = (
service.searchanalytics()
.query(siteUrl=SITE_URL, body=request)
.execute()
)
rows = response.get("rows", [])
if not rows:
break
for row in rows:
query = row["keys"][0]
rows_by_query[query] = {
"clicks": row["clicks"],
"impressions": row["impressions"],
"ctr": row["ctr"],
"position": row["position"],
}
if len(rows) < 25000:
break
start_row += 25000
return rows_by_query
ポイントは rowLimit: 25000 でページングしている点です。クエリ数は数万件になることが普通なので、startRow を進めて全件取得します。
3. 「順位据え置き × CTR 低下」を判定する
2期間のクエリ統計を突き合わせ、順位の変動が小さい(地盤沈下の疑い)のに CTR が有意に落ちたクエリを抽出します。
def detect_ctr_erosion(
prev: dict,
curr: dict,
min_impressions: int = 100,
position_tolerance: float = 1.0,
ctr_drop_threshold: float = 0.15,
) -> list[dict]:
"""順位はほぼ維持なのに CTR が落ちたクエリを抽出する
- min_impressions: ノイズ除去のための最小表示回数
- position_tolerance: 「順位据え置き」とみなす平均順位の差(±)
- ctr_drop_threshold: CTR 低下率の閾値(0.15 = 15%以上の低下)
"""
eroded = []
for query, now in curr.items():
before = prev.get(query)
if before is None:
continue
if now["impressions"] < min_impressions:
continue
position_shift = now["position"] - before["position"]
# 順位が悪化(数値増)しすぎ/改善しすぎは対象外(純粋な順位変動のため)
if abs(position_shift) > position_tolerance:
continue
if before["ctr"] == 0:
continue
ctr_drop_rate = (before["ctr"] - now["ctr"]) / before["ctr"]
if ctr_drop_rate < ctr_drop_threshold:
continue
eroded.append({
"query": query,
"position_before": round(before["position"], 1),
"position_after": round(now["position"], 1),
"ctr_before": round(before["ctr"] * 100, 2),
"ctr_after": round(now["ctr"] * 100, 2),
"ctr_drop_rate": round(ctr_drop_rate * 100, 1),
"impressions": now["impressions"],
})
# 影響の大きい順(低下率 × 表示回数)に並べる
eroded.sort(key=lambda r: r["ctr_drop_rate"] * r["impressions"], reverse=True)
return eroded
4. 実行
def main():
service = build_client()
prev = fetch_query_stats(service, "2026-03-01", "2026-03-31")
curr = fetch_query_stats(service, "2026-04-01", "2026-04-30")
eroded = detect_ctr_erosion(prev, curr)
print(f"{'query':<30} pos CTR(before→after) drop% impr")
for r in eroded[:20]:
print(
f"{r['query']:<30} "
f"{r['position_before']}→{r['position_after']} "
f"{r['ctr_before']}%→{r['ctr_after']}% "
f"-{r['ctr_drop_rate']}% {r['impressions']}"
)
if __name__ == "__main__":
main()
出力イメージ(数値はサンプル):
query pos CTR(before→after) drop% impr
nextjs ssg 実装 2.1→2.3 8.4%→5.1% -39.3% 1820
seo チェック ツール 1.4→1.5 12.0%→8.8% -26.7% 3400
faq 構造化データ 3.2→3.1 6.1%→4.9% -19.7% 980
順位はほぼ動いていないのに CTR だけが二桁%落ちているクエリ が並んでいたら、それが地盤沈下の候補です。これらのクエリの実際の SERP を手で確認すると、AI Overviews が出ていたり、広告枠やショッピング枠が増えていたりするはずです。
「読まれる状態」になっているか:AIクローラー向けSSRチェック
地盤沈下への対抗策の一つが「AI に引用される側になる」ことですが、その前提として AIクローラーがあなたのページを読めるか を確認する必要があります。AIクローラー(GPTBot, ClaudeBot, PerplexityBot など)は JavaScript レンダリングを公式には保証していません。 SPA 構成だと「Google には映るが AI には映らない」状態になりがちです。
「JS なしで本文 HTML が出力されているか」は、curl で UA を偽装して簡易チェックできます。
# AIクローラーの代表的な UA で取得し、本文がHTMLに含まれるか確認
curl -s -A "Mozilla/5.0 (compatible; GPTBot/1.0; +https://openai.com/gptbot)" \
https://example.com/your-article | grep -c "記事内の特徴的な一文"
0 が返ってきたら、その本文はクライアントサイドでしか描画されていない=AIクローラーには空ページに見えている可能性が高いです。Python でレンダリング前後を比較するとより確実です。
import requests
AI_BOT_UA = "Mozilla/5.0 (compatible; GPTBot/1.0; +https://openai.com/gptbot)"
MARKER = "記事内の特徴的な一文" # 本文中の一意なフレーズ
def is_readable_by_ai_crawler(url: str) -> bool:
"""JSなしの素のHTMLに本文が含まれているかを判定する"""
res = requests.get(url, headers={"User-Agent": AI_BOT_UA}, timeout=10)
res.raise_for_status()
return MARKER in res.text
print("AI可読:" , is_readable_by_ai_crawler("https://example.com/your-article"))
対策は明快で、SSR/SSG で本文 HTML を出力すること。Next.js なら App Router の Server Component や generateStaticParams、もしくは ISR で、本文がレスポンス HTML に含まれる状態を作ります。引用される以前に、まず読まれる状態が先です。
引用されやすくする:直接回答 × 構造化データ
AI に引用される情報源の条件はシンプルで、①論点への明快な直接回答がある ②根拠と出典を持つ ③構造化されている の3点です。小手先の裏技はなく、結局「良いSEOそのもの」に収束します。
実装で効くのは、FAQ を構造化データ(JSON-LD)で明示することです。質問と回答のペアは AI が抽出しやすく、強調スニペットにも乗りやすい形式です。
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "同順位なのにCTRが落ちる原因は?",
"acceptedAnswer": {
"@type": "Answer",
"text": "SERPの面積を広告枠・AI Overviews・リッチリザルトが奪う『面積の地盤沈下』が主因です。順位ではなく順位×CTRの時系列で検出します。"
}
},
{
"@type": "Question",
"name": "順位が低いページはAIに引用されない?",
"acceptedAnswer": {
"@type": "Answer",
"text": "いいえ。Ahrefsの調査ではAI引用の62%がトップ10圏外から来ており、順位とAI引用は単純な代替関係ではありません。"
}
}
]
}
</script>
ポイントは、acceptedAnswer の冒頭1〜2文で結論を言い切ることです。AIO引用の約55%がページ上部から抽出されるという傾向(Seer Interactive)に合わせ、回答文の先頭に結論、後ろに補足、の順で書きます。本文の各セクションも同じく「結論ファースト」にすると引用率が上がります。
まとめ:評価軸を「順位」から「面積 × クリック後の信頼」へ
陰謀論を恨んでも前進しません。「面積を取られる」前提で計測を組み替えるのが現実的な打ち手です。
- 検出:Search Console API で「順位据え置き × CTR 低下クエリ」を定期抽出する(本記事のコード)
- 可読性:AIクローラー UA で SSR/SSG が効いているか確認する
- 引用最適化:直接回答 + FAQ 構造化データで「引用されやすい形」にする
- 継続計測:順位が下がってからではなく、CTR の兆候で先手を打つ
順位が下がるのは結果であって、その前に CTR という先行指標が動きます。そこを機械的に拾えるようにしておくのが、地盤沈下の時代の守り方です。
戦略的な背景(なぜGoogleが今さら自社広告を打つのか、オーガニック=信頼の堀という逆説)に踏み込んだ原文はこちらです。
🔗 AI検索の脅威と『オーガニック=信頼の堀』という逆説(CodeQuest.work)
SEOスコアチェックツール: SEO_CHECK — RINIAディレクターツール。45項目チェックで、自分のページが「面積とCTR」の観点で健全かを無料診断できます。
Web制作・SEO関連の技術情報サイト: CodeQuest.work