Perplexity / Gemini / OpenAI の API で測る AI 検索の実態 — 「質と競合」の調査実装
GSC の生成AIレポート登場で、API による AI 検索調査は不要になったのか。答えは半分だけです。「量」は GSC が正解になりましたが、「何と言われているか」「競合比」「非Google面」は API 調査が唯一の手段のままです。クエリセット設計から引用抽出・share of voice 集計・GSC 一次データとの較正まで、動くコードで示します。
by Shin
Search Console の生成AIレポートが出た今、API での AI 検索調査はもう不要か。答えは半分だけ——「量」(AI 面に出た回数)は GSC の一次データが正解になりましたが、「質」(何と言われたか)・競合比較・非Google面(Perplexity / ChatGPT)は、検索系 API に実際にプロンプトを投げて観測するのが引き続き唯一の手段です。この記事では残り半分の調査を、クエリセット設計 → 3エンジンからの引用抽出 → share of voice 集計 → GSC 一次データに目盛りを合わせるところ、まで動くコードで通します。「なぜ引用されるか・何を書くべきか」の戦略論は LLM時代のSEO に、流入側の計測は計測の実装記事に譲ります。
役割分担の整理 — GSC が答える問い・API でしか答えられない問い
2026年6月3日、Google は Search Console の生成AI専用パフォーマンスレポートを発表しました。AI Overviews / AI Mode での自社の表示回数が、ページ・国・デバイス・日付別の一次データとして見えます。これで「Google の AI 面に自社がどれだけ出ているか」を API で推定する使い方——既存の GEO 計測記事の多くが扱ってきた型——は陳腐化しました。一次データがあるのに推定する理由はありません。
だがレポートの仕様を正確に読むと、答えてくれない問いのほうが多いことが分かります。メトリクスは表示回数のみでクリックは分離されず、クエリ次元が無く、対象は自プロパティのみ、面は Google の AI 面のみ。しかも UK のサイト群からの段階展開中で、日本のサイトではレポート自体がまだ見えない可能性が高いです(2026年7月時点)。
| 問い | GSC 生成AIレポート | API 調査 |
|---|---|---|
| AI 面に何回出たか(量) | ◎ 一次データ | △ 推定のみ(もう使わない) |
| 何と言われたか(質・文脈・正確さ) | ×(永遠に出ない可能性が高い) | ◎ 回答テキストが取れる |
| 競合と比べてどうか(share of voice) | ×(自プロパティのみ) | ◎ 同一クエリで比較できる |
| Perplexity / ChatGPT ではどうか | ×(Google 面のみ) | ◎ 唯一の観測手段 |
| どのクエリで出たか・なぜ出ないか | ×(クエリ次元なし) | ◎ クエリセットを組んで実験できる |
つまり GSC の登場で API 調査は不要になったのではなく、役割が「量の推定」から「質・競合・非Googleの診断」へ絞り込まれました。計測の実装記事の4面の表で言えば、あちらが「自社に何が起きたか」(流入・表示)を受け持ち、本記事は「AI 検索空間で自社と競合がどう扱われているか」を受け持ちます。
調査設計 — クエリセット・エンジン・実行間隔
コードの前に、設計で誤りやすい前提をひとつ潰しておきます。API の応答は、実際の検索面の再現ではありません。同じエンジン名でも、API 経由の回答は、そのときモデルが検索結果を参照したかどうか、パーソナライズ、UI 側の引用選択によって、ユーザーが画面で見るものと乖離します。だからこの調査は「AI 検索面のスクリーンショット」ではなく、同一条件を保った定点観測として設計します。絶対値ではなく、時系列の変化と競合との相対だけを読みます。
クエリセットは3群で組みます。(1) 勝負クエリ——自社が引用されたい商業・比較系クエリ、(2) ブランドクエリ——自社名と競合名(「◯◯とは」「◯◯ 評判」)、(3) 情報系クエリ——自社コンテンツが答えを持つ質問。各群10〜20本、合計30〜50本もあれば傾向は出ます。多くしすぎないこと——クエリを増やすより、同一クエリを複数回サンプリングするほうに予算を使います。AI の回答は同一クエリでも揺れるので、1回の観測は信頼できません(揺れ自体を後で指標にします)。
実行間隔は週1〜2回で足ります。日次で回しても意思決定の粒度は変わりません。コストのオーダーを試算しておくと、40クエリ × 3サンプル × 週1回 ≒ 月500リクエスト/エンジン。Perplexity の sonar は執筆時点の料金で入出力 $1/1M トークン+検索リクエスト $5〜12/1,000件なので、月数ドルのオーダーに収まります。OpenAI / Gemini も従量課金で同じ規模感ですが、単価は改定されるので各社の料金表で執筆時点を確認してください。マーケター一人の予算で回る、というのがこの構成の要点です。
実装1 — 3つの API から回答と引用元を抽出する
エンジンごとにレスポンス構造が違うので、先に共通スキーマを決めてそこへ正規化します。必要なのは5フィールドだけです。
from dataclasses import dataclass
from datetime import datetime, timezone
@dataclass
class Observation:
engine: str # "perplexity" | "openai" | "gemini"
query: str
answer_text: str # 回答本文(後で「何と言われたか」の採点に使う)
cited_urls: list[str] # 引用元 URL(後で share of voice に使う)
fetched_at: str # ISO 8601 (UTC)
def _now() -> str:
return datetime.now(timezone.utc).isoformat()Perplexity は OpenAI 互換のチャット形式で、引用は search_results(title / url / date 付き)に入ります。旧来の citations(URL の裸配列)も残っているのでフォールバックにします。
import os
import httpx
PPLX_ENDPOINT = "https://api.perplexity.ai/v1/sonar"
def fetch_perplexity(query: str, model: str = "sonar") -> Observation:
resp = httpx.post(
PPLX_ENDPOINT,
headers={"Authorization": f"Bearer {os.environ['PERPLEXITY_API_KEY']}"},
json={"model": model, "messages": [{"role": "user", "content": query}]},
timeout=60,
)
resp.raise_for_status()
return parse_perplexity(query, resp.json())
def parse_perplexity(query: str, data: dict) -> Observation:
urls = [r["url"] for r in data.get("search_results") or []]
if not urls:
urls = list(data.get("citations") or [])
return Observation(
engine="perplexity",
query=query,
answer_text=data["choices"][0]["message"]["content"],
cited_urls=urls,
fetched_at=_now(),
)OpenAI は Responses API に web_search ツールを付けます。引用は出力メッセージの annotations に url_citation として入ります。
from openai import OpenAI
def fetch_openai(query: str, model: str = "gpt-5.5") -> Observation:
client = OpenAI() # OPENAI_API_KEY
resp = client.responses.create(
model=model,
tools=[{"type": "web_search"}],
input=query,
)
return parse_openai(query, resp)
def parse_openai(query: str, resp) -> Observation:
urls: list[str] = []
texts: list[str] = []
for item in resp.output:
if item.type != "message":
continue
for block in item.content:
if block.type != "output_text":
continue
texts.append(block.text)
for ann in block.annotations or []:
if ann.type == "url_citation":
urls.append(ann.url)
return Observation("openai", query, "\n".join(texts), urls, _now())Gemini は google_search ツールを呼び、その検索結果を根拠に回答します。現行の推奨インターフェースである Interactions API では、旧 generateContent の groundingMetadata(文字インデックスで別オブジェクトを参照する形)と違い、引用が model_output ステップ内テキストの annotations にインラインで返るため、3エンジンの抽出コードがほぼ同型になります。
from google import genai
def fetch_gemini(query: str, model: str = "gemini-3.5-flash") -> Observation:
client = genai.Client() # GEMINI_API_KEY
interaction = client.interactions.create(
model=model,
input=query,
tools=[{"type": "google_search"}],
)
return parse_gemini(query, interaction)
def parse_gemini(query: str, interaction) -> Observation:
urls: list[str] = []
texts: list[str] = []
for step in interaction.steps or []:
if step.type != "model_output":
continue
for block in step.content or []:
if getattr(block, "type", None) != "text":
continue
texts.append(block.text)
for ann in block.annotations or []:
if ann.type == "url_citation":
urls.append(ann.url)
return Observation("gemini", query, "\n".join(texts), urls, _now())実装2 — share of voice と「何と言われたか」の採点
引用元 URL をドメインに畳めば、ターゲットクエリ群で誰が引用を取っているか(share of voice)が出ます。SoV は「そのエンジンで観測した全クエリのうち、当該ドメインが1回以上引用されたクエリの割合」と定義します。
import pandas as pd
from urllib.parse import urlparse
TRACKED = {
"example.com": "自社",
"competitor-a.example": "競合A",
"competitor-b.example": "競合B",
}
def to_domain(url: str) -> str:
host = urlparse(url).netloc.lower()
return host.removeprefix("www.")
def share_of_voice(observations: list[Observation]) -> pd.DataFrame:
total: dict[str, set[str]] = {}
rows = []
for o in observations:
total.setdefault(o.engine, set()).add(o.query)
for d in {to_domain(u) for u in o.cited_urls}:
rows.append({"engine": o.engine, "query": o.query, "domain": d})
df = pd.DataFrame(rows)
sov = (
df.groupby(["engine", "domain"])["query"]
.nunique().rename("queries_cited").reset_index()
)
sov["queries_total"] = sov["engine"].map(lambda e: len(total[e]))
sov["sov"] = sov["queries_cited"] / sov["queries_total"]
return (
sov[sov["domain"].isin(TRACKED)]
.sort_values(["engine", "sov"], ascending=[True, False])
.reset_index(drop=True)
)同一クエリを複数回サンプリングしているので、引用の揺れも指標にできます。サンプル間の引用ドメイン集合の Jaccard 距離を平均すれば、「このクエリの引用は安定して自社/不安定」が数値になります。揺れが大きいクエリは1回の観測で一喜一憂してはいけないクエリです。
def citation_volatility(observations: list[Observation]) -> pd.DataFrame:
rows = []
grouped: dict[tuple[str, str], list[set[str]]] = {}
for o in observations:
grouped.setdefault((o.engine, o.query), []).append(
{to_domain(u) for u in o.cited_urls}
)
for (engine, query), sets in grouped.items():
if len(sets) < 2:
continue
dists = []
for i in range(len(sets)):
for j in range(i + 1, len(sets)):
union = sets[i] | sets[j]
dists.append(1 - len(sets[i] & sets[j]) / len(union) if union else 0.0)
rows.append({"engine": engine, "query": query,
"samples": len(sets), "volatility": sum(dists) / len(dists)})
return pd.DataFrame(rows)引用の有無だけでなく、回答の中で自社がどう説明されているか(answer_text)が API 調査にしか取れない核心です。ここは全件を人が読むのではなく、LLM で一次スクリーニングして異常だけ人が読みます。
import json
STANCE_PROMPT = """\
次の AI 検索の回答テキストの中で「{brand}」がどう扱われているかを判定してください。
回答テキスト:
---
{answer}
---
次のキーを持つ JSON だけを返す:
mentioned (bool), stance ("recommended" / "neutral" / "negative" / "absent"),
claim (回答が {brand} について述べた主張の要約。1文。absent なら空文字)"""
def score_stance(obs: Observation, brand: str, model: str = "gpt-5.5") -> dict:
client = OpenAI()
resp = client.responses.create(
model=model,
input=STANCE_PROMPT.format(brand=brand, answer=obs.answer_text),
)
return json.loads(resp.output_text)採点者も LLM なので、この判定自体に誤りが混ざります。運用の型は「stance が negative または前回から変化した観測だけ人が原文を読む」——スクリーニングとしては十分で、全量の正解ラベルとしては扱いません。結果は Observation ごと Supabase の日次テーブルに入れておけば、既存の計測基盤のダッシュボードにそのまま載ります。
create table ai_search_observations (
id bigint generated always as identity primary key,
engine text not null,
query text not null,
answer_text text,
cited_urls jsonb,
fetched_at timestamptz not null
);実装3 — GSC 一次データとの突き合わせ(較正)
Google 面に限っては、正解データ(GSC 生成AIレポートの表示回数)が存在します。ならば API 観測をそれに突き合わせて、自分の API 調査がどれだけ実面とズレているかを測っておくべきです。これが GSC 登場後の API 調査の新しい作法で、レポートが見えない日本のサイトでも、展開され次第すぐ実行できるよう用意しておく価値があります。
レポートにはクエリ次元が無いので、突き合わせはページ単位になります。生成AIレポートの「ページ」をエクスポートし、Gemini 観測(Google 面のプロキシ)でよく引用されたページと順位相関を見ます。絶対値は比較できません(片や表示回数、片や自作クエリセット内の引用数)ので、両者を順位に落として乖離を見ます。
def gsc_calibration(observations: list[Observation], gsc_pages_csv: str) -> pd.DataFrame:
api = pd.DataFrame([
{"page": u, "query": o.query}
for o in observations if o.engine == "gemini"
for u in o.cited_urls
])
api_pages = (
api.groupby("page")["query"].nunique()
.rename("api_cited_queries").reset_index()
)
gsc = pd.read_csv(gsc_pages_csv) # 列: page, impressions(エクスポート時の列名に合わせる)
merged = api_pages.merge(gsc, on="page", how="outer").fillna(0)
merged["api_rank"] = merged["api_cited_queries"].rank(ascending=False)
merged["gsc_rank"] = merged["impressions"].rank(ascending=False)
merged["rank_gap"] = (merged["api_rank"] - merged["gsc_rank"]).abs()
return merged.sort_values("rank_gap", ascending=False)rank_gap が大きいページは2通りに読めます。API では引用されるのに GSC 表示が少ない——クエリセットが実際の検索需要とズレています。GSC 表示は多いのに API で引用されない——実面では出ているのに API 側が参照する検索結果には拾われない領域で、API 調査の死角としてマークします。どちらにせよ「API 観測を鵜呑みにしてよい範囲」が具体的なページ群として特定できます。
限界と解釈の注意
最後に、この実装で「分かったことにしてはいけない」境界を明示しておきます。
第一に、API 応答はプロキシであって実面ではありません。較正(実装3)ができるのは Google 面だけで、Perplexity / ChatGPT には正解データが存在しません。つまり非Google面の観測は原理的に較正不能なまま運用します。第二に、スナップショット性。同一クエリでも回答は揺れるので、単発の観測から結論を出しません——複数サンプリングと citation_volatility で揺れごと観測します。第三に、引用された=流入する、ではありません。引用はゼロクリックの回答の中の脚注にすぎないことも多いです。流入が起きたかはリファラ側の計測でしか分かりません。第四に、運用面——各 API の利用規約とレート制限は確認すること。この調査は自分のクエリを自分の API 契約で投げるだけなのでスクレイピングの問題は生じませんが、規約は変わります。
そして最も重要な原則は、計測の実装記事の結論と同じです。単一の「AI 可視性スコア」に畳まないこと。SoV・stance・揺れ・GSC 表示回数はそれぞれ別の問いに答える指標であり、1つの数字に合成すると最も不確かな前提(API のプロキシ性)が全体を汚染します。市販の GEO ツールが不要になるわけでもありません——違いは透明性(何をどう測ったか全部見える)・自社データとの接続・コストであって、優劣ではありません。この観測が効く先の意思決定——例えば AI 面に出続けるか、ブロックするかの判断に share of voice をどう使うか——は、AI Overview に出すか出さないかで扱っています。