桜泥棒PROJECT
  • 更新情報
  • PROJECT管理
  • 桜泥棒CYCLE
  • 桜泥棒BEER
  • BLOG
  • CONTACT
🤸🏽
桜泥棒PROJECT
自転車趣味×AIの終着点。Notionから最新データを引き出し、理想の1台を組み上げる『デジタル・メカニック』をWebに召喚

自転車趣味×AIの終着点。Notionから最新データを引き出し、理想の1台を組み上げる『デジタル・メカニック』をWebに召喚

担当者
U
Untitled
作成日時
Dec 28, 2025 1:17 PM
変更日時
Dec 30, 2025 10:19 PM
Status
完了
Project
🚲桜泥棒サイクル
Tag
MCP
実施予定期間
January 4, 2026

このタスクの目的

1. 「静的なリスト」から「動的なエージェント」へ

単にパーツの名前と価格が並んでいるだけのNotionページを、ユーザーの要望(予算、用途、好み)に応じて**リアルタイムで考え、提案してくれる「バーチャル店員」**に変貌させます。

2. 互換性と創造性のギャップを埋める

自転車ビルドにおいて最も高いハードルである**「パーツ同士の互換性」**の判断をAIがサポートします。

  • 自分のパーツリスト(Notion)から在庫を確認。
  • Web上の膨大な知識から互換性を照合。
  • 完成車のイメージまで視覚化して提示。 これにより、専門知識がなくても理想の1台をシミュレーションできる環境を作ります。

3. 「維持費0円」でプロ級のシステムを構築

高価なSaaSや法人向けツールを使わず、Cloudflare WorkersやGemini APIといった最新の無料枠を組み合わせることで、個人クリエイターでも持続可能な、高機能なWebサービスの実装を証明します。

公式リリース情報

桜泥棒サイクルラボ 桜泥棒サイクルラボ桜泥棒サイクルラボ 桜泥棒サイクルラボ
のページにて、AIパーツセレクターという入力窓を設置し、Notion管理の最新のパーツリストのDBを読み込んだ上でプロンプトに基づいた回答が返ってくるようにはなりました。

image

ただ、まだ回答の内容の調整がまだ必要なので別タスクでやります。

アクション項目

以下のGeminiの回答通りに進めてみる。

💡

「プランA」は、自由度が高く、運用コストをほぼゼロに抑えられる最もプロフェッショナルな構成です。

この構成を実現するための具体的な手順と、土台となるコードを整理しました。

全体図

  1. Notion API: パーツリスト(データベース)からデータを取得。
  2. Cloudflare Workers (中継役):
    • Notionからデータを取得し、Geminiに送るプロンプトを組み立てる。
    • Gemini APIを呼び出す。
    • APIキー(Notion/Gemini)を隠蔽するセキュリティの役割。
  3. Super.so (フロントエンド): ユーザーが入力する「窓」を表示し、Workersと通信する。

ステップ1:準備物

  • Notion API Key: Notion Developersで作成。対象のデータベースに「コネクト」で権限を追加してください。
  • Gemini API Key: Google AI Studioで取得。
  • Cloudflareアカウント: Workersを作成するために必要。

ステップ2:Cloudflare Workersの作成

Cloudflareのダッシュボードで新しいWorkerを作成し、以下のコードをベースに書き換えます。

※このコードは、フロントからの質問を受け取り、Notionのデータを添えてGeminiに投げる「エージェントの脳」になります。

JavaScript

設定: Cloudflareの管理画面で「Settings > Variables」から NOTION_API_KEY, NOTION_DB_ID, GEMINI_API_KEY を環境変数として登録してください。

ステップ3:Super.so(Notion)への「窓」の設置

Notionのページに「コードブロック」を作成し、以下のHTML/JSを貼り付けます。Super.so側でコードブロックをHTMLとしてレンダリングする設定を有効にしてください。

HTML

