TouringApp タグアーキテクチャ設計書

Phase 1(バイク/車)+ Phase 2(キャンピングカー/インバウンド/ファミリー)対応

設計原則

現在の5カテゴリ体系は、多様なユーザーの好みを表現するには不十分です。しかし、単純にカテゴリを増やすだけでは、抽出精度の低下とデータの管理複雑化を招きます。以下の原則に従い、実用的かつ拡張可能なタグ体系を設計します。

1. 抽出可能なものだけ抽出する

Claude がYouTube字幕から確実に抽出できる情報と、Google Places API等で後から補完する情報を明確に分離。抽出スキーマを無理に膨らませない。

2. カテゴリは排他的、タグは複数付与

各スポットには1つのプライマリカテゴリ複数のタグを付与。カテゴリは「何か」、タグは「どんな体験ができるか」を表す。

3. Phase 2 フィールドは予約のみ

インバウンド向け(nameEn等)やファミリー向け(childFriendly等)フィールドは、Phase 1 ではnull。スキーマに「空席」を用意しておく。

4. Firestoreクエリを最適化

array-contains でタグ検索、where句でカテゴリフィルタ。複合インデックスを最小限に保つ。regionフィールドで地理的絞り込み。

3層データアーキテクチャ

スポットデータを「抽出 → 補完 → ユーザー」の3層で管理します。各層で異なるデータソースからフィールドを追加していきます。

Layer 1 — AI抽出
Claude による構造化抽出
YouTube字幕・ブログテキストから抽出。主観的・体験的な情報。
name category prefecture description tags[] season ridingDifficulty youtuberRating suitableFor[]
Layer 2 — API補完
Google Places / 道の駅API / じゃらん等
客観的・実用的な情報を外部APIから自動付与。
latitude, longitude rating userRatingCount address businessHours parking{} facilities[] accessibility{} photoUrls[]
Layer 3 — ユーザー生成
ユーザーのアクション・フィードバック
訪問ログ、お気に入り、口コミでスポットデータが進化。
visitCount favoriteCount userTags[] averageUserRating trendingScore
ポイント: Layer 1 の抽出スキーマを肥大化させず、Layer 2・3 で段階的に情報を豊かにしていく。Claude の抽出精度を維持しつつ、リッチなフィルタリングを実現。

カテゴリ体系(5 → 14カテゴリ)

現在の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 (新規)
変更のポイント:
  • restonsen(温泉)と rest_area(道の駅/SA)に分離。温泉は独立した訪問目的になるため。
  • shrineshrine(神社仏閣)と 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 変更なし

タグ体系(6次元・約80タグ)

260個の候補タグから、Claude が字幕テキストから確実に判定できるものAPIで自動付与できるものに絞り込みました。実用性を重視し、フィルタリングに使える粒度に整理しています。

設計判断: 260タグの候補から約80タグに絞った理由 — タグが多すぎるとClaude の抽出精度が下がり、ユーザーのフィルタUIも複雑になります。「80タグ × 14カテゴリ」の組み合わせで十分な表現力を持ちながら、管理可能な規模を維持します。

A. 体験・雰囲気タグ(Claude抽出)

🏔 景観・自然
絶景 映え 秘境 穴場 定番 パワースポット 世界遺産 紅葉 花畑 雪景色 星空 夕日・朝日 夜景 雲海 富士山ビュー
🍜 グルメ
ご当地グルメ 海鮮 ラーメン 蕎麦・うどん 焼肉・BBQ B級グルメ スイーツ カフェ 食べ歩き 地酒・ワイナリー 朝食・モーニング
🛣 道路・走行
ワインディング 直線・快走路 海沿い 山岳路 林道 酷道・険道 絶景ロード 無料道路 有料道路

B. 対象者タグ(Claude抽出 + Phase 2拡張)

👥 誰と行くか・対象者
ソロ向き カップル向き グループ向き ファミリー向き 初心者向き 上級者向き 女性ライダー向き

C. 季節タグ(Claude抽出)

🌸 おすすめ季節
通年 期間限定

D. 実用タグ(API補完 — Layer 2)

