feat: 記事ごとのポッドキャスト生成と新規記事検出システム、モダンUIの実装

- 新規記事検出システム: 記事の重複チェックと新規記事のみ処理
- 記事単位ポッドキャスト: フィード統合から記事個別エピソードに変更
- 6時間間隔バッチ処理: 自動定期実行スケジュールの改善
- 完全UIリニューアル: ダッシュボード・フィード管理・エピソード管理の3画面構成
- アクセシビリティ強化: ARIA属性、キーボードナビ、高コントラスト対応
- データベース刷新: feeds/articles/episodes階層構造への移行
- 中央集権設定管理: services/config.ts による設定統一
- エラーハンドリング改善: 全モジュールでの堅牢なエラー処理
- TypeScript型安全性向上: null安全性とインターフェース改善

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-06-07 08:47:10 +09:00
parent c8cadfed62
commit 986743949f
14 changed files with 2395 additions and 568 deletions

66
CLAUDE.md Normal file
View File

@ -0,0 +1,66 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Common Development Commands
- **Install dependencies**: `bun install`
- **Build frontend**: `bun run build:frontend`
- **Start server**: `bun run start` or `bun run server.ts`
- **Frontend development**: `bun run dev:frontend`
- **Manual batch process**: `bun run scripts/fetch_and_generate.ts`
- **Type checking**: `bunx tsc --noEmit`
## Architecture Overview
This is a RSS-to-podcast automation system built with Bun runtime, Hono web framework, React frontend, and SQLite database.
### Core Components
- **server.ts**: Main Hono web server serving both API endpoints and static frontend files
- **scripts/fetch_and_generate.ts**: Batch processing script that fetches RSS feeds, generates summaries, and creates audio files
- **services/**: Core business logic modules:
- `config.ts`: Centralized configuration management with validation
- `database.ts`: SQLite operations for episodes and feed tracking
- `llm.ts`: OpenAI integration for content generation and feed classification
- `tts.ts`: Text-to-speech via VOICEVOX API
- `podcast.ts`: RSS feed generation
- **frontend/**: Vite React SPA for browsing feeds and episodes
### Data Flow
1. RSS feeds listed in `feed_urls.txt` are processed daily
2. Latest articles are classified by category and summarized via OpenAI
3. Summaries are converted to audio using VOICEVOX
4. Episodes are stored in SQLite (`data/podcast.db`) with audio files in `public/podcast_audio/`
5. RSS feed is generated at `/podcast.xml` for podcast clients
6. Web UI serves the React frontend for browsing content
### Key Directories
- `data/`: SQLite database storage
- `public/podcast_audio/`: Generated MP3 files
- `frontend/dist/`: Built React application (served statically)
### Environment Configuration
The application uses `services/config.ts` for centralized configuration management. Required `.env` variables include:
- `OPENAI_API_KEY`: OpenAI API key (required)
- `VOICEVOX_HOST`: VOICEVOX server URL (default: http://localhost:50021)
- `VOICEVOX_STYLE_ID`: Voice style ID (default: 0)
- Podcast metadata and other optional settings
Configuration is validated on startup. See README.md for complete list.
### Deployment
The application runs as a single server process on port 3000, automatically executing batch processing on startup and daily at midnight.
### Recent Improvements
- **ES Module Compatibility**: Fixed all ES module issues and removed deprecated `__dirname` usage
- **Centralized Configuration**: Added `services/config.ts` for type-safe configuration management
- **Enhanced Error Handling**: Improved error handling and validation throughout the codebase
- **Type Safety**: Fixed TypeScript errors and added proper null checks
- **Code Structure**: Simplified RSS generation logic and removed code duplication
- **Path Resolution**: Standardized path handling using the config module

View File

@ -1,12 +1,118 @@
/* Global styles for the Next.js app */ @tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom styles for better accessibility and design */
.line-clamp-1 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}
.line-clamp-2 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
/* Custom slider styles */
.slider::-webkit-slider-thumb {
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #8b5cf6;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
}
.slider::-webkit-slider-thumb:hover {
background: #7c3aed;
transform: scale(1.1);
}
.slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #8b5cf6;
cursor: pointer;
border: none;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
/* Focus states for accessibility */
.focus-ring:focus {
outline: 2px solid #8b5cf6;
outline-offset: 2px;
}
/* Animation for loading states */
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: .5;
}
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* Improved scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* High contrast mode support */
@media (prefers-contrast: high) {
.border-gray-200 {
border-color: #000;
}
.text-gray-600 {
color: #000;
}
.bg-gray-50 {
background-color: #fff;
}
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Global font and base styles */
html, html,
body { body {
padding: 0; padding: 0;
margin: 0; margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans',
sans-serif; sans-serif;
background-color: #f9fafb; /* light background */
color: #111827; /* dark text */
} }
*, *,
@ -14,10 +120,3 @@ body {
*::after { *::after {
box-sizing: border-box; box-sizing: border-box;
} }
/* Center content by default */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}

View File

@ -1,5 +1,7 @@
import FeedList from "../components/FeedList"; import { useState } from "react";
import FeedManager from "../components/FeedManager";
import EpisodePlayer from "../components/EpisodePlayer"; import EpisodePlayer from "../components/EpisodePlayer";
import Dashboard from "../components/Dashboard";
export const metadata = { export const metadata = {
title: "Voice RSS Summary", title: "Voice RSS Summary",
@ -7,20 +9,99 @@ export const metadata = {
}; };
export default function Home() { export default function Home() {
const [activeTab, setActiveTab] = useState<'dashboard' | 'episodes' | 'feeds'>('dashboard');
return ( return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-purple-50">
{/* Header */}
<header className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-6">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M9 12a3 3 0 106 0 3 3 0 00-6 0z" />
</svg>
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">Voice RSS Summary</h1>
<p className="text-sm text-gray-600">AI音声ポッドキャスト自動生成システム</p>
</div>
</div>
<div className="hidden md:flex items-center space-x-4">
<div className="flex items-center space-x-2 text-sm text-gray-500">
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
<span></span>
</div>
</div>
</div>
</div>
</header>
{/* Navigation */}
<nav className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex space-x-8">
{[
{ id: 'dashboard', label: 'ダッシュボード', icon: '📊' },
{ id: 'episodes', label: 'エピソード', icon: '🎧' },
{ id: 'feeds', label: 'フィード管理', icon: '📡' }
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`flex items-center space-x-2 py-4 px-1 border-b-2 font-medium text-sm transition-colors duration-200 ${
activeTab === tab.id
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
aria-current={activeTab === tab.id ? 'page' : undefined}
>
<span role="img" aria-hidden="true">{tab.icon}</span>
<span>{tab.label}</span>
</button>
))}
</div>
</div>
</nav>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="space-y-8"> <div className="space-y-8">
<section> {activeTab === 'dashboard' && <Dashboard />}
<h1 className="text-2xl font-bold mb-4">Voice RSS Summary</h1> {activeTab === 'episodes' && (
<p className="mb-6">RSSフィードから自動生成された音声ポッドキャストを再生</p> <div className="bg-white rounded-xl shadow-sm p-6">
</section> <div className="flex items-center space-x-3 mb-6">
<section> <div className="w-8 h-8 bg-purple-100 rounded-lg flex items-center justify-center">
<h2 className="text-xl font-semibold mb-4"></h2> <span role="img" aria-hidden="true" className="text-lg">🎧</span>
<FeedList /> </div>
</section> <h2 className="text-xl font-semibold text-gray-900"></h2>
<section> </div>
<h2 className="text-xl font-semibold mb-4"></h2>
<EpisodePlayer /> <EpisodePlayer />
</section> </div>
)}
{activeTab === 'feeds' && (
<div className="bg-white rounded-xl shadow-sm p-6">
<div className="flex items-center space-x-3 mb-6">
<div className="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
<span role="img" aria-hidden="true" className="text-lg">📡</span>
</div>
<h2 className="text-xl font-semibold text-gray-900"></h2>
</div>
<FeedManager />
</div>
)}
</div>
</main>
{/* Footer */}
<footer className="bg-gray-50 border-t mt-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="text-center text-gray-500 text-sm">
<p>© 2025 Voice RSS Summary. AI技術により最新のニュースを音声でお届けします</p>
</div>
</div>
</footer>
</div> </div>
); );
} }

View File

@ -0,0 +1,255 @@
import { useEffect, useState } from "react";
interface Stats {
totalFeeds: number;
activeFeeds: number;
totalEpisodes: number;
lastUpdated: string;
}
interface RecentEpisode {
id: string;
title: string;
createdAt: string;
article: {
title: string;
link: string;
};
feed: {
title: string;
};
}
export default function Dashboard() {
const [stats, setStats] = useState<Stats | null>(null);
const [recentEpisodes, setRecentEpisodes] = useState<RecentEpisode[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchDashboardData();
}, []);
const fetchDashboardData = async () => {
try {
// Fetch stats and recent episodes in parallel
const [statsResponse, episodesResponse] = await Promise.all([
fetch("/api/stats"),
fetch("/api/episodes")
]);
if (!statsResponse.ok || !episodesResponse.ok) {
throw new Error("Failed to fetch dashboard data");
}
const statsData = await statsResponse.json();
const episodesData = await episodesResponse.json();
setStats(statsData);
setRecentEpisodes(episodesData.slice(0, 5)); // Show latest 5 episodes
setLoading(false);
} catch (err) {
setError(err instanceof Error ? err.message : "エラーが発生しました");
setLoading(false);
}
};
const triggerBatchProcess = async () => {
try {
const response = await fetch("/api/batch/trigger", { method: "POST" });
if (response.ok) {
alert("バッチ処理を開始しました。新しいエピソードの生成には時間がかかる場合があります。");
} else {
alert("バッチ処理の開始に失敗しました。");
}
} catch (error) {
alert("エラーが発生しました。");
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="flex items-center space-x-3">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
<span className="text-gray-600">...</span>
</div>
</div>
);
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center">
<div className="text-red-400">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800"></h3>
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
);
}
return (
<div className="space-y-8">
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
<span role="img" aria-hidden="true" className="text-lg">📡</span>
</div>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600"></p>
<p className="text-2xl font-semibold text-gray-900">{stats?.totalFeeds || 0}</p>
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-green-100 rounded-lg flex items-center justify-center">
<span role="img" aria-hidden="true" className="text-lg"></span>
</div>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600"></p>
<p className="text-2xl font-semibold text-gray-900">{stats?.activeFeeds || 0}</p>
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-purple-100 rounded-lg flex items-center justify-center">
<span role="img" aria-hidden="true" className="text-lg">🎧</span>
</div>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600"></p>
<p className="text-2xl font-semibold text-gray-900">{stats?.totalEpisodes || 0}</p>
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-yellow-100 rounded-lg flex items-center justify-center">
<span role="img" aria-hidden="true" className="text-lg">🕒</span>
</div>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600"></p>
<p className="text-sm font-semibold text-gray-900">
{stats?.lastUpdated ? new Date(stats.lastUpdated).toLocaleDateString('ja-JP') : '未取得'}
</p>
</div>
</div>
</div>
</div>
{/* Action Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-gradient-to-r from-blue-500 to-purple-600 rounded-xl shadow-sm p-6 text-white">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold"></h3>
<p className="text-blue-100 text-sm mt-1">
</p>
</div>
<button
onClick={triggerBatchProcess}
className="bg-white bg-opacity-20 hover:bg-opacity-30 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-white focus:ring-opacity-50"
aria-label="バッチ処理を手動実行"
>
</button>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900"></h3>
<div className="flex items-center space-x-2 mt-2">
<div className="w-3 h-3 bg-green-400 rounded-full animate-pulse"></div>
<span className="text-sm text-gray-600"> (6)</span>
</div>
</div>
<div className="text-green-500">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
</div>
</div>
{/* Recent Episodes */}
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900"></h3>
<span className="text-sm text-gray-500">{recentEpisodes.length} </span>
</div>
{recentEpisodes.length === 0 ? (
<div className="text-center py-8">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<span role="img" aria-hidden="true" className="text-2xl">🎧</span>
</div>
<p className="text-gray-500"></p>
<p className="text-sm text-gray-400 mt-1"></p>
</div>
) : (
<div className="space-y-4">
{recentEpisodes.map((episode) => (
<div
key={episode.id}
className="flex items-start space-x-4 p-4 rounded-lg border border-gray-100 hover:bg-gray-50 transition-colors duration-200"
>
<div className="w-10 h-10 bg-gradient-to-r from-blue-400 to-purple-500 rounded-lg flex items-center justify-center text-white font-semibold text-sm">
<span role="img" aria-hidden="true">🎵</span>
</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium text-gray-900 truncate">
{episode.title}
</h4>
<p className="text-sm text-gray-500 mt-1">
{episode.feed?.title} {new Date(episode.createdAt).toLocaleDateString('ja-JP')}
</p>
{episode.article && (
<a
href={episode.article.link}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:text-blue-800 mt-1 inline-block"
>
</a>
)}
</div>
<div className="flex-shrink-0">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@ -1,24 +1,62 @@
import { useEffect, useState } from "react"; import { useEffect, useState, useRef } from "react";
interface Episode { interface Episode {
id: string; id: string;
title: string; title: string;
pubDate: string; description?: string;
audioPath: string; audioPath: string;
sourceLink: string; duration?: number;
fileSize?: number;
createdAt: string;
article: {
id: string;
title: string;
link: string;
description?: string;
pubDate: string;
};
feed: {
id: string;
title?: string;
url: string;
};
} }
export default function EpisodePlayer() { export default function EpisodePlayer() {
const [episodes, setEpisodes] = useState<Episode[]>([]); const [episodes, setEpisodes] = useState<Episode[]>([]);
const [selectedEpisode, setSelectedEpisode] = useState<Episode | null>(null); const [selectedEpisode, setSelectedEpisode] = useState<Episode | null>(null);
const [audioUrl, setAudioUrl] = useState<string | null>(null); const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const audioRef = useRef<HTMLAudioElement>(null);
useEffect(() => { useEffect(() => {
fetchEpisodes(); fetchEpisodes();
}, []); }, []);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const updateTime = () => setCurrentTime(audio.currentTime);
const updateDuration = () => setDuration(audio.duration);
const handleEnded = () => setIsPlaying(false);
audio.addEventListener('timeupdate', updateTime);
audio.addEventListener('loadedmetadata', updateDuration);
audio.addEventListener('ended', handleEnded);
return () => {
audio.removeEventListener('timeupdate', updateTime);
audio.removeEventListener('loadedmetadata', updateDuration);
audio.removeEventListener('ended', handleEnded);
};
}, [selectedEpisode]);
const fetchEpisodes = async () => { const fetchEpisodes = async () => {
try { try {
const response = await fetch("/api/episodes"); const response = await fetch("/api/episodes");
@ -35,45 +73,267 @@ export default function EpisodePlayer() {
}; };
const handlePlay = (episode: Episode) => { const handlePlay = (episode: Episode) => {
if (selectedEpisode?.id === episode.id) {
// Toggle play/pause for the same episode
if (isPlaying) {
audioRef.current?.pause();
setIsPlaying(false);
} else {
audioRef.current?.play();
setIsPlaying(true);
}
} else {
// Play new episode
setSelectedEpisode(episode); setSelectedEpisode(episode);
setAudioUrl(`/podcast_audio/${episode.audioPath}`); setIsPlaying(true);
setCurrentTime(0);
}
}; };
if (loading) return <div>...</div>; const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
if (error) return <div className="text-red-500">: {error}</div>; const audio = audioRef.current;
if (audio) {
const newTime = parseFloat(e.target.value);
audio.currentTime = newTime;
setCurrentTime(newTime);
}
};
const formatTime = (time: number) => {
if (isNaN(time)) return "0:00";
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
const formatFileSize = (bytes?: number) => {
if (!bytes) return "不明";
const mb = bytes / (1024 * 1024);
return `${mb.toFixed(1)} MB`;
};
const filteredEpisodes = episodes.filter(episode =>
episode.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
episode.article.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
episode.feed.title?.toLowerCase().includes(searchTerm.toLowerCase())
);
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="flex items-center space-x-3">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500"></div>
<span className="text-gray-600">...</span>
</div>
</div>
);
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center">
<div className="text-red-400">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800"></h3>
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
);
}
return ( return (
<div className="space-y-4"> <div className="space-y-6">
<h3 className="text-lg font-medium"></h3> {/* Search */}
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
placeholder="エピソードを検索..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
{/* Audio Player */}
{selectedEpisode && (
<div className="bg-gradient-to-r from-purple-50 to-blue-50 rounded-xl p-6 border border-purple-100">
<div className="flex items-center space-x-4 mb-4">
<div className="w-12 h-12 bg-gradient-to-r from-purple-500 to-blue-600 rounded-full flex items-center justify-center text-white">
<span role="img" aria-hidden="true" className="text-xl">🎵</span>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-gray-900 truncate">{selectedEpisode.title}</h3>
<p className="text-sm text-gray-600">{selectedEpisode.feed.title}</p>
</div>
<button
onClick={() => handlePlay(selectedEpisode)}
className="w-12 h-12 bg-white rounded-full shadow-md flex items-center justify-center hover:shadow-lg transition-shadow duration-200"
aria-label={isPlaying ? "一時停止" : "再生"}
>
{isPlaying ? (
<svg className="w-6 h-6 text-gray-700" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-6 h-6 text-gray-700 ml-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
</svg>
)}
</button>
</div>
{/* Progress Bar */}
<div className="space-y-2"> <div className="space-y-2">
{episodes.map((episode) => ( <input
type="range"
min="0"
max={duration || 0}
value={currentTime}
onChange={handleSeek}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer slider"
aria-label="再生位置"
/>
<div className="flex justify-between text-xs text-gray-500">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
{/* Episode Info */}
<div className="mt-4 space-y-2 text-sm">
{selectedEpisode.description && (
<p className="text-gray-700">{selectedEpisode.description}</p>
)}
<div className="flex items-center space-x-4 text-gray-500">
<span>🗓 {new Date(selectedEpisode.createdAt).toLocaleDateString('ja-JP')}</span>
<span>💾 {formatFileSize(selectedEpisode.fileSize)}</span>
{selectedEpisode.article.link && (
<a
href={selectedEpisode.article.link}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800"
>
📄
</a>
)}
</div>
</div>
<audio
ref={audioRef}
src={`/podcast_audio/${selectedEpisode.audioPath}`}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
className="hidden"
/>
</div>
)}
{/* Episodes List */}
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900"></h3>
<span className="text-sm text-gray-500">{filteredEpisodes.length} </span>
</div>
{filteredEpisodes.length === 0 ? (
<div className="text-center py-12 bg-gray-50 rounded-xl">
<div className="w-16 h-16 bg-gray-200 rounded-full flex items-center justify-center mx-auto mb-4">
<span role="img" aria-hidden="true" className="text-2xl">🎧</span>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
{searchTerm ? "検索結果がありません" : "エピソードがありません"}
</h3>
<p className="text-gray-500">
{searchTerm ? "別のキーワードで検索してみてください" : "フィードを追加してバッチ処理を実行してください"}
</p>
</div>
) : (
<div className="grid gap-4">
{filteredEpisodes.map((episode) => (
<div <div
key={episode.id} key={episode.id}
className="flex justify-between items-center p-2 hover:bg-gray-50 rounded" className={`border rounded-xl p-4 transition-all duration-200 cursor-pointer ${
> selectedEpisode?.id === episode.id
<span>{episode.title}</span> ? 'border-purple-300 bg-purple-50 shadow-md'
<button : 'border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm'
}`}
onClick={() => handlePlay(episode)} onClick={() => handlePlay(episode)}
className="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600" role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handlePlay(episode);
}
}}
aria-label={`エピソード: ${episode.title}`}
> >
<div className="flex items-start space-x-4">
</button> <div className="flex-shrink-0">
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${
selectedEpisode?.id === episode.id
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-600'
}`}>
{selectedEpisode?.id === episode.id && isPlaying ? (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
</svg>
)}
</div>
</div>
<div className="flex-1 min-w-0">
<h4 className="text-base font-medium text-gray-900 mb-1 line-clamp-2">
{episode.title}
</h4>
<p className="text-sm text-gray-600 mb-2 line-clamp-1">
{episode.feed.title}
</p>
{episode.description && (
<p className="text-sm text-gray-500 mb-2 line-clamp-2">
{episode.description}
</p>
)}
<div className="flex items-center space-x-4 text-xs text-gray-500">
<span>📅 {new Date(episode.createdAt).toLocaleDateString('ja-JP')}</span>
<span>💾 {formatFileSize(episode.fileSize)}</span>
{episode.article.link && (
<a
href={episode.article.link}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800"
onClick={(e) => e.stopPropagation()}
>
📄
</a>
)}
</div>
</div>
</div>
</div> </div>
))} ))}
</div> </div>
{selectedEpisode && (
<div className="mt-6">
<h4 className="text-md font-semibold mb-2">
: {selectedEpisode.title}
</h4>
{audioUrl ? (
<audio src={audioUrl} controls className="w-full" />
) : (
<div>...</div>
)} )}
</div> </div>
)}
</div> </div>
); );
} }

