0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

気分に合ったYouTube動画をお勧めするアプリを構築 (ChatGPT + React + Node.js + Docker desktop)

Posted at

ブラウザでフロントエンドにアクセスして、その時の気分を入力したら、ChatGPTが気分に沿った検索ワードを生成してYouTubeからおすすめの動画を紹介してくれるキュレーションアプリを構築します。

仕組みとして

  • フロントエンドはReact
  • バックエンドはNode.js

を用います。

(A) バックエンド側で入力した気分に沿った検索キーワードの生成をOpenAI APIを通してChatGPTに依頼
(B) キーワードを元にYouTube APIで動画を検索して最後フロントエンドにおすすめ動画3本のリンクを表示
する処理の流れとなっています。

プロジェクト構成

curaTube/
├── backend/            // Node.js + Express バックエンド
│   ├── package.json
│   ├── server.js
│   └── Dockerfile
├── frontend/           // React + Vite フロントエンド
│   ├── package.json
│   ├── vite.config.js
│   ├── src/
│   │   └── App.jsx
│   └── Dockerfile
├── .env                // 環境変数ファイル
└── docker-compose.yml  // Docker構成ファイル
  • PCはWindows 11 Pro / 23H2 / 22631.5472

開発環境セットアップ・プロジェクト構築

1. WSL2 + Ubuntu / Docker Desktop + WSL統合 / ChatGPT(OpenAI)・YouTube APIキー取得

WSL2 + Ubuntuインストール、Docker Desktopインストール + WSL統合設定、OpenAI APIキー設定

下記サイトの構築手順[1]、[2]、[4]と同じです。
https://qiita.com/xuepan07/items/d1550d3a5a67ab6c2d2f

YouTube Data API v3 キー設定

  1. [Google Cloud Console]にアクセス
    https://console.cloud.google.com/
  2. 新しいプロジェクトを作成
  3. YouTube Data API v3 を有効化
  4. 認証情報でAPIキーを作成

以下のサイトを参考にしました。
https://kitaka-web.com/youtube-data-api/

2. プロジェクトセットアップ

(Ubuntu(WSL2)上で作業)

mkdir -p ~/projects/curaTube
cd ~/projects/curaTube

3. バックエンドセットアップ(Node.js + Express)

(Ubuntu(WSL2)上で作業)

mkdir backend && cd backend
npm init -y
npm install express cors dotenv axios

backend/package.json

(Ubuntu(WSL2)上で作業)

nvim package.json
{
  "name": "curatube-backend",
  "version": "1.0.0",
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js"
  },
  "dependencies": {
    "express": "^4.18.2",
    "cors": "^2.8.5",
    "dotenv": "^16.3.1",
    "axios": "^1.5.0"
  },
  "devDependencies": {
    "nodemon": "^3.0.1"
  }
}

backend/server.js

(Ubuntu(WSL2)上で作業)

nvim server.js
const express = require('express');
const cors = require('cors');
const axios = require('axios');
require('dotenv').config();

const app = express();
const PORT = process.env.PORT || 3001;

// ミドルウェア
app.use(cors());
app.use(express.json());

// OpenAI APIで気分に応じた検索クエリを生成
async function generateSearchQuery(mood) {
  try {
    const response = await axios.post(
      'https://api.openai.com/v1/chat/completions',
      {
        model: 'gpt-4o-mini',
        messages: [
          {
            role: 'system',
            content: 'あなたは気分に応じたYouTube検索クエリを生成するアシスタントです。ユーザーの気分を受け取り、その気分に最適なYouTube動画を見つけるための検索キーワードを3つ生成してください。キーワードは日本語で、カンマ区切りで返してください。'
          },
          {
            role: 'user',
            content: `今の気分: ${mood}`
          }
        ],
        max_tokens: 100,
        temperature: 0.7
      },
      {
        headers: {
          'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
          'Content-Type': 'application/json'
        }
      }
    );

    const searchKeywords = response.data.choices[0].message.content.trim();
    return searchKeywords.split(',').map(keyword => keyword.trim());
  } catch (error) {
    console.error('OpenAI API Error:', error.response?.data || error.message);
    // フォールバック:基本的な検索キーワードを返す
    return [`${mood} 音楽`, `${mood} 癒し`, `${mood} BGM`];
  }
}

