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

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

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

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

117
services/config.ts Normal file
View File

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