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:
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 path from "path";
|
||||
import fs from "fs";
|
||||
import crypto from "crypto";
|
||||
import { config } from "./config.js";
|
||||
|
||||
// データベースディレクトリのパスを取得
|
||||
const dbDir = path.join(__dirname, "../data");
|
||||
|
||||
// ディレクトリが存在しない場合は作成
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
// Initialize database with proper error handling
|
||||
function initializeDatabase(): Database {
|
||||
// Ensure data directory exists
|
||||
if (!fs.existsSync(config.paths.dataDir)) {
|
||||
fs.mkdirSync(config.paths.dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Create database file if it doesn't exist
|
||||
if (!fs.existsSync(config.paths.dbPath)) {
|
||||
fs.closeSync(fs.openSync(config.paths.dbPath, "w"));
|
||||
}
|
||||
|
||||
const db = new Database(config.paths.dbPath);
|
||||
|
||||
// Ensure schema is set up
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS processed_feed_items (
|
||||
feed_url TEXT NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
processed_at TEXT NOT NULL,
|
||||
PRIMARY KEY(feed_url, item_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS episodes (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
pubDate TEXT NOT NULL,
|
||||
audioPath TEXT NOT NULL,
|
||||
sourceLink TEXT NOT NULL
|
||||
);`);
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
const dbPath = path.join(dbDir, "podcast.db");
|
||||
const db = new Database(dbPath);
|
||||
const db = initializeDatabase();
|
||||
|
||||
// Ensure schema is set up
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS processed_feed_items (
|
||||
feed_url TEXT NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
processed_at TEXT NOT NULL,
|
||||
PRIMARY KEY(feed_url, item_id)
|
||||
);
|
||||
export interface Feed {
|
||||
id: string;
|
||||
url: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
lastUpdated?: string;
|
||||
createdAt: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
CREATE TABLE IF NOT EXISTS episodes (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
pubDate TEXT NOT NULL,
|
||||
audioPath TEXT NOT NULL,
|
||||
sourceLink TEXT NOT NULL
|
||||
);`);
|
||||
export interface Article {
|
||||
id: string;
|
||||
feedId: string;
|
||||
title: string;
|
||||
link: string;
|
||||
description?: string;
|
||||
content?: string;
|
||||
pubDate: string;
|
||||
discoveredAt: string;
|
||||
processed: boolean;
|
||||
}
|
||||
|
||||
export interface Episode {
|
||||
id: string;
|
||||
articleId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
audioPath: string;
|
||||
duration?: number;
|
||||
fileSize?: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// Legacy interface for backward compatibility
|
||||
export interface LegacyEpisode {
|
||||
id: string;
|
||||
title: string;
|
||||
pubDate: string;
|
||||
@ -37,30 +80,286 @@ export interface Episode {
|
||||
sourceLink: string;
|
||||
}
|
||||
|
||||
// Feed management functions
|
||||
export async function saveFeed(feed: Omit<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(
|
||||
feedUrl: string,
|
||||
itemId: string,
|
||||
): Promise<boolean> {
|
||||
const stmt = db.prepare(
|
||||
"SELECT 1 FROM processed_feed_items WHERE feed_url = ? AND item_id = ?",
|
||||
);
|
||||
const row = stmt.get(feedUrl, itemId);
|
||||
if (row) return true;
|
||||
const insert = db.prepare(
|
||||
"INSERT INTO processed_feed_items (feed_url, item_id, processed_at) VALUES (?, ?, ?)",
|
||||
);
|
||||
insert.run(feedUrl, itemId, new Date().toISOString());
|
||||
return false;
|
||||
if (!feedUrl || !itemId) {
|
||||
throw new Error("feedUrl and itemId are required");
|
||||
}
|
||||
|
||||
try {
|
||||
const stmt = db.prepare(
|
||||
"SELECT 1 FROM processed_feed_items WHERE feed_url = ? AND item_id = ?",
|
||||
);
|
||||
const row = stmt.get(feedUrl, itemId);
|
||||
if (row) return true;
|
||||
|
||||
const insert = db.prepare(
|
||||
"INSERT INTO processed_feed_items (feed_url, item_id, processed_at) VALUES (?, ?, ?)",
|
||||
);
|
||||
insert.run(feedUrl, itemId, new Date().toISOString());
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error("Error marking item as processed:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveEpisode(ep: Episode): Promise<void> {
|
||||
const stmt = db.prepare(
|
||||
"INSERT OR IGNORE INTO episodes (id, title, pubDate, audioPath, sourceLink) VALUES (?, ?, ?, ?, ?)",
|
||||
);
|
||||
stmt.run(ep.id, ep.title, ep.pubDate, ep.audioPath, ep.sourceLink);
|
||||
// Episode management functions
|
||||
export async function saveEpisode(episode: Omit<Episode, 'id' | 'createdAt'>): Promise<string> {
|
||||
const id = crypto.randomUUID();
|
||||
const createdAt = new Date().toISOString();
|
||||
|
||||
if (!episode.articleId || !episode.title || !episode.audioPath) {
|
||||
throw new Error("articleId, title, and audioPath are required");
|
||||
}
|
||||
|
||||
try {
|
||||
const stmt = db.prepare(
|
||||
"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[]> {
|
||||
const stmt = db.prepare("SELECT * FROM episodes ORDER BY pubDate DESC");
|
||||
return stmt.all() as Episode[];
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
SELECT
|
||||
e.id,
|
||||
e.article_id as articleId,
|
||||
e.title,
|
||||
e.description,
|
||||
e.audio_path as audioPath,
|
||||
e.duration,
|
||||
e.file_size as fileSize,
|
||||
e.created_at as createdAt
|
||||
FROM episodes e
|
||||
ORDER BY e.created_at DESC
|
||||
`);
|
||||
return stmt.all() as Episode[];
|
||||
} catch (error) {
|
||||
console.error("Error fetching episodes:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchEpisodesWithArticles(): Promise<(Episode & { article: Article, feed: Feed })[]> {
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
SELECT
|
||||
e.id,
|
||||
e.article_id as articleId,
|
||||
e.title,
|
||||
e.description,
|
||||
e.audio_path as audioPath,
|
||||
e.duration,
|
||||
e.file_size as fileSize,
|
||||
e.created_at as createdAt,
|
||||
a.id as article_id,
|
||||
a.feed_id as article_feedId,
|
||||
a.title as article_title,
|
||||
a.link as article_link,
|
||||
a.description as article_description,
|
||||
a.content as article_content,
|
||||
a.pub_date as article_pubDate,
|
||||
a.discovered_at as article_discoveredAt,
|
||||
a.processed as article_processed,
|
||||
f.id as feed_id,
|
||||
f.url as feed_url,
|
||||
f.title as feed_title,
|
||||
f.description as feed_description,
|
||||
f.last_updated as feed_lastUpdated,
|
||||
f.created_at as feed_createdAt,
|
||||
f.active as feed_active
|
||||
FROM episodes e
|
||||
JOIN articles a ON e.article_id = a.id
|
||||
JOIN feeds f ON a.feed_id = f.id
|
||||
ORDER BY e.created_at DESC
|
||||
`);
|
||||
|
||||
const rows = stmt.all() as any[];
|
||||
|
||||
return rows.map(row => ({
|
||||
id: row.id,
|
||||
articleId: row.articleId,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
audioPath: row.audioPath,
|
||||
duration: row.duration,
|
||||
fileSize: row.fileSize,
|
||||
createdAt: row.createdAt,
|
||||
article: {
|
||||
id: row.article_id,
|
||||
feedId: row.article_feedId,
|
||||
title: row.article_title,
|
||||
link: row.article_link,
|
||||
description: row.article_description,
|
||||
content: row.article_content,
|
||||
pubDate: row.article_pubDate,
|
||||
discoveredAt: row.article_discoveredAt,
|
||||
processed: Boolean(row.article_processed)
|
||||
},
|
||||
feed: {
|
||||
id: row.feed_id,
|
||||
url: row.feed_url,
|
||||
title: row.feed_title,
|
||||
description: row.feed_description,
|
||||
lastUpdated: row.feed_lastUpdated,
|
||||
createdAt: row.feed_createdAt,
|
||||
active: Boolean(row.feed_active)
|
||||
}
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error fetching episodes with articles:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function closeDatabase(): void {
|
||||
db.close();
|
||||
}
|
||||
|
@ -1,12 +1,20 @@
|
||||
import { OpenAI, ClientOptions } from "openai";
|
||||
import { config, validateConfig } from "./config.js";
|
||||
|
||||
// Validate config on module load
|
||||
validateConfig();
|
||||
|
||||
const clientOptions: ClientOptions = {
|
||||
apiKey: import.meta.env["OPENAI_API_KEY"],
|
||||
baseURL: import.meta.env["OPENAI_API_ENDPOINT"],
|
||||
apiKey: config.openai.apiKey,
|
||||
baseURL: config.openai.endpoint,
|
||||
};
|
||||
const openai = new OpenAI(clientOptions);
|
||||
|
||||
export async function openAI_ClassifyFeed(title: string): Promise<string> {
|
||||
if (!title || title.trim() === "") {
|
||||
throw new Error("Feed title is required for classification");
|
||||
}
|
||||
|
||||
const prompt = `
|
||||
以下のRSSフィードのタイトルを見て、適切なトピックカテゴリに分類してください。
|
||||
|
||||
@ -26,26 +34,52 @@ export async function openAI_ClassifyFeed(title: string): Promise<string> {
|
||||
|
||||
分類結果を上記カテゴリのいずれか1つだけ返してください。
|
||||
`;
|
||||
const response = await openai.chat.completions.create({
|
||||
model: import.meta.env["OPENAI_MODEL_NAME"] ?? "gpt-4o-mini",
|
||||
messages: [{ role: "user", content: prompt.trim() }],
|
||||
temperature: 0.3,
|
||||
});
|
||||
const category = response.choices[0]!.message?.content?.trim() || "その他";
|
||||
return category;
|
||||
|
||||
try {
|
||||
const response = await openai.chat.completions.create({
|
||||
model: config.openai.modelName,
|
||||
messages: [{ role: "user", content: prompt.trim() }],
|
||||
temperature: 0.3,
|
||||
});
|
||||
|
||||
const category = response.choices[0]?.message?.content?.trim();
|
||||
if (!category) {
|
||||
console.warn("OpenAI returned empty category, using default");
|
||||
return "その他";
|
||||
}
|
||||
|
||||
return category;
|
||||
} catch (error) {
|
||||
console.error("Error classifying feed:", error);
|
||||
throw new Error(`Failed to classify feed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function openAI_GeneratePodcastContent(
|
||||
title: string,
|
||||
items: Array<{ title: string; link: string }>,
|
||||
): Promise<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 = `
|
||||
あなたはプロのポッドキャスタです。以下に示すフィードタイトルに基づき、そのトピックに関する詳細なポッドキャスト原稿を作成してください。
|
||||
|
||||
フィードタイトル: ${title}
|
||||
|
||||
関連するニュース記事:
|
||||
${items.map((item, i) => `${i + 1}. ${item.title} - ${item.link}`).join("\n")}
|
||||
${validItems.map((item, i) => `${i + 1}. ${item.title} - ${item.link}`).join("\n")}
|
||||
|
||||
以下の要件を満たしてください:
|
||||
1. 各ニュース記事の内容を要約し、関連性を説明してください
|
||||
@ -56,11 +90,22 @@ ${items.map((item, i) => `${i + 1}. ${item.title} - ${item.link}`).join("\n")}
|
||||
|
||||
この構成でポッドキャスト原稿を書いてください。
|
||||
`;
|
||||
const response = await openai.chat.completions.create({
|
||||
model: import.meta.env["OPENAI_MODEL_NAME"] ?? "gpt-4o-mini",
|
||||
messages: [{ role: "user", content: prompt.trim() }],
|
||||
temperature: 0.7,
|
||||
});
|
||||
const scriptText = response.choices[0]!.message?.content?.trim() || "";
|
||||
return scriptText;
|
||||
|
||||
try {
|
||||
const response = await openai.chat.completions.create({
|
||||
model: config.openai.modelName,
|
||||
messages: [{ role: "user", content: prompt.trim() }],
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
const scriptText = response.choices[0]?.message?.content?.trim();
|
||||
if (!scriptText) {
|
||||
throw new Error("OpenAI returned empty podcast content");
|
||||
}
|
||||
|
||||
return scriptText;
|
||||
} catch (error) {
|
||||
console.error("Error generating podcast content:", error);
|
||||
throw new Error(`Failed to generate podcast content: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
@ -1,118 +1,77 @@
|
||||
import { promises as fs } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { Episode, fetchAllEpisodes } from "./database";
|
||||
import { dirname } from "path";
|
||||
import { Episode, fetchAllEpisodes } from "./database.js";
|
||||
import path from "node:path";
|
||||
import fsSync from "node:fs";
|
||||
import { config } from "./config.js";
|
||||
|
||||
export async function updatePodcastRSS() {
|
||||
const episodes: Episode[] = await fetchAllEpisodes();
|
||||
function escapeXml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
const channelTitle =
|
||||
import.meta.env["PODCAST_TITLE"] ?? "自動生成ポッドキャスト";
|
||||
const channelLink =
|
||||
import.meta.env["PODCAST_LINK"] ?? "https://your-domain.com/podcast";
|
||||
const channelDescription =
|
||||
import.meta.env["PODCAST_DESCRIPTION"] ??
|
||||
"RSSフィードから自動生成された音声ポッドキャスト";
|
||||
const channelLanguage = import.meta.env["PODCAST_LANGUAGE"] ?? "ja";
|
||||
const channelAuthor = import.meta.env["PODCAST_AUTHOR"] ?? "管理者";
|
||||
const channelCategories =
|
||||
import.meta.env["PODCAST_CATEGORIES"] ?? "Technology";
|
||||
const channelTTL = import.meta.env["PODCAST_TTL"] ?? "60";
|
||||
const lastBuildDate = new Date().toUTCString();
|
||||
const baseUrl =
|
||||
import.meta.env["PODCAST_BASE_URL"] ?? "https://your-domain.com";
|
||||
|
||||
let itemsXml = "";
|
||||
for (const ep of episodes) {
|
||||
const fileUrl = `${baseUrl}/podcast_audio/${path.basename(ep.audioPath)}`;
|
||||
const pubDate = new Date(ep.pubDate).toUTCString();
|
||||
const fileSize = fsSync.statSync(
|
||||
path.join(import.meta.dir, "..", "public/podcast_audio", ep.audioPath),
|
||||
).size;
|
||||
itemsXml += `
|
||||
<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 = "";
|
||||
function createItemXml(episode: Episode): string {
|
||||
const fileUrl = `${config.podcast.baseUrl}/podcast_audio/${path.basename(episode.audioPath)}`;
|
||||
const pubDate = new Date(episode.createdAt).toUTCString();
|
||||
|
||||
let fileSize = 0;
|
||||
try {
|
||||
existingXml = await fs.readFile(outputPath, "utf-8");
|
||||
} catch (err) {
|
||||
// ファイルが存在しない場合は新規作成
|
||||
console.log("既存のpodcast.xmlが見つかりません。新規作成します。");
|
||||
}
|
||||
|
||||
if (existingXml) {
|
||||
// 既存のitem部分を抽出
|
||||
const existingItemsMatch = existingXml.match(
|
||||
/<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());
|
||||
const audioPath = path.join(config.paths.podcastAudioDir, episode.audioPath);
|
||||
if (fsSync.existsSync(audioPath)) {
|
||||
fileSize = fsSync.statSync(audioPath).size;
|
||||
}
|
||||
} else {
|
||||
// 新規作成
|
||||
} catch (error) {
|
||||
console.warn(`Could not get file size for ${episode.audioPath}:`, error);
|
||||
}
|
||||
|
||||
return `
|
||||
<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"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>${channelTitle}</title>
|
||||
<link>${channelLink}</link>
|
||||
<description><![CDATA[${channelDescription}]]></description>
|
||||
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
||||
${itemsXml}
|
||||
</channel>
|
||||
</rss>
|
||||
`;
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>${escapeXml(config.podcast.title)}</title>
|
||||
<link>${escapeXml(config.podcast.link)}</link>
|
||||
<description><![CDATA[${escapeXml(config.podcast.description)}]]></description>
|
||||
<language>${config.podcast.language}</language>
|
||||
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
||||
<ttl>${config.podcast.ttl}</ttl>
|
||||
<author>${escapeXml(config.podcast.author)}</author>
|
||||
<category>${escapeXml(config.podcast.categories)}</category>${itemsXml}
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
// Ensure directory exists
|
||||
await fs.mkdir(dirname(outputPath), { recursive: true });
|
||||
await fs.writeFile(outputPath, rssXml.trim());
|
||||
await fs.writeFile(outputPath, rssXml);
|
||||
|
||||
console.log(`RSS feed updated with ${episodes.length} episodes`);
|
||||
} catch (error) {
|
||||
console.error("Error updating podcast RSS:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
159
services/tts.ts
159
services/tts.ts
@ -1,10 +1,7 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import ffmpegPath from "ffmpeg-static";
|
||||
|
||||
// VOICEVOX APIの設定
|
||||
const VOICEVOX_HOST = import.meta.env["VOICEVOX_HOST"];
|
||||
const VOICEVOX_STYLE_ID = parseInt(import.meta.env["VOICEVOX_STYLE_ID"] ?? "0");
|
||||
import { config } from "./config.js";
|
||||
|
||||
interface VoiceStyle {
|
||||
styleId: number;
|
||||
@ -12,80 +9,106 @@ interface VoiceStyle {
|
||||
|
||||
// 環境変数からデフォルトの声設定を取得
|
||||
const defaultVoiceStyle: VoiceStyle = {
|
||||
styleId: VOICEVOX_STYLE_ID,
|
||||
styleId: config.voicevox.styleId,
|
||||
};
|
||||
|
||||
export async function generateTTS(
|
||||
itemId: string,
|
||||
scriptText: string,
|
||||
): Promise<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}`);
|
||||
const encodedText = encodeURIComponent(scriptText);
|
||||
|
||||
const queryUrl = `${VOICEVOX_HOST}/audio_query?text=${encodedText}&speaker=${defaultVoiceStyle.styleId}`;
|
||||
const synthesisUrl = `${VOICEVOX_HOST}/synthesis?speaker=${defaultVoiceStyle.styleId}`;
|
||||
const queryUrl = `${config.voicevox.host}/audio_query?text=${encodedText}&speaker=${defaultVoiceStyle.styleId}`;
|
||||
const synthesisUrl = `${config.voicevox.host}/synthesis?speaker=${defaultVoiceStyle.styleId}`;
|
||||
|
||||
const queryResponse = await fetch(queryUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
try {
|
||||
const queryResponse = await fetch(queryUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!queryResponse.ok) {
|
||||
throw new Error("VOICEVOX 音声合成クエリ生成に失敗しました");
|
||||
if (!queryResponse.ok) {
|
||||
const errorText = await queryResponse.text();
|
||||
throw new Error(`VOICEVOX audio query failed (${queryResponse.status}): ${errorText}`);
|
||||
}
|
||||
|
||||
const audioQuery = await queryResponse.json();
|
||||
|
||||
console.log(`音声合成開始: ${itemId}`);
|
||||
const audioResponse = await fetch(synthesisUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(audioQuery),
|
||||
});
|
||||
|
||||
if (!audioResponse.ok) {
|
||||
const errorText = await audioResponse.text();
|
||||
console.error(`音声合成失敗: ${itemId}`);
|
||||
throw new Error(`VOICEVOX synthesis failed (${audioResponse.status}): ${errorText}`);
|
||||
}
|
||||
|
||||
const audioArrayBuffer = await audioResponse.arrayBuffer();
|
||||
const audioBuffer = Buffer.from(audioArrayBuffer);
|
||||
|
||||
// 出力ディレクトリの準備
|
||||
const outputDir = config.paths.podcastAudioDir;
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
const wavFilePath = path.resolve(outputDir, `${itemId}.wav`);
|
||||
const mp3FilePath = path.resolve(outputDir, `${itemId}.mp3`);
|
||||
|
||||
console.log(`WAVファイル保存開始: ${wavFilePath}`);
|
||||
fs.writeFileSync(wavFilePath, audioBuffer);
|
||||
console.log(`WAVファイル保存完了: ${wavFilePath}`);
|
||||
|
||||
console.log(`MP3変換開始: ${wavFilePath} -> ${mp3FilePath}`);
|
||||
|
||||
const ffmpegCmd = ffmpegPath || "ffmpeg";
|
||||
const result = Bun.spawnSync({
|
||||
cmd: [
|
||||
ffmpegCmd,
|
||||
"-i",
|
||||
wavFilePath,
|
||||
"-codec:a",
|
||||
"libmp3lame",
|
||||
"-qscale:a",
|
||||
"2",
|
||||
"-y", // Overwrite output file
|
||||
mp3FilePath,
|
||||
],
|
||||
});
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
const stderr = result.stderr ? new TextDecoder().decode(result.stderr) : "Unknown error";
|
||||
throw new Error(`FFmpeg conversion failed: ${stderr}`);
|
||||
}
|
||||
|
||||
// Wavファイルを削除
|
||||
if (fs.existsSync(wavFilePath)) {
|
||||
fs.unlinkSync(wavFilePath);
|
||||
}
|
||||
|
||||
console.log(`TTS生成完了: ${itemId}`);
|
||||
|
||||
return path.basename(mp3FilePath);
|
||||
} catch (error) {
|
||||
console.error("Error generating TTS:", error);
|
||||
throw new Error(`Failed to generate TTS: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
|
||||
const audioQuery = await queryResponse.json();
|
||||
|
||||
console.log(`音声合成開始: ${itemId}`);
|
||||
const audioResponse = await fetch(synthesisUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(audioQuery),
|
||||
});
|
||||
|
||||
if (!audioResponse.ok) {
|
||||
console.error(`音声合成失敗: ${itemId}`);
|
||||
throw new Error("VOICEVOX 音声合成に失敗しました");
|
||||
}
|
||||
|
||||
const audioArrayBuffer = await audioResponse.arrayBuffer();
|
||||
const audioBuffer = Buffer.from(audioArrayBuffer);
|
||||
|
||||
// 出力ディレクトリの準備
|
||||
const outputDir = path.join(__dirname, "../public/podcast_audio");
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
const wavFilePath = path.resolve(outputDir, `${itemId}.wav`);
|
||||
const mp3FilePath = path.resolve(outputDir, `${itemId}.mp3`);
|
||||
|
||||
console.log(`WAVファイル保存開始: ${wavFilePath}`);
|
||||
fs.writeFileSync(wavFilePath, audioBuffer);
|
||||
console.log(`WAVファイル保存完了: ${wavFilePath}`);
|
||||
|
||||
console.log(`MP3変換開始: ${wavFilePath} -> ${mp3FilePath}`);
|
||||
Bun.spawnSync({
|
||||
cmd: [
|
||||
ffmpegPath || "ffmpeg",
|
||||
"-i",
|
||||
wavFilePath,
|
||||
"-codec:a",
|
||||
"libmp3lame",
|
||||
"-qscale:a",
|
||||
"2",
|
||||
mp3FilePath,
|
||||
],
|
||||
});
|
||||
|
||||
// Wavファイルを削除
|
||||
fs.unlinkSync(wavFilePath);
|
||||
console.log(`TTS生成完了: ${itemId}`);
|
||||
|
||||
return path.basename(mp3FilePath);
|
||||
}
|
||||
|
Reference in New Issue
Block a user