毎朝起きて Google Search Console を開き、4 つの Lab サイトと 2 つの Blog サイトを順番にチェックし、次に GA4 を開いて同じ作業を繰り返す。気がつくと 1 時間が消えていて、本当にやりたい記事執筆や作品制作の時間が後ろにずれていく。半年ほどそれを続けて、これは仕組みで解決すべきだと観念しました。
廣川政樹です。アーティスト活動と並行して 2014 年から個人開発で iOS/Android アプリ事業を続けていて、累計 5,000 万 DL ほど。現在は Lab 4 サイト(Claude/Gemini/Antigravity/Rork)と Blog 2 サイト(Lacrima/Mystery)を一人で運営しています。Lacrima では 6 ヶ月で 29,300 クリック、Mystery では同じ期間で 7,049 クリックという規模で、いずれもメンバーシップとアフィリエイトで収益化しています。
このノートは、Streamlit と Gemini API を組み合わせて、GSC と GA4 のデータを 1 画面に統合したブログ運営ダッシュボードを作った実装メモです。「複数サイトを運営していて、毎朝の指標確認に消耗している」「Gemini API で何か実用的なものを作ってみたいけれど良い題材が思いつかない」という方の参考になればと思って書きます。
なぜ GA4 と GSC を別画面で見ていると消耗するか
GA4 と GSC は同じく Google が提供していますが、見せてくれる視点が大きく違います。GSC は「Google 検索から見た自サイト」(クエリ・表示・クリック・順位)で、GA4 は「ユーザーから見たサイト内挙動」(ユーザー・セッション・エンゲージメント・コンバージョン)です。両方を見ないと、「順位は良いのにエンゲージメントが低い記事」や「順位は悪いのに直接流入で稼いでくれている記事」が見えてきません。
ただ、別タブで開いて見比べる、というやり方は破綻しやすい。サイトが 1 つなら問題ありませんが、6 サイト分やると 12 タブ。同じ期間設定をそれぞれで切るのも面倒で、つい今日の数字だけを眺めて終わってしまいます。「順位は下がっているのにエンゲージメントは上がっている」のような複合的なシグナルを拾えないまま、なんとなく印象だけで運営判断をしていた、というのが半年前の私の状態でした。
Streamlit の良いところは、Python データ分析のスキルセットがそのまま Web UI になることです。pandas で取得・整形したデータを st.dataframe や st.line_chart に渡せば、再ロード可能なダッシュボードが 100 行程度で組み上がります。サーバー運用も streamlit run app.py で済むので、個人開発者の運用コストとも相性が良いです。
ダッシュボードの全体構造
最終的に組み上がった構成です。
┌──────────────────────────────────────────────┐
│ Streamlit (app.py) │
│ ┌──────────────────────────────────────┐ │
│ │ サイドバー: サイト選択・期間選択 │ │
│ ├──────────────────────────────────────┤ │
│ │ KPI カード: クリック / 表示 / CTR / 順位 │ │
│ ├──────────────────────────────────────┤ │
│ │ 時系列グラフ: 28 日間トレンド │ │
│ ├──────────────────────────────────────┤ │
│ │ Top クエリ: 順位 + クリック + 評価 │ │
│ ├──────────────────────────────────────┤ │
│ │ 低品質記事候補: Gemini スコア < 60 │ │
│ └──────────────────────────────────────┘ │
└──────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
GSC API GA4 Data API Gemini API
(search (engagement, (article
analytics conversion) quality
+ sites) scoring)
ポイントは 3 つです。(1) GSC と GA4 を同じデータフレームに揃える こと(ページ URL をキーに join)。(2) 記事品質スコアリングを Gemini 2.5 Flash で行う こと(コストが安く、判定がしっかりしているので個人運用と相性が良い)。(3) Streamlit のキャッシュ機構で API 呼び出しを抑える こと(GSC API は 1 日あたりの上限があり、ぶつけると翌日まで止まります)。
認証 — サービスアカウントで全部済ませる
GSC API も GA4 Data API も Google のサービスアカウントで認証できます。個人運用ではアカウントごとに OAuth を回すのが面倒なので、サービスアカウントを 1 つ作って GSC のプロパティと GA4 のプロパティに権限を付与しておきます。
# auth.py — 共通認証ヘルパ
from google.oauth2 import service_account
from googleapiclient.discovery import build
from google.analytics.data_v1beta import BetaAnalyticsDataClient
SCOPES = [
"https://www.googleapis.com/auth/webmasters.readonly",
"https://www.googleapis.com/auth/analytics.readonly",
]
def get_creds(key_path: str = "service_account.json"):
return service_account.Credentials.from_service_account_file(
key_path, scopes=SCOPES
)
def gsc_client(creds):
return build("searchconsole", "v1", credentials=creds)
def ga4_client(creds):
return BetaAnalyticsDataClient(credentials=creds)
サービスアカウントの JSON キーは Streamlit の secrets 機能で持たせます。.streamlit/secrets.toml に書いておけば、コードからは st.secrets["service_account"] で読めて、誤って Git にコミットする事故を防げます。
# .streamlit/secrets.toml
[service_account]
type = "service_account"
project_id = "your-project"
private_key_id = "..."
private_key = "-----BEGIN PRIVATE KEY-----\n..."
client_email = "blog-dashboard@your-project.iam.gserviceaccount.com"
GSC データ取得 — クエリ単位とページ単位を両方持つ
GSC API は 1 リクエストで 1 つの「ディメンション組」しか返してくれないので、クエリ単位とページ単位を別々に取って結合します。
# gsc.py
import pandas as pd
from datetime import date, timedelta
def fetch_gsc(client, site_url: str, days: int = 28) -> dict:
end = date.today() - timedelta(days=2) # GSC は 2 日遅延
start = end - timedelta(days=days)
body_query = {
"startDate": start.isoformat(),
"endDate": end.isoformat(),
"dimensions": ["query"],
"rowLimit": 5000,
}
body_page = {
**body_query,
"dimensions": ["page"],
}
queries = client.searchanalytics().query(
siteUrl=site_url, body=body_query
).execute().get("rows", [])
pages = client.searchanalytics().query(
siteUrl=site_url, body=body_page
).execute().get("rows", [])
df_q = pd.DataFrame([{
"query": r["keys"][0],
"clicks": r["clicks"],
"impressions": r["impressions"],
"ctr": r["ctr"],
"position": r["position"],
} for r in queries])
df_p = pd.DataFrame([{
"page": r["keys"][0],
"clicks": r["clicks"],
"impressions": r["impressions"],
"ctr": r["ctr"],
"position": r["position"],
} for r in pages])
return {"queries": df_q, "pages": df_p}
end = date.today() - timedelta(days=2) の 2 日マージンが個人運用での実用的な工夫です。GSC は 1〜2 日のデータ遅延があり、当日分を含めると未確定値が混ざって運営判断を狂わせます。
GA4 データ取得 — ページ単位のエンゲージメント
GA4 Data API は Reports API を使います。GSC のページ URL と join できるように、ページパス単位で取得しておきます。
# ga4.py
from google.analytics.data_v1beta.types import (
DateRange, Dimension, Metric, RunReportRequest,
)
def fetch_ga4(client, property_id: str, days: int = 28) -> pd.DataFrame:
request = RunReportRequest(
property=f"properties/{property_id}",
dimensions=[Dimension(name="pagePath")],
metrics=[
Metric(name="activeUsers"),
Metric(name="screenPageViews"),
Metric(name="averageSessionDuration"),
Metric(name="engagementRate"),
],
date_ranges=[DateRange(
start_date=f"{days}daysAgo", end_date="today"
)],
limit=10000,
)
response = client.run_report(request)
return pd.DataFrame([{
"page": row.dimension_values[0].value,
"active_users": int(row.metric_values[0].value),
"page_views": int(row.metric_values[1].value),
"avg_duration": float(row.metric_values[2].value),
"engagement_rate": float(row.metric_values[3].value),
} for row in response.rows])
ここでハマったのが GA4 と GSC の ページ URL 表記の不一致 です。GSC は完全 URL(https://gemilab.net/ja/articles/...)、GA4 は相対パス(/ja/articles/...)。join 前に必ず統一しておきます。
def normalize_page(url: str, site_origin: str) -> str:
if url.startswith("http"):
return url.replace(site_origin, "")
return url
# join 前に揃える
gsc["page"] = gsc["page"].apply(lambda u: normalize_page(u, "https://gemilab.net"))
merged = pd.merge(gsc, ga4, on="page", how="outer")
記事品質スコアリング — Gemini 2.5 Flash で安く判定する
ここが今回の本丸です。各記事の MDX 本文を Gemini 2.5 Flash に読ませて、Helpful Content System の観点で品質スコアを 0-100 で付けてもらいます。
Gemini 2.5 Flash を選んだ理由は、コストです。1M 入力トークンあたり $0.075、出力 $0.30 で、1 記事あたり 8,000 字(約 3,000 トークン)として 1 サイト 100 記事評価しても $0.03 程度。週次で全記事走査しても月 $0.50 行きません。Pro ほどの精度は要らないが、テンプレ的・水増し的な記事を弾くなら Flash で十分という感触です。
# quality.py
from google import genai
from google.genai import types
PROMPT = """あなたは Google Search Console と Helpful Content System の運用に詳しい
ブログ品質評価アシスタントです。以下の記事を読んで、品質を 0〜100 点で評価してください。
評価軸:
- 独自性(公式ドキュメントの要約になっていないか): 30 点
- 実用性(読者が記事を読んだ後に具体的なアクションが取れるか): 30 点
- 文体(テンプレ的な AI 解説調になっていないか): 20 点
- 構造(H2 構成・コード例・メトリクスのバランス): 20 点
出力は JSON のみ:
{
"score": 75,
"uniqueness": 25,
"practicality": 22,
"tone": 16,
"structure": 12,
"deletion_candidate": false,
"improvements": ["導入文がテンプレ気味", "コード例の説明不足"]
}
deletion_candidate は score < 60 の場合 true にしてください。
---
{article_body}
"""
def score_article(client, article_body: str) -> dict:
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=PROMPT.format(article_body=article_body[:30000]),
config=types.GenerateContentConfig(
response_mime_type="application/json",
temperature=0.1,
),
)
import json
return json.loads(response.text)
ポイントは 3 つです。(1) response_mime_type="application/json" でレスポンスを JSON モードに固定する こと(パース失敗が消えます)。(2) temperature=0.1 で判定の揺れを抑える こと。(3) 入力を 30,000 文字に切る こと(長すぎる記事は冒頭だけでも品質傾向が分かるので、コスト抑制を優先)。
Streamlit に統合する — @st.cache_data でレート制限を回避
ここまでの 3 つ(GSC・GA4・Gemini スコアリング)を Streamlit に統合します。@st.cache_data で TTL を設けて、同じセッション内で再ロードしても API を叩き直さないようにします。
# app.py
import streamlit as st
import pandas as pd
from auth import get_creds, gsc_client, ga4_client
from gsc import fetch_gsc
from ga4 import fetch_ga4
from quality import score_article
from google import genai
# サイト設定
SITES = {
"Claude Lab": {
"url": "sc-domain:claudelab.net",
"ga4": "123456789",
"origin": "https://claudelab.net",
},
"Gemini Lab": {
"url": "sc-domain:gemilab.net",
"ga4": "234567890",
"origin": "https://gemilab.net",
},
# ...
}
@st.cache_data(ttl=3600) # 1 時間キャッシュ
def load_gsc(site_url, days):
creds = get_creds()
client = gsc_client(creds)
return fetch_gsc(client, site_url, days)
@st.cache_data(ttl=3600)
def load_ga4(property_id, days):
creds = get_creds()
client = ga4_client(creds)
return fetch_ga4(client, property_id, days)
# サイドバー
st.sidebar.title("運営ダッシュボード")
site_key = st.sidebar.selectbox("サイト", list(SITES.keys()))
days = st.sidebar.slider("期間(日)", 7, 90, 28)
site = SITES[site_key]
# データ取得
gsc = load_gsc(site["url"], days)
ga4 = load_ga4(site["ga4"], days)
# KPI カード
totals = gsc["queries"]
col1, col2, col3, col4 = st.columns(4)
col1.metric("クリック", f"{totals['clicks'].sum():,}")
col2.metric("表示", f"{totals['impressions'].sum():,}")
col3.metric("CTR", f"{totals['ctr'].mean()*100:.1f}%")
col4.metric("平均順位", f"{totals['position'].mean():.1f}")
# Top クエリ
st.subheader("Top クエリ (クリック順)")
st.dataframe(
totals.sort_values("clicks", ascending=False).head(30),
use_container_width=True,
)
ttl=3600 の 1 時間キャッシュは、GSC API の 1 日あたり呼び出し上限(プロパティあたり 1,200 リクエスト/日)を踏まえています。複数サイトを切り替えながら見ていると、キャッシュなしだと簡単に上限に届きます。
削除候補の自動抽出 — 週次で回す
ここまでがダッシュボード本体で、もう一つ運用上重要なのが「品質スコア 60 未満の記事」を週次でリスト化することです。Helpful Content System のペナルティを受けたあとに学んだのですが、低品質記事はサイト全体の評価を下げるので、見つけたら削除(または加筆昇格)が必要です。
# weekly_scan.py
import os
import json
from pathlib import Path
from quality import score_article
from google import genai
def scan_site(site_dir: Path, output: Path):
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
deletion_candidates = []
for mdx in site_dir.rglob("*.mdx"):
body = mdx.read_text(encoding="utf-8")
try:
score = score_article(client, body)
except Exception as e:
print(f"⚠️ skip {mdx}: {e}")
continue
if score["deletion_candidate"]:
deletion_candidates.append({
"path": str(mdx.relative_to(site_dir)),
"score": score["score"],
"improvements": score["improvements"],
})
output.write_text(
json.dumps(deletion_candidates, ensure_ascii=False, indent=2)
)
print(f"✅ {len(deletion_candidates)} candidates → {output}")
if __name__ == "__main__":
for site_name, site_dir in [
("claudelab", Path("/tmp/repos/claudelab.net/content/articles/ja")),
("gemilab", Path("/tmp/repos/gemilab.net/content/articles/ja")),
]:
scan_site(site_dir, Path(f"/tmp/scan_{site_name}.json"))
Cowork のスケジュールタスクで毎週月曜の朝に走らせて、出力 JSON を Slack に通知させています。100 記事 / サイト × 4 サイトで $0.15 程度のコスト。手動で 1 記事ずつレビューしていた頃の数時間が、レビュー対象が絞られて 30 分に短縮されました。
ハマりどころ — 個人運用で気をつけたい 4 点
実装中につまずいた点を 4 つ共有します。
第一に、GSC API のレート制限はサイト単位ではなくプロパティ単位 です。sc-domain: プロパティで複数のサブドメインを束ねている場合、それぞれ別カウントになってくれません。これは想定外で、初日に上限に当たりました。サイト数が多い場合は ttl=3600 のキャッシュが必須です。
第二に、Gemini API のレスポンスが稀に JSON モードを破る ことがあります。response_mime_type="application/json" を指定しても、思考過程が前置きされて返ってくるケースを 100 回に 1 回程度観測しました。try / except json.JSONDecodeError で囲んで、失敗時はスキップする処理を入れています。
第三に、Streamlit の @st.cache_data は引数のハッシュで判定する ので、引数に dict やリストを渡すとキャッシュキーが期待通りにならないことがあります。サイト設定の辞書を直接渡すのではなく、必要なフィールドを個別に渡すように分解するのが安全です。
第四に、サービスアカウントの JSON キーは絶対に Git にコミットしない こと。Streamlit の secrets 機能か、Cloud Run の Secret Manager か、いずれかに分離します。一度間違えてリポジトリに混入させてしまい、慌ててローテーションした経験があります。GitHub の Secret Scanning が検出して通知してくれたので助かりましたが、本番運用では先回りで防ぐべきです。
運用 7 ヶ月で見えてきた効果
定量的な変化を共有します。Lacrima と Mystery の数字は本稿執筆時点(過去 28 日)で以下です。
- Lacrima: 28 日で 5,200 クリック / CTR 11.2% / 平均掲載順位 4.8 / 平均エンゲージメント 5 分 48 秒
- Mystery: 28 日で 1,300 クリック / CTR 8.4% / 平均掲載順位 6.1 / 平均エンゲージメント 14 分 12 秒
ダッシュボード導入前の手作業時代から比べて、削除候補発見の精度が上がり、Lacrima では 6 ヶ月で約 80 本を整理しました。Mystery では約 30 本。整理後の平均掲載順位は両サイトとも 0.5 〜 1.0 程度改善しています。
削除候補の自動抽出によって、「なんとなく良さそう」「なんとなく低品質そう」という直感判定から、「Gemini スコア 58 でテンプレ的導入文の指摘あり」のように根拠ベースで判断できるようになったのが、運営者としての安心感の差分として一番大きかったです。AI が判定したものを最終的に人間が確認する、というワークフローに切り替えることで、判断の責任を Gemini に押し付けず、しかし作業量は 5 分の 1 に圧縮できています。
次に手を入れる予定
このダッシュボードでも残課題があります。
第一に、プレミアム転換率の可視化 です。Stripe の Webhook を Streamlit に流し込んで、どの記事経由でメンバーシップ登録が発生したかをトラッキングしたい。GA4 のコンバージョン設定でも近いことはできますが、Stripe 側の正確な金額と紐づけたいので別経路で取り込み予定です。
第二に、A/B テストの自動評価 です。記事タイトルや description を週単位で差し替えて、CTR の変化を Gemini に判定させる流れを試作中です。個人運用では手動 A/B が現実的でなかった領域なので、自動化できれば伸びしろが大きい部分です。
第三に、サイト横断のキーワード重複検出 です。4 つの Lab サイトで似たキーワードを書きすぎるとサイト同士で食い合いになるので、横断的に「Claude Lab の Claude Code 記事と Gemini Lab の Code Assist 記事のクエリ被り」をスコアリングする画面を追加予定です。
ブログ運営は、書く側だけでなく数字を見る側のコストも積み重なります。一つの画面に集約するだけで、運営者のメンタル負荷が想像以上に下がるので、複数サイトで疲弊している方には強くお勧めします。同じように個人で複数サイト運営している方の参考になれば嬉しいです。