// YouTube Data API v3で動画を検索
async function searchYouTubeVideos(query, maxResults = 3) {
  try {
    const response = await axios.get('https://www.googleapis.com/youtube/v3/search', {
      params: {
        part: 'snippet',
        q: query,
        type: 'video',
        maxResults: maxResults,
        order: 'relevance',
        key: process.env.YOUTUBE_API_KEY,
        regionCode: 'JP',
        relevanceLanguage: 'ja'
      }
    });

    return response.data.items.map(item => ({
      id: item.id.videoId,
      title: item.snippet.title,
      description: item.snippet.description,
      thumbnail: item.snippet.thumbnails.medium.url,
      url: `https://www.youtube.com/watch?v=${item.id.videoId}`,
      channel: item.snippet.channelTitle,
      publishedAt: item.snippet.publishedAt
    }));
  } catch (error) {
    console.error('YouTube API Error:', error.response?.data || error.message);
    return [];
  }
}

// 推薦エンドポイント
app.post('/api/recommend', async (req, res) => {
  try {
    const { mood } = req.body;
    
    if (!mood) {
      return res.status(400).json({ error: '気分を入力してください' });
    }

    console.log(`気分: ${mood} に基づいて動画を検索中...`);

    // 1. OpenAI APIで検索クエリを生成
    const searchQueries = await generateSearchQuery(mood);
    console.log('生成された検索クエリ:', searchQueries);

    // 2. 各検索クエリでYouTube動画を検索
    const allVideos = [];
    for (const query of searchQueries) {
      const videos = await searchYouTubeVideos(query, 2);
      allVideos.push(...videos);
    }

    // 3. 重複を削除し、最大3本に制限
    const uniqueVideos = allVideos.filter((video, index, self) => 
      index === self.findIndex(v => v.id === video.id)
    ).slice(0, 3);

    res.json({
      mood,
      searchQueries,
      videos: uniqueVideos
    });

  } catch (error) {
    console.error('推薦エラー:', error);
    res.status(500).json({ error: '動画の推薦中にエラーが発生しました' });
  }
});

// ヘルスチェック
app.get('/health', (req, res) => {
  res.json({ status: 'OK', timestamp: new Date().toISOString() });
});

app.listen(PORT, () => {
  console.log(`Backend server running on port ${PORT}`);
});

backend/Dockerfile

(Ubuntu(WSL2)上で作業)

nvim Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3001
CMD ["npm", "start"]

4. フロントエンドセットアップ(React + Vite)

(Ubuntu(WSL2)上で作業)

cd ..
mkdir frontend && cd frontend
npm create vite@latest . -- --template react
npm install vite@latest @vitejs/plugin-react@latest
npm install

frontend/package.json

(Ubuntu(WSL2)上で作業)

nvim package.json
{
  "name": "curatube-frontend",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@types/react": "^18.2.15",
    "@types/react-dom": "^18.2.7",
    "@vitejs/plugin-react": "^4.0.3",
    "eslint": "^8.45.0",
    "eslint-plugin-react": "^7.32.2",
    "eslint-plugin-react-hooks": "^4.6.0",
    "eslint-plugin-react-refresh": "^0.4.3",
    "vite": "^5.0.0"
  }
}

frontend/vite.config.js

(Ubuntu(WSL2)上で作業)

nvim vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  server: {
    host: '0.0.0.0',
    port: 3000,
    proxy: {
      '/api': {
        target: 'http://backend:3001',
        changeOrigin: true,
      },
    },
  },
})

frontend/src/App.jsx

