データベース設計
このページでできること
- クヌギGEO がどのテーブルにどんなデータを保存しているかを把握できます。
- SQLite を 2 種類のファイルに分割している意図と、バックアップ・移行で扱う対象を理解できます。
- ご自身でレポートを実装する際に、参照先のテーブル・カラムを特定できます。
1. 設計思想:システム DB + プロダクト DB
クヌギGEO は SQLite を 2 種類のファイルに分けて使用しています。スナップショット(日次で積み上がる最大級のテーブル)が肥大化しても、他のメタ情報を巻き込まずにバックアップ・移行・削除ができるようにすることが目的です。
| 用途 | ファイル | 主な格納物 |
|---|---|---|
| システム DB | data/app.db |
ユーザー・アプリ設定・プロダクト・キーワード・GA4 認証・拡張レポート ON/OFF など、全プロダクト共通のメタ情報 |
| プロダクト DB | data/products/product_<productId>.db |
そのプロダクトに紐づく 調査スナップショット(survey_snapshots。日次で増え続けるため分割) |
スナップショットへのアクセス時は、システム接続に ATTACH DATABASE <product_<id>.db> AS pdb でアタッチし、pdb.survey_snapshots として参照します(api/bootstrap.php の llmo_attach_product_db())。
app.db 単独ではなく app.db・app.db-wal・app.db-shm の 3 ファイルを揃えてコピーするのが安全です(プロダクト DB 側も同様)。
2. システム DB のテーブル一覧
users — アカウント
Web ログインアカウントを管理します。username と email はメールアドレスで同期されます。
| 列 | 型 | 説明 |
|---|---|---|
id | INTEGER PK | 主キー |
username | TEXT UNIQUE | ログイン ID(メールアドレス) |
password | TEXT | パスワードハッシュ(PHP password_hash) |
email | TEXT | パスワード再発行メール送信先(username と同期) |
initial_setup_complete | INTEGER | 初回セットアップ済みフラグ |
role | TEXT | admin / product_manager / viewer |
app_settings — システム共通設定(id=1 の 1 行のみ)
| 列 | 説明 |
|---|---|
dataforseo_login / dataforseo_password | DataForSEO API 認証情報(平文) |
usd_jpy | 表示用ドル円レート(既定 159) |
llm_chatgpt_model / llm_claude_model / llm_gemini_model / llm_perplexity_model | 各 LLM の既定モデル |
llm_web_search | LLM Responses での Web 検索 ON/OFF(既定 1) |
llm_models_json / llm_models_updated_at | DataForSEO の最新サポートモデル一覧スナップショット |
ai_overview_retry | AI Overview が空のとき再試行するか(既定 1) |
dataforseo_serp_plan / dataforseo_llm_plan | SERP / LLM の実行プラン(live / priority / standard) |
survey_schedule_json | 旧グローバルスケジュール(互換のみ。新は products.survey_schedule_json) |
google_oauth_client_id / google_oauth_client_secret | GA4 連携用 Google OAuth 情報 |
brand_name / brand_logo_path | サイトブランディング(ヘッダー表示名・ロゴ) |
products — プロダクト
| 列 | 説明 |
|---|---|
id / name / sort_order / created_at | 基本情報 |
survey_schedule_json | このプロダクト固有の曜日スケジュール(軸ごとの曜日 ON/OFF) |
product_keywords — キーワード
| 列 | 説明 |
|---|---|
id / product_id | 主キー / プロダクト FK |
keyword | 検索キーワード文字列 |
target_url | 検索結果順位のヒット判定に使う URL もしくはホスト |
own_brands | JSON 配列(自社ブランド名一覧) |
rival_brands | JSON 配列。新形式 [{"name":"...","url":"..."}, ...](最大 5 件) |
fetch_rank / fetch_ai_overview / fetch_ai_mode | SERP 系の取得 ON/OFF |
fetch_llm_chatgpt / fetch_llm_claude / fetch_llm_gemini / fetch_llm_perplexity | LLM Responses の取得 ON/OFF |
location_code / device | 地域コード(2392 = Japan)/desktop | mobile |
sort_order / created_at | 並び順・作成日時 |
user_products — ユーザのアクセス可能プロダクト
システム管理者(admin)以外(プロダクト管理者 product_manager/閲覧者 viewer)に対して、見せるプロダクトを user_id × product_id で限定するためのテーブル。システム管理者(admin)はこのテーブルに関係なく全プロダクトに到達できます。
product_ga4 — プロダクトの GA4 連携情報
| 列 | 説明 |
|---|---|
product_id | プロダクト FK(PK) |
property_id | GA4 プロパティ ID(数字のみ) |
google_email / google_name | 連携した Google アカウント情報(表示用) |
access_token / refresh_token / token_expires_at / scopes | OAuth 認可トークン(平文で保存されます。Basic 認証下で運用してください) |
cv_mode / cv_event_names | CV 集計方針(key_events | events)と対象イベント名 JSON |
track_flags_json / sources_json | プロダクト単位の「計測対象」「流入種別と判定条件」オーバーライド |
connected_at | 連携日時 |
ga4_oauth_states — OAuth フロー中の state 保持(短命)
「Google で認証」を押したときに発行する state を一時保存します。コールバック後は不要になります。
product_extended_reports — プロダクトごとの拡張レポート ON/OFF
products/extended_reports/*.php を自動検出する拡張レポート機能で、プロダクトごとに表示 ON/OFF をオーバーライドします。行が無いプロダクトは各レポートの @report-default-enabled に従います。
| 列 | 説明 |
|---|---|
product_id / report_key | 複合 PK |
enabled | 0 / 1 |
updated_at | 更新日時 |
password_reset_tokens — パスワード再発行トークン
「パスワードを忘れた」フローで発行する短命トークン。token_hash はハッシュで保存され、平文は DB には保存されません。
login_attempts — ログイン試行レート制限
ログイン成功・失敗を記録し、ブルートフォース対策に使います。
3. プロダクト DB(product_<id>.db)のテーブル
survey_snapshots — 調査スナップショット
各キーワード × 軸の取得結果(JSON)を 1 行ずつ保存します。日次で増え続けるテーブルなので、プロダクトごとにファイルを分けてあります。
| 列 | 型 | 説明 |
|---|---|---|
id | INTEGER PK | 主キー |
product_keyword_id | INTEGER | システム DB の product_keywords.id への論理 FK(attach 越しのため SQLite では強制されません) |
captured_at | TEXT | 同一バッチで揃えた取得日時(ISO 8601) |
result_json | TEXT | 取得結果一式の JSON。軸ごとのフラグ・派生指標・参照 URL・LLM 回答本文などを保持します(詳細は 下記「result_json の構造」) |
UNIQUE(product_keyword_id, captured_at) のため、同一バッチでの重複保存は発生しません(再取得時は INSERT OR REPLACE 相当の動作)。
result_json の構造(JSON フィールド一覧)
調査ランナー(cron/lib/survey_runner.php)と手動実行ハンドラ(api/catalog_handlers.php の llmo_catalog_save_snapshot)は、軸ごとの結果フラグ・派生指標を 1 つのオブジェクトに詰めて JSON 化し、この列に保存します。
共通/メタ
| キー | 型 | 説明 |
|---|---|---|
status | string | success / error。Organic または AI Mode が失敗したとき error |
keyword | string | 調査対象のキーワード文字列 |
message | string | status=error のとき、軸別の失敗理由を | 区切りで連結 |
cost_usd / cost_jpy | number | このキーワードの合計コスト(Organic + AI Mode + LLM の合算)。JPY は app_settings.usd_jpy 換算 |
順位取得(fetch_rank)
| キー | 型 | 説明 |
|---|---|---|
organic_count | int | DataForSEO が返した organic 結果の件数 |
organic_rankings[] | array | 取得できた組織検索結果を 全件保存(matrix・拡張レポート用途) |
organic_rankings[].rank_absolute | int / null | SERP 全体での絶対順位 |
organic_rankings[].rank_group | int / null | DataForSEO の rank_group |
organic_rankings[].position | string / null | SERP の表示位置(left / right 等) |
organic_rankings[].url / .domain | string | 結果 URL とドメイン |
organic_rankings[].title | string | 結果タイトル |
organic_rankings[].description | string | スニペット(DB 肥大防止のため 400 文字でトリム) |
organic_rankings[].breadcrumb | string | 表示パンくず |
organic_rankings[].is_featured_snippet | bool | フィーチャードスニペット枠か |
target_rank | int / null | キーワードの target_url にマッチした自社順位(圏外なら null) |
target_url | string / null | 実際にマッチした URL(product_keywords.target_url ではなく検出結果) |
rival_ranks[] | array | 競合 URL ベースの順位検出結果。rival_brands の各エントリに対して 1 要素 |
rival_ranks[].name / .url | string | 競合ブランド名と URL |
rival_ranks[].rank | int / null | マッチした順位(圏外 / URL 未設定なら null) |
rival_ranks[].matched_url | string | 実際にマッチした URL |
Google AI Overview(fetch_ai_overview)
| キー | 型 | 説明 |
|---|---|---|
has_ai_overview | bool | AI Overview が返ってきたか |
ai_overview_async | bool | DataForSEO が非同期 AIO(asynchronous_ai_overview)を返したか |
ai_overview_retried | bool | AIO が空だったため再試行したか(app_settings.ai_overview_retry が ON のとき) |
brand_hit | bool / null | 自社ブランドが AIO 本文に出現したか(own_brands 未設定時は null) |
brand_matched[] | string[] | 実際に検出した自社ブランド名一覧 |
brand_metrics_ai_overview | object | プロミネンス指標(下記スキーマ) |
target_refs_ai_overview[] | string[] | AIO の参照 URL のうち target_url にマッチしたもの |
rival_refs_ai_overview[] | array | 競合別の参照 URL マッチ:name / url / matched_urls[] |
Google AI Mode(fetch_ai_mode)
独立エンドポイント(serp/google/ai_mode)で取得します。
| キー | 型 | 説明 |
|---|---|---|
has_ai_mode_ai_overview | bool | AI Mode の AIO が返ってきたか |
ai_mode_async | bool | 非同期 AIO 扱いか |
ai_mode_cost_usd / ai_mode_cost_jpy | number | AI Mode 単独のコスト(合算は cost_usd) |
brand_hit_ai_mode | bool / null | 自社ブランドが AI Mode 本文に出現したか |
brand_matched_ai_mode[] | string[] | 検出した自社ブランド名一覧 |
brand_metrics_ai_mode | object | プロミネンス指標(下記スキーマ) |
target_refs_ai_mode[] | string[] | AI Mode の参照 URL のうち target_url にマッチしたもの |
rival_refs_ai_mode[] | array | 競合別の参照 URL マッチ:name / url / matched_urls[] |
LLM Responses(fetch_llm_chatgpt / _claude / _gemini / _perplexity)
下記の <axis> は llm_chatgpt / llm_claude / llm_gemini / llm_perplexity のいずれか。各軸ごとに同じ形式で並びます。本文は llm_engines[].data として保存されます。
| キー | 型 | 説明 |
|---|---|---|
has_<axis> | bool | 有効な回答(sections あり)が得られたか |
llm_<axis>_error | string | 失敗時のエラーメッセージ(成功時はキー自体存在しない) |
<axis>_cost_usd / <axis>_cost_jpy | number | 軸単独のコスト |
<axis>_model | string | 実際に呼び出されたモデル名 |
brand_hit_<axis> | bool / null | 自社ブランドが回答本文に出現したか |
brand_matched_<axis>[] | string[] | 検出した自社ブランド名一覧 |
brand_metrics_<axis> | object | プロミネンス指標(下記スキーマ) |
llm_engines[] | array | 軸 1 件につき 1 要素。本文・引用 URL を含む |
llm_engines[].axis | string | llm_chatgpt 等 |
llm_engines[].vendor | string | openai / anthropic / google / perplexity |
llm_engines[].model | string | 利用モデル名 |
llm_engines[].data.main | object | keyword / datetime / model_name / input_tokens / output_tokens / web_search / money_spent |
llm_engines[].data.sections[] | array | 回答本文。type / text / annotations[](ref_index / title / url) |
llm_engines[].data.annotations[] | array | 全引用 URL のフラット一覧(ref_index / title / url) |
brand_metrics_* 共通スキーマ(プロミネンス指標)
brand_metrics_ai_overview / brand_metrics_ai_mode / brand_metrics_<axis> はすべて同じ形です。本文が空のときも items は 0 値で埋めて返します。
| キー | 型 | 説明 |
|---|---|---|
total_chars | int | 解析対象テキストの全文字数 |
items[] | array | 自社・競合のターゲット 1 件につき 1 要素 |
items[].name | string | ターゲット名(自社 or 競合) |
items[].kind | string | own / rival |
items[].rival_index | int / null | 競合の場合 rival_brands 内インデックス(0..4)。自社は null |
items[].count | int | 本文中の出現回数 |
items[].first_offset / .last_offset | int / null | 最初/最後の出現文字位置 |
items[].position_ratio | number / null | 0..1。本文先頭からの相対位置 |
items[].mentioned_chars | int | マッチした文字数の合計(出現分の延べ文字量) |
items[].position_band | string / null | 位置帯ラベル(先頭・中盤・末尾 等) |
items[].mention_order | int / null | 言及順位(自社・競合間で何番目に登場したか) |
items[].prominence_score | number | 登場回数 × 文字量 × 配置を合成した複合スコア |
fetch_rank / fetch_ai_overview / fetch_ai_mode / fetch_llm_* が OFF のキーワードでは、対応する派生キー自体が result_json に登場しない(または初期値の false / null)になります。レポート実装時は isset() や has_* フラグを必ずチェックしてください。
4. バックアップと復元
- 定期バックアップ: 手動での調査実行画面で「DB バックアップ」を実行すると、
backup/app_YYYY-MM-DD_HHmmss.dbとして生成されます。cron で日次にcp -pも推奨。 - 取得すべきもの:
data/app.db(およびapp.db-wal/app.db-shm)data/products/product_*.db(および-wal/-shm)data/auth_secret.bin(パスワードリセットトークン署名鍵 ※消すと既存リセットリンクが無効化)- カスタムロゴをアップロードしている場合
img/custom/
- 復元: サーバを停止 → 上記ファイルをまるごと差し戻し → サーバを起動。
result_json は概ね 5〜30 KB 程度(LLM の回答テキストを含むため変動)。100 KW × 1 年 で 200〜700 MB を見込んでください。蓄積が進んだら 調査データ削除 で古い期間を切り詰められます。
5. マイグレーション方針
スキーマ変更は api/bootstrap.php の冒頭で CREATE TABLE IF NOT EXISTS および ALTER TABLE ... ADD COLUMN(llmoAddColumnIfMissing ヘルパ)で適用しています。既存のカラムは原則削除しません。「旧 → 新」へ移行する場合は新カラムを並列で追加し、起動時のマイグレーションで値をコピーする戦略を採っています(例:products.survey_schedule_json は旧 app_settings.survey_schedule_json から一度だけシードされます)。