🙌

GeminiとGoogleサービスで作る、お祭り検索&訪問記録アプリ開発記

に公開

🏮 はじめに

この記事は、**第2回 AI Agent Hackathon with Google Cloud**の参加作品として開発した「お祭り検索&訪問記録アプリ」の技術解説です。Gemini APIの強力な検索能力と、Google Drive / Spreadsheetを組み合わせ、日本全国のお祭り情報を手軽に検索し、撮った写真から自動で訪問記録を作成する実用的なWebアプリを開発しました。

https://youtu.be/AREaBwxaIYs

🎯 対象読者

  • PythonやStreamlitでポートフォリオとなるアプリを作ってみたい方
  • Gemini APIをはじめとするLLMの具体的な活用事例に興味がある方
  • Google DriveやSpreadsheetなど、普段使いのサービスをAPI経由で操作してみたい方
  • GIS(地理情報システム)や写真のEXIFデータ活用に関心のある方
  • お祭りが好きで、ITで何か面白いことができないかと考えている方

ⅰ. プロジェクトが対象とするユーザー像と課題、ソリューション

👤 ユーザー像

このアプリは、以下のような情熱や悩みを抱える人々を想定しています。

  1. お祭り行きたい人: 日本全国のユニークなお祭りに参加したいが、情報収集に手間を感じている。観光サイトや自治体のページを個別に調べるのは大変...。
  2. 思い出整理したい人: 旅行やイベントでたくさんの写真を撮るが、後から見返したときに「これ、どこのお祭りだっけ?」となりがち。写真の整理が追いつかない。
  3. ライフロガー: 自分が訪れた場所を地図上で可視化し、行動履歴を眺めるのが好き。お祭りをテーマにした自分だけの訪問マップを作りたい。

🌊 課題:情報過多の海と、整理されない思い出

ユーザーが直面する課題は、大きく2つに分類できます。

  1. 情報の散在と検索の困難さ: お祭り情報は、公式ウェブサイト、観光情報サイト、SNSなど、インターネット上の至る所に散らばっています。開催時期や場所を横断的に検索し、一覧で比較するのは非常に困難です。
  2. 記録の断絶: 写真はスマートフォンやクラウドストレージに大量に保存されているものの、それが「いつ、どこで、何のお祭りに行った記録」なのかという文脈(コンテキスト)と結びついていません。この「記録の断絶」が、思い出の価値を半減させてしまっています。

✅ ソリューションと特徴: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でデプロイしました。

📝 アーキテクチャの解説

  1. ユーザーインターフェース (Streamlit):
    ユーザーはWebブラウザからStreamlitで構築されたUIにアクセスします。テキスト入力、ボタンクリック、データ表示など、すべての対話はこの層で行われます。Streamlitを採用したことで、Pythonのみで迅速にインタラクティブなUIを開発できました。

  2. お祭り検索フロー (Gemini API):

    • ユーザーが「東京 7月」などのキーワードを入力すると、StreamlitアプリはそれをGemini APIへのプロンプトに含めて送信します。
    • プロンプトでは、Geminiに搭載されたGoogle Search Toolを活用し、Web全体から関連情報を検索するように指示しています。
    • さらに、「必ず指定したJSON形式で返すこと」という厳密な指示(System Prompt)を与えることで、LLMからの出力を安定した構造化データとして受け取ります。
    • 受け取ったJSONデータはPandas DataFrameに変換され、Google Sheets API (gspread) を通じてスプレッドシートに書き込まれます。
  3. 訪問記録作成フロー (Google Drive & EXIF):

    • ユーザーが「訪問記録を更新」ボタンを押すと、アプリはGoogle Drive APIを呼び出し、指定されたフォルダ内の画像ファイル一覧を取得します。
    • 各画像ファイルをメモリ上にダウンロードし、Pillowライブラリを使ってEXIF情報を抽出します。ここから撮影日時とGPS座標(緯度・経度)が得られます。
    • 抽出したGPS座標と撮影日時、そしてGoogle Spreadsheetから取得したお祭りリストの情報を照合します。
    • Haversine式を用いて写真の撮影地とお祭りの開催地の距離を計算し、かつ開催期間内に撮影されたものであれば、「訪問した」と判定します。
    • 判定結果は、訪問記録用の別シートに書き込まれます。複数の写真が同一のお祭りで撮影された場合は、1つの「訪問グループ」として集約し、記録の重複を防ぎます。
  4. データ永続化 (Google Spreadsheet):
    本格的なデータベース(例: PostgreSQL, Firestore)の代わりにGoogle Spreadsheetを採用しました。これにより、以下のメリットが生まれます。

    • セットアップが容易: APIを有効化するだけですぐに利用可能。
    • 可視性と編集性: ユーザーはいつでもスプレッドシートを直接開いて、データの確認や手動での修正ができます。
    • コスト効率: 小規模な利用であれば、ほぼ無料で運用可能です。
  5. 可視化 (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)を更新した直後にこれを呼び出すことで、ユーザーはリロードボタンを押すことなく、常に最新の状態を見ることができます。

🔗 プロジェクトリンク

初心者かつ時間が足りなかったため、Gemini CLIにはGithub対応や記事作成で大活躍していただきました。このアプリはフィールドワーク全般に拡張できると思うので、引き続き開発に努めます。
https://cloud.google.com/blog/ja/topics/developers-practitioners/introducing-gemini-cli/

皆さんの次の開発プロジェクトのヒントやインスピレーションになれば幸いです。
ありがとうございました!

Discussion