(Ubuntu(WSL2)上で作業)

mkdir src && cd src
nvim App.jsx
import { useState } from 'react';
import './App.css';

function App() {
  const [mood, setMood] = useState('');
  const [videos, setVideos] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');
  const [searchQueries, setSearchQueries] = useState([]);

  const handleSubmit = async (e) => {
    e.preventDefault();
    if (!mood.trim()) return;

    setLoading(true);
    setError('');
    setVideos([]);

    try {
      const response = await fetch('/api/recommend', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ mood: mood.trim() }),
      });

      if (!response.ok) {
        throw new Error('推薦の取得に失敗しました');
      }

      const data = await response.json();
      setVideos(data.videos);
      setSearchQueries(data.searchQueries);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="app">
      <header className="header">
        <h1>curaTube</h1>
        <p>あなたの気分に合わせたYouTube動画をお勧めします</p>
      </header>

      <main className="main">
        <form onSubmit={handleSubmit} className="search-form">
          <div className="input-group">
            <input
              type="text"
              value={mood}
              onChange={(e) => setMood(e.target.value)}
              placeholder="今の気分を入力してください(例:リラックスしたい、元気になりたい、集中したい)"
              className="mood-input"
              disabled={loading}
            />
            <button type="submit" disabled={loading || !mood.trim()} className="search-button">
              {loading ? '検索中...' : '検索'}
            </button>
          </div>
        </form>

        {error && (
          <div className="error">
            <p>ERROR {error}</p>
          </div>
        )}

        {searchQueries.length > 0 && (
          <div className="search-queries">
            <h3>生成された検索キーワード:</h3>
            <div className="queries">
              {searchQueries.map((query, index) => (
                <span key={index} className="query-tag">
                  {query}
                </span>
              ))}
            </div>
          </div>
        )}

        {videos.length > 0 && (
          <div className="videos">
            <h2>おすすめ動画</h2>
            <div className="video-grid">
              {videos.map((video) => (
                <div key={video.id} className="video-card">
                  <div className="video-thumbnail">
                    <img src={video.thumbnail} alt={video.title} />
                  </div>
                  <div className="video-info">
                    <h3>{video.title}</h3>
                    <p className="video-channel">{video.channel}</p>
                    <p className="video-description">
                      {video.description.substring(0, 100)}...
                    </p>
                    <a 
                      href={video.url} 
                      target="_blank" 
                      rel="noopener noreferrer"
                      className="watch-button"
                    >
                      動画を見る
                    </a>
                  </div>
                </div>
              ))}
            </div>
          </div>
        )}
      </main>
    </div>
  );
}

export default App;

frontend/src/App.css

(Ubuntu(WSL2)上で作業)

nvim App.css
.app {
  max-width: 1200px;
  margin: 0 auto;
  padding: 20px;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
}

.header {
  text-align: center;
  margin-bottom: 2rem;
}

.header h1 {
  font-size: 2.5rem;
  color: #ff6b6b;
  margin-bottom: 0.5rem;
}

.header p {
  color: #666;
  font-size: 1.1rem;
}

.search-form {
  margin-bottom: 2rem;
}

.input-group {
  display: flex;
  gap: 1rem;
  max-width: 600px;
  margin: 0 auto;
}

.mood-input {
  flex: 1;
  padding: 12px 16px;
  border: 2px solid #ddd;
  border-radius: 8px;
  font-size: 1rem;
  transition: border-color 0.3s;
}

.mood-input:focus {
  outline: none;
  border-color: #ff6b6b;
}

.search-button {
  padding: 12px 24px;
  background: #ff6b6b;
  color: white;
  border: none;
  border-radius: 8px;
  font-size: 1rem;
  cursor: pointer;
  transition: background 0.3s;
}

.search-button:hover:not(:disabled) {
  background: #ff5252;
}

.search-button:disabled {
  background: #ccc;
  cursor: not-allowed;
}

