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:
66
CLAUDE.md
Normal file
66
CLAUDE.md
Normal 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
|
@ -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,
|
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 */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
*::after {
|
*::after {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Center content by default */
|
|
||||||
.container {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 1rem;
|
|
||||||
}
|
|
@ -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="space-y-8">
|
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-purple-50">
|
||||||
<section>
|
{/* Header */}
|
||||||
<h1 className="text-2xl font-bold mb-4">Voice RSS Summary</h1>
|
<header className="bg-white shadow-sm border-b">
|
||||||
<p className="mb-6">RSSフィードから自動生成された音声ポッドキャストを再生・管理できます。</p>
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
</section>
|
<div className="flex justify-between items-center py-6">
|
||||||
<section>
|
<div className="flex items-center space-x-3">
|
||||||
<h2 className="text-xl font-semibold mb-4">フィード一覧</h2>
|
<div className="w-10 h-10 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg flex items-center justify-center">
|
||||||
<FeedList />
|
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
</section>
|
<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" />
|
||||||
<section>
|
</svg>
|
||||||
<h2 className="text-xl font-semibold mb-4">エピソードプレイヤー</h2>
|
</div>
|
||||||
<EpisodePlayer />
|
<div>
|
||||||
</section>
|
<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">
|
||||||
|
{activeTab === 'dashboard' && <Dashboard />}
|
||||||
|
{activeTab === 'episodes' && (
|
||||||
|
<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-purple-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>
|
||||||
|
<EpisodePlayer />
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
255
frontend/src/components/Dashboard.tsx
Normal file
255
frontend/src/components/Dashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -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) => {
|
||||||
setSelectedEpisode(episode);
|
if (selectedEpisode?.id === episode.id) {
|
||||||
setAudioUrl(`/podcast_audio/${episode.audioPath}`);
|
// 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 <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="space-y-2">
|
<div className="relative">
|
||||||
{episodes.map((episode) => (
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
<div
|
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
key={episode.id}
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
className="flex justify-between items-center p-2 hover:bg-gray-50 rounded"
|
</svg>
|
||||||
>
|
</div>
|
||||||
<span>{episode.title}</span>
|
<input
|
||||||
<button
|
type="text"
|
||||||
onClick={() => handlePlay(episode)}
|
placeholder="エピソードを検索..."
|
||||||
className="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600"
|
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"
|
||||||
</button>
|
/>
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Audio Player */}
|
||||||
{selectedEpisode && (
|
{selectedEpisode && (
|
||||||
<div className="mt-6">
|
<div className="bg-gradient-to-r from-purple-50 to-blue-50 rounded-xl p-6 border border-purple-100">
|
||||||
<h4 className="text-md font-semibold mb-2">
|
<div className="flex items-center space-x-4 mb-4">
|
||||||
再生中: {selectedEpisode.title}
|
<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">
|
||||||
</h4>
|
<span role="img" aria-hidden="true" className="text-xl">🎵</span>
|
||||||
{audioUrl ? (
|
</div>
|
||||||
<audio src={audioUrl} controls className="w-full" />
|
<div className="flex-1 min-w-0">
|
||||||
) : (
|
<h3 className="text-lg font-semibold text-gray-900 truncate">{selectedEpisode.title}</h3>
|
||||||
<div>音声ファイルを読み込み中...</div>
|
<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">
|
||||||
|
<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>
|
</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
|
||||||
|
key={episode.id}
|
||||||
|
className={`border rounded-xl p-4 transition-all duration-200 cursor-pointer ${
|
||||||
|
selectedEpisode?.id === episode.id
|
||||||
|
? 'border-purple-300 bg-purple-50 shadow-md'
|
||||||
|
: 'border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm'
|
||||||
|
}`}
|
||||||
|
onClick={() => handlePlay(episode)}
|
||||||
|
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">
|
||||||
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
243
frontend/src/components/FeedManager.tsx
Normal file
243
frontend/src/components/FeedManager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
49
schema.sql
49
schema.sql
@ -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);
|
||||||
);
|
|
||||||
|
@ -2,143 +2,407 @@ 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
|
|
||||||
|
// 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<string[]> {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(config.paths.feedUrlsFile, "utf-8");
|
||||||
|
return data
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((url) => url.trim())
|
.map((url) => url.trim())
|
||||||
.filter((url) => url.length > 0);
|
.filter((url) => url.length > 0 && !url.startsWith("#"));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`フィードURLファイルの読み込みに失敗: ${feedUrlsFile}`);
|
console.warn(`⚠️ Failed to read feed URLs file: ${config.paths.feedUrlsFile}`);
|
||||||
feedUrls = [];
|
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<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 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 parser = new Parser<FeedItem>();
|
||||||
const feed = await parser.parseURL(url);
|
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);
|
||||||
|
|
||||||
|
if (latest5Items.length === 0) {
|
||||||
|
console.log(`No items found in feed: ${feedTitle}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// FIXME: 昨日の記事のみフィルタリング
|
// Generate podcast content (old way - multiple articles in one podcast)
|
||||||
// const yesterday = new Date();
|
console.log(`Generating podcast content for: ${feedTitle}`);
|
||||||
// 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}`);
|
|
||||||
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,
|
if (validItems.length === 0) {
|
||||||
validItems,
|
console.log(`No valid items found in feed: ${feedTitle}`);
|
||||||
);
|
|
||||||
|
|
||||||
// トピックごとの統合音声生成
|
|
||||||
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("アイテムが空です");
|
|
||||||
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 fallbackId = item.link || item.title || JSON.stringify(item);
|
const itemId = (item as any)["id"] as string | undefined;
|
||||||
const finalItemId =
|
const fallbackId = item.link || item.title || JSON.stringify(item);
|
||||||
itemId && typeof itemId === "string" && itemId.trim() !== ""
|
const finalItemId = 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,
|
||||||
});
|
});
|
||||||
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);
|
||||||
|
});
|
||||||
|
}
|
419
server.ts
419
server.ts
@ -1,218 +1,296 @@
|
|||||||
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) => {
|
||||||
const id = c.req.param("id");
|
try {
|
||||||
console.log("Regeneration requested for episode ID:", id);
|
const episodes = await fetchAllEpisodes();
|
||||||
// TODO: 再生成ロジックを実装
|
return c.json(episodes);
|
||||||
return c.json({ result: `Regeneration requested for ${id}` });
|
} 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) => {
|
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 {
|
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()) {
|
||||||
|
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()) {
|
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(
|
|
||||||
now.getFullYear(),
|
|
||||||
now.getMonth(),
|
|
||||||
now.getDate() + 1,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
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(
|
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
117
services/config.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
@ -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)) {
|
||||||
if (!fs.existsSync(dbDir)) {
|
fs.mkdirSync(config.paths.dataDir, { recursive: true });
|
||||||
fs.mkdirSync(dbDir, { 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 = initializeDatabase();
|
||||||
const db = new Database(dbPath);
|
|
||||||
|
|
||||||
// Ensure schema is set up
|
export interface Feed {
|
||||||
db.exec(`CREATE TABLE IF NOT EXISTS processed_feed_items (
|
id: string;
|
||||||
feed_url TEXT NOT NULL,
|
url: string;
|
||||||
item_id TEXT NOT NULL,
|
title?: string;
|
||||||
processed_at TEXT NOT NULL,
|
description?: string;
|
||||||
PRIMARY KEY(feed_url, item_id)
|
lastUpdated?: string;
|
||||||
);
|
createdAt: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS episodes (
|
export interface Article {
|
||||||
id TEXT PRIMARY KEY,
|
id: string;
|
||||||
title TEXT NOT NULL,
|
feedId: string;
|
||||||
pubDate TEXT NOT NULL,
|
title: string;
|
||||||
audioPath TEXT NOT NULL,
|
link: string;
|
||||||
sourceLink TEXT NOT NULL
|
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> {
|
||||||
const stmt = db.prepare(
|
if (!feedUrl || !itemId) {
|
||||||
"SELECT 1 FROM processed_feed_items WHERE feed_url = ? AND item_id = ?",
|
throw new Error("feedUrl and itemId are required");
|
||||||
);
|
}
|
||||||
const row = stmt.get(feedUrl, itemId);
|
|
||||||
if (row) return true;
|
try {
|
||||||
const insert = db.prepare(
|
const stmt = db.prepare(
|
||||||
"INSERT INTO processed_feed_items (feed_url, item_id, processed_at) VALUES (?, ?, ?)",
|
"SELECT 1 FROM processed_feed_items WHERE feed_url = ? AND item_id = ?",
|
||||||
);
|
);
|
||||||
insert.run(feedUrl, itemId, new Date().toISOString());
|
const row = stmt.get(feedUrl, itemId);
|
||||||
return false;
|
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<void> {
|
// Episode management functions
|
||||||
const stmt = db.prepare(
|
export async function saveEpisode(episode: Omit<Episode, 'id' | 'createdAt'>): Promise<string> {
|
||||||
"INSERT OR IGNORE INTO episodes (id, title, pubDate, audioPath, sourceLink) VALUES (?, ?, ?, ?, ?)",
|
const id = crypto.randomUUID();
|
||||||
);
|
const createdAt = new Date().toISOString();
|
||||||
stmt.run(ep.id, ep.title, ep.pubDate, ep.audioPath, ep.sourceLink);
|
|
||||||
|
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<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 {
|
||||||
return stmt.all() as Episode[];
|
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();
|
||||||
}
|
}
|
||||||
|
@ -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つだけ返してください。
|
||||||
`;
|
`;
|
||||||
const response = await openai.chat.completions.create({
|
|
||||||
model: import.meta.env["OPENAI_MODEL_NAME"] ?? "gpt-4o-mini",
|
try {
|
||||||
messages: [{ role: "user", content: prompt.trim() }],
|
const response = await openai.chat.completions.create({
|
||||||
temperature: 0.3,
|
model: config.openai.modelName,
|
||||||
});
|
messages: [{ role: "user", content: prompt.trim() }],
|
||||||
const category = response.choices[0]!.message?.content?.trim() || "その他";
|
temperature: 0.3,
|
||||||
return category;
|
});
|
||||||
|
|
||||||
|
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(
|
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")}
|
|||||||
|
|
||||||
この構成でポッドキャスト原稿を書いてください。
|
この構成でポッドキャスト原稿を書いてください。
|
||||||
`;
|
`;
|
||||||
const response = await openai.chat.completions.create({
|
|
||||||
model: import.meta.env["OPENAI_MODEL_NAME"] ?? "gpt-4o-mini",
|
try {
|
||||||
messages: [{ role: "user", content: prompt.trim() }],
|
const response = await openai.chat.completions.create({
|
||||||
temperature: 0.7,
|
model: config.openai.modelName,
|
||||||
});
|
messages: [{ role: "user", content: prompt.trim() }],
|
||||||
const scriptText = response.choices[0]!.message?.content?.trim() || "";
|
temperature: 0.7,
|
||||||
return scriptText;
|
});
|
||||||
|
|
||||||
|
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'}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
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 =
|
let fileSize = 0;
|
||||||
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 += `
|
|
||||||
<item>
|
|
||||||
<title><![CDATA[${ep.title}]]></title>
|
|
||||||
<description><![CDATA[${ep.title.replace(/\]\]>/g, "]]>").replace(/&/g, "&").replace(/\]\]>/g, "]]>")}]]></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が見つかりません。新規作成します。");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingXml) {
|
|
||||||
// 既存のitem部分を抽出
|
|
||||||
const existingItemsMatch = existingXml.match(
|
|
||||||
/<channel>([\s\S]*?)<\/channel>/,
|
|
||||||
);
|
|
||||||
if (existingItemsMatch) {
|
|
||||||
const existingItems = existingItemsMatch[1];
|
|
||||||
const newItemStartIndex = existingItems!.lastIndexOf("<item>");
|
|
||||||
|
|
||||||
// 新しいitemを追加
|
|
||||||
const updatedItems = existingItems + itemsXml;
|
|
||||||
|
|
||||||
// lastBuildDateを更新
|
|
||||||
const updatedXml = existingXml.replace(
|
|
||||||
/<lastBuildDate>.*?<\/lastBuildDate>/,
|
|
||||||
`<lastBuildDate>${lastBuildDate}</lastBuildDate>`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// items部分を置き換え
|
|
||||||
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"?>
|
|
||||||
<rss version="2.0">
|
|
||||||
<channel>
|
|
||||||
<title>${channelTitle}</title>
|
|
||||||
<link>${channelLink}</link>
|
|
||||||
<description><![CDATA[${channelDescription}]]></description>
|
|
||||||
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
|
||||||
${itemsXml}
|
|
||||||
</channel>
|
|
||||||
</rss>
|
|
||||||
`;
|
|
||||||
await fs.writeFile(outputPath, rssXml.trim());
|
|
||||||
}
|
}
|
||||||
} else {
|
} catch (error) {
|
||||||
// 新規作成
|
console.warn(`Could not get file size for ${episode.audioPath}:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<item>
|
||||||
|
<title><![CDATA[${escapeXml(episode.title)}]]></title>
|
||||||
|
<description><![CDATA[${escapeXml(episode.title)}]]></description>
|
||||||
|
<author>${escapeXml(config.podcast.author)}</author>
|
||||||
|
<category>${escapeXml(config.podcast.categories)}</category>
|
||||||
|
<language>${config.podcast.language}</language>
|
||||||
|
<ttl>${config.podcast.ttl}</ttl>
|
||||||
|
<enclosure url="${escapeXml(fileUrl)}" length="${fileSize}" type="audio/mpeg" />
|
||||||
|
<guid>${escapeXml(fileUrl)}</guid>
|
||||||
|
<pubDate>${pubDate}</pubDate>
|
||||||
|
</item>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePodcastRSS(): Promise<void> {
|
||||||
|
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 = `<?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>
|
||||||
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
<language>${config.podcast.language}</language>
|
||||||
${itemsXml}
|
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
||||||
</channel>
|
<ttl>${config.podcast.ttl}</ttl>
|
||||||
</rss>
|
<author>${escapeXml(config.podcast.author)}</author>
|
||||||
`;
|
<category>${escapeXml(config.podcast.categories)}</category>${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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
159
services/tts.ts
159
services/tts.ts
@ -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,80 +9,106 @@ 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}`;
|
||||||
|
|
||||||
const queryResponse = await fetch(queryUrl, {
|
try {
|
||||||
method: "POST",
|
const queryResponse = await fetch(queryUrl, {
|
||||||
headers: {
|
method: "POST",
|
||||||
"Content-Type": "application/json",
|
headers: {
|
||||||
Accept: "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
Accept: "application/json",
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
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();
|
||||||
|
|
||||||
|
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);
|
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user