このプランのメリット

  • リアルタイム: ユーザーがボタンを押した瞬間にNotionの最新データベースを取得します。在庫や価格の変更が即座に反映されます。
  • セキュリティ: GeminiのAPIキーがサイトのソースコードに露出しないため安全です。
  • コスト: Cloudflare Workersは無料枠(1日10万リクエスト)が非常に大きいため、個人サイトであれば一生無料で使い続けられる可能性が高いです。

次のステップへのアドバイス

  1. まずは Cloudflare Workers のアカウントを作り、ダッシュボードからHello Worldを動かすところから始めてみてください。
  2. データベースのプロパティ名(「名前」や「価格」など)を、実際のNotionの項目名と正確に一致させるのがコツです。

まずはこの構成で、簡単な疎通確認(Notionのデータをコンソールに表示するなど)から進めてみるのはいかがでしょうか?uper.so特有の読み込み遅延を克服。 • APIキーの健全性診断 ◦ 診断用コードを実行し、Googleがそのキーに対して許可している「実在のモデル名」を特定したこと。 • CORS通信の壁の突破 ◦ ブラウザからのOPTIONSリクエスト(事前確認)に対して適切なヘッダーを返すことで「Failed to fetch」を解消。 • プロパティの完全一致 ◦ Notion側の実名(「仕入値」など)とコード内の変数を1文字違わず一致させたこと。

上記のGeminiの回答の方向性から何とかWebサイト上でBotが動くまでの試行錯誤の軌跡を簡単にGeminiにまとめてもらいました。

🚲 桜泥棒サイクル AIパーツセレクター開発ログ

今回のプロジェクトの目的:「Notionの在庫データベースをAI(Gemini)が読み取り、Webサイト上でパーツ構成を提案するシステムの構築」

1. フロントエンド:ボタンとプログラムの紐付け

Webサイト(Super.so)上のHTML/JavaScriptが正しく動作するまでのプロセスです。

  • ✅ 成功:ボタンへのイベントリスナー実装
    • 初期は onclick が反応しませんでしたが、JavaScriptから強制的にボタンを探して命令を書き込む形式(btn.onclick = ...)に変更することで、クリック感と動作の確実性を確保しました。
  • ❌ 失敗:識別子の重複エラー
    • ログ: Uncaught SyntaxError: Identifier 'observer' has already been declared
    • 原因: 過去のテストコードがNotionページ内に残っており、同じ名前の変数を二度定義しようとしたため。
    • 解決: 重複コードの削除と、変数名のバッティングを避ける設計で解消。

2. バックエンド:Notion APIとの連携

サーバー(Cloudflare Workers)がNotionのデータを取得できるまでのプロセスです。

  • ✅ 成功:データベースIDの特定と権限付与
    • 複雑なURLから正しいID(32文字の英数字)を抜き出し、Notion側でインテグレーションを「接続先」に追加することで開通しました。
  • ✅ 成功:プロパティ名(列名)の完全一致
    • Notion側の列名が「価格」ではなく「仕入値」であることを特定。コード内の指定を正確に書き換えることで、データ取得に成功しました。
  • ❌ 失敗:Notion 400エラー / 404エラー
    • ログ: Notion API Error: 400 / Database with ID ... does not contain any data sources
    • 原因: インテグレーションがデータベースに「招待(接続)」されていなかったため。
    • 解決: Notionの右上メニューからボットを明示的に追加して解消。

3. AI連携:Gemini APIの呼び出し

在庫データをAIに渡し、回答を生成させるまでのプロセスです。

  • ✅ 成功:稼働モデルの特定と診断
    • 診断用コードを用いて、そのAPIキーで現在使用可能なモデル一覧を直接取得。gemini-flash-latest という正確な名称を特定しました。
  • ⚠️ 失敗:モデル不在エラー (404)
    • ログ: models/gemini-1.5-flash is not found for API version v1beta
    • 原因: 指定したモデル名がAPIの現在のリストに存在しなかったため。
    • 解決: 診断結果に基づき、実在する gemini-flash-latest に変更。
  • ⚠️ 失敗:クォータ(制限)エラー
    • ログ: You exceeded your current quota... limit: 0, model: gemini-2.0-flash
    • 原因: 最新モデル(2.0等)は無料枠の制限が非常に厳しく、リクエストが拒否されたため。
    • 解決: 無料枠の広い安定版モデル(1.5系 / latest)へ切り替えることで解消。