.error {
  text-align: center;
  margin: 1rem 0;
  color: #f44336;
}

.search-queries {
  margin: 1rem 0;
  text-align: center;
}

.queries {
  display: flex;
  gap: 0.5rem;
  justify-content: center;
  flex-wrap: wrap;
  margin-top: 0.5rem;
}

.query-tag {
  background: #e3f2fd;
  color: #1976d2;
  padding: 4px 12px;
  border-radius: 16px;
  font-size: 0.9rem;
}

.videos {
  margin-top: 2rem;
}

.videos h2 {
  text-align: center;
  margin-bottom: 1.5rem;
  color: #333;
}

.video-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
  gap: 1.5rem;
}

.video-card {
  border: 1px solid #ddd;
  border-radius: 12px;
  overflow: hidden;
  transition: transform 0.3s, box-shadow 0.3s;
}

.video-card:hover {
  transform: translateY(-4px);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}

.video-thumbnail {
  width: 100%;
  height: 200px;
  overflow: hidden;
}

.video-thumbnail img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.video-info {
  padding: 1rem;
}

.video-info h3 {
  margin: 0 0 0.5rem 0;
  font-size: 1.1rem;
  line-height: 1.4;
  color: #333;
}

.video-channel {
  color: #666;
  font-size: 0.9rem;
  margin: 0 0 0.5rem 0;
}

.video-description {
  color: #888;
  font-size: 0.9rem;
  line-height: 1.4;
  margin: 0 0 1rem 0;
}

.watch-button {
  display: inline-block;
  background: #ff6b6b;
  color: white;
  padding: 8px 16px;
  text-decoration: none;
  border-radius: 6px;
  font-size: 0.9rem;
  transition: background 0.3s;
}

.watch-button:hover {
  background: #ff5252;
}

@media (max-width: 768px) {
  .input-group {
    flex-direction: column;
  }
  
  .video-grid {
    grid-template-columns: 1fr;
  }
}

frontend/Dockerfile

(Ubuntu(WSL2)上で作業)

cd ..
nvim Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]

5. 環境変数設定

.env ファイル

プロジェクトルートに作成
(Ubuntu(WSL2)上で作業)

cd ..
nvim .env
# OpenAI API Key
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxx

# YouTube Data API v3 Key
YOUTUBE_API_KEY=AIzaxxxxxxxxxxxxxxxxxxxxxxxxx

# Backend Port
PORT=3001

6. Docker Compose設定

docker-compose.yml

(Ubuntu(WSL2)上で作業)

nvim docker-compose.yml
version: '3.8'
services:
  backend:
    build: ./backend
    ports:
      - "3001:3001"
    environment:
      - OPENAI_API_KEY=${OPENAI_API_KEY}
      - YOUTUBE_API_KEY=${YOUTUBE_API_KEY}
      - PORT=3001
    volumes:
      - ./backend:/app
      - /app/node_modules
    restart: unless-stopped

  frontend:
    build: ./frontend
    ports:
      - "3000:3000"
    environment:
      - VITE_API_URL=http://localhost:3001
    volumes:
      - ./frontend:/app
      - /app/node_modules
    depends_on:
      - backend
    restart: unless-stopped

7. 起動手順

(1) Docker 起動

Docker Desktopのアイコンをダブルクリックしてアプリケーション起動。

(2) Docker Compose でビルド・起動

(Ubuntu(WSL2)上で作業)

# プロジェクトルートで実行
docker-compose build
docker-compose up

8. 動作確認

(1) ブラウザで http://localhost:3000 にアクセス

curatube_01.png

(2) 気分を入力(例:「ワクワクする」「元気になりたい」等)

curatube_02.png

(3) 検索ボタンをクリック
(4) OpenAI APIが検索キーワードを生成し、YouTube APIで動画を検索
(5) 推薦された3本の動画が表示される

curatube_03.png

8. Docker 終了

(Ubuntu(WSL2)上で作業)

docker-compose down
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?