From 986743949f457809bff153b3899ff3f5661ed74d Mon Sep 17 00:00:00 2001 From: Satsuki Akiba Date: Sat, 7 Jun 2025 08:47:10 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A8=98=E4=BA=8B=E3=81=94=E3=81=A8?= =?UTF-8?q?=E3=81=AE=E3=83=9D=E3=83=83=E3=83=89=E3=82=AD=E3=83=A3=E3=82=B9?= =?UTF-8?q?=E3=83=88=E7=94=9F=E6=88=90=E3=81=A8=E6=96=B0=E8=A6=8F=E8=A8=98?= =?UTF-8?q?=E4=BA=8B=E6=A4=9C=E5=87=BA=E3=82=B7=E3=82=B9=E3=83=86=E3=83=A0?= =?UTF-8?q?=E3=80=81=E3=83=A2=E3=83=80=E3=83=B3UI=E3=81=AE=E5=AE=9F?= =?UTF-8?q?=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新規記事検出システム: 記事の重複チェックと新規記事のみ処理 - 記事単位ポッドキャスト: フィード統合から記事個別エピソードに変更 - 6時間間隔バッチ処理: 自動定期実行スケジュールの改善 - 完全UIリニューアル: ダッシュボード・フィード管理・エピソード管理の3画面構成 - アクセシビリティ強化: ARIA属性、キーボードナビ、高コントラスト対応 - データベース刷新: feeds/articles/episodes階層構造への移行 - 中央集権設定管理: services/config.ts による設定統一 - エラーハンドリング改善: 全モジュールでの堅牢なエラー処理 - TypeScript型安全性向上: null安全性とインターフェース改善 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- CLAUDE.md | 66 +++ frontend/src/app/globals.css | 121 +++++- frontend/src/app/page.tsx | 111 +++++- frontend/src/components/Dashboard.tsx | 255 ++++++++++++ frontend/src/components/EpisodePlayer.tsx | 330 +++++++++++++-- frontend/src/components/FeedManager.tsx | 243 +++++++++++ schema.sql | 49 ++- scripts/fetch_and_generate.ts | 466 +++++++++++++++++----- server.ts | 419 +++++++++++-------- services/config.ts | 117 ++++++ services/database.ts | 379 ++++++++++++++++-- services/llm.ts | 79 +++- services/podcast.ts | 169 +++----- services/tts.ts | 159 ++++---- 14 files changed, 2395 insertions(+), 568 deletions(-) create mode 100644 CLAUDE.md create mode 100644 frontend/src/components/Dashboard.tsx create mode 100644 frontend/src/components/FeedManager.tsx create mode 100644 services/config.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2bc38f6 --- /dev/null +++ b/CLAUDE.md @@ -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 \ No newline at end of file diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 3ef3bb1..7ffb5a7 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1,23 +1,122 @@ -/* 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, body { padding: 0; margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif; - background-color: #f9fafb; /* light background */ - color: #111827; /* dark text */ } *, *::before, *::after { box-sizing: border-box; -} - -/* Center content by default */ -.container { - max-width: 1200px; - margin: 0 auto; - padding: 0 1rem; -} +} \ No newline at end of file diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 48b97ea..7b6286b 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,5 +1,7 @@ -import FeedList from "../components/FeedList"; +import { useState } from "react"; +import FeedManager from "../components/FeedManager"; import EpisodePlayer from "../components/EpisodePlayer"; +import Dashboard from "../components/Dashboard"; export const metadata = { title: "Voice RSS Summary", @@ -7,20 +9,99 @@ export const metadata = { }; export default function Home() { + const [activeTab, setActiveTab] = useState<'dashboard' | 'episodes' | 'feeds'>('dashboard'); + return ( -
-
-

Voice RSS Summary

-

RSSフィードから自動生成された音声ポッドキャストを再生・管理できます。

-
-
-

フィード一覧

- -
-
-

エピソードプレイヤー

- -
+
+ {/* Header */} +
+
+
+
+
+ +
+
+

Voice RSS Summary

+

AI音声ポッドキャスト自動生成システム

+
+
+
+
+
+ システム稼働中 +
+
+
+
+
+ + {/* Navigation */} + + + {/* Main Content */} +
+
+ {activeTab === 'dashboard' && } + {activeTab === 'episodes' && ( +
+
+
+ +
+

エピソード管理

+
+ +
+ )} + {activeTab === 'feeds' && ( +
+
+
+ +
+

フィード管理

+
+ +
+ )} +
+
+ + {/* Footer */} +
+
+
+

© 2025 Voice RSS Summary. AI技術により最新のニュースを音声でお届けします。

