ブラウザでフロントエンドにアクセスして、その時の気分を入力したら、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 キー設定
- [Google Cloud Console]にアクセス
https://console.cloud.google.com/ - 新しいプロジェクトを作成
- YouTube Data API v3 を有効化
- 認証情報で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
にアクセス
(2) 気分を入力(例:「ワクワクする」「元気になりたい」等)
(3) 検索ボタンをクリック
(4) OpenAI APIが検索キーワードを生成し、YouTube APIで動画を検索
(5) 推薦された3本の動画が表示される
8. Docker 終了
(Ubuntu(WSL2)上で作業)
docker-compose down