GeminiとGoogleサービスで作る、お祭り検索&訪問記録アプリ開発記
🏮 はじめに
この記事は、**第2回 AI Agent Hackathon with Google Cloud**の参加作品として開発した「お祭り検索&訪問記録アプリ」の技術解説です。Gemini APIの強力な検索能力と、Google Drive / Spreadsheetを組み合わせ、日本全国のお祭り情報を手軽に検索し、撮った写真から自動で訪問記録を作成する実用的なWebアプリを開発しました。
🎯 対象読者
- PythonやStreamlitでポートフォリオとなるアプリを作ってみたい方
- Gemini APIをはじめとするLLMの具体的な活用事例に興味がある方
- Google DriveやSpreadsheetなど、普段使いのサービスをAPI経由で操作してみたい方
- GIS(地理情報システム)や写真のEXIFデータ活用に関心のある方
- お祭りが好きで、ITで何か面白いことができないかと考えている方
ⅰ. プロジェクトが対象とするユーザー像と課題、ソリューション
👤 ユーザー像
このアプリは、以下のような情熱や悩みを抱える人々を想定しています。
- お祭り行きたい人: 日本全国のユニークなお祭りに参加したいが、情報収集に手間を感じている。観光サイトや自治体のページを個別に調べるのは大変...。
- 思い出整理したい人: 旅行やイベントでたくさんの写真を撮るが、後から見返したときに「これ、どこのお祭りだっけ?」となりがち。写真の整理が追いつかない。
- ライフロガー: 自分が訪れた場所を地図上で可視化し、行動履歴を眺めるのが好き。お祭りをテーマにした自分だけの訪問マップを作りたい。
🌊 課題:情報過多の海と、整理されない思い出
ユーザーが直面する課題は、大きく2つに分類できます。
- 情報の散在と検索の困難さ: お祭り情報は、公式ウェブサイト、観光情報サイト、SNSなど、インターネット上の至る所に散らばっています。開催時期や場所を横断的に検索し、一覧で比較するのは非常に困難です。
- 記録の断絶: 写真はスマートフォンやクラウドストレージに大量に保存されているものの、それが「いつ、どこで、何のお祭りに行った記録」なのかという文脈(コンテキスト)と結びついていません。この「記録の断絶」が、思い出の価値を半減させてしまっています。
✅ ソリューションと特徴:GeminiとGoogleサービスによる「探す」と「記録する」の自動化
これらの課題を解決するため、本アプリは以下の3つの特徴的なソリューションを提供します。
1. 🔍 Gemini APIによる「自然言語お祭り検索」
「東京 7月」「京都 伝統的」「東北の変わった祭り」といった曖昧な自然言語のキーワードで、Gemini APIがWeb上から最新のお祭り情報を検索します。ユーザーはもう複数のサイトを渡り歩く必要はありません。検索結果は整形され、アプリ内の一覧(実体はGoogle Spreadsheet)に自動で追加されます。
2. 📸 Google Drive連携による「全自動・訪問記録」
ユーザーが位置情報付きの写真をGoogle Driveにアップロードするだけで、アプリが写真のEXIFデータ(撮影日時、GPS座標)を自動で解析。登録済みのお祭りリストと照合し、「〇月〇日に△△祭を訪問しました」という訪問記録を全自動で作成します。
3. 🗺️ インタラクティブな「思い出の可視化」
- お祭りマップ: 登録したすべてのお祭りが地図上にマッピングされ、どこでどんなイベントがあるのかを一目で把握できます。
- 訪問ヒートマップ: 自分が訪れた場所がヒートマップとして表示され、活動範囲や訪問頻度の高いエリアを直感的に理解できます。
これらのソリューションにより、情報収集から記録、そして振り返りまで、お祭り体験のすべてがシームレスに繋がるのです。
ⅱ. システムアーキテクチャ
このアプリケーションは、Streamlitを中心に、各種GoogleのサービスAPIを連携させることで実現されています。Cloud Runでデプロイしました。
📝 アーキテクチャの解説
-
ユーザーインターフェース (Streamlit):
ユーザーはWebブラウザからStreamlitで構築されたUIにアクセスします。テキスト入力、ボタンクリック、データ表示など、すべての対話はこの層で行われます。Streamlitを採用したことで、Pythonのみで迅速にインタラクティブなUIを開発できました。 -
お祭り検索フロー (Gemini API):
- ユーザーが「東京 7月」などのキーワードを入力すると、StreamlitアプリはそれをGemini APIへのプロンプトに含めて送信します。
- プロンプトでは、Geminiに搭載されたGoogle Search Toolを活用し、Web全体から関連情報を検索するように指示しています。
- さらに、「必ず指定したJSON形式で返すこと」という厳密な指示(System Prompt)を与えることで、LLMからの出力を安定した構造化データとして受け取ります。
- 受け取ったJSONデータはPandas DataFrameに変換され、Google Sheets API (
gspread
) を通じてスプレッドシートに書き込まれます。
-
訪問記録作成フロー (Google Drive & EXIF):
- ユーザーが「訪問記録を更新」ボタンを押すと、アプリはGoogle Drive APIを呼び出し、指定されたフォルダ内の画像ファイル一覧を取得します。
- 各画像ファイルをメモリ上にダウンロードし、Pillowライブラリを使ってEXIF情報を抽出します。ここから撮影日時とGPS座標(緯度・経度)が得られます。
- 抽出したGPS座標と撮影日時、そしてGoogle Spreadsheetから取得したお祭りリストの情報を照合します。
- Haversine式を用いて写真の撮影地とお祭りの開催地の距離を計算し、かつ開催期間内に撮影されたものであれば、「訪問した」と判定します。
- 判定結果は、訪問記録用の別シートに書き込まれます。複数の写真が同一のお祭りで撮影された場合は、1つの「訪問グループ」として集約し、記録の重複を防ぎます。
-
データ永続化 (Google Spreadsheet):
本格的なデータベース(例: PostgreSQL, Firestore)の代わりにGoogle Spreadsheetを採用しました。これにより、以下のメリットが生まれます。- セットアップが容易: APIを有効化するだけですぐに利用可能。
- 可視性と編集性: ユーザーはいつでもスプレッドシートを直接開いて、データの確認や手動での修正ができます。
- コスト効率: 小規模な利用であれば、ほぼ無料で運用可能です。
-
可視化 (Folium & Streamlit Components):
-
folium
ライブラリを使い、お祭りの場所を示すマーカー付きの地図や、訪問記録のヒートマップを生成します。 - 生成された地図はHTMLとして出力され、
streamlit.components.v1.html
を使ってStreamlitアプリ内に埋め込まれます。
-
✨ 主な機能の実装解説と工夫した点
🤖 Geminiの出力を安定させるプロンプトエンジニアリング
LLMをアプリケーションに組み込む際の最大の課題は、出力の不安定さです。今回は、以下のプロンプト設計により、これを克服しました。
prompt = f"""
あなたは日本のお祭りを検索する優秀なAIです。
次のリクエスト "{query}" に合うお祭りをウェブで調べて、**必ず**以下のJSON形式のリストのみを返してください。
重要:各お祭りの正確な住所と緯度経度を調べて含めてください。
他の文章・説明・補足文・装飾・コードブロックなどは**一切追加しないでください**。
見つからない場合は [] だけを返してください。
[
{{
"Festival Name": "三社祭",
"Location": "東京都台東区浅草2-3-1 浅草神社",
"Event Period": "2025年5月16日~18日",
"Description": "例大祭で神輿渡御が見どころ",
"Recommended Highlights": "本社神輿の宮出し・宮入り",
"Latitude": 35.714844,
"Longitude": 139.796707
}}
]
"""
- 役割の明確化: 「優秀なAIです」と役割を与えることで、期待する振る舞いを促します。
- 出力形式の厳密な指定: 「必ず以下のJSON形式のリストのみを返してください」と強調し、具体的な例を示すことで、形式の逸脱を強力に防ぎます。
- 禁止事項の明記: 「他の文章・説明...は一切追加しないでください」と具体的に禁止することで、余計なテキストが混入するのを防ぎます。
- 緯度経度の要求: 地図表示に不可欠な緯度経度も、プロンプトに含めることで一度に取得し、後続のGeocoding APIの呼び出し回数を減らしています。
📍 写真をアップするだけ!UXを実現するEXIF活用と訪問マッチング
このアプリのコア体験である「自動訪問記録」は、以下のロジックで実現されています。
def find_closest_festival(photo_lat, photo_lon, photo_date, festival_data_df):
min_distance = float('inf')
closest = None
for _, festival in festival_data_df.iterrows():
try:
# 1. 距離の計算 (2km以内か?)
fest_lat, fest_lon = float(festival.get('Latitude')), float(festival.get('Longitude'))
distance = haversine_distance(photo_lat, photo_lon, fest_lat, fest_lon)
if distance < min_distance and distance < 2.0: # 閾値は2km
# 2. 期間の確認 (開催期間中に撮影されたか?)
period_str = festival.get('Event Period')
if period_str:
start_date, end_date = parse_event_period(period_str)
if start_date and end_date and start_date <= photo_date < end_date:
min_distance = distance
closest = {'name': festival['Festival Name'], ...}
except (ValueError, TypeError, AttributeError):
continue
return closest
- 距離と期間のAND条件: 写真の撮影地から半径2km以内、かつ、お祭りの開催期間中に撮影された、という2つの条件を満たした場合にのみ「訪問」と判定します。これにより、近くを通りかかっただけ、といった誤判定を大幅に削減しました。※今回は便宜上2kmとしました。
- 訪問グループ化: 同じ日に同じお祭りで撮影された複数の写真は、1つの訪問記録として集約するロジックを加えました。「訪問回数」ではなく「訪問したお祭りの種類」を正確にカウントするためです。※集約ロジックを外せば1枚1枚の写真管理に特化できます。
⚡ Streamlitによる高速なUI開発と即時反映
データが更新された後に、UIに即座に反映させる工夫も重要です。
if st.button("🔍 検索"):
# ... (Geminiでお祭り情報を取得し、シートに追加する処理) ...
if new_rows:
ws.append_rows(new_rows)
st.success(f"{len(new_rows)}件の新しいお祭りを追加しました。")
st.cache_data.clear() # 関連するキャッシュをクリア
st.rerun() # ページを再実行してUIを即時更新
st.rerun()
は、スクリプトを最初から再実行させる強力なコマンドです。データソース(Google Spreadsheet)を更新した直後にこれを呼び出すことで、ユーザーはリロードボタンを押すことなく、常に最新の状態を見ることができます。
🔗 プロジェクトリンク
-
デプロイ先URL: https://hackathon-app-114970122290.asia-northeast1.run.app/
-
アプリの元ネタ(自作) 5-Day Gen AI Intensive Course with Google
https://www.kaggle.com/code/yasuhitoyanagisawa/japanese-festival-recommendations
初心者かつ時間が足りなかったため、Gemini CLIにはGithub対応や記事作成で大活躍していただきました。このアプリはフィールドワーク全般に拡張できると思うので、引き続き開発に努めます。
皆さんの次の開発プロジェクトのヒントやインスピレーションになれば幸いです。
ありがとうございました!
Discussion