🛠 最終的な成功システム構成

項目
設定内容
通信プロトコル
HTTPS POST (CORS対応)
Notion ID
2d8d1e6720f8808fa059c767c0d0385b
参照プロパティ
名前、仕入値、重量、パーツグループ
AIモデル
gemini-flash-latest
APIバージョン
v1beta

決定事項

付録

アイデアメモ

議事内容

その他

桜泥棒PROJECT
FacebookXInstagramFacebookXInstagram
export default {
  async fetch(request, env) {
    // CORS設定(Super.soからのアクセスを許可)
    const headers = {
      "Access-Control-Allow-Origin": "*", // 本番は自分のドメインに絞ることを推奨
      "Access-Control-Allow-Methods": "POST, OPTIONS",
      "Access-Control-Allow-Headers": "Content-Type",
    };

    if (request.method === "OPTIONS") return new Response(null, { headers });

    const { prompt } = await request.json();

    // 1. Notionからパーツリストを取得
    const notionResponse = await fetch(`https://api.notion.com/v1/databases/${env.NOTION_DB_ID}/query`, {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${env.NOTION_API_KEY}`,
        "Notion-Version": "2022-06-28",
        "Content-Type": "application/json"
      }
    });
    const notionData = await notionResponse.json();
    
    // AIが読みやすいようにデータを整形(ここが精度の肝)
    const partsList = notionData.results.map(page => {
      const p = page.properties;
      return `- ${p.名前.title[0]?.plain_text}: ¥${p.価格.number}, 重さ:${p.重量.number}g`;
    }).join("\n");

    // 2. Gemini APIを呼び出す
    const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${env.GEMINI_API_KEY}`;
    
    const aiResponse = await fetch(geminiUrl, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        contents: [{
          parts: [{
            text: `あなたは自転車のプロショップ店員です。以下の最新パーツリストのみを使用して、ユーザーの要望に最適な完成車を提案してください。\n\n【パーツリスト】\n${partsList}\n\n【ユーザーの要望】\n${prompt}`
          }]
        }]
      })
    });

    const aiData = await aiResponse.json();
    const answer = aiData.candidates[0].content.parts[0].text;

    return new Response(JSON.stringify({ answer }), { headers });
  }
};
<div id="ai-chat-container" style="border: 1px solid #ccc; padding: 20px; border-radius: 10px; background: #f9f9f9;">
  <p><strong>🚲 AIパーツセレクター</strong></p>
  <textarea id="user-prompt" placeholder="例:予算30万円でヒルクライム向けの構成を教えて" style="width: 100%; height: 80px; margin-bottom: 10px; border-radius: 5px; border: 1px solid #ddd;"></textarea>
  <button onclick="askAI()" id="send-btn" style="background: #000; color: #fff; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer;">提案を受ける</button>
  <div id="ai-response" style="margin-top: 20px; white-space: pre-wrap; line-height: 1.6; font-size: 0.95em;"></div>
</div>

<script>
async function askAI() {
  const prompt = document.getElementById('user-prompt').value;
  const responseDiv = document.getElementById('ai-response');
  const btn = document.getElementById('send-btn');
  
  if(!prompt) return;
  
  btn.innerText = "考えています...";
  btn.disabled = true;
  responseDiv.innerText = "パーツリストを照合中...";

  try {
    const res = await fetch('https://あなたのWorkerのURL.workers.dev/', {
      method: 'POST',
      body: JSON.stringify({ prompt })
    });
    const data = await res.json();
    responseDiv.innerText = data.answer;
  } catch (e) {
    responseDiv.innerText = "エラーが発生しました。時間を置いて試してください。";
  } finally {
    btn.innerText = "提案を受ける";
    btn.disabled = false;
  }
}
</script>