+
+
+
); -} +} \ No newline at end of file diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx new file mode 100644 index 0000000..e9c61d2 --- /dev/null +++ b/frontend/src/components/Dashboard.tsx @@ -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(null); + const [recentEpisodes, setRecentEpisodes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+
+ 読み込み中... +
+
+ ); + } + + if (error) { + return ( +
+
+
+ + + +
+
+

エラー

+

{error}

+
+
+
+ ); + } + + return ( +
+ {/* Stats Cards */} +
+
+
+
+
+ +
+
+
+

総フィード数

+

{stats?.totalFeeds || 0}

+
+
+
+ +
+
+
+
+ +
+
+
+

アクティブフィード

+

{stats?.activeFeeds || 0}

+
+
+
+ +
+
+
+
+ +
+
+
+

総エピソード数

+

{stats?.totalEpisodes || 0}

+
+
+
+ +
+
+
+
+ +
+
+
+

最終更新

+

+ {stats?.lastUpdated ? new Date(stats.lastUpdated).toLocaleDateString('ja-JP') : '未取得'} +

+
+
+
+
+ + {/* Action Cards */} +
+
+
+
+

手動バッチ実行

+

+ 新しい記事をすぐにチェックしてポッドキャストを生成 +

+
+ +
+
+ +
+
+
+

システム状態

+
+
+ 自動バッチ処理 (6時間間隔) +
+
+
+ + + +
+
+
+
+ + {/* Recent Episodes */} +
+
+

最新エピソード

+ {recentEpisodes.length} エピソード +
+ + {recentEpisodes.length === 0 ? ( +
+
+ +
+

まだエピソードがありません

+

フィードを追加してバッチ処理を実行してください

+
+ ) : ( +
+ {recentEpisodes.map((episode) => ( +
+
+ +
+
+

+ {episode.title} +

+

+ {episode.feed?.title} • {new Date(episode.createdAt).toLocaleDateString('ja-JP')} +

+ {episode.article && ( + + 元記事を見る → + + )} +
+
+ + 生成済み + +
+
+ ))} +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/EpisodePlayer.tsx b/frontend/src/components/EpisodePlayer.tsx index c5725b8..076430e 100644 --- a/frontend/src/components/EpisodePlayer.tsx +++ b/frontend/src/components/EpisodePlayer.tsx @@ -1,24 +1,62 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; interface Episode { id: string; title: string; - pubDate: string; + description?: 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() { const [episodes, setEpisodes] = useState([]); const [selectedEpisode, setSelectedEpisode] = useState(null); - const [audioUrl, setAudioUrl] = useState(null); + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [searchTerm, setSearchTerm] = useState(""); + + const audioRef = useRef(null); useEffect(() => { 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 () => { try { const response = await fetch("/api/episodes"); @@ -35,45 +73,267 @@ export default function EpisodePlayer() { }; const handlePlay = (episode: Episode) => { - setSelectedEpisode(episode); - setAudioUrl(`/podcast_audio/${episode.audioPath}`); + 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); + setIsPlaying(true); + setCurrentTime(0); + } }; - if (loading) return
読み込み中...
; - if (error) return
エラー: {error}
; + const handleSeek = (e: React.ChangeEvent) => { + 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 ( +
+
+
+ 読み込み中... +
+
+ ); + } + + if (error) { + return ( +
+
+
+ + + +
+
+

エラー

+

{error}

+
+
+
+ ); + } return ( -
-

最近のエピソード

-
- {episodes.map((episode) => ( -
- {episode.title} - -
- ))} +
+ {/* Search */} +
+
+ + + +
+ 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" + />
+ {/* Audio Player */} {selectedEpisode && ( -
-

- 再生中: {selectedEpisode.title} -

- {audioUrl ? ( -
); -} +} \ No newline at end of file diff --git a/frontend/src/components/FeedManager.tsx b/frontend/src/components/FeedManager.tsx new file mode 100644 index 0000000..1b4dd07 --- /dev/null +++ b/frontend/src/components/FeedManager.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+
+ 読み込み中... +
+
+ ); + } + + return ( +
+ {/* Add New Feed Form */} +
+

新しいフィードを追加

+
+
+ +
+ 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" + /> + +
+

+ RSS または Atom フィードの URL を入力してください +

+
+
+
+ + {/* Error Display */} + {error && ( +
+
+
+ + + +
+
+

エラー

+

{error}

+
+
+
+ )} + + {/* Feeds List */} +
+
+

登録済みフィード

+ {feeds.length} フィード +
+ + {feeds.length === 0 ? ( +
+
+ +
+

フィードがありません

+

上のフォームから RSS フィードを追加してください

+
+ ) : ( +
+ {feeds.map((feed) => ( +
+
+
+
+
+

+ {feed.title || 'タイトル未取得'} +

+
+ + {feed.description && ( +

+ {feed.description} +

+ )} + +
+
+ URL: + + {feed.url} + +
+ +
+ 追加日: {new Date(feed.createdAt).toLocaleDateString('ja-JP')} + {feed.lastUpdated && ( + 最終更新: {new Date(feed.lastUpdated).toLocaleDateString('ja-JP')} + )} +
+
+
+ +
+ + {feed.active ? 'アクティブ' : '無効'} + + + {/* Future: Add edit/delete buttons here */} + +
+
+
+ ))} +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/schema.sql b/schema.sql index 8c23c66..d8ccebd 100644 --- a/schema.sql +++ b/schema.sql @@ -1,4 +1,40 @@ -- 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 ( feed_url 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) ); -CREATE TABLE IF NOT EXISTS episodes ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - pubDate TEXT NOT NULL, - audioPath TEXT NOT NULL, - sourceLink TEXT NOT NULL -); +-- Create indexes for better performance +CREATE INDEX IF NOT EXISTS idx_articles_feed_id ON articles(feed_id); +CREATE INDEX IF NOT EXISTS idx_articles_pub_date ON articles(pub_date); +CREATE INDEX IF NOT EXISTS idx_articles_processed ON articles(processed); +CREATE INDEX IF NOT EXISTS idx_episodes_article_id ON episodes(article_id); +CREATE INDEX IF NOT EXISTS idx_feeds_active ON feeds(active); diff --git a/scripts/fetch_and_generate.ts b/scripts/fetch_and_generate.ts index 77977e8..085c6be 100644 --- a/scripts/fetch_and_generate.ts +++ b/scripts/fetch_and_generate.ts @@ -2,143 +2,407 @@ import Parser from "rss-parser"; import { openAI_ClassifyFeed, openAI_GeneratePodcastContent, -} from "../services/llm"; -import { generateTTS } from "../services/tts"; -import { saveEpisode, markAsProcessed } from "../services/database"; -import { updatePodcastRSS } from "../services/podcast"; +} from "../services/llm.js"; +import { generateTTS } from "../services/tts.js"; +import { + 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 fs from "fs/promises"; interface FeedItem { - id: string; - title: string; - link: string; - pubDate: string; + id?: string; + title?: string; + link?: string; + pubDate?: string; contentSnippet?: string; + content?: string; + description?: string; } -import fs from "fs/promises"; -import path from "path"; -import { fileURLToPath } from "url"; - -const __filename = fileURLToPath(import.meta.url); -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[]; +/** + * Main batch processing function + * Processes all feeds and generates podcasts for new articles + */ +export async function batchProcess(): Promise { try { - const data = await fs.readFile(feedUrlsPath, "utf-8"); - feedUrls = data + console.log("🚀 Starting enhanced batch process..."); + + // Load feed URLs from file + const feedUrls = await loadFeedUrls(); + if (feedUrls.length === 0) { + console.log("ℹ️ No feed URLs found."); + return; + } + + console.log(`📡 Processing ${feedUrls.length} feeds...`); + + // Process each feed URL + for (const url of feedUrls) { + try { + await processFeedUrl(url); + } 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(); + + console.log("✅ Enhanced batch process completed:", new Date().toISOString()); + } catch (error) { + console.error("💥 Batch process failed:", error); + throw error; + } +} + +/** + * Load feed URLs from configuration file + */ +async function loadFeedUrls(): Promise { + try { + const data = await fs.readFile(config.paths.feedUrlsFile, "utf-8"); + return data .split("\n") .map((url) => url.trim()) - .filter((url) => url.length > 0); + .filter((url) => url.length > 0 && !url.startsWith("#")); } catch (err) { - console.warn(`フィードURLファイルの読み込みに失敗: ${feedUrlsFile}`); - feedUrls = []; + console.warn(`⚠️ Failed to read feed URLs file: ${config.paths.feedUrlsFile}`); + console.warn("📝 Please create the file with one RSS URL per line."); + return []; } - - // フィードごとに処理 - for (const url of feedUrls) { - try { - await processFeedUrl(url); - } finally { - } - } - - await updatePodcastRSS(); - console.log("処理完了:", new Date().toISOString()); } -const processFeedUrl = async (url: string) => { +/** + * Process a single feed URL and discover new articles + */ +async function processFeedUrl(url: string): Promise { + 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(); + 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 { + 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 { + 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 { + 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 { + console.log(`🔄 Legacy processing for: ${url}`); + const parser = new Parser(); const feed = await parser.parseURL(url); - // フィードのカテゴリ分類 + // Feed classification const feedTitle = feed.title || url; 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); + + if (latest5Items.length === 0) { + console.log(`No items found in feed: ${feedTitle}`); + return; + } - // FIXME: 昨日の記事のみフィルタリング - // const yesterday = new Date(); - // yesterday.setDate(yesterday.getDate() - 1); - // 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; - // } - - // ポッドキャスト原稿生成 - console.log(`ポッドキャスト原稿生成開始: ${feedTitle}`); + // Generate podcast content (old way - multiple articles in one podcast) + console.log(`Generating podcast content for: ${feedTitle}`); const validItems = latest5Items.filter((item): item is FeedItem => { return !!item.title && !!item.link; }); - const podcastContent = await openAI_GeneratePodcastContent( - feedTitle, - validItems, - ); - - // トピックごとの統合音声生成 - const feedUrlHash = crypto.createHash("md5").update(url).digest("hex"); - 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("アイテムが空です"); + + if (validItems.length === 0) { + console.log(`No valid items found in feed: ${feedTitle}`); 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({ - id: uniqueId, + articleId: tempArticleId, title: `${category}: ${feedTitle}`, - pubDate: pub.toISOString(), - audioPath: audioFilePath, - sourceLink: url, + description: `Legacy podcast for feed: ${feedTitle}`, + audioPath: audioFilePath }); - console.log(`エピソード保存完了: ${category} - ${feedTitle}`); + console.log(`Legacy episode saved: ${category} - ${feedTitle}`); - // 個別記事の処理記録 + // Mark individual articles as processed (legacy) for (const item of latest5Items) { - const itemId = item["id"] as string | undefined; - const fallbackId = item.link || item.title || JSON.stringify(item); - const finalItemId = - itemId && typeof itemId === "string" && itemId.trim() !== "" + try { + const itemId = (item as any)["id"] as string | undefined; + const fallbackId = item.link || item.title || JSON.stringify(item); + const finalItemId = itemId && typeof itemId === "string" && itemId.trim() !== "" ? itemId : `fallback-${Buffer.from(fallbackId).toString("base64")}`; - if (!finalItemId || finalItemId.trim() === "") { - console.warn(`フィードアイテムのIDを生成できませんでした`, { - feedUrl: url, - itemTitle: item.title, - itemLink: item.link, - }); - continue; - } + if (!finalItemId || finalItemId.trim() === "") { + console.warn(`Could not generate ID for feed item`, { + feedUrl: url, + itemTitle: item.title, + itemLink: item.link, + }); + continue; + } - const already = await markAsProcessed(url, finalItemId); - if (already) { - console.log(`既に処理済み: ${finalItemId}`); - continue; + const alreadyProcessed = await markAsProcessed(url, finalItemId); + if (alreadyProcessed) { + console.log(`Already processed: ${finalItemId}`); + } + } catch (error) { + console.error(`Error marking item as processed:`, error); } } -}; +} */ -batchProcess().catch((err) => { - console.error("バッチ処理中にエラーが発生しました:", err); -}); +// Export function for use in server +export async function addNewFeedUrl(feedUrl: string): Promise { + 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); + }); +} \ No newline at end of file diff --git a/server.ts b/server.ts index be7e0b0..335c85a 100644 --- a/server.ts +++ b/server.ts @@ -1,218 +1,296 @@ import { Hono } from "hono"; import { serve } from "@hono/node-server"; -import fs from "fs"; 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; - -// データベースパスの設定 -const dbPath = path.join(projectRoot, "data/podcast.db"); -const dataDir = path.dirname(dbPath); -if (!fs.existsSync(dataDir)) { - fs.mkdirSync(dataDir, { recursive: true }); +// Validate configuration on startup +try { + validateConfig(); + console.log("Configuration validated successfully"); +} catch (error) { + console.error("Configuration validation failed:", error); + 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(); -// APIルート(順序を最適化) +// API routes app.get("/api/feeds", async (c) => { - const rows = db - .query("SELECT feed_url FROM processed_feed_items GROUP BY feed_url") - .all() as { feed_url: string }[]; - return c.json(rows.map((r) => r.feed_url)); + try { + const feeds = await getAllFeeds(); + return c.json(feeds); + } catch (error) { + console.error("Error fetching feeds:", error); + return c.json({ error: "Failed to fetch feeds" }, 500); + } }); app.post("/api/feeds", async (c) => { try { const { feedUrl } = await c.req.json<{ feedUrl: string }>(); - console.log("Received feedUrl to add:", feedUrl); - // TODO: feedUrl をデータベースに保存する処理 - return c.json({ result: "OK" }); - } catch (e) { - return c.json({ error: "Invalid JSON body" }, 400); + + if (!feedUrl || typeof feedUrl !== "string" || !feedUrl.startsWith('http')) { + return c.json({ error: "Valid feed URL is required" }, 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) => { - const episodes = db - .query("SELECT * FROM episodes ORDER BY pubDate DESC") - .all(); - return c.json(episodes); +app.get("/api/episodes", async (c) => { + try { + const episodes = await fetchEpisodesWithArticles(); + 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) => { - const id = c.req.param("id"); - console.log("Regeneration requested for episode ID:", id); - // TODO: 再生成ロジックを実装 - return c.json({ result: `Regeneration requested for ${id}` }); +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"); + + if (!id || id.trim() === "") { + 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) => { - const filePath = path.join(frontendBuildDir, c.req.path); - const file = Bun.file(filePath); - if (await file.exists()) { - const contentType = filePath.endsWith(".js") - ? "application/javascript" - : filePath.endsWith(".css") - ? "text/css" - : "application/octet-stream"; - const blob = await file.arrayBuffer(); - return c.body(blob, 200, { "Content-Type": contentType }); - } - return c.notFound(); -}); - -// podcast_audio -app.get("/podcast_audio/*", async (c) => { - const audioFileName = c.req.path.substring("/podcast_audio/".length); - const audioFilePath = path.join(podcastAudioDir, audioFileName); - const file = Bun.file(audioFilePath); - if (await file.exists()) { - const blob = await file.arrayBuffer(); - return c.body(blob, 200, { "Content-Type": "audio/mpeg" }); - } - return c.notFound(); -}); - -// podcast.xml -app.get("/podcast.xml", async (c) => { - const filePath = path.join(generalPublicDir, "podcast.xml"); try { + const filePath = path.join(config.paths.frontendBuildDir, c.req.path); const file = Bun.file(filePath); + + if (await file.exists()) { + const contentType = filePath.endsWith(".js") + ? "application/javascript" + : filePath.endsWith(".css") + ? "text/css" + : "application/octet-stream"; + const blob = await file.arrayBuffer(); + return c.body(blob, 200, { "Content-Type": contentType }); + } + return c.notFound(); + } catch (error) { + console.error("Error serving asset:", error); + return c.notFound(); + } +}); + +app.get("/podcast_audio/*", async (c) => { + try { + const audioFileName = c.req.path.substring("/podcast_audio/".length); + + // Basic security check + if (audioFileName.includes("..") || audioFileName.includes("/")) { + return c.notFound(); + } + + const audioFilePath = path.join(config.paths.podcastAudioDir, audioFileName); + const file = Bun.file(audioFilePath); + + if (await file.exists()) { + const blob = await file.arrayBuffer(); + return c.body(blob, 200, { "Content-Type": "audio/mpeg" }); + } + return c.notFound(); + } catch (error) { + console.error("Error serving audio file:", error); + return c.notFound(); + } +}); + +app.get("/podcast.xml", async (c) => { + try { + const filePath = path.join(config.paths.publicDir, "podcast.xml"); + const file = Bun.file(filePath); + if (await file.exists()) { const blob = await file.arrayBuffer(); return c.body(blob, 200, { "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); - } - 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"); + + console.warn("podcast.xml not found"); + return c.notFound(); } 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() { - const now = new Date(); - const nextRun = new Date( - now.getFullYear(), - now.getMonth(), - now.getDate() + 1, - 0, - 0, - 0, - ); +app.get("/", serveIndex); - const delay = nextRun.getTime() - now.getTime(); +app.get("/index.html", serveIndex); + +// Catch-all for SPA routing +app.get("*", serveIndex); + +// Batch processing functions +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 + 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 () => { try { - console.log("Running daily batch process..."); - runBatchProcess(); - console.log("Daily batch process completed"); + console.log("🔄 Running scheduled 6-hourly batch process..."); + await runBatchProcess(); + console.log("✅ Scheduled batch process completed"); } catch (error) { - console.error("Error during daily batch process:", error); + console.error("❌ Error during scheduled batch process:", error); } - // 次回実行を再設定 - scheduleDailyBatchProcess(); - }, delay); + // Schedule next run + scheduleSixHourlyBatchProcess(); + }, SIX_HOURS_MS); } -const runBatchProcess = () => { +async function runBatchProcess(): Promise { try { - console.log("Running batch process..."); - Bun.spawn(["bun", "run", "scripts/fetch_and_generate.ts"]); - console.log("Batch process completed"); + await batchProcess(); } catch (error) { - console.error("Error during batch process:", error); + console.error("Batch process failed:", error); + throw error; } -}; +} // サーバー起動 serve( @@ -221,9 +299,12 @@ serve( port: 3000, }, (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(); - scheduleDailyBatchProcess(); + scheduleSixHourlyBatchProcess(); }, ); diff --git a/services/config.ts b/services/config.ts new file mode 100644 index 0000000..89dbb26 --- /dev/null +++ b/services/config.ts @@ -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"); + } +} \ No newline at end of file diff --git a/services/database.ts b/services/database.ts index 88cd420..85f1393 100644 --- a/services/database.ts +++ b/services/database.ts @@ -1,35 +1,78 @@ import { Database } from "bun:sqlite"; -import path from "path"; import fs from "fs"; +import crypto from "crypto"; +import { config } from "./config.js"; -// データベースディレクトリのパスを取得 -const dbDir = path.join(__dirname, "../data"); - -// ディレクトリが存在しない場合は作成 -if (!fs.existsSync(dbDir)) { - fs.mkdirSync(dbDir, { recursive: true }); +// Initialize database with proper error handling +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(config.paths.dbPath)) { + fs.closeSync(fs.openSync(config.paths.dbPath, "w")); + } + + const db = new Database(config.paths.dbPath); + + // Ensure schema is set up + db.exec(`CREATE TABLE IF NOT EXISTS processed_feed_items ( + feed_url TEXT NOT NULL, + item_id TEXT NOT NULL, + processed_at TEXT NOT NULL, + PRIMARY KEY(feed_url, item_id) + ); + + CREATE TABLE IF NOT EXISTS episodes ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + pubDate TEXT NOT NULL, + audioPath TEXT NOT NULL, + sourceLink TEXT NOT NULL + );`); + + return db; } -const dbPath = path.join(dbDir, "podcast.db"); -const db = new Database(dbPath); +const db = initializeDatabase(); -// Ensure schema is set up -db.exec(`CREATE TABLE IF NOT EXISTS processed_feed_items ( - feed_url TEXT NOT NULL, - item_id TEXT NOT NULL, - processed_at TEXT NOT NULL, - PRIMARY KEY(feed_url, item_id) -); +export interface Feed { + id: string; + url: string; + title?: string; + description?: string; + lastUpdated?: string; + createdAt: string; + active: boolean; +} -CREATE TABLE IF NOT EXISTS episodes ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - pubDate TEXT NOT NULL, - audioPath TEXT NOT NULL, - sourceLink TEXT NOT NULL -);`); +export interface Article { + id: string; + feedId: string; + title: string; + link: string; + description?: string; + content?: string; + pubDate: string; + discoveredAt: string; + processed: boolean; +} 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; title: string; pubDate: string; @@ -37,30 +80,286 @@ export interface Episode { sourceLink: string; } +// Feed management functions +export async function saveFeed(feed: Omit): Promise { + 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 { + 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 { + 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): Promise { + 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 { + 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 { + 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( feedUrl: string, itemId: string, ): Promise { - const stmt = db.prepare( - "SELECT 1 FROM processed_feed_items WHERE feed_url = ? AND item_id = ?", - ); - const row = stmt.get(feedUrl, itemId); - if (row) return true; - const insert = db.prepare( - "INSERT INTO processed_feed_items (feed_url, item_id, processed_at) VALUES (?, ?, ?)", - ); - insert.run(feedUrl, itemId, new Date().toISOString()); - return false; + if (!feedUrl || !itemId) { + throw new Error("feedUrl and itemId are required"); + } + + try { + const stmt = db.prepare( + "SELECT 1 FROM processed_feed_items WHERE feed_url = ? AND item_id = ?", + ); + const row = stmt.get(feedUrl, itemId); + if (row) return true; + + const insert = db.prepare( + "INSERT INTO processed_feed_items (feed_url, item_id, processed_at) VALUES (?, ?, ?)", + ); + insert.run(feedUrl, itemId, new Date().toISOString()); + return false; + } catch (error) { + console.error("Error marking item as processed:", error); + throw error; + } } -export async function saveEpisode(ep: Episode): Promise { - const stmt = db.prepare( - "INSERT OR IGNORE INTO episodes (id, title, pubDate, audioPath, sourceLink) VALUES (?, ?, ?, ?, ?)", - ); - stmt.run(ep.id, ep.title, ep.pubDate, ep.audioPath, ep.sourceLink); +// Episode management functions +export async function saveEpisode(episode: Omit): Promise { + 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( + "INSERT INTO episodes (id, article_id, title, description, audio_path, duration, file_size, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ); + 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 { + 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 { - const stmt = db.prepare("SELECT * FROM episodes ORDER BY pubDate DESC"); - return stmt.all() as Episode[]; + 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[]; + } 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(); } diff --git a/services/llm.ts b/services/llm.ts index 5fbf62c..3f7f3e2 100644 --- a/services/llm.ts +++ b/services/llm.ts @@ -1,12 +1,20 @@ import { OpenAI, ClientOptions } from "openai"; +import { config, validateConfig } from "./config.js"; + +// Validate config on module load +validateConfig(); const clientOptions: ClientOptions = { - apiKey: import.meta.env["OPENAI_API_KEY"], - baseURL: import.meta.env["OPENAI_API_ENDPOINT"], + apiKey: config.openai.apiKey, + baseURL: config.openai.endpoint, }; const openai = new OpenAI(clientOptions); export async function openAI_ClassifyFeed(title: string): Promise { + if (!title || title.trim() === "") { + throw new Error("Feed title is required for classification"); + } + const prompt = ` 以下のRSSフィードのタイトルを見て、適切なトピックカテゴリに分類してください。 @@ -26,26 +34,52 @@ export async function openAI_ClassifyFeed(title: string): Promise { 分類結果を上記カテゴリのいずれか1つだけ返してください。 `; - const response = await openai.chat.completions.create({ - model: import.meta.env["OPENAI_MODEL_NAME"] ?? "gpt-4o-mini", - messages: [{ role: "user", content: prompt.trim() }], - temperature: 0.3, - }); - const category = response.choices[0]!.message?.content?.trim() || "その他"; - return category; + + try { + const response = await openai.chat.completions.create({ + model: config.openai.modelName, + messages: [{ role: "user", content: prompt.trim() }], + temperature: 0.3, + }); + + const category = response.choices[0]?.message?.content?.trim(); + if (!category) { + console.warn("OpenAI returned empty category, using default"); + return "その他"; + } + + 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( title: string, items: Array<{ title: string; link: string }>, ): Promise { + 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 = ` あなたはプロのポッドキャスタです。以下に示すフィードタイトルに基づき、そのトピックに関する詳細なポッドキャスト原稿を作成してください。 フィードタイトル: ${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. 各ニュース記事の内容を要約し、関連性を説明してください @@ -56,11 +90,22 @@ ${items.map((item, i) => `${i + 1}. ${item.title} - ${item.link}`).join("\n")} この構成でポッドキャスト原稿を書いてください。 `; - const response = await openai.chat.completions.create({ - model: import.meta.env["OPENAI_MODEL_NAME"] ?? "gpt-4o-mini", - messages: [{ role: "user", content: prompt.trim() }], - temperature: 0.7, - }); - const scriptText = response.choices[0]!.message?.content?.trim() || ""; - return scriptText; + + try { + const response = await openai.chat.completions.create({ + model: config.openai.modelName, + messages: [{ role: "user", content: prompt.trim() }], + temperature: 0.7, + }); + + const scriptText = response.choices[0]?.message?.content?.trim(); + if (!scriptText) { + throw new Error("OpenAI returned empty podcast content"); + } + + 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'}`); + } } diff --git a/services/podcast.ts b/services/podcast.ts index 0416df3..8759141 100644 --- a/services/podcast.ts +++ b/services/podcast.ts @@ -1,118 +1,77 @@ import { promises as fs } from "fs"; -import { join, dirname } from "path"; -import { Episode, fetchAllEpisodes } from "./database"; +import { dirname } from "path"; +import { Episode, fetchAllEpisodes } from "./database.js"; import path from "node:path"; import fsSync from "node:fs"; +import { config } from "./config.js"; -export async function updatePodcastRSS() { - const episodes: Episode[] = await fetchAllEpisodes(); +function escapeXml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} - const channelTitle = - import.meta.env["PODCAST_TITLE"] ?? "自動生成ポッドキャスト"; - const channelLink = - 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 = ""; - 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 += ` - - <![CDATA[${ep.title}]]> - /g, "]]>").replace(/&/g, "&").replace(/\]\]>/g, "]]>")}]]> - ${channelAuthor} - ${channelCategories} - ${channelLanguage} - ${channelTTL} - - ${fileUrl} - ${pubDate} - - `; - } - - const outputPath = join(__dirname, "../public/podcast.xml"); - - // 既存のRSSファイルの読み込み - let existingXml = ""; +function createItemXml(episode: Episode): string { + const fileUrl = `${config.podcast.baseUrl}/podcast_audio/${path.basename(episode.audioPath)}`; + const pubDate = new Date(episode.createdAt).toUTCString(); + + let fileSize = 0; try { - existingXml = await fs.readFile(outputPath, "utf-8"); - } catch (err) { - // ファイルが存在しない場合は新規作成 - console.log("既存のpodcast.xmlが見つかりません。新規作成します。"); - } - - if (existingXml) { - // 既存のitem部分を抽出 - const existingItemsMatch = existingXml.match( - /([\s\S]*?)<\/channel>/, - ); - if (existingItemsMatch) { - const existingItems = existingItemsMatch[1]; - const newItemStartIndex = existingItems!.lastIndexOf(""); - - // 新しいitemを追加 - const updatedItems = existingItems + itemsXml; - - // lastBuildDateを更新 - const updatedXml = existingXml.replace( - /.*?<\/lastBuildDate>/, - `${lastBuildDate}`, - ); - - // items部分を置き換え - const finalXml = updatedXml.replace( - /[\s\S]*?<\/channel>/, - `${updatedItems}`, - ); - - // ファイルに書き込み - await fs.writeFile(outputPath, finalXml.trim()); - } else { - // 不正なフォーマットの場合は新規作成 - const rssXml = ` - - - ${channelTitle} - ${channelLink} - - ${lastBuildDate} - ${itemsXml} - - - `; - await fs.writeFile(outputPath, rssXml.trim()); + const audioPath = path.join(config.paths.podcastAudioDir, episode.audioPath); + if (fsSync.existsSync(audioPath)) { + fileSize = fsSync.statSync(audioPath).size; } - } else { - // 新規作成 + } catch (error) { + console.warn(`Could not get file size for ${episode.audioPath}:`, error); + } + + return ` + + <![CDATA[${escapeXml(episode.title)}]]> + + ${escapeXml(config.podcast.author)} + ${escapeXml(config.podcast.categories)} + ${config.podcast.language} + ${config.podcast.ttl} + + ${escapeXml(fileUrl)} + ${pubDate} + `; +} + +export async function updatePodcastRSS(): Promise { + try { + const episodes: Episode[] = await fetchAllEpisodes(); + const lastBuildDate = new Date().toUTCString(); + + const itemsXml = episodes.map(createItemXml).join("\n"); + const outputPath = path.join(config.paths.publicDir, "podcast.xml"); + + // Create RSS XML content const rssXml = ` - - - ${channelTitle} - ${channelLink} - - ${lastBuildDate} - ${itemsXml} - - - `; + + + ${escapeXml(config.podcast.title)} + ${escapeXml(config.podcast.link)} + + ${config.podcast.language} + ${lastBuildDate} + ${config.podcast.ttl} + ${escapeXml(config.podcast.author)} + ${escapeXml(config.podcast.categories)}${itemsXml} + +`; // Ensure directory exists 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; } } diff --git a/services/tts.ts b/services/tts.ts index 0615070..064b7df 100644 --- a/services/tts.ts +++ b/services/tts.ts @@ -1,10 +1,7 @@ import fs from "fs"; import path from "path"; import ffmpegPath from "ffmpeg-static"; - -// VOICEVOX APIの設定 -const VOICEVOX_HOST = import.meta.env["VOICEVOX_HOST"]; -const VOICEVOX_STYLE_ID = parseInt(import.meta.env["VOICEVOX_STYLE_ID"] ?? "0"); +import { config } from "./config.js"; interface VoiceStyle { styleId: number; @@ -12,80 +9,106 @@ interface VoiceStyle { // 環境変数からデフォルトの声設定を取得 const defaultVoiceStyle: VoiceStyle = { - styleId: VOICEVOX_STYLE_ID, + styleId: config.voicevox.styleId, }; export async function generateTTS( itemId: string, scriptText: string, ): Promise { + 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}`); const encodedText = encodeURIComponent(scriptText); - const queryUrl = `${VOICEVOX_HOST}/audio_query?text=${encodedText}&speaker=${defaultVoiceStyle.styleId}`; - const synthesisUrl = `${VOICEVOX_HOST}/synthesis?speaker=${defaultVoiceStyle.styleId}`; + const queryUrl = `${config.voicevox.host}/audio_query?text=${encodedText}&speaker=${defaultVoiceStyle.styleId}`; + const synthesisUrl = `${config.voicevox.host}/synthesis?speaker=${defaultVoiceStyle.styleId}`; - const queryResponse = await fetch(queryUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - }); + try { + const queryResponse = await fetch(queryUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + }); - if (!queryResponse.ok) { - throw new Error("VOICEVOX 音声合成クエリ生成に失敗しました"); + if (!queryResponse.ok) { + const errorText = await queryResponse.text(); + throw new Error(`VOICEVOX audio query failed (${queryResponse.status}): ${errorText}`); + } + + const audioQuery = await queryResponse.json(); + + console.log(`音声合成開始: ${itemId}`); + const audioResponse = await fetch(synthesisUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(audioQuery), + }); + + if (!audioResponse.ok) { + const errorText = await audioResponse.text(); + console.error(`音声合成失敗: ${itemId}`); + throw new Error(`VOICEVOX synthesis failed (${audioResponse.status}): ${errorText}`); + } + + const audioArrayBuffer = await audioResponse.arrayBuffer(); + const audioBuffer = Buffer.from(audioArrayBuffer); + + // 出力ディレクトリの準備 + const outputDir = config.paths.podcastAudioDir; + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + const wavFilePath = path.resolve(outputDir, `${itemId}.wav`); + const mp3FilePath = path.resolve(outputDir, `${itemId}.mp3`); + + console.log(`WAVファイル保存開始: ${wavFilePath}`); + fs.writeFileSync(wavFilePath, audioBuffer); + console.log(`WAVファイル保存完了: ${wavFilePath}`); + + console.log(`MP3変換開始: ${wavFilePath} -> ${mp3FilePath}`); + + const ffmpegCmd = ffmpegPath || "ffmpeg"; + const result = Bun.spawnSync({ + cmd: [ + ffmpegCmd, + "-i", + wavFilePath, + "-codec:a", + "libmp3lame", + "-qscale:a", + "2", + "-y", // Overwrite output file + mp3FilePath, + ], + }); + + if (result.exitCode !== 0) { + const stderr = result.stderr ? new TextDecoder().decode(result.stderr) : "Unknown error"; + throw new Error(`FFmpeg conversion failed: ${stderr}`); + } + + // Wavファイルを削除 + if (fs.existsSync(wavFilePath)) { + fs.unlinkSync(wavFilePath); + } + + console.log(`TTS生成完了: ${itemId}`); + + 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'}`); } - - const audioQuery = await queryResponse.json(); - - console.log(`音声合成開始: ${itemId}`); - const audioResponse = await fetch(synthesisUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(audioQuery), - }); - - if (!audioResponse.ok) { - console.error(`音声合成失敗: ${itemId}`); - throw new Error("VOICEVOX 音声合成に失敗しました"); - } - - const audioArrayBuffer = await audioResponse.arrayBuffer(); - const audioBuffer = Buffer.from(audioArrayBuffer); - - // 出力ディレクトリの準備 - const outputDir = path.join(__dirname, "../public/podcast_audio"); - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); - } - - const wavFilePath = path.resolve(outputDir, `${itemId}.wav`); - const mp3FilePath = path.resolve(outputDir, `${itemId}.mp3`); - - console.log(`WAVファイル保存開始: ${wavFilePath}`); - fs.writeFileSync(wavFilePath, audioBuffer); - console.log(`WAVファイル保存完了: ${wavFilePath}`); - - console.log(`MP3変換開始: ${wavFilePath} -> ${mp3FilePath}`); - Bun.spawnSync({ - cmd: [ - ffmpegPath || "ffmpeg", - "-i", - wavFilePath, - "-codec:a", - "libmp3lame", - "-qscale:a", - "2", - mp3FilePath, - ], - }); - - // Wavファイルを削除 - fs.unlinkSync(wavFilePath); - console.log(`TTS生成完了: ${itemId}`); - - return path.basename(mp3FilePath); }