View File

@ -0,0 +1,243 @@
import { useEffect, useState } from "react";
interface Feed {
id: string;
url: string;
title?: string;
description?: string;
lastUpdated?: string;
createdAt: string;
active: boolean;
}
export default function FeedManager() {
const [feeds, setFeeds] = useState<Feed[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [newFeedUrl, setNewFeedUrl] = useState("");
const [addingFeed, setAddingFeed] = useState(false);
useEffect(() => {
fetchFeeds();
}, []);
const fetchFeeds = async () => {
try {
setLoading(true);
const response = await fetch("/api/feeds");
if (!response.ok) {
throw new Error("フィードの取得に失敗しました");
}
const data = await response.json();
setFeeds(data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "エラーが発生しました");
} finally {
setLoading(false);
}
};
const addFeed = async (e: React.FormEvent) => {
e.preventDefault();
if (!newFeedUrl.trim()) {
alert("フィードURLを入力してください");
return;
}
if (!newFeedUrl.startsWith('http')) {
alert("有効なURLを入力してください");
return;
}
try {
setAddingFeed(true);
const response = await fetch("/api/feeds", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ feedUrl: newFeedUrl }),
});
const result = await response.json();
if (response.ok) {
if (result.result === "EXISTS") {
alert("このフィードは既に登録されています");
} else {
alert("フィードが正常に追加されました");
setNewFeedUrl("");
fetchFeeds(); // Refresh the list
}
} else {
alert(result.error || "フィードの追加に失敗しました");
}
} catch (err) {
alert("エラーが発生しました");
} finally {
setAddingFeed(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="flex items-center space-x-3">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
<span className="text-gray-600">...</span>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Add New Feed Form */}
<div className="bg-gradient-to-r from-blue-50 to-purple-50 rounded-xl p-6 border border-blue-100">
<h3 className="text-lg font-semibold text-gray-900 mb-4"></h3>
<form onSubmit={addFeed} className="space-y-4">
<div>
<label htmlFor="feedUrl" className="block text-sm font-medium text-gray-700 mb-2">
RSS URL
</label>
<div className="flex space-x-3">
<input
type="url"
id="feedUrl"
value={newFeedUrl}
onChange={(e) => setNewFeedUrl(e.target.value)}
placeholder="https://example.com/feed.xml"
className="flex-1 border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
disabled={addingFeed}
aria-describedby="feedUrl-help"
/>
<button
type="submit"
disabled={addingFeed || !newFeedUrl.trim()}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
{addingFeed ? (
<div className="flex items-center space-x-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<span>...</span>
</div>
) : (
"追加"
)}
</button>
</div>
<p id="feedUrl-help" className="text-xs text-gray-500 mt-2">
RSS Atom URL
</p>
</div>
</form>
</div>
{/* Error Display */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center">
<div className="text-red-400">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800"></h3>
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
)}
{/* Feeds List */}
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900"></h3>
<span className="text-sm text-gray-500">{feeds.length} </span>
</div>
{feeds.length === 0 ? (
<div className="text-center py-12 bg-gray-50 rounded-xl">
<div className="w-16 h-16 bg-gray-200 rounded-full flex items-center justify-center mx-auto mb-4">
<span role="img" aria-hidden="true" className="text-2xl">📡</span>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2"></h3>
<p className="text-gray-500"> RSS </p>
</div>
) : (
<div className="grid gap-4">
{feeds.map((feed) => (
<div
key={feed.id}
className="bg-white border border-gray-200 rounded-xl p-6 hover:shadow-md transition-shadow duration-200"
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-3 mb-2">
<div className={`w-3 h-3 rounded-full ${feed.active ? 'bg-green-400' : 'bg-gray-400'}`}></div>
<h4 className="text-lg font-medium text-gray-900 truncate">
{feed.title || 'タイトル未取得'}
</h4>
</div>
{feed.description && (
<p className="text-sm text-gray-600 mb-3 line-clamp-2">
{feed.description}
</p>
)}
<div className="space-y-2">
<div className="flex items-center space-x-2">
<span className="text-xs font-medium text-gray-500">URL:</span>
<a
href={feed.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:text-blue-800 truncate max-w-xs"
title={feed.url}
>
{feed.url}
</a>
</div>
<div className="flex items-center space-x-4 text-xs text-gray-500">
<span>: {new Date(feed.createdAt).toLocaleDateString('ja-JP')}</span>
{feed.lastUpdated && (
<span>: {new Date(feed.lastUpdated).toLocaleDateString('ja-JP')}</span>
)}
</div>
</div>
</div>
<div className="flex items-center space-x-2 ml-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
feed.active
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}>
{feed.active ? 'アクティブ' : '無効'}
</span>
{/* Future: Add edit/delete buttons here */}
<button
className="text-gray-400 hover:text-gray-600 p-1"
title="設定"
aria-label={`${feed.title || feed.url}の設定`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
</svg>
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@ -1,4 +1,40 @@
-- schema.sql -- schema.sql
CREATE TABLE IF NOT EXISTS feeds (
id TEXT PRIMARY KEY,
url TEXT NOT NULL UNIQUE,
title TEXT,
description TEXT,
last_updated TEXT,
created_at TEXT NOT NULL,
active BOOLEAN DEFAULT 1
);
CREATE TABLE IF NOT EXISTS articles (
id TEXT PRIMARY KEY,
feed_id TEXT NOT NULL,
title TEXT NOT NULL,
link TEXT NOT NULL UNIQUE,
description TEXT,
content TEXT,
pub_date TEXT NOT NULL,
discovered_at TEXT NOT NULL,
processed BOOLEAN DEFAULT 0,
FOREIGN KEY(feed_id) REFERENCES feeds(id)
);
CREATE TABLE IF NOT EXISTS episodes (
id TEXT PRIMARY KEY,
article_id TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
audio_path TEXT NOT NULL,
duration INTEGER,
file_size INTEGER,
created_at TEXT NOT NULL,
FOREIGN KEY(article_id) REFERENCES articles(id)
);
-- Migration: Keep existing data structure for backward compatibility
CREATE TABLE IF NOT EXISTS processed_feed_items ( CREATE TABLE IF NOT EXISTS processed_feed_items (
feed_url TEXT NOT NULL, feed_url TEXT NOT NULL,
item_id TEXT NOT NULL, item_id TEXT NOT NULL,
@ -6,10 +42,9 @@ CREATE TABLE IF NOT EXISTS processed_feed_items (
PRIMARY KEY(feed_url, item_id) PRIMARY KEY(feed_url, item_id)
); );
CREATE TABLE IF NOT EXISTS episodes ( -- Create indexes for better performance
id TEXT PRIMARY KEY, CREATE INDEX IF NOT EXISTS idx_articles_feed_id ON articles(feed_id);
title TEXT NOT NULL, CREATE INDEX IF NOT EXISTS idx_articles_pub_date ON articles(pub_date);
pubDate TEXT NOT NULL, CREATE INDEX IF NOT EXISTS idx_articles_processed ON articles(processed);
audioPath TEXT NOT NULL, CREATE INDEX IF NOT EXISTS idx_episodes_article_id ON episodes(article_id);
sourceLink TEXT NOT NULL CREATE INDEX IF NOT EXISTS idx_feeds_active ON feeds(active);
);

View File

@ -2,128 +2,366 @@ import Parser from "rss-parser";
import { import {
openAI_ClassifyFeed, openAI_ClassifyFeed,
openAI_GeneratePodcastContent, openAI_GeneratePodcastContent,
} from "../services/llm"; } from "../services/llm.js";
import { generateTTS } from "../services/tts"; import { generateTTS } from "../services/tts.js";
import { saveEpisode, markAsProcessed } from "../services/database"; import {
import { updatePodcastRSS } from "../services/podcast"; saveFeed,
getFeedByUrl,
saveArticle,
getUnprocessedArticles,
markArticleAsProcessed,
saveEpisode
} from "../services/database.js";
import { updatePodcastRSS } from "../services/podcast.js";
import { config } from "../services/config.js";
import crypto from "crypto"; import crypto from "crypto";
import fs from "fs/promises";
interface FeedItem { interface FeedItem {
id: string; id?: string;
title: string; title?: string;
link: string; link?: string;
pubDate: string; pubDate?: string;
contentSnippet?: string; contentSnippet?: string;
content?: string;
description?: string;
} }
import fs from "fs/promises"; /**
import path from "path"; * Main batch processing function
import { fileURLToPath } from "url"; * Processes all feeds and generates podcasts for new articles
*/
const __filename = fileURLToPath(import.meta.url); export async function batchProcess(): Promise<void> {
const __dirname = path.dirname(__filename);
export async function batchProcess() {
const feedUrlsFile = import.meta.env["FEED_URLS_FILE"] ?? "feed_urls.txt";
const feedUrlsPath = path.resolve(__dirname, "..", feedUrlsFile);
let feedUrls: string[];
try { try {
const data = await fs.readFile(feedUrlsPath, "utf-8"); console.log("🚀 Starting enhanced batch process...");
feedUrls = data
.split("\n") // Load feed URLs from file
.map((url) => url.trim()) const feedUrls = await loadFeedUrls();
.filter((url) => url.length > 0); if (feedUrls.length === 0) {
} catch (err) { console.log(" No feed URLs found.");
console.warn(`フィードURLファイルの読み込みに失敗: ${feedUrlsFile}`); return;
feedUrls = [];
} }
// フィードごとに処理 console.log(`📡 Processing ${feedUrls.length} feeds...`);
// Process each feed URL
for (const url of feedUrls) { for (const url of feedUrls) {
try { try {
await processFeedUrl(url); await processFeedUrl(url);
} finally { } catch (error) {
console.error(`❌ Failed to process feed ${url}:`, error);
// Continue with other feeds
} }
} }
// Process unprocessed articles and generate podcasts
await processUnprocessedArticles();
// Update RSS feed
await updatePodcastRSS(); await updatePodcastRSS();
console.log("処理完了:", new Date().toISOString());
console.log("✅ Enhanced batch process completed:", new Date().toISOString());
} catch (error) {
console.error("💥 Batch process failed:", error);
throw error;
}
} }
const processFeedUrl = async (url: string) => { /**
* Load feed URLs from configuration file
*/
async function loadFeedUrls(): Promise<string[]> {
try {
const data = await fs.readFile(config.paths.feedUrlsFile, "utf-8");
return data
.split("\n")
.map((url) => url.trim())
.filter((url) => url.length > 0 && !url.startsWith("#"));
} catch (err) {
console.warn(`⚠️ Failed to read feed URLs file: ${config.paths.feedUrlsFile}`);
console.warn("📝 Please create the file with one RSS URL per line.");
return [];
}
}
/**
* Process a single feed URL and discover new articles
*/
async function processFeedUrl(url: string): Promise<void> {
if (!url || !url.startsWith('http')) {
throw new Error(`Invalid feed URL: ${url}`);
}
console.log(`🔍 Processing feed: ${url}`);
try {
// Parse RSS feed
const parser = new Parser<FeedItem>(); const parser = new Parser<FeedItem>();
const feed = await parser.parseURL(url); const feed = await parser.parseURL(url);
// フィードのカテゴリ分類 // Get or create feed record
let feedRecord = await getFeedByUrl(url);
if (!feedRecord) {
console.log(` Adding new feed: ${feed.title || url}`);
await saveFeed({
url,
title: feed.title,
description: feed.description,
lastUpdated: new Date().toISOString(),
active: true
});
feedRecord = await getFeedByUrl(url);
}
if (!feedRecord) {
throw new Error("Failed to create or retrieve feed record");
}
// Process feed items and save new articles
const newArticlesCount = await discoverNewArticles(feedRecord, feed.items || []);
// Update feed last updated timestamp
if (newArticlesCount > 0) {
await saveFeed({
url: feedRecord.url,
title: feedRecord.title,
description: feedRecord.description,
lastUpdated: new Date().toISOString(),
active: feedRecord.active
});
}
console.log(`📊 Feed processed: ${feed.title || url} (${newArticlesCount} new articles)`);
} catch (error) {
console.error(`💥 Error processing feed ${url}:`, error);
throw error;
}
}
/**
* Discover and save new articles from feed items
*/
async function discoverNewArticles(feed: any, items: FeedItem[]): Promise<number> {
let newArticlesCount = 0;
for (const item of items) {
if (!item.title || !item.link) {
console.warn("⚠️ Skipping item without title or link");
continue;
}
try {
// Generate article ID based on link
const articleId = await saveArticle({
feedId: feed.id,
title: item.title,
link: item.link,
description: item.description || item.contentSnippet,
content: item.content,
pubDate: item.pubDate || new Date().toISOString(),
processed: false
});
// Check if this is truly a new article
if (articleId) {
newArticlesCount++;
console.log(`📄 New article discovered: ${item.title}`);
}
} catch (error) {
console.error(`❌ Error saving article: ${item.title}`, error);
}
}
return newArticlesCount;
}
/**
* Process unprocessed articles and generate podcasts
*/
async function processUnprocessedArticles(): Promise<void> {
console.log("🎧 Processing unprocessed articles...");
try {
// Get unprocessed articles (limit to prevent overwhelming)
const unprocessedArticles = await getUnprocessedArticles(20);
if (unprocessedArticles.length === 0) {
console.log(" No unprocessed articles found.");
return;
}
console.log(`🎯 Found ${unprocessedArticles.length} unprocessed articles`);
for (const article of unprocessedArticles) {
try {
await generatePodcastForArticle(article);
await markArticleAsProcessed(article.id);
console.log(`✅ Podcast generated for: ${article.title}`);
} catch (error) {
console.error(`❌ Failed to generate podcast for article: ${article.title}`, error);
// Don't mark as processed if generation failed
}
}
} catch (error) {
console.error("💥 Error processing unprocessed articles:", error);
throw error;
}
}
/**
* Generate podcast for a single article
*/
async function generatePodcastForArticle(article: any): Promise<void> {
console.log(`🎤 Generating podcast for: ${article.title}`);
try {
// Get feed information for context
const feed = await getFeedByUrl(article.feedId);
const feedTitle = feed?.title || "Unknown Feed";
// Classify the article/feed
const category = await openAI_ClassifyFeed(`${feedTitle}: ${article.title}`);
console.log(`🏷️ Article classified as: ${category}`);
// Generate podcast content for this single article
const podcastContent = await openAI_GeneratePodcastContent(
article.title,
[{
title: article.title,
link: article.link
}]
);
// Generate unique ID for the episode
const episodeId = crypto.randomUUID();
// Generate TTS audio
const audioFilePath = await generateTTS(episodeId, podcastContent);
console.log(`🔊 Audio generated: ${audioFilePath}`);
// Get audio file stats
const audioStats = await getAudioFileStats(audioFilePath);
// Save episode
await saveEpisode({
articleId: article.id,
title: `${category}: ${article.title}`,
description: article.description || `Podcast episode for: ${article.title}`,
audioPath: audioFilePath,
duration: audioStats.duration,
fileSize: audioStats.size
});
console.log(`💾 Episode saved for article: ${article.title}`);
} catch (error) {
console.error(`💥 Error generating podcast for article: ${article.title}`, error);
throw error;
}
}
/**
* Get audio file statistics
*/
async function getAudioFileStats(audioFileName: string): Promise<{ duration?: number, size: number }> {
try {
const audioPath = `${config.paths.podcastAudioDir}/${audioFileName}`;
const stats = await fs.stat(audioPath);
return {
size: stats.size,
// TODO: Add duration calculation using ffprobe if needed
duration: undefined
};
} catch (error) {
console.warn(`⚠️ Could not get audio file stats for ${audioFileName}:`, error);
return { size: 0 };
}
}
/**
* Legacy function compatibility - process feed URL the old way
* This is kept for backward compatibility during migration
*/
// Commented out to fix TypeScript unused variable warnings
/* async function legacyProcessFeedUrl(url: string): Promise<void> {
console.log(`🔄 Legacy processing for: ${url}`);
const parser = new Parser<FeedItem>();
const feed = await parser.parseURL(url);
// Feed classification
const feedTitle = feed.title || url; const feedTitle = feed.title || url;
const category = await openAI_ClassifyFeed(feedTitle); const category = await openAI_ClassifyFeed(feedTitle);
console.log(`フィード分類完了: ${feedTitle} - ${category}`); console.log(`Feed classified: ${feedTitle} - ${category}`);
const latest5Items = feed.items.slice(0, 5); const latest5Items = (feed.items || []).slice(0, 5);
// FIXME: 昨日の記事のみフィルタリング if (latest5Items.length === 0) {
// const yesterday = new Date(); console.log(`No items found in feed: ${feedTitle}`);
// yesterday.setDate(yesterday.getDate() - 1); return;
// const yesterdayItems = feed.items.filter((item) => { }
// const pub = new Date(item.pubDate || "");
// return (
// pub.getFullYear() === yesterday.getFullYear() &&
// pub.getMonth() === yesterday.getMonth() &&
// pub.getDate() === yesterday.getDate()
// );
// });
// if (yesterdayItems.length === 0) {
// console.log(`昨日の記事が見つかりません: ${feedTitle}`);
// return;
// }
// ポッドキャスト原稿生成 // Generate podcast content (old way - multiple articles in one podcast)
console.log(`ポッドキャスト原稿生成開始: ${feedTitle}`); console.log(`Generating podcast content for: ${feedTitle}`);
const validItems = latest5Items.filter((item): item is FeedItem => { const validItems = latest5Items.filter((item): item is FeedItem => {
return !!item.title && !!item.link; return !!item.title && !!item.link;
}); });
const podcastContent = await openAI_GeneratePodcastContent(
feedTitle,
validItems,
);
// トピックごとの統合音声生成 if (validItems.length === 0) {
const feedUrlHash = crypto.createHash("md5").update(url).digest("hex"); console.log(`No valid items found in feed: ${feedTitle}`);
const categoryHash = crypto.createHash("md5").update(category).digest("hex");
const uniqueId = `${feedUrlHash}-${categoryHash}`;
const audioFilePath = await generateTTS(uniqueId, podcastContent);
console.log(`音声ファイル生成完了: ${audioFilePath}`);
// エピソードとして保存各フィードにつき1つの統合エピソード
const firstItem = latest5Items[0];
if (!firstItem) {
console.warn("アイテムが空です");
return; return;
} }
const pub = new Date(firstItem.pubDate || "");
const podcastContent = await openAI_GeneratePodcastContent(
feedTitle,
validItems as any
);
// Generate unique ID for this feed and category combination
const feedUrlHash = crypto.createHash("md5").update(url).digest("hex");
const categoryHash = crypto.createHash("md5").update(category).digest("hex");
const timestamp = new Date().getTime();
const uniqueId = `${feedUrlHash}-${categoryHash}-${timestamp}`;
const audioFilePath = await generateTTS(uniqueId, podcastContent);
console.log(`Audio file generated: ${audioFilePath}`);
// Save as legacy episode
const firstItem = latest5Items[0];
if (!firstItem) {
console.warn("No items found");
return;
}
const pubDate = new Date(firstItem.pubDate || new Date());
// For now, save using the new episode structure
// TODO: Remove this once migration is complete
const tempArticleId = crypto.randomUUID();
await saveEpisode({ await saveEpisode({
id: uniqueId, articleId: tempArticleId,
title: `${category}: ${feedTitle}`, title: `${category}: ${feedTitle}`,
pubDate: pub.toISOString(), description: `Legacy podcast for feed: ${feedTitle}`,
audioPath: audioFilePath, audioPath: audioFilePath
sourceLink: url,
}); });
console.log(`エピソード保存完了: ${category} - ${feedTitle}`); console.log(`Legacy episode saved: ${category} - ${feedTitle}`);
// 個別記事の処理記録 // Mark individual articles as processed (legacy)
for (const item of latest5Items) { for (const item of latest5Items) {
const itemId = item["id"] as string | undefined; try {
const itemId = (item as any)["id"] as string | undefined;
const fallbackId = item.link || item.title || JSON.stringify(item); const fallbackId = item.link || item.title || JSON.stringify(item);
const finalItemId = const finalItemId = itemId && typeof itemId === "string" && itemId.trim() !== ""
itemId && typeof itemId === "string" && itemId.trim() !== ""
? itemId ? itemId
: `fallback-${Buffer.from(fallbackId).toString("base64")}`; : `fallback-${Buffer.from(fallbackId).toString("base64")}`;
if (!finalItemId || finalItemId.trim() === "") { if (!finalItemId || finalItemId.trim() === "") {
console.warn(`フィードアイテムのIDを生成できませんでした`, { console.warn(`Could not generate ID for feed item`, {
feedUrl: url, feedUrl: url,
itemTitle: item.title, itemTitle: item.title,
itemLink: item.link, itemLink: item.link,
@ -131,14 +369,40 @@ const processFeedUrl = async (url: string) => {
continue; continue;
} }
const already = await markAsProcessed(url, finalItemId); const alreadyProcessed = await markAsProcessed(url, finalItemId);
if (already) { if (alreadyProcessed) {
console.log(`既に処理済み: ${finalItemId}`); console.log(`Already processed: ${finalItemId}`);
continue; }
} catch (error) {
console.error(`Error marking item as processed:`, error);
} }
} }
}; } */
batchProcess().catch((err) => { // Export function for use in server
console.error("バッチ処理中にエラーが発生しました:", err); export async function addNewFeedUrl(feedUrl: string): Promise<void> {
}); if (!feedUrl || !feedUrl.startsWith('http')) {
throw new Error('Invalid feed URL');
}
try {
// Add to feeds table
await saveFeed({
url: feedUrl,
active: true
});
console.log(`✅ Feed URL added: ${feedUrl}`);
} catch (error) {
console.error(`❌ Failed to add feed URL: ${feedUrl}`, error);
throw error;
}
}
// Run if this script is executed directly
if (import.meta.main) {
batchProcess().catch((err) => {
console.error("💥 Batch process failed:", err);
process.exit(1);
});
}

365
server.ts
View File

@ -1,69 +1,161 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { serve } from "@hono/node-server"; import { serve } from "@hono/node-server";
import fs from "fs";
import path from "path"; import path from "path";
import { Database } from "bun:sqlite"; import { config, validateConfig } from "./services/config.js";
import {
fetchAllEpisodes,
fetchEpisodesWithArticles,
getAllFeeds,
getFeedByUrl
} from "./services/database.js";
import { batchProcess, addNewFeedUrl } from "./scripts/fetch_and_generate.js";
const projectRoot = import.meta.dirname; // Validate configuration on startup
try {
// データベースパスの設定 validateConfig();
const dbPath = path.join(projectRoot, "data/podcast.db"); console.log("Configuration validated successfully");
const dataDir = path.dirname(dbPath); } catch (error) {
if (!fs.existsSync(dataDir)) { console.error("Configuration validation failed:", error);
fs.mkdirSync(dataDir, { recursive: true }); process.exit(1);
} }
const db = new Database(dbPath);
if (!fs.existsSync(dbPath)) {
fs.closeSync(fs.openSync(dbPath, "w"));
}
db.exec(fs.readFileSync(path.join(projectRoot, "schema.sql"), "utf-8"));
// 静的ファイルパスの設定
const frontendBuildDir = path.join(projectRoot, "frontend", "dist");
const podcastAudioDir = path.join(projectRoot, "public", "podcast_audio");
const generalPublicDir = path.join(projectRoot, "public");
const app = new Hono(); const app = new Hono();
// APIルート(順序を最適化) // API routes
app.get("/api/feeds", async (c) => { app.get("/api/feeds", async (c) => {
const rows = db try {
.query("SELECT feed_url FROM processed_feed_items GROUP BY feed_url") const feeds = await getAllFeeds();
.all() as { feed_url: string }[]; return c.json(feeds);
return c.json(rows.map((r) => r.feed_url)); } catch (error) {
console.error("Error fetching feeds:", error);
return c.json({ error: "Failed to fetch feeds" }, 500);
}
}); });
app.post("/api/feeds", async (c) => { app.post("/api/feeds", async (c) => {
try { try {
const { feedUrl } = await c.req.json<{ feedUrl: string }>(); const { feedUrl } = await c.req.json<{ feedUrl: string }>();
console.log("Received feedUrl to add:", feedUrl);
// TODO: feedUrl をデータベースに保存する処理 if (!feedUrl || typeof feedUrl !== "string" || !feedUrl.startsWith('http')) {
return c.json({ result: "OK" }); return c.json({ error: "Valid feed URL is required" }, 400);
} catch (e) { }
return c.json({ error: "Invalid JSON body" }, 400);
console.log(" Adding new feed URL:", feedUrl);
// Check if feed already exists
const existingFeed = await getFeedByUrl(feedUrl);
if (existingFeed) {
return c.json({
result: "EXISTS",
message: "Feed URL already exists",
feed: existingFeed
});
}
// Add new feed
await addNewFeedUrl(feedUrl);
return c.json({
result: "CREATED",
message: "Feed URL added successfully",
feedUrl
});
} catch (error) {
console.error("Error adding feed:", error);
return c.json({ error: "Failed to add feed" }, 500);
} }
}); });
app.get("/api/episodes", (c) => { app.get("/api/episodes", async (c) => {
const episodes = db try {
.query("SELECT * FROM episodes ORDER BY pubDate DESC") const episodes = await fetchEpisodesWithArticles();
.all();
return c.json(episodes); return c.json(episodes);
} catch (error) {
console.error("Error fetching episodes:", error);
return c.json({ error: "Failed to fetch episodes" }, 500);
}
}); });
app.post("/api/episodes/:id/regenerate", (c) => { app.get("/api/episodes/simple", async (c) => {
try {
const episodes = await fetchAllEpisodes();
return c.json(episodes);
} catch (error) {
console.error("Error fetching simple episodes:", error);
return c.json({ error: "Failed to fetch episodes" }, 500);
}
});
app.post("/api/episodes/:id/regenerate", async (c) => {
try {
const id = c.req.param("id"); const id = c.req.param("id");
console.log("Regeneration requested for episode ID:", id);
// TODO: 再生成ロジックを実装 if (!id || id.trim() === "") {
return c.json({ result: `Regeneration requested for ${id}` }); return c.json({ error: "Episode ID is required" }, 400);
}
console.log("🔄 Regeneration requested for episode ID:", id);
// TODO: Implement regeneration logic
return c.json({
result: "PENDING",
episodeId: id,
status: "pending",
message: "Regeneration feature will be implemented in a future update"
});
} catch (error) {
console.error("Error requesting regeneration:", error);
return c.json({ error: "Failed to request regeneration" }, 500);
}
});
// New API endpoints for enhanced functionality
app.get("/api/stats", async (c) => {
try {
const feeds = await getAllFeeds();
const episodes = await fetchAllEpisodes();
const stats = {
totalFeeds: feeds.length,
activeFeeds: feeds.filter(f => f.active).length,
totalEpisodes: episodes.length,
lastUpdated: new Date().toISOString()
};
return c.json(stats);
} catch (error) {
console.error("Error fetching stats:", error);
return c.json({ error: "Failed to fetch statistics" }, 500);
}
});
app.post("/api/batch/trigger", async (c) => {
try {
console.log("🚀 Manual batch process triggered via API");
// Run batch process in background
runBatchProcess().catch(error => {
console.error("❌ Manual batch process failed:", error);
});
return c.json({
result: "TRIGGERED",
message: "Batch process started in background",
timestamp: new Date().toISOString()
});
} catch (error) {
console.error("Error triggering batch process:", error);
return c.json({ error: "Failed to trigger batch process" }, 500);
}
}); });
// 静的ファイルの処理 // 静的ファイルの処理
// Vite ビルドの静的ファイルmain.js, assets/ など) // Static file handlers
app.get("/assets/*", async (c) => { app.get("/assets/*", async (c) => {
const filePath = path.join(frontendBuildDir, c.req.path); try {
const filePath = path.join(config.paths.frontendBuildDir, c.req.path);
const file = Bun.file(filePath); const file = Bun.file(filePath);
if (await file.exists()) { if (await file.exists()) {
const contentType = filePath.endsWith(".js") const contentType = filePath.endsWith(".js")
? "application/javascript" ? "application/javascript"
@ -74,145 +166,131 @@ app.get("/assets/*", async (c) => {
return c.body(blob, 200, { "Content-Type": contentType }); return c.body(blob, 200, { "Content-Type": contentType });
} }
return c.notFound(); return c.notFound();
} catch (error) {
console.error("Error serving asset:", error);
return c.notFound();
}
}); });
// podcast_audio
app.get("/podcast_audio/*", async (c) => { app.get("/podcast_audio/*", async (c) => {
try {
const audioFileName = c.req.path.substring("/podcast_audio/".length); const audioFileName = c.req.path.substring("/podcast_audio/".length);
const audioFilePath = path.join(podcastAudioDir, audioFileName);
// Basic security check
if (audioFileName.includes("..") || audioFileName.includes("/")) {
return c.notFound();
}
const audioFilePath = path.join(config.paths.podcastAudioDir, audioFileName);
const file = Bun.file(audioFilePath); const file = Bun.file(audioFilePath);
if (await file.exists()) { if (await file.exists()) {
const blob = await file.arrayBuffer(); const blob = await file.arrayBuffer();
return c.body(blob, 200, { "Content-Type": "audio/mpeg" }); return c.body(blob, 200, { "Content-Type": "audio/mpeg" });
} }
return c.notFound(); return c.notFound();
} catch (error) {
console.error("Error serving audio file:", error);
return c.notFound();
}
}); });
// podcast.xml
app.get("/podcast.xml", async (c) => { app.get("/podcast.xml", async (c) => {
const filePath = path.join(generalPublicDir, "podcast.xml");
try { try {
const filePath = path.join(config.paths.publicDir, "podcast.xml");
const file = Bun.file(filePath); const file = Bun.file(filePath);
if (await file.exists()) { if (await file.exists()) {
const blob = await file.arrayBuffer(); const blob = await file.arrayBuffer();
return c.body(blob, 200, { return c.body(blob, 200, {
"Content-Type": "application/xml; charset=utf-8", "Content-Type": "application/xml; charset=utf-8",
"Cache-Control": "public, max-age=3600", // Cache for 1 hour
}); });
} }
} catch (e) {
console.error(`Error serving podcast.xml ${filePath}:`, e); console.warn("podcast.xml not found");
}
return c.notFound(); return c.notFound();
});
// フィードURL追加API
app.post("/api/add-feed", async (c) => {
const { feedUrl } = await c.req.json();
if (!feedUrl || typeof feedUrl !== "string") {
return c.json({ error: "フィードURLが無効です" }, 400);
}
try {
// フィードURLを追加するロジック例: scripts/fetch_and_generate.ts で実装)
const { addNewFeedUrl } = require("./scripts/fetch_and_generate");
await addNewFeedUrl(feedUrl);
return c.json({ message: "フィードが追加されました" });
} catch (err) {
console.error("フィード追加エラー:", err);
return c.json({ error: "フィードの追加に失敗しました" }, 500);
}
});
// フォールバックとして index.htmlルートパス
app.get("/", async (c) => {
const indexPath = path.join(frontendBuildDir, "index.html");
const file = Bun.file(indexPath);
if (await file.exists()) {
console.log(`Serving index.html from ${indexPath}`);
const blob = await file.arrayBuffer();
return c.body(blob, 200, { "Content-Type": "text/html; charset=utf-8" });
}
console.error(`index.html not found at ${indexPath}`);
return c.notFound();
});
// フォールバックとして index.html明示的なパス
app.get("/index.html", async (c) => {
const indexPath = path.join(frontendBuildDir, "index.html");
const file = Bun.file(indexPath);
if (await file.exists()) {
console.log(`Serving index.html from ${indexPath}`);
const blob = await file.arrayBuffer();
return c.body(blob, 200, { "Content-Type": "text/html; charset=utf-8" });
}
console.error(`index.html not found at ${indexPath}`);
return c.notFound();
});
// その他のパスも index.html へフォールバック
app.get("*", async (c) => {
const indexPath = path.join(frontendBuildDir, "index.html");
const file = Bun.file(indexPath);
if (await file.exists()) {
console.log(`Serving index.html from ${indexPath}`);
const blob = await file.arrayBuffer();
return c.body(blob, 200, { "Content-Type": "text/html; charset=utf-8" });
}
console.error(`index.html not found at ${indexPath}`);
return c.notFound();
});
/**
* 初回実行後に1日ごとのバッチ処理をスケジュールする関数
*/
function scheduleFirstBatchProcess() {
try {
console.log("Running initial batch process...");
runBatchProcess();
console.log("Initial batch process completed");
} catch (error) { } catch (error) {
console.error("Error during initial batch process:", error); console.error("Error serving podcast.xml:", error);
return c.notFound();
}
});
// Legacy endpoint - redirect to new one
app.post("/api/add-feed", async (c) => {
return c.json({
error: "This endpoint is deprecated. Use POST /api/feeds instead.",
newEndpoint: "POST /api/feeds"
}, 410);
});
// Frontend fallback routes
async function serveIndex(c: any) {
try {
const indexPath = path.join(config.paths.frontendBuildDir, "index.html");
const file = Bun.file(indexPath);
if (await file.exists()) {
const blob = await file.arrayBuffer();
return c.body(blob, 200, { "Content-Type": "text/html; charset=utf-8" });
}
console.error(`index.html not found at ${indexPath}`);
return c.text("Frontend not built. Run 'bun run build:frontend'", 404);
} catch (error) {
console.error("Error serving index.html:", error);
return c.text("Internal server error", 500);
} }
} }
function scheduleDailyBatchProcess() { app.get("/", serveIndex);
const now = new Date();
const nextRun = new Date( app.get("/index.html", serveIndex);
now.getFullYear(),
now.getMonth(), // Catch-all for SPA routing
now.getDate() + 1, app.get("*", serveIndex);
0,
0, // Batch processing functions
0, function scheduleFirstBatchProcess() {
); setTimeout(async () => {
try {
console.log("🚀 Running initial batch process...");
await runBatchProcess();
console.log("✅ Initial batch process completed");
} catch (error) {
console.error("❌ Error during initial batch process:", error);
}
}, 10000); // Wait 10 seconds after startup
}
function scheduleSixHourlyBatchProcess() {
const SIX_HOURS_MS = 6 * 60 * 60 * 1000; // 6 hours in milliseconds
const delay = nextRun.getTime() - now.getTime();
console.log( console.log(
`Next daily batch process scheduled in ${delay / 1000 / 60} minutes`, `🕕 Next batch process scheduled in 6 hours (${new Date(Date.now() + SIX_HOURS_MS).toLocaleString()})`
); );
setTimeout(async () => { setTimeout(async () => {
try { try {
console.log("Running daily batch process..."); console.log("🔄 Running scheduled 6-hourly batch process...");
runBatchProcess(); await runBatchProcess();
console.log("Daily batch process completed"); console.log("✅ Scheduled batch process completed");
} catch (error) { } catch (error) {
console.error("Error during daily batch process:", error); console.error("Error during scheduled batch process:", error);
} }
// 次回実行を再設定 // Schedule next run
scheduleDailyBatchProcess(); scheduleSixHourlyBatchProcess();
}, delay); }, SIX_HOURS_MS);
} }
const runBatchProcess = () => { async function runBatchProcess(): Promise<void> {
try { try {
console.log("Running batch process..."); await batchProcess();
Bun.spawn(["bun", "run", "scripts/fetch_and_generate.ts"]);
console.log("Batch process completed");
} catch (error) { } catch (error) {
console.error("Error during batch process:", error); console.error("Batch process failed:", error);
throw error;
} }
}; }
// サーバー起動 // サーバー起動
serve( serve(
@ -221,9 +299,12 @@ serve(
port: 3000, port: 3000,
}, },
(info) => { (info) => {
console.log(`Server is running on http://localhost:${info.port}`); console.log(`🌟 Server is running on http://localhost:${info.port}`);
// 初回実行 console.log(`📡 Using configuration from: ${config.paths.projectRoot}`);
console.log(`🗄️ Database: ${config.paths.dbPath}`);
// Schedule batch processes
scheduleFirstBatchProcess(); scheduleFirstBatchProcess();
scheduleDailyBatchProcess(); scheduleSixHourlyBatchProcess();
}, },
); );

117
services/config.ts Normal file
View File

@ -0,0 +1,117 @@
import path from "path";
interface Config {
// OpenAI Configuration
openai: {
apiKey: string;
endpoint: string;
modelName: string;
};
// VOICEVOX Configuration
voicevox: {
host: string;
styleId: number;
};
// Podcast Configuration
podcast: {
title: string;
link: string;
description: string;
language: string;
author: string;
categories: string;
ttl: string;
baseUrl: string;
};
// File paths
paths: {
projectRoot: string;
dataDir: string;
dbPath: string;
publicDir: string;
podcastAudioDir: string;
frontendBuildDir: string;
feedUrlsFile: string;
};
}
function getRequiredEnv(key: string): string {
const value = import.meta.env[key];
if (!value) {
throw new Error(`Required environment variable ${key} is not set`);
}
return value;
}
function getOptionalEnv(key: string, defaultValue: string): string {
return import.meta.env[key] ?? defaultValue;
}
function createConfig(): Config {
const projectRoot = import.meta.dirname ? path.dirname(import.meta.dirname) : process.cwd();
const dataDir = path.join(projectRoot, "data");
const publicDir = path.join(projectRoot, "public");
return {
openai: {
apiKey: getRequiredEnv("OPENAI_API_KEY"),
endpoint: getOptionalEnv("OPENAI_API_ENDPOINT", "https://api.openai.com/v1"),
modelName: getOptionalEnv("OPENAI_MODEL_NAME", "gpt-4o-mini"),
},
voicevox: {
host: getOptionalEnv("VOICEVOX_HOST", "http://localhost:50021"),
styleId: parseInt(getOptionalEnv("VOICEVOX_STYLE_ID", "0")),
},
podcast: {
title: getOptionalEnv("PODCAST_TITLE", "自動生成ポッドキャスト"),
link: getOptionalEnv("PODCAST_LINK", "https://your-domain.com/podcast"),
description: getOptionalEnv("PODCAST_DESCRIPTION", "RSSフィードから自動生成された音声ポッドキャスト"),
language: getOptionalEnv("PODCAST_LANGUAGE", "ja"),
author: getOptionalEnv("PODCAST_AUTHOR", "管理者"),
categories: getOptionalEnv("PODCAST_CATEGORIES", "Technology"),
ttl: getOptionalEnv("PODCAST_TTL", "60"),
baseUrl: getOptionalEnv("PODCAST_BASE_URL", "https://your-domain.com"),
},
paths: {
projectRoot,
dataDir,
dbPath: path.join(dataDir, "podcast.db"),
publicDir,
podcastAudioDir: path.join(publicDir, "podcast_audio"),
frontendBuildDir: path.join(projectRoot, "frontend", "dist"),
feedUrlsFile: path.join(projectRoot, getOptionalEnv("FEED_URLS_FILE", "feed_urls.txt")),
},
};
}
export const config = createConfig();
export function validateConfig(): void {
// Validate required configuration
if (!config.openai.apiKey) {
throw new Error("OPENAI_API_KEY is required");
}
if (isNaN(config.voicevox.styleId)) {
throw new Error("VOICEVOX_STYLE_ID must be a valid number");
}
// Validate URLs
try {
new URL(config.voicevox.host);
} catch {
throw new Error("VOICEVOX_HOST must be a valid URL");
}
try {
new URL(config.openai.endpoint);
} catch {
throw new Error("OPENAI_API_ENDPOINT must be a valid URL");
}
}

View File

@ -1,35 +1,78 @@
import { Database } from "bun:sqlite"; import { Database } from "bun:sqlite";
import path from "path";
import fs from "fs"; import fs from "fs";
import crypto from "crypto";
import { config } from "./config.js";
// データベースディレクトリのパスを取得 // Initialize database with proper error handling
const dbDir = path.join(__dirname, "../data"); function initializeDatabase(): Database {
// Ensure data directory exists
if (!fs.existsSync(config.paths.dataDir)) {
fs.mkdirSync(config.paths.dataDir, { recursive: true });
}
// ディレクトリが存在しない場合は作成 // Create database file if it doesn't exist
if (!fs.existsSync(dbDir)) { if (!fs.existsSync(config.paths.dbPath)) {
fs.mkdirSync(dbDir, { recursive: true }); fs.closeSync(fs.openSync(config.paths.dbPath, "w"));
} }
const dbPath = path.join(dbDir, "podcast.db"); const db = new Database(config.paths.dbPath);
const db = new Database(dbPath);
// Ensure schema is set up // Ensure schema is set up
db.exec(`CREATE TABLE IF NOT EXISTS processed_feed_items ( db.exec(`CREATE TABLE IF NOT EXISTS processed_feed_items (
feed_url TEXT NOT NULL, feed_url TEXT NOT NULL,
item_id TEXT NOT NULL, item_id TEXT NOT NULL,
processed_at TEXT NOT NULL, processed_at TEXT NOT NULL,
PRIMARY KEY(feed_url, item_id) PRIMARY KEY(feed_url, item_id)
); );
CREATE TABLE IF NOT EXISTS episodes ( CREATE TABLE IF NOT EXISTS episodes (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
title TEXT NOT NULL, title TEXT NOT NULL,
pubDate TEXT NOT NULL, pubDate TEXT NOT NULL,
audioPath TEXT NOT NULL, audioPath TEXT NOT NULL,
sourceLink TEXT NOT NULL sourceLink TEXT NOT NULL
);`); );`);
return db;
}
const db = initializeDatabase();
export interface Feed {
id: string;
url: string;
title?: string;
description?: string;
lastUpdated?: string;
createdAt: string;
active: boolean;
}
export interface Article {
id: string;
feedId: string;
title: string;
link: string;
description?: string;
content?: string;
pubDate: string;
discoveredAt: string;
processed: boolean;
}
export interface Episode { export interface Episode {
id: string;
articleId: string;
title: string;
description?: string;
audioPath: string;
duration?: number;
fileSize?: number;
createdAt: string;
}
// Legacy interface for backward compatibility
export interface LegacyEpisode {
id: string; id: string;
title: string; title: string;
pubDate: string; pubDate: string;
@ -37,30 +80,286 @@ export interface Episode {
sourceLink: string; sourceLink: string;
} }
// Feed management functions
export async function saveFeed(feed: Omit<Feed, 'id' | 'createdAt'>): Promise<string> {
const id = crypto.randomUUID();
const createdAt = new Date().toISOString();
try {
const stmt = db.prepare(
"INSERT OR REPLACE INTO feeds (id, url, title, description, last_updated, created_at, active) VALUES (?, ?, ?, ?, ?, ?, ?)"
);
stmt.run(id, feed.url, feed.title || null, feed.description || null, feed.lastUpdated || null, createdAt, feed.active ? 1 : 0);
return id;
} catch (error) {
console.error("Error saving feed:", error);
throw error;
}
}
export async function getFeedByUrl(url: string): Promise<Feed | null> {
try {
const stmt = db.prepare("SELECT * FROM feeds WHERE url = ?");
const row = stmt.get(url) as any;
if (!row) return null;
return {
id: row.id,
url: row.url,
title: row.title,
description: row.description,
lastUpdated: row.last_updated,
createdAt: row.created_at,
active: Boolean(row.active)
};
} catch (error) {
console.error("Error getting feed by URL:", error);
throw error;
}
}
export async function getAllFeeds(): Promise<Feed[]> {
try {
const stmt = db.prepare("SELECT * FROM feeds WHERE active = 1 ORDER BY created_at DESC");
const rows = stmt.all() as any[];
return rows.map(row => ({
id: row.id,
url: row.url,
title: row.title,
description: row.description,
lastUpdated: row.last_updated,
createdAt: row.created_at,
active: Boolean(row.active)
}));
} catch (error) {
console.error("Error getting all feeds:", error);
throw error;
}
}
// Article management functions
export async function saveArticle(article: Omit<Article, 'id' | 'discoveredAt'>): Promise<string> {
const id = crypto.randomUUID();
const discoveredAt = new Date().toISOString();
try {
const stmt = db.prepare(
"INSERT OR IGNORE INTO articles (id, feed_id, title, link, description, content, pub_date, discovered_at, processed) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
);
const result = stmt.run(id, article.feedId, article.title, article.link, article.description || null, article.content || null, article.pubDate, discoveredAt, article.processed ? 1 : 0);
// Return existing ID if article already exists
if (result.changes === 0) {
const existing = db.prepare("SELECT id FROM articles WHERE link = ?").get(article.link) as any;
return existing?.id || id;
}
return id;
} catch (error) {
console.error("Error saving article:", error);
throw error;
}
}
export async function getUnprocessedArticles(limit?: number): Promise<Article[]> {
try {
const sql = `SELECT * FROM articles WHERE processed = 0 ORDER BY pub_date DESC ${limit ? `LIMIT ${limit}` : ''}`;
const stmt = db.prepare(sql);
const rows = stmt.all() as any[];
return rows.map(row => ({
id: row.id,
feedId: row.feed_id,
title: row.title,
link: row.link,
description: row.description,
content: row.content,
pubDate: row.pub_date,
discoveredAt: row.discovered_at,
processed: Boolean(row.processed)
}));
} catch (error) {
console.error("Error getting unprocessed articles:", error);
throw error;
}
}
export async function markArticleAsProcessed(articleId: string): Promise<void> {
try {
const stmt = db.prepare("UPDATE articles SET processed = 1 WHERE id = ?");
stmt.run(articleId);
} catch (error) {
console.error("Error marking article as processed:", error);
throw error;
}
}
// Legacy function for backward compatibility
export async function markAsProcessed( export async function markAsProcessed(
feedUrl: string, feedUrl: string,
itemId: string, itemId: string,
): Promise<boolean> { ): Promise<boolean> {
if (!feedUrl || !itemId) {
throw new Error("feedUrl and itemId are required");
}
try {
const stmt = db.prepare( const stmt = db.prepare(
"SELECT 1 FROM processed_feed_items WHERE feed_url = ? AND item_id = ?", "SELECT 1 FROM processed_feed_items WHERE feed_url = ? AND item_id = ?",
); );
const row = stmt.get(feedUrl, itemId); const row = stmt.get(feedUrl, itemId);
if (row) return true; if (row) return true;
const insert = db.prepare( const insert = db.prepare(
"INSERT INTO processed_feed_items (feed_url, item_id, processed_at) VALUES (?, ?, ?)", "INSERT INTO processed_feed_items (feed_url, item_id, processed_at) VALUES (?, ?, ?)",
); );
insert.run(feedUrl, itemId, new Date().toISOString()); insert.run(feedUrl, itemId, new Date().toISOString());
return false; return false;
} catch (error) {
console.error("Error marking item as processed:", error);
throw error;
}
} }
export async function saveEpisode(ep: Episode): Promise<void> { // Episode management functions
export async function saveEpisode(episode: Omit<Episode, 'id' | 'createdAt'>): Promise<string> {
const id = crypto.randomUUID();
const createdAt = new Date().toISOString();
if (!episode.articleId || !episode.title || !episode.audioPath) {
throw new Error("articleId, title, and audioPath are required");
}
try {
const stmt = db.prepare( const stmt = db.prepare(
"INSERT OR IGNORE INTO episodes (id, title, pubDate, audioPath, sourceLink) VALUES (?, ?, ?, ?, ?)", "INSERT INTO episodes (id, article_id, title, description, audio_path, duration, file_size, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
); );
stmt.run(ep.id, ep.title, ep.pubDate, ep.audioPath, ep.sourceLink); stmt.run(id, episode.articleId, episode.title, episode.description || null, episode.audioPath, episode.duration || null, episode.fileSize || null, createdAt);
return id;
} catch (error) {
console.error("Error saving episode:", error);
throw error;
}
}
// Legacy function for backward compatibility
export async function saveLegacyEpisode(ep: LegacyEpisode): Promise<void> {
if (!ep.id || !ep.title || !ep.pubDate || !ep.audioPath || !ep.sourceLink) {
throw new Error("All episode fields are required");
}
try {
// For now, save to a temporary table for migration
const stmt = db.prepare(
"CREATE TABLE IF NOT EXISTS legacy_episodes (id TEXT PRIMARY KEY, title TEXT, pubDate TEXT, audioPath TEXT, sourceLink TEXT)"
);
stmt.run();
const insert = db.prepare(
"INSERT OR IGNORE INTO legacy_episodes (id, title, pubDate, audioPath, sourceLink) VALUES (?, ?, ?, ?, ?)",
);
insert.run(ep.id, ep.title, ep.pubDate, ep.audioPath, ep.sourceLink);
} catch (error) {
console.error("Error saving legacy episode:", error);
throw error;
}
} }
export async function fetchAllEpisodes(): Promise<Episode[]> { export async function fetchAllEpisodes(): Promise<Episode[]> {
const stmt = db.prepare("SELECT * FROM episodes ORDER BY pubDate DESC"); try {
const stmt = db.prepare(`
SELECT
e.id,
e.article_id as articleId,
e.title,
e.description,
e.audio_path as audioPath,
e.duration,
e.file_size as fileSize,
e.created_at as createdAt
FROM episodes e
ORDER BY e.created_at DESC
`);
return stmt.all() as Episode[]; return stmt.all() as Episode[];
} catch (error) {
console.error("Error fetching episodes:", error);
throw error;
}
}
export async function fetchEpisodesWithArticles(): Promise<(Episode & { article: Article, feed: Feed })[]> {
try {
const stmt = db.prepare(`
SELECT
e.id,
e.article_id as articleId,
e.title,
e.description,
e.audio_path as audioPath,
e.duration,
e.file_size as fileSize,
e.created_at as createdAt,
a.id as article_id,
a.feed_id as article_feedId,
a.title as article_title,
a.link as article_link,
a.description as article_description,
a.content as article_content,
a.pub_date as article_pubDate,
a.discovered_at as article_discoveredAt,
a.processed as article_processed,
f.id as feed_id,
f.url as feed_url,
f.title as feed_title,
f.description as feed_description,
f.last_updated as feed_lastUpdated,
f.created_at as feed_createdAt,
f.active as feed_active
FROM episodes e
JOIN articles a ON e.article_id = a.id
JOIN feeds f ON a.feed_id = f.id
ORDER BY e.created_at DESC
`);
const rows = stmt.all() as any[];
return rows.map(row => ({
id: row.id,
articleId: row.articleId,
title: row.title,
description: row.description,
audioPath: row.audioPath,
duration: row.duration,
fileSize: row.fileSize,
createdAt: row.createdAt,
article: {
id: row.article_id,
feedId: row.article_feedId,
title: row.article_title,
link: row.article_link,
description: row.article_description,
content: row.article_content,
pubDate: row.article_pubDate,
discoveredAt: row.article_discoveredAt,
processed: Boolean(row.article_processed)
},
feed: {
id: row.feed_id,
url: row.feed_url,
title: row.feed_title,
description: row.feed_description,
lastUpdated: row.feed_lastUpdated,
createdAt: row.feed_createdAt,
active: Boolean(row.feed_active)
}
}));
} catch (error) {
console.error("Error fetching episodes with articles:", error);
throw error;
}
}
export function closeDatabase(): void {
db.close();
} }

View File

@ -1,12 +1,20 @@
import { OpenAI, ClientOptions } from "openai"; import { OpenAI, ClientOptions } from "openai";
import { config, validateConfig } from "./config.js";
// Validate config on module load
validateConfig();
const clientOptions: ClientOptions = { const clientOptions: ClientOptions = {
apiKey: import.meta.env["OPENAI_API_KEY"], apiKey: config.openai.apiKey,
baseURL: import.meta.env["OPENAI_API_ENDPOINT"], baseURL: config.openai.endpoint,
}; };
const openai = new OpenAI(clientOptions); const openai = new OpenAI(clientOptions);
export async function openAI_ClassifyFeed(title: string): Promise<string> { export async function openAI_ClassifyFeed(title: string): Promise<string> {
if (!title || title.trim() === "") {
throw new Error("Feed title is required for classification");
}
const prompt = ` const prompt = `
以下のRSSフィードのタイトルを見て、適切なトピックカテゴリに分類してください。 以下のRSSフィードのタイトルを見て、適切なトピックカテゴリに分類してください。
@ -26,26 +34,52 @@ export async function openAI_ClassifyFeed(title: string): Promise<string> {
分類結果を上記カテゴリのいずれか1つだけ返してください。 分類結果を上記カテゴリのいずれか1つだけ返してください。
`; `;
try {
const response = await openai.chat.completions.create({ const response = await openai.chat.completions.create({
model: import.meta.env["OPENAI_MODEL_NAME"] ?? "gpt-4o-mini", model: config.openai.modelName,
messages: [{ role: "user", content: prompt.trim() }], messages: [{ role: "user", content: prompt.trim() }],
temperature: 0.3, temperature: 0.3,
}); });
const category = response.choices[0]!.message?.content?.trim() || "その他";
const category = response.choices[0]?.message?.content?.trim();
if (!category) {
console.warn("OpenAI returned empty category, using default");
return "その他";
}
return category; return category;
} catch (error) {
console.error("Error classifying feed:", error);
throw new Error(`Failed to classify feed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
} }
export async function openAI_GeneratePodcastContent( export async function openAI_GeneratePodcastContent(
title: string, title: string,
items: Array<{ title: string; link: string }>, items: Array<{ title: string; link: string }>,
): Promise<string> { ): Promise<string> {
if (!title || title.trim() === "") {
throw new Error("Feed title is required for podcast content generation");
}
if (!items || items.length === 0) {
throw new Error("At least one news item is required for podcast content generation");
}
// Validate items
const validItems = items.filter(item => item.title && item.link);
if (validItems.length === 0) {
throw new Error("No valid news items found (title and link required)");
}
const prompt = ` const prompt = `
あなたはプロのポッドキャスタです。以下に示すフィードタイトルに基づき、そのトピックに関する詳細なポッドキャスト原稿を作成してください。 あなたはプロのポッドキャスタです。以下に示すフィードタイトルに基づき、そのトピックに関する詳細なポッドキャスト原稿を作成してください。
フィードタイトル: ${title} フィードタイトル: ${title}
関連するニュース記事: 関連するニュース記事:
${items.map((item, i) => `${i + 1}. ${item.title} - ${item.link}`).join("\n")} ${validItems.map((item, i) => `${i + 1}. ${item.title} - ${item.link}`).join("\n")}
以下の要件を満たしてください: 以下の要件を満たしてください:
1. 各ニュース記事の内容を要約し、関連性を説明してください 1. 各ニュース記事の内容を要約し、関連性を説明してください
@ -56,11 +90,22 @@ ${items.map((item, i) => `${i + 1}. ${item.title} - ${item.link}`).join("\n")}
この構成でポッドキャスト原稿を書いてください。 この構成でポッドキャスト原稿を書いてください。
`; `;
try {
const response = await openai.chat.completions.create({ const response = await openai.chat.completions.create({
model: import.meta.env["OPENAI_MODEL_NAME"] ?? "gpt-4o-mini", model: config.openai.modelName,
messages: [{ role: "user", content: prompt.trim() }], messages: [{ role: "user", content: prompt.trim() }],
temperature: 0.7, temperature: 0.7,
}); });
const scriptText = response.choices[0]!.message?.content?.trim() || "";
const scriptText = response.choices[0]?.message?.content?.trim();
if (!scriptText) {
throw new Error("OpenAI returned empty podcast content");
}
return scriptText; return scriptText;
} catch (error) {
console.error("Error generating podcast content:", error);
throw new Error(`Failed to generate podcast content: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
} }

View File

@ -1,118 +1,77 @@
import { promises as fs } from "fs"; import { promises as fs } from "fs";
import { join, dirname } from "path"; import { dirname } from "path";
import { Episode, fetchAllEpisodes } from "./database"; import { Episode, fetchAllEpisodes } from "./database.js";
import path from "node:path"; import path from "node:path";
import fsSync from "node:fs"; import fsSync from "node:fs";
import { config } from "./config.js";
export async function updatePodcastRSS() { function escapeXml(text: string): string {
const episodes: Episode[] = await fetchAllEpisodes(); return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
const channelTitle = function createItemXml(episode: Episode): string {
import.meta.env["PODCAST_TITLE"] ?? "自動生成ポッドキャスト"; const fileUrl = `${config.podcast.baseUrl}/podcast_audio/${path.basename(episode.audioPath)}`;
const channelLink = const pubDate = new Date(episode.createdAt).toUTCString();
import.meta.env["PODCAST_LINK"] ?? "https://your-domain.com/podcast";
const channelDescription =
import.meta.env["PODCAST_DESCRIPTION"] ??
"RSSフィードから自動生成された音声ポッドキャスト";
const channelLanguage = import.meta.env["PODCAST_LANGUAGE"] ?? "ja";
const channelAuthor = import.meta.env["PODCAST_AUTHOR"] ?? "管理者";
const channelCategories =
import.meta.env["PODCAST_CATEGORIES"] ?? "Technology";
const channelTTL = import.meta.env["PODCAST_TTL"] ?? "60";
const lastBuildDate = new Date().toUTCString();
const baseUrl =
import.meta.env["PODCAST_BASE_URL"] ?? "https://your-domain.com";
let itemsXml = ""; let fileSize = 0;
for (const ep of episodes) {
const fileUrl = `${baseUrl}/podcast_audio/${path.basename(ep.audioPath)}`;
const pubDate = new Date(ep.pubDate).toUTCString();
const fileSize = fsSync.statSync(
path.join(import.meta.dir, "..", "public/podcast_audio", ep.audioPath),
).size;
itemsXml += `
<item>
<title><![CDATA[${ep.title}]]></title>
<description><![CDATA[${ep.title.replace(/\]\]>/g, "]]&gt;").replace(/&/g, "&amp;").replace(/\]\]>/g, "]]&gt;")}]]></description>
<author>${channelAuthor}</author>
<category>${channelCategories}</category>
<language>${channelLanguage}</language>
<ttl>${channelTTL}</ttl>
<enclosure url="${fileUrl}" length="${fileSize}" type="audio/mpeg" />
<guid>${fileUrl}</guid>
<pubDate>${pubDate}</pubDate>
</item>
`;
}
const outputPath = join(__dirname, "../public/podcast.xml");
// 既存のRSSファイルの読み込み
let existingXml = "";
try { try {
existingXml = await fs.readFile(outputPath, "utf-8"); const audioPath = path.join(config.paths.podcastAudioDir, episode.audioPath);
} catch (err) { if (fsSync.existsSync(audioPath)) {
// ファイルが存在しない場合は新規作成 fileSize = fsSync.statSync(audioPath).size;
console.log("既存のpodcast.xmlが見つかりません。新規作成します。"); }
} catch (error) {
console.warn(`Could not get file size for ${episode.audioPath}:`, error);
} }
if (existingXml) { return `
// 既存のitem部分を抽出 <item>
const existingItemsMatch = existingXml.match( <title><![CDATA[${escapeXml(episode.title)}]]></title>
/<channel>([\s\S]*?)<\/channel>/, <description><![CDATA[${escapeXml(episode.title)}]]></description>
); <author>${escapeXml(config.podcast.author)}</author>
if (existingItemsMatch) { <category>${escapeXml(config.podcast.categories)}</category>
const existingItems = existingItemsMatch[1]; <language>${config.podcast.language}</language>
const newItemStartIndex = existingItems!.lastIndexOf("<item>"); <ttl>${config.podcast.ttl}</ttl>
<enclosure url="${escapeXml(fileUrl)}" length="${fileSize}" type="audio/mpeg" />
<guid>${escapeXml(fileUrl)}</guid>
<pubDate>${pubDate}</pubDate>
</item>`;
}
// 新しいitemを追加 export async function updatePodcastRSS(): Promise<void> {
const updatedItems = existingItems + itemsXml; try {
const episodes: Episode[] = await fetchAllEpisodes();
const lastBuildDate = new Date().toUTCString();
// lastBuildDateを更新 const itemsXml = episodes.map(createItemXml).join("\n");
const updatedXml = existingXml.replace( const outputPath = path.join(config.paths.publicDir, "podcast.xml");
/<lastBuildDate>.*?<\/lastBuildDate>/,
`<lastBuildDate>${lastBuildDate}</lastBuildDate>`,
);
// items部分を置き換え // Create RSS XML content
const finalXml = updatedXml.replace(
/<channel>[\s\S]*?<\/channel>/,
`<channel>${updatedItems}</channel>`,
);
// ファイルに書き込み
await fs.writeFile(outputPath, finalXml.trim());
} else {
// 不正なフォーマットの場合は新規作成
const rssXml = `<?xml version="1.0" encoding="UTF-8"?> const rssXml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"> <rss version="2.0">
<channel> <channel>
<title>${channelTitle}</title> <title>${escapeXml(config.podcast.title)}</title>
<link>${channelLink}</link> <link>${escapeXml(config.podcast.link)}</link>
<description><![CDATA[${channelDescription}]]></description> <description><![CDATA[${escapeXml(config.podcast.description)}]]></description>
<language>${config.podcast.language}</language>
<lastBuildDate>${lastBuildDate}</lastBuildDate> <lastBuildDate>${lastBuildDate}</lastBuildDate>
${itemsXml} <ttl>${config.podcast.ttl}</ttl>
<author>${escapeXml(config.podcast.author)}</author>
<category>${escapeXml(config.podcast.categories)}</category>${itemsXml}
</channel> </channel>
</rss> </rss>`;
`;
await fs.writeFile(outputPath, rssXml.trim());
}
} else {
// 新規作成
const rssXml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>${channelTitle}</title>
<link>${channelLink}</link>
<description><![CDATA[${channelDescription}]]></description>
<lastBuildDate>${lastBuildDate}</lastBuildDate>
${itemsXml}
</channel>
</rss>
`;
// Ensure directory exists // Ensure directory exists
await fs.mkdir(dirname(outputPath), { recursive: true }); await fs.mkdir(dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, rssXml.trim()); await fs.writeFile(outputPath, rssXml);
console.log(`RSS feed updated with ${episodes.length} episodes`);
} catch (error) {
console.error("Error updating podcast RSS:", error);
throw error;
} }
} }

View File

@ -1,10 +1,7 @@
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import ffmpegPath from "ffmpeg-static"; import ffmpegPath from "ffmpeg-static";
import { config } from "./config.js";
// VOICEVOX APIの設定
const VOICEVOX_HOST = import.meta.env["VOICEVOX_HOST"];
const VOICEVOX_STYLE_ID = parseInt(import.meta.env["VOICEVOX_STYLE_ID"] ?? "0");
interface VoiceStyle { interface VoiceStyle {
styleId: number; styleId: number;
@ -12,19 +9,28 @@ interface VoiceStyle {
// 環境変数からデフォルトの声設定を取得 // 環境変数からデフォルトの声設定を取得
const defaultVoiceStyle: VoiceStyle = { const defaultVoiceStyle: VoiceStyle = {
styleId: VOICEVOX_STYLE_ID, styleId: config.voicevox.styleId,
}; };
export async function generateTTS( export async function generateTTS(
itemId: string, itemId: string,
scriptText: string, scriptText: string,
): Promise<string> { ): Promise<string> {
if (!itemId || itemId.trim() === "") {
throw new Error("Item ID is required for TTS generation");
}
if (!scriptText || scriptText.trim() === "") {
throw new Error("Script text is required for TTS generation");
}
console.log(`TTS生成開始: ${itemId}`); console.log(`TTS生成開始: ${itemId}`);
const encodedText = encodeURIComponent(scriptText); const encodedText = encodeURIComponent(scriptText);
const queryUrl = `${VOICEVOX_HOST}/audio_query?text=${encodedText}&speaker=${defaultVoiceStyle.styleId}`; const queryUrl = `${config.voicevox.host}/audio_query?text=${encodedText}&speaker=${defaultVoiceStyle.styleId}`;
const synthesisUrl = `${VOICEVOX_HOST}/synthesis?speaker=${defaultVoiceStyle.styleId}`; const synthesisUrl = `${config.voicevox.host}/synthesis?speaker=${defaultVoiceStyle.styleId}`;
try {
const queryResponse = await fetch(queryUrl, { const queryResponse = await fetch(queryUrl, {
method: "POST", method: "POST",
headers: { headers: {
@ -34,7 +40,8 @@ export async function generateTTS(
}); });
if (!queryResponse.ok) { if (!queryResponse.ok) {
throw new Error("VOICEVOX 音声合成クエリ生成に失敗しました"); const errorText = await queryResponse.text();
throw new Error(`VOICEVOX audio query failed (${queryResponse.status}): ${errorText}`);
} }
const audioQuery = await queryResponse.json(); const audioQuery = await queryResponse.json();
@ -49,15 +56,16 @@ export async function generateTTS(
}); });
if (!audioResponse.ok) { if (!audioResponse.ok) {
const errorText = await audioResponse.text();
console.error(`音声合成失敗: ${itemId}`); console.error(`音声合成失敗: ${itemId}`);
throw new Error("VOICEVOX 音声合成に失敗しました"); throw new Error(`VOICEVOX synthesis failed (${audioResponse.status}): ${errorText}`);
} }
const audioArrayBuffer = await audioResponse.arrayBuffer(); const audioArrayBuffer = await audioResponse.arrayBuffer();
const audioBuffer = Buffer.from(audioArrayBuffer); const audioBuffer = Buffer.from(audioArrayBuffer);
// 出力ディレクトリの準備 // 出力ディレクトリの準備
const outputDir = path.join(__dirname, "../public/podcast_audio"); const outputDir = config.paths.podcastAudioDir;
if (!fs.existsSync(outputDir)) { if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true }); fs.mkdirSync(outputDir, { recursive: true });
} }
@ -70,22 +78,37 @@ export async function generateTTS(
console.log(`WAVファイル保存完了: ${wavFilePath}`); console.log(`WAVファイル保存完了: ${wavFilePath}`);
console.log(`MP3変換開始: ${wavFilePath} -> ${mp3FilePath}`); console.log(`MP3変換開始: ${wavFilePath} -> ${mp3FilePath}`);
Bun.spawnSync({
const ffmpegCmd = ffmpegPath || "ffmpeg";
const result = Bun.spawnSync({
cmd: [ cmd: [
ffmpegPath || "ffmpeg", ffmpegCmd,
"-i", "-i",
wavFilePath, wavFilePath,
"-codec:a", "-codec:a",
"libmp3lame", "libmp3lame",
"-qscale:a", "-qscale:a",
"2", "2",
"-y", // Overwrite output file
mp3FilePath, mp3FilePath,
], ],
}); });
if (result.exitCode !== 0) {
const stderr = result.stderr ? new TextDecoder().decode(result.stderr) : "Unknown error";
throw new Error(`FFmpeg conversion failed: ${stderr}`);
}
// Wavファイルを削除 // Wavファイルを削除
if (fs.existsSync(wavFilePath)) {
fs.unlinkSync(wavFilePath); fs.unlinkSync(wavFilePath);
}
console.log(`TTS生成完了: ${itemId}`); console.log(`TTS生成完了: ${itemId}`);
return path.basename(mp3FilePath); return path.basename(mp3FilePath);
} catch (error) {
console.error("Error generating TTS:", error);
throw new Error(`Failed to generate TTS: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
} }