🅿️ 駐車場・アクセス
駐車場あり バイク駐輪可 大型車OK RV対応 駐車場無料 EV充電
🏢 施設・設備
トイレあり WiFi レストラン併設 売店 シャワー コインランドリー ペット可 バリアフリー 授乳室

E. インバウンドタグ(Phase 2 — 将来用)

🌏 外国人旅行者向け
英語対応 多言語メニュー 外国人人気 伝統文化体験 JapanTravel定番 ハラール対応 ベジタリアン対応

タグの管理方針

Phase 1 で Claude が抽出するタグ: A(体験・雰囲気)+ B(対象者)+ C(季節)= 約43タグ
Phase 1 で API が補完するタグ: D(実用)= 約15タグ
Phase 2 で追加するタグ: E(インバウンド)= 約7タグ
合計: 約65タグ(必要に応じて追加可能、Firestoreの配列に上限なし)

Claude 抽出スキーマ(新)

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 あり あり 変更なし

Firestore touring_spots コレクション(新設計)

Claude 抽出(Layer 1)+ API補完(Layer 2)+ ユーザー生成(Layer 3)の全フィールドを含む完全なドキュメント構造。

// touring_spots/{spotId} { // === 基本情報(Layer 1: Claude抽出) === "name": "道の駅 箱根峠", "category": "rest_area", // 14カテゴリから1つ "prefecture": "神奈川県", "region": "kanto", // 自動マッピング(変更なし) "description": "芦ノ湖を一望できる絶景の道の駅。ご当地ソフトクリームが人気。", "tags": ["絶景", "ご当地グルメ", "富士山ビュー", "定番"], // ★新規 "season": "all", "ridingDifficulty": "beginner", "suitableFor": ["solo", "couple", "group", "family"], // ★新規 // === 位置情報(Layer 2: Google Places補完) === "latitude": 35.1923, "longitude": 139.0211, "address": "神奈川県足柄下郡箱根町箱根381-22", // === 評価情報(Layer 2: Google Places補完) === "rating": 4.1, "userRatingCount": 856, // === 施設情報(Layer 2: 道の駅API/Places補完) === "facilities": ["restaurant", "shop", "toilet", "parking"], // ★新規 "parking": { // ★新規 "motorcycle": true, "car": true, "rv": false, // Phase 2: キャンピングカー対応 "free": true }, // === ソース情報(Layer 1: 抽出元データ) === "mentionCount": 5, "sources": [ { "type": "youtube", // ★新規: "youtube"|"google"|"michinoeki"|"jalan" "channelName": "モトブログチャンネル", "videoTitle": "箱根ツーリング最高すぎた", "videoId": "abc123", "youtuberRating": "recommended", "mentionedAtSec": 330, "extractedAt": "2026-02-22T10:00:00Z" } ], // === Phase 2 予約フィールド(現在はnull) === "nameEn": null, // Phase 2: インバウンド向け英語名 "descriptionEn": null, // Phase 2: 英語説明 "accessibility": null, // Phase 2: {"wheelchair": bool, "stroller": bool} "businessHours": null, // Phase 2: 営業時間 "photoUrls": null, // Phase 2: Google Places Photos // === ユーザー集計(Layer 3: アプリからのフィードバック) === "visitCount": 0, // ★新規: ユーザーの訪問回数合計 "favoriteCount": 0, // ★新規: お気に入り登録数 // === メタデータ === "dataCompleteness": 0.6, // ★新規: 0.0-1.0 データ充実度 "createdAt": "2026-02-22T10:00:00Z", "updatedAt": "2026-02-22T15:30:00Z" }

新規フィールド一覧

フィールド ソース 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 スポット写真の表示

Firestoreインデックス設計

必要な複合インデックス:
  • region + mentionCount DESC — 地域別人気順(既存)
  • region + category + mentionCount DESC — 地域×カテゴリ
  • tags (array-contains) + region — タグ検索
  • suitableFor (array-contains) + region — 対象者フィルタ
  • dataCompleteness ASC — 未補完スポット優先処理

エンリッチメントフロー

各データソースがどのフィールドを埋めるか。スポットが最初に登録されてから徐々にデータが充実していく流れ。

