📍
Gemini × Google Sheets × Supabase で構築する「聖地データ」収集パイプライン

はじめに
- 個人で開発している聖地巡礼支援アプリである SeichiMap の話です。
- マップ系アプリを個人で作る際、最大の壁となるのが「データの収集と管理」です。
- 数千箇所のスポット情報を一人で調べて入力するのは物理的に不可能です。かといって、従来のスクレイピングでは「第何話のどのシーンか」という文脈までは取得できない。
- Google Gemini 2.5 Pro を活用し、「AIによる収集 + Google Sheetsによる管理 + Supabaseへの同期」という半自動パイプラインを構築してみました。
マップUI開発の話は以下にまとめてます:
アーキテクチャ全体像 (ETL Pipeline)
全体のデータフローは以下のようになっています。管理画面(Admin Panel)を自作せず、Google SheetsをそのままDB管理ツールとして使うのがポイントです。
- Crawler (Python): concurrent.futures で並列化し、Gemini APIを叩いてエリアごとの聖地データを収集。
- Geocoder (Python): 収集したデータの住所・スポット名を元に、Google Maps APIで正確な座標(緯度経度)を付与。
- Cleanser (Python): 既存データ(Master)と収集データ(Staging)を比較し、重複を排除。
- Review (Human): スプレッドシート上で目視チェック。ここだけ人間が介入します。
- Sync (GAS): ボタン一つで本番DB (Supabase) へ反映。
Gemini 2.5 Pro による文脈抽出
- 通常の検索やクローリングだと「作品名」と「場所」の紐付けはできても、「それが具体的にどんなシーンだったか」という文脈抽出はできない。
- Gemini 2.5 Pro は文脈理解力が非常に高く、以下のような厳格なプロンプトでも指示を遵守してくれます。(2025年12月現時点での最新はGemini 3ですが、コストの問題でやめました())
プロンプト設計のキモ
「感情的なおすすめ文は不要」「1レコード1作品の原則」などを徹底させています。
prompt = f"""
あなたは「聖地巡礼マップ」のデータベースを作成する、極めて厳格なリサーチャーです。
ターゲット: "{area_name}" (日本)
【重要ルール】
1. **1レコード = 1作品**: 1つの場所が複数作品の聖地である場合、必ず別々のオブジェクトとして出力すること。
2. **具体的描写**: descriptionには「第何話」「どのシーン」で登場したかを客観的に記述すること。
3. **絵文字**: 作品の内容に合った絵文字を1つ選定すること。
【出力フォーマット】
JSON配列のみを出力してください。
keys: name_ja, work_title_ja, type, emoji, description_ja, address...
"""
並列処理の実装
Gemini APIのレート制限に配慮しつつ、ThreadPoolExecutor でエリアごとに並列処理を行います。
def main():
# ...エリア定義の読み込み...
print("Starting research...")
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
futures = [executor.submit(process_task, task) for task in tasks]
for future in tqdm(as_completed(futures), total=len(tasks)):
# Geminiから返ってきたJSONをパースしてリスト化
# スプレッドシートへの書き込みは後でまとめて行う
pass
Geocoding: 座標の精緻化
- LLMが出力する緯度経度は「大体の場所」であることが多く、マップアプリとしては致命的です。
- そこで、
googlemapsライブラリを使用して、正規化された住所とスポット名から正確なピン位置を取得します。
def get_accurate_location(name, address):
query = f"{address} {name}"
result = gmaps.geocode(query)
if result:
loc = result[0]['geometry']['location']
return loc['lat'], loc['lng'], result[0]['formatted_address']
return None, None, None
# API制限(429)対策として、指数バックオフ付きのリトライロジックを実装
def batch_update_with_retry(sheet_obj, cells_list):
for attempt in range(max_retries):
try:
sheet_obj.update_cells(cells_list)
return
except gspread.exceptions.APIError as e:
if "429" in str(e):
time.sleep((2 ** attempt) * 10)
Cleansing: 重複データの排除
- クローラーを何度も回すと、同じ場所が何度もヒットしてしまいます。
- 単純な文字列一致では「JR新宿駅」と「新宿駅」を別物と判定してしまうため、Pythonの
rapidfuzz(曖昧検索) とgeopy(距離計算) を組み合わせて重複判定を行っています。- 作品名: 厳密にチェック(別作品なら同じ場所でも登録OK)
- 距離: 30m以内なら重複の疑いあり
- 場所名: 距離が近く、かつ名前が類似していれば「重複」とみなす
from rapidfuzz import fuzz
from geopy.distance import geodesic
def is_same_spot(row_a, row_b):
# 作品名が違うなら別レコード扱い
if fuzz.ratio(row_a['work_title'], row_b['work_title']) < 85:
return False, "Different Work"
# 距離計算
dist = geodesic((row_a['lat'], row_a['lng']), (row_b['lat'], row_b['lng'])).meters
# 距離が近く(30m以内)、かつ名前も似ている場合は重複
if dist < 30 and fuzz.token_set_ratio(row_a['name'], row_b['name']) >= 60:
return True, "Duplicate"
return False, "No Match"
Sync: GAS から Supabase へ
- Python側での処理が終わると、整形されたデータがスプレッドシートに出力されます。
- 人間が最後に目視でチェック(Staging -> Masterシートへ移動)した後、GASを実行して本番環境へ反映します。
- Supabase側には sync_spots というRPCを用意しておき、一括でUpsertを行います。
function syncDataToSupabase() {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('data');
const rows = sheet.getDataRange().getValues();
// ...ヘッダー処理...
// データ整形
const payloadData = rows.map(row => ({
name_ja: row[0],
work_title_ja: row[2],
// ...
lat: parseFloat(row[9]),
lng: parseFloat(row[10]),
vod_platforms: parseVodArray(row[13]) // Python風リスト文字列をパース
}));
// Supabase RPC 呼び出し
const rpcUrl = `${SUPABASE_URL}/rest/v1/rpc/sync_spots`;
const options = {
method: 'post',
headers: {
'apikey': SUPABASE_KEY,
'Content-Type': 'application/json'
},
payload: JSON.stringify({ data: payloadData })
};
UrlFetchApp.fetch(rpcUrl, options);
}
まとめ
このパイプラインのおかげで、個人開発でありながら数千件規模の聖地データを、高い品質で維持・運用することが可能になりました。「データ入力が辛くて個人開発が進まない」という方は、ぜひ LLM × Python × Sheets の構成を試してみてください。
#Python #Supabase #Gemini #GoogleSheets #個人開発 #ETL
Discussion