Phase 1(バイク/車)+ Phase 2(キャンピングカー/インバウンド/ファミリー)対応
現在の5カテゴリ体系は、多様なユーザーの好みを表現するには不十分です。しかし、単純にカテゴリを増やすだけでは、抽出精度の低下とデータの管理複雑化を招きます。以下の原則に従い、実用的かつ拡張可能なタグ体系を設計します。
Claude がYouTube字幕から確実に抽出できる情報と、Google Places API等で後から補完する情報を明確に分離。抽出スキーマを無理に膨らませない。
各スポットには1つのプライマリカテゴリと複数のタグを付与。カテゴリは「何か」、タグは「どんな体験ができるか」を表す。
インバウンド向け(nameEn等)やファミリー向け(childFriendly等)フィールドは、Phase 1 ではnull。スキーマに「空席」を用意しておく。
array-contains でタグ検索、where句でカテゴリフィルタ。複合インデックスを最小限に保つ。regionフィールドで地理的絞り込み。
スポットデータを「抽出 → 補完 → ユーザー」の3層で管理します。各層で異なるデータソースからフィールドを追加していきます。
現在の5カテゴリを14に拡張します。各スポットには1つだけプライマリカテゴリを付与します。
| カテゴリ | 日本語名 | 具体例 | Phase | 旧カテゴリ |
|---|---|---|---|---|
| scenery | 絶景・自然景観 | 展望台、湖、海岸、山頂、滝、渓谷 | P1 | scenery |
| food | グルメ・飲食 | レストラン、食堂、ラーメン、カフェ、海鮮丼 | P1 | food |
| onsen | 温泉・入浴施設 | 温泉地、日帰り温泉、足湯、スーパー銭湯 | P1 | rest(分離) |
| shrine | 神社仏閣 | 神社、寺院、鳥居、御朱印 | P1 | shrine |
| castle | 城・歴史的建造物 | 城、城跡、史跡、古い町並み、遺跡 | P1 | shrine(分離) |
| museum | 博物館・美術館 | 博物館、美術館、記念館、資料館 | P1 | (新規) |
| park | 公園・庭園 | 国立公園、庭園、フラワーパーク、テーマパーク | P1 | (新規) |
| road | 走りが楽しい道路 | 峠、スカイライン、海沿い道路、酷道 | P1 | road |
| rest_area | 道の駅・SA/PA | 道の駅、SA、PA、ハイウェイオアシス | P1 | rest |
| camp | キャンプ場 | キャンプ場、RVパーク、グランピング | P1 | (新規) |
| beach | ビーチ・海辺 | 海水浴場、サーフスポット、磯遊び | P1 | (新規) |
| activity | アクティビティ・体験 | 釣り、SUP、カヌー、スキー、果物狩り | P1 | (新規) |
| shopping | 市場・特産品 | 朝市、直売所、土産物店、酒蔵、ワイナリー | P1 | (新規) |
| accommodation | 宿泊施設 | 旅館、ホテル、ゲストハウス、ライダーハウス | P2 | (新規) |
rest → onsen(温泉)と rest_area(道の駅/SA)に分離。温泉は独立した訪問目的になるため。shrine → shrine(神社仏閣)と castle(城・歴史)に分離。城は歴史カテゴリとして独立。camp を新設 — Phase 2のキャンピングカーユーザーに必須。Phase 1でもバイクキャンプツーリング需要がある。accommodation はPhase 2で有効化。現在の抽出プロンプトでは除外ルールを維持。| 旧カテゴリ | 新カテゴリ | マイグレーション |
|---|---|---|
scenery |
scenery |
変更なし |
food |
food |
変更なし |
shrine |
shrine or castle |
城・史跡は castle に再分類 |
rest |
onsen or rest_area |
名前に「温泉」含む → onsen、それ以外 → rest_area |
road |
road |
変更なし |
YouTube字幕からClaude が構造化抽出する際のJSONスキーマ。Claude が判定可能なフィールドのみに絞っています。
// spot_extraction_schema.json { "type": "object", "properties": { "spots": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string", "description": "Google Mapsで検索可能な正式名称" }, "category": { "type": "string", "enum": ["scenery", "food", "onsen", "shrine", "castle", "museum", "park", "road", "rest_area", "camp", "beach", "activity", "shopping"] }, "prefecture": { "type": "string", "description": "都道府県名(例: 東京都、神奈川県)" }, "description": { "type": "string", "description": "スポットの魅力(動画の感想ベース、1〜2文)" }, "tags": { "type": "array", "items": { "type": "string" }, "description": "体験タグ(絶景,映え,秘境,穴場,定番,ご当地グルメ,海鮮,ワインディング等)" }, "season": { "type": "string", "enum": ["spring", "summer", "autumn", "winter", "all"] }, "ridingDifficulty": { "type": "string", "enum": ["beginner", "intermediate", "advanced"] }, "youtuberRating": { "type": "string", "enum": ["highly_recommended", "recommended", "mentioned"] }, "mentionedAtSec": { "type": "integer" }, "suitableFor": { "type": "array", "items": { "type": "string", "enum": ["solo", "couple", "group", "family"] }, "description": "このスポットに適した訪問スタイル" } }, "required": ["name", "category", "prefecture", "description", "tags", "season", "ridingDifficulty", "youtuberRating", "mentionedAtSec", "suitableFor"], "additionalProperties": false } } }, "required": ["spots"], "additionalProperties": false }
| フィールド | 現行 | 新 | 変更内容 |
|---|---|---|---|
category |
5 enum | 13 enum | 5 → 13カテゴリに拡張(accommodationはPhase 2) |
tags |
なし | string[] | 体験・雰囲気タグの配列を新設 |
suitableFor |
なし | enum[] | 訪問スタイル(solo/couple/group/family)を新設 |
name |
あり | あり | 変更なし |
prefecture |
あり | あり | 変更なし |
description |
あり | あり | 変更なし |
season |
あり | あり | 変更なし |
ridingDifficulty |
あり | あり | 変更なし |
youtuberRating |
あり | あり | 変更なし |
mentionedAtSec |
あり | あり | 変更なし |
Claude 抽出(Layer 1)+ API補完(Layer 2)+ ユーザー生成(Layer 3)の全フィールドを含む完全なドキュメント構造。
| フィールド | 型 | ソース | Phase | 用途 |
|---|---|---|---|---|
tags |
string[] | Claude抽出 | P1 | 体験・雰囲気によるフィルタ(array-contains) |
suitableFor |
string[] | Claude抽出 | P1 | 訪問スタイルフィルタ(Phase 2: family対応) |
facilities |
string[] | 道の駅API / Places | P1 | 施設情報フィルタ |
parking |
map | 道の駅API / Places | P1 | 駐車場タイプフィルタ(RV = Phase 2) |
sources[].type |
string | バックエンド | P1 | データソース種別の管理 |
visitCount |
integer | ユーザー行動 | P1 | 人気度スコア、トレンド算出 |
favoriteCount |
integer | ユーザー行動 | P1 | お気に入り数によるランキング |
dataCompleteness |
float | バックエンド | P1 | データ品質管理(未補完スポットの優先処理) |
nameEn |
string? | 翻訳API | P2 | インバウンド向け英語表示 |
descriptionEn |
string? | 翻訳API | P2 | インバウンド向け英語説明 |
accessibility |
map? | Places API | P2 | 車椅子・ベビーカー対応情報 |
businessHours |
string? | Places API | P2 | 営業時間情報 |
photoUrls |
string[]? | Places Photos | P2 | スポット写真の表示 |
region + mentionCount DESC — 地域別人気順(既存)region + category + mentionCount DESC — 地域×カテゴリtags (array-contains) + region — タグ検索suitableFor (array-contains) + region — 対象者フィルタdataCompleteness ASC — 未補完スポット優先処理各データソースがどのフィールドを埋めるか。スポットが最初に登録されてから徐々にデータが充実していく流れ。
# spot_service.py に追加 def calculate_completeness(spot_data: dict) -> float: score = 0.0 weights = { "name": 0.10, "category": 0.05, "prefecture": 0.05, "description": 0.10, "tags": 0.10, # len >= 1 で加算 "latitude": 0.15, # 位置情報は重要 "longitude": 0.15, "rating": 0.10, "facilities": 0.05, "parking": 0.05, "visitCount": 0.05, # > 0 で加算 "favoriteCount": 0.05, } for field, weight in weights.items(): value = spot_data.get(field) if value is not None: if isinstance(value, list) and len(value) > 0: score += weight elif isinstance(value, (int, float)) and value > 0: score += weight elif isinstance(value, str) and value: score += weight elif isinstance(value, dict): score += weight return round(score, 2)
Phase 2 で追加されるユーザー層と、それに対応するスキーマ変更の影響範囲。
| ユーザー層 | カテゴリ変更 | タグ追加 | フィールド有効化 | プロンプト変更 |
|---|---|---|---|---|
| キャンピングカー | accommodation 有効化 | RV対応, 電源サイト, ダンプステーション | parking.rv | 宿泊施設の除外ルール緩和 |
| インバウンド | 変更なし | 英語対応, ハラール, ベジタリアン等 | nameEn, descriptionEn | 英語名の同時抽出追加 |
| ファミリー | 変更なし | 授乳室, キッズスペース, ベビーカーOK | accessibility | suitableFor: family の判定強化 |
| 地方活性化 | 変更なし | 地域おこし, 過疎地域, 伝統工芸 | なし(tags配列で対応) | ローカル体験の抽出優先度UP |
spot_extraction_schema.json — category enum に "accommodation" 追加(1行)spot_extraction.py — 宿泊施設の除外ルール削除 + インバウンドタグ追加(数行)spot_service.py — nameEn/descriptionEn の翻訳バッチ処理追加既存データへの影響: ゼロ。Firestoreはスキーマレスのため、新フィールドはnullとして存在しないだけ。既存ドキュメントの変更は不要。
ridingDifficulty はバイク/車向けの指標ですが、Phase 2でもそのまま使えます。
vehicleCompatibility: ["motorcycle", "car", "rv", "bicycle"] を tags に追加する可能性あり。ただしPhase 1では不要。
既存データ(touring_spots コレクション)への影響と、コード変更箇所。
ただし、既存の category 値の変換は必要です:
# 既存データの category 変換(1回実行のバッチ) def migrate_categories(): spots = db.collection("touring_spots").stream() for doc in spots: data = doc.to_dict() old_cat = data.get("category") new_cat = old_cat # デフォルトはそのまま if old_cat == "rest": name = data.get("name", "") if "温泉" in name or "湯" in name: new_cat = "onsen" else: new_cat = "rest_area" elif old_cat == "shrine": name = data.get("name", "") if "城" in name or "史跡" in name or "遺跡" in name: new_cat = "castle" if new_cat != old_cat: doc.reference.update({"category": new_cat})