YouTube字幕
+ Claude抽出
Layer 1 フィールド(即時) name, category, prefecture, region, description, tags[], season, ridingDifficulty, youtuberRating, suitableFor[], mentionedAtSec
dataCompleteness: 0.4
Google Places
API
Layer 2 フィールド(バッチ補完) latitude, longitude, address, rating, userRatingCount, parking(一部)
dataCompleteness: 0.4 → 0.7
道の駅 API
じゃらん等
Layer 2 フィールド(バッチ補完) facilities[], parking(RV/EV), 追加tags(駐車場無料, WiFi等)
dataCompleteness: 0.7 → 0.85
ユーザー
アクション
Layer 3 フィールド(リアルタイム) visitCount++, favoriteCount++
dataCompleteness: 0.85 → 1.0

dataCompleteness の算出ロジック

# 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 拡張性設計

Phase 2 で追加されるユーザー層と、それに対応するスキーマ変更の影響範囲。

Phase 2 ユーザー層と必要な変更

ユーザー層 カテゴリ変更 タグ追加 フィールド有効化 プロンプト変更
キャンピングカー accommodation 有効化 RV対応, 電源サイト, ダンプステーション parking.rv 宿泊施設の除外ルール緩和
インバウンド 変更なし 英語対応, ハラール, ベジタリアン等 nameEn, descriptionEn 英語名の同時抽出追加
ファミリー 変更なし 授乳室, キッズスペース, ベビーカーOK accessibility suitableFor: family の判定強化
地方活性化 変更なし 地域おこし, 過疎地域, 伝統工芸 なし(tags配列で対応) ローカル体験の抽出優先度UP
Phase 2 移行時の作業量:
  • spot_extraction_schema.json — category enum に "accommodation" 追加(1行)
  • spot_extraction.py — 宿泊施設の除外ルール削除 + インバウンドタグ追加(数行)
  • spot_service.py — nameEn/descriptionEn の翻訳バッチ処理追加
  • Firestore — スキーマレスのため変更不要(null → 値を入れるだけ)
  • iOS — フィルタUIに新カテゴリ/タグ追加

既存データへの影響: ゼロ。Firestoreはスキーマレスのため、新フィールドはnullとして存在しないだけ。既存ドキュメントの変更は不要。

ridingDifficulty の Phase 2 対応

設計判断: ridingDifficulty はバイク/車向けの指標ですが、Phase 2でもそのまま使えます。
  • キャンピングカー → beginner/intermediate のスポットのみ推薦(大型車は狭い道NG)
  • ファミリー → beginner のスポットを優先推薦
  • インバウンド → そのまま表示(road difficulty として普遍的に有用)
Phase 2では vehicleCompatibility: ["motorcycle", "car", "rv", "bicycle"] を tags に追加する可能性あり。ただしPhase 1では不要。

マイグレーション計画

既存データ(touring_spots コレクション)への影響と、コード変更箇所。

Firestoreデータ マイグレーション

Firestoreはスキーマレス → マイグレーションスクリプト不要
新フィールド(tags, suitableFor等)は既存ドキュメントに存在しなくても問題なし。読み取り時にデフォルト値を適用。

ただし、既存の 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})

コード変更箇所チェックリスト

設計サマリー

14
プライマリカテゴリ
~65
タグ(Phase 1: ~58)
3層
データアーキテクチャ
8
変更ファイル数

Phase 1(バイク/車ツーリング)でのユーザー体験

パーソナライズフィルタの例:
  • 「絶景 × ワインディング × ソロ向き × 秋」→ 紅葉の峠道ルート
  • 「ご当地グルメ × 海鮮 × カップル向き × 通年」→ 海沿いグルメツーリング
  • 「秘境 × 穴場 × 上級者向き × 夏」→ 林道アドベンチャー
  • 「温泉 × 道の駅 × グループ向き × 冬」→ 温泉はしごツーリング

Phase 2 拡張時の追加作業量

最小限の変更で対応可能:
  • キャンピングカー対応: スキーマ1行 + プロンプト数行 + parking.rv の補完パイプライン
  • インバウンド対応: nameEn/descriptionEn の翻訳バッチ + 多言語UI
  • ファミリー対応: accessibility フィールド補完 + suitableFor: family の活用
  • 既存データの破壊的変更: ゼロ

次のアクション