From cd0e4065fce284ed8610e061fe038edd7db5d12a Mon Sep 17 00:00:00 2001 From: Satsuki Akiba Date: Sun, 8 Jun 2025 21:50:31 +0900 Subject: [PATCH] Update category management and RSS endpoint handling --- admin-server.ts | 2 +- frontend/src/App.tsx | 10 +- frontend/src/components/CategoryList.tsx | 9 +- frontend/src/components/RSSEndpoints.tsx | 199 +++++++++++++++ frontend/src/styles.css | 298 ++++++++++++++++++++++ schema.sql | 1 + scripts/fetch_and_generate.ts | 34 +-- server.ts | 133 +++++++++- services/batch-scheduler.ts | 27 +- services/content-extractor.ts | 2 +- services/database.ts | 301 ++++++++++++++++++++++- services/llm.ts | 66 +++++ services/podcast.ts | 159 +++++++++--- 13 files changed, 1171 insertions(+), 70 deletions(-) create mode 100644 frontend/src/components/RSSEndpoints.tsx diff --git a/admin-server.ts b/admin-server.ts index eae27d0..36af725 100644 --- a/admin-server.ts +++ b/admin-server.ts @@ -3,7 +3,7 @@ import path from "path"; import { serve } from "@hono/node-server"; import { Hono } from "hono"; import { basicAuth } from "hono/basic-auth"; -import { addNewFeedUrl, batchProcess } from "./scripts/fetch_and_generate.js"; +import { addNewFeedUrl } from "./scripts/fetch_and_generate.js"; import { batchScheduler } from "./services/batch-scheduler.js"; import { config, validateConfig } from "./services/config.js"; import { closeBrowser } from "./services/content-extractor.js"; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 33e72c8..bd2cceb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,10 +5,11 @@ import EpisodeList from "./components/EpisodeList"; import FeedDetail from "./components/FeedDetail"; import FeedList from "./components/FeedList"; import FeedManager from "./components/FeedManager"; +import RSSEndpoints from "./components/RSSEndpoints"; function App() { const location = useLocation(); - const isMainPage = ["/", "/feeds", "/categories", "/feed-requests"].includes( + const isMainPage = ["/", "/feeds", "/categories", "/feed-requests", "/rss-endpoints"].includes( location.pathname, ); @@ -48,6 +49,12 @@ function App() { > フィードリクエスト + + RSS配信 + )} @@ -58,6 +65,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/CategoryList.tsx b/frontend/src/components/CategoryList.tsx index 9744ba1..de634a8 100644 --- a/frontend/src/components/CategoryList.tsx +++ b/frontend/src/components/CategoryList.tsx @@ -18,7 +18,6 @@ interface CategoryGroup { function CategoryList() { const [groupedFeeds, setGroupedFeeds] = useState({}); - const [categories, setCategories] = useState([]); const [selectedCategory, setSelectedCategory] = useState(null); const [filteredFeeds, setFilteredFeeds] = useState([]); const [loading, setLoading] = useState(true); @@ -52,10 +51,9 @@ function CategoryList() { } const groupedData = await groupedResponse.json(); - const categoriesData = await categoriesResponse.json(); + await categoriesResponse.json(); setGroupedFeeds(groupedData.groupedFeeds || {}); - setCategories(categoriesData.categories || []); } catch (err) { console.error("Category fetch error:", err); setError(err instanceof Error ? err.message : "エラーが発生しました"); @@ -72,11 +70,6 @@ function CategoryList() { return groupedFeeds[category]?.length || 0; }; - const getTotalEpisodesCount = (feeds: Feed[]) => { - // This would require an additional API call to get episode counts per feed - // For now, just return the feed count - return feeds.length; - }; if (loading) { return
読み込み中...
; diff --git a/frontend/src/components/RSSEndpoints.tsx b/frontend/src/components/RSSEndpoints.tsx new file mode 100644 index 0000000..0e0d1a2 --- /dev/null +++ b/frontend/src/components/RSSEndpoints.tsx @@ -0,0 +1,199 @@ +import { useEffect, useState } from "react"; + +interface RSSEndpoint { + title: string; + url: string; + description: string; + feedCategory?: string; +} + +interface RSSEndpointsData { + main: RSSEndpoint; + categories: RSSEndpoint[]; + feeds: RSSEndpoint[]; +} + +export default function RSSEndpoints() { + const [endpoints, setEndpoints] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [copiedUrl, setCopiedUrl] = useState(null); + + useEffect(() => { + fetchRSSEndpoints(); + }, []); + + const fetchRSSEndpoints = async () => { + try { + setLoading(true); + const response = await fetch("/api/rss-endpoints"); + if (!response.ok) { + throw new Error("RSS エンドポイント情報の取得に失敗しました"); + } + const data = await response.json(); + setEndpoints(data.endpoints); + } catch (err) { + setError(err instanceof Error ? err.message : "エラーが発生しました"); + } finally { + setLoading(false); + } + }; + + const copyToClipboard = async (url: string) => { + try { + await navigator.clipboard.writeText(url); + setCopiedUrl(url); + setTimeout(() => setCopiedUrl(null), 2000); + } catch (err) { + console.error("クリップボードへのコピーに失敗しました:", err); + } + }; + + const openInNewTab = (url: string) => { + window.open(url, "_blank"); + }; + + if (loading) return
読み込み中...
; + if (error) return
エラー: {error}
; + if (!endpoints) return
データが見つかりません
; + + return ( +
+
+

RSS 配信エンドポイント

+

以下のRSSフィードURLをポッドキャストアプリに追加して音声コンテンツをお楽しみください。

+
+ + {/* メインフィード */} +
+

📻 メインフィード

+
+
+

{endpoints.main.title}

+ 全エピソード +
+

{endpoints.main.description}

+
+ {endpoints.main.url} +
+ + +
+
+
+
+ + {/* カテゴリ別フィード */} + {endpoints.categories.length > 0 && ( +
+

🏷️ カテゴリ別フィード

+
+ {endpoints.categories.map((endpoint, index) => ( +
+
+

{endpoint.title}

+ カテゴリ +
+

{endpoint.description}

+
+ {endpoint.url} +
+ + +
+
+
+ ))} +
+
+ )} + + {/* フィード別フィード */} + {endpoints.feeds.length > 0 && ( +
+

📡 フィード別配信

+
+ {endpoints.feeds.map((endpoint, index) => ( +
+
+

{endpoint.title}

+
+ フィード + {endpoint.feedCategory && ( + + {endpoint.feedCategory} + + )} +
+
+

{endpoint.description}

+
+ {endpoint.url} +
+ + +
+
+
+ ))} +
+
+ )} + + {/* 使用方法の説明 */} +
+

📱 使用方法

+
+
+

1. ポッドキャストアプリを選択

+

Apple Podcasts、Google Podcasts、Spotify、Pocket Casts など、RSS フィードに対応したポッドキャストアプリを使用してください。

+
+
+

2. RSS フィードを追加

+

上記のURLをコピーして、ポッドキャストアプリの「フィードを追加」や「URLから購読」機能を使用してください。

+
+
+

3. エピソードを楽しむ

+

フィードが追加されると、新しいエピソードが自動的にアプリに配信されます。

+
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 9b4f391..5357b03 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -223,3 +223,301 @@ body { display: flex; gap: 10px; } + +/* Feed management specific styles */ +.feed-manager { + max-width: 800px; + margin: 0 auto; +} + +.feed-section { + margin-bottom: 2rem; + padding: 1.5rem; + background: #f8f9fa; + border-radius: 8px; + border: 1px solid #e9ecef; +} + +.feed-section h2 { + margin: 0 0 1rem 0; + color: #495057; + font-size: 1.5rem; + font-weight: 600; +} + +/* RSS Endpoints specific styles */ +.rss-endpoints { + max-width: 1200px; + margin: 0 auto; + padding: 1rem; +} + +.rss-endpoints-header { + text-align: center; + margin-bottom: 2rem; + padding: 1.5rem; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border-radius: 12px; +} + +.rss-endpoints-header h1 { + margin: 0 0 0.5rem 0; + font-size: 2rem; + font-weight: 700; +} + +.rss-endpoints-header p { + margin: 0; + opacity: 0.9; + font-size: 1.1rem; +} + +.rss-section { + margin-bottom: 3rem; +} + +.rss-section h2 { + margin: 0 0 1.5rem 0; + color: #2c3e50; + font-size: 1.5rem; + font-weight: 600; + border-bottom: 2px solid #3498db; + padding-bottom: 0.5rem; +} + +.rss-endpoints-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); + gap: 1.5rem; +} + +.rss-endpoint-card { + background: white; + border: 1px solid #e1e8ed; + border-radius: 12px; + padding: 1.5rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + transition: all 0.2s ease; + position: relative; +} + +.rss-endpoint-card:hover { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); + transform: translateY(-2px); +} + +.rss-endpoint-card.main-feed { + grid-column: 1 / -1; + background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); + color: white; + border: none; +} + +.rss-endpoint-card.main-feed .endpoint-header h3 { + color: white; +} + +.rss-endpoint-card.main-feed .endpoint-description { + color: rgba(255, 255, 255, 0.9); +} + +.endpoint-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; + flex-wrap: wrap; + gap: 0.5rem; +} + +.endpoint-header h3 { + margin: 0; + color: #2c3e50; + font-size: 1.2rem; + font-weight: 600; + flex: 1; + min-width: 200px; +} + +.endpoint-badges { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.endpoint-badge { + padding: 0.25rem 0.75rem; + border-radius: 20px; + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.endpoint-badge.main { + background: rgba(255, 255, 255, 0.2); + color: white; +} + +.endpoint-badge.category { + background: #e3f2fd; + color: #1976d2; +} + +.endpoint-badge.feed { + background: #f3e5f5; + color: #7b1fa2; +} + +.endpoint-badge.category-tag { + background: #e8f5e8; + color: #2e7d32; +} + +.endpoint-description { + color: #666; + margin-bottom: 1rem; + line-height: 1.5; +} + +.endpoint-url { + background: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 8px; + padding: 1rem; + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.rss-endpoint-card.main-feed .endpoint-url { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.endpoint-url code { + background: none; + border: none; + color: #495057; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 0.9rem; + word-break: break-all; + flex: 1; + min-width: 200px; +} + +.rss-endpoint-card.main-feed .endpoint-url code { + color: rgba(255, 255, 255, 0.9); +} + +.endpoint-actions { + display: flex; + gap: 0.5rem; + flex-shrink: 0; +} + +.copy-btn, .open-btn { + background: #3498db; + color: white; + border: none; + border-radius: 6px; + padding: 0.5rem; + cursor: pointer; + transition: all 0.2s ease; + font-size: 1rem; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; +} + +.copy-btn:hover, .open-btn:hover { + background: #2980b9; + transform: scale(1.05); +} + +.copy-btn.copied { + background: #27ae60; +} + +.rss-endpoint-card.main-feed .copy-btn, +.rss-endpoint-card.main-feed .open-btn { + background: rgba(255, 255, 255, 0.2); + color: white; +} + +.rss-endpoint-card.main-feed .copy-btn:hover, +.rss-endpoint-card.main-feed .open-btn:hover { + background: rgba(255, 255, 255, 0.3); +} + +.rss-endpoint-card.main-feed .copy-btn.copied { + background: rgba(39, 174, 96, 0.8); +} + +.usage-info { + background: #f8f9fa; + border-radius: 12px; + padding: 2rem; + margin-top: 3rem; +} + +.usage-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1.5rem; + margin-top: 1.5rem; +} + +.usage-card { + background: white; + border-radius: 8px; + padding: 1.5rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.usage-card h3 { + margin: 0 0 1rem 0; + color: #2c3e50; + font-size: 1.1rem; + font-weight: 600; +} + +.usage-card p { + margin: 0; + color: #666; + line-height: 1.6; +} + +@media (max-width: 768px) { + .rss-endpoints { + padding: 0.5rem; + } + + .rss-endpoints-grid { + grid-template-columns: 1fr; + } + + .endpoint-url { + flex-direction: column; + align-items: stretch; + gap: 0.75rem; + } + + .endpoint-url code { + min-width: unset; + text-align: center; + } + + .endpoint-actions { + justify-content: center; + } + + .usage-cards { + grid-template-columns: 1fr; + } +} diff --git a/schema.sql b/schema.sql index dac68ba..856e584 100644 --- a/schema.sql +++ b/schema.sql @@ -31,6 +31,7 @@ CREATE TABLE IF NOT EXISTS episodes ( audio_path TEXT NOT NULL, duration INTEGER, file_size INTEGER, + category TEXT, created_at TEXT NOT NULL, FOREIGN KEY(article_id) REFERENCES articles(id) ); diff --git a/scripts/fetch_and_generate.ts b/scripts/fetch_and_generate.ts index 09bf690..e5545b9 100644 --- a/scripts/fetch_and_generate.ts +++ b/scripts/fetch_and_generate.ts @@ -14,6 +14,7 @@ import { } from "../services/database.js"; import { openAI_ClassifyFeed, + openAI_ClassifyEpisode, openAI_GeneratePodcastContent, } from "../services/llm.js"; import { updatePodcastRSS } from "../services/podcast.js"; @@ -352,7 +353,7 @@ async function processRetryQueue(abortSignal?: AbortSignal): Promise { await updateQueueItemStatus(item.id, "processing"); // Attempt TTS generation without re-queuing on failure - const audioFilePath = await generateTTSWithoutQueue( + await generateTTSWithoutQueue( item.itemId, item.scriptText, item.retryCount, @@ -448,25 +449,13 @@ async function generatePodcastForArticle( } // Get feed information for context - const feed = await getFeedById(article.feedId); - const feedTitle = feed?.title || "Unknown Feed"; + await getFeedById(article.feedId); // Check for cancellation before classification if (abortSignal?.aborted) { throw new Error("Podcast generation was cancelled"); } - // Classify the article/feed - const category = await openAI_ClassifyFeed( - `${feedTitle}: ${article.title}`, - ); - console.log(`🏷️ Article classified as: ${category}`); - - // Check for cancellation before content generation - if (abortSignal?.aborted) { - throw new Error("Podcast generation was cancelled"); - } - // Enhance article content with web scraping if needed console.log(`🔍 Enhancing content for: ${article.title}`); const enhancedContent = await enhanceArticleContent( @@ -476,6 +465,11 @@ async function generatePodcastForArticle( article.description, ); + // Check for cancellation before content generation + if (abortSignal?.aborted) { + throw new Error("Podcast generation was cancelled"); + } + // Generate podcast content for this single article const podcastContent = await openAI_GeneratePodcastContent(article.title, [ { @@ -486,6 +480,15 @@ async function generatePodcastForArticle( }, ]); + // Classify the episode based on the podcast content + console.log(`🏷️ Classifying episode content for: ${article.title}`); + const episodeCategory = await openAI_ClassifyEpisode( + article.title, + enhancedContent.description, + enhancedContent.content, + ); + console.log(`🏷️ Episode classified as: ${episodeCategory}`); + // Check for cancellation before TTS if (abortSignal?.aborted) { throw new Error("Podcast generation was cancelled"); @@ -544,12 +547,13 @@ async function generatePodcastForArticle( try { await saveEpisode({ articleId: article.id, - title: `${category}: ${article.title}`, + title: article.title, description: article.description || `Podcast episode for: ${article.title}`, audioPath: audioFilePath, duration: audioStats.duration, fileSize: audioStats.size, + category: episodeCategory, }); console.log(`💾 Episode saved for article: ${article.title}`); diff --git a/server.ts b/server.ts index 3c2e831..97917b1 100644 --- a/server.ts +++ b/server.ts @@ -1,7 +1,7 @@ import path from "path"; import { serve } from "@hono/node-server"; import { Hono } from "hono"; -import { batchScheduler } from "./services/batch-scheduler.js"; +import "./services/batch-scheduler.js"; import { config, validateConfig } from "./services/config.js"; import { closeBrowser } from "./services/content-extractor.js"; @@ -120,6 +120,48 @@ app.get("/podcast.xml", async (c) => { } }); +// Category-specific RSS feeds +app.get("/podcast/category/:category.xml", async (c) => { + try { + const category = decodeURIComponent(c.req.param("category") || ""); + if (!category) { + return c.notFound(); + } + const { generateCategoryRSS } = await import("./services/podcast.js"); + + const rssXml = await generateCategoryRSS(category); + + return c.body(rssXml, 200, { + "Content-Type": "application/xml; charset=utf-8", + "Cache-Control": "public, max-age=3600", // Cache for 1 hour + }); + } catch (error) { + console.error(`Error generating category RSS for "${c.req.param("category")}":`, error); + return c.notFound(); + } +}); + +// Feed-specific RSS feeds +app.get("/podcast/feed/:feedId.xml", async (c) => { + try { + const feedId = c.req.param("feedId"); + if (!feedId) { + return c.notFound(); + } + const { generateFeedRSS } = await import("./services/podcast.js"); + + const rssXml = await generateFeedRSS(feedId); + + return c.body(rssXml, 200, { + "Content-Type": "application/xml; charset=utf-8", + "Cache-Control": "public, max-age=3600", // Cache for 1 hour + }); + } catch (error) { + console.error(`Error generating feed RSS for "${c.req.param("feedId")}":`, error); + return c.notFound(); + } +}); + app.get("/default-thumbnail.svg", async (c) => { try { const filePath = path.join(config.paths.publicDir, "default-thumbnail.svg"); @@ -561,6 +603,95 @@ app.post("/api/feed-requests", async (c) => { } }); +// Episode category API endpoints +app.get("/api/episode-categories", async (c) => { + try { + const { getAllEpisodeCategories } = await import("./services/database.js"); + const categories = await getAllEpisodeCategories(); + return c.json({ categories }); + } catch (error) { + console.error("Error fetching episode categories:", error); + return c.json({ error: "Failed to fetch episode categories" }, 500); + } +}); + +app.get("/api/episodes/by-category", async (c) => { + try { + const category = c.req.query("category"); + const { getEpisodesByCategory } = await import("./services/database.js"); + const episodes = await getEpisodesByCategory(category); + return c.json({ episodes }); + } catch (error) { + console.error("Error fetching episodes by category:", error); + return c.json({ error: "Failed to fetch episodes by category" }, 500); + } +}); + +app.get("/api/episodes/grouped-by-category", async (c) => { + try { + const { getEpisodesGroupedByCategory } = await import("./services/database.js"); + const groupedEpisodes = await getEpisodesGroupedByCategory(); + return c.json({ groupedEpisodes }); + } catch (error) { + console.error("Error fetching episodes grouped by category:", error); + return c.json({ error: "Failed to fetch episodes grouped by category" }, 500); + } +}); + +app.get("/api/episode-category-stats", async (c) => { + try { + const { getEpisodeCategoryStats } = await import("./services/database.js"); + const stats = await getEpisodeCategoryStats(); + return c.json({ stats }); + } catch (error) { + console.error("Error fetching episode category stats:", error); + return c.json({ error: "Failed to fetch episode category stats" }, 500); + } +}); + +// RSS endpoints information API +app.get("/api/rss-endpoints", async (c) => { + try { + const { + getAllEpisodeCategories, + fetchActiveFeeds + } = await import("./services/database.js"); + + const [episodeCategories, activeFeeds] = await Promise.all([ + getAllEpisodeCategories(), + fetchActiveFeeds() + ]); + + const protocol = c.req.header("x-forwarded-proto") || "http"; + const host = c.req.header("host") || "localhost:3000"; + const baseUrl = `${protocol}://${host}`; + + const endpoints = { + main: { + title: "全エピソード", + url: `${baseUrl}/podcast.xml`, + description: "すべてのエピソードを含むメインRSSフィード" + }, + categories: episodeCategories.map(category => ({ + title: `カテゴリ: ${category}`, + url: `${baseUrl}/podcast/category/${encodeURIComponent(category)}.xml`, + description: `「${category}」カテゴリのエピソードのみ` + })), + feeds: activeFeeds.map(feed => ({ + title: `フィード: ${feed.title || feed.url}`, + url: `${baseUrl}/podcast/feed/${feed.id}.xml`, + description: `「${feed.title || feed.url}」からのエピソードのみ`, + feedCategory: feed.category + })) + }; + + return c.json({ endpoints }); + } catch (error) { + console.error("Error fetching RSS endpoints:", error); + return c.json({ error: "Failed to fetch RSS endpoints" }, 500); + } +}); + // Episode page with OG metadata - must be before catch-all app.get("/episode/:episodeId", async (c) => { const episodeId = c.req.param("episodeId"); diff --git a/services/batch-scheduler.ts b/services/batch-scheduler.ts index 0275198..c1ef80d 100644 --- a/services/batch-scheduler.ts +++ b/services/batch-scheduler.ts @@ -86,24 +86,39 @@ class BatchScheduler { try { console.log("🔄 Running scheduled batch process..."); - // Run migration for feeds without categories (only once) + // Run migrations (only once per startup) if (!this.migrationCompleted) { try { + // Feed category migration const { migrateFeedsWithCategories, getFeedCategoryMigrationStatus } = await import("./database.js"); - const migrationStatus = await getFeedCategoryMigrationStatus(); + const feedMigrationStatus = await getFeedCategoryMigrationStatus(); - if (!migrationStatus.migrationComplete) { + if (!feedMigrationStatus.migrationComplete) { console.log("🔄 Running feed category migration..."); await migrateFeedsWithCategories(); - this.migrationCompleted = true; + console.log("✅ Feed category migration completed"); } else { console.log("✅ Feed category migration already complete"); - this.migrationCompleted = true; } + + // Episode category migration + const { migrateEpisodesWithCategories, getEpisodeCategoryMigrationStatus } = + await import("./database.js"); + const episodeMigrationStatus = await getEpisodeCategoryMigrationStatus(); + + if (!episodeMigrationStatus.migrationComplete) { + console.log("🔄 Running episode category migration..."); + await migrateEpisodesWithCategories(); + console.log("✅ Episode category migration completed"); + } else { + console.log("✅ Episode category migration already complete"); + } + + this.migrationCompleted = true; } catch (migrationError) { console.error( - "❌ Error during feed category migration:", + "❌ Error during category migrations:", migrationError, ); // Don't fail the entire batch process due to migration error diff --git a/services/content-extractor.ts b/services/content-extractor.ts index 437c1b7..79fe32a 100644 --- a/services/content-extractor.ts +++ b/services/content-extractor.ts @@ -230,7 +230,7 @@ export async function extractArticleContent( } export async function enhanceArticleContent( - originalTitle: string, + _originalTitle: string, originalLink: string, originalContent?: string, originalDescription?: string, diff --git a/services/database.ts b/services/database.ts index ad1c9da..631087f 100644 --- a/services/database.ts +++ b/services/database.ts @@ -159,6 +159,7 @@ function initializeDatabase(): Database { audio_path TEXT NOT NULL, duration INTEGER, file_size INTEGER, + category TEXT, created_at TEXT NOT NULL, FOREIGN KEY(article_id) REFERENCES articles(id) ); @@ -207,14 +208,22 @@ function initializeDatabase(): Database { // ALTER // ALTER TABLE feeds ADD COLUMN category TEXT DEFAULT NULL; - // Ensure the category column exists - const infos = db.prepare("PRAGMA table_info(feeds);").all(); - const hasCategory = infos.some((col: any) => col.name === "category"); + // Ensure the category column exists in feeds + const feedInfos = db.prepare("PRAGMA table_info(feeds);").all(); + const hasFeedCategory = feedInfos.some((col: any) => col.name === "category"); - if (!hasCategory) { + if (!hasFeedCategory) { db.exec("ALTER TABLE feeds ADD COLUMN category TEXT DEFAULT NULL;"); } + // Ensure the category column exists in episodes + const episodeInfos = db.prepare("PRAGMA table_info(episodes);").all(); + const hasEpisodeCategory = episodeInfos.some((col: any) => col.name === "category"); + + if (!hasEpisodeCategory) { + db.exec("ALTER TABLE episodes ADD COLUMN category TEXT DEFAULT NULL;"); + } + return db; } @@ -251,6 +260,7 @@ export interface Episode { audioPath: string; duration?: number; fileSize?: number; + category?: string; createdAt: string; } @@ -271,6 +281,7 @@ export interface EpisodeWithFeedInfo { audioPath: string; duration?: number; fileSize?: number; + category?: string; createdAt: string; articleId: string; articleTitle: string; @@ -415,6 +426,7 @@ export async function fetchEpisodesWithFeedInfo(): Promise< e.audio_path as audioPath, e.duration, e.file_size as fileSize, + e.category, e.created_at as createdAt, e.article_id as articleId, a.title as articleTitle, @@ -440,6 +452,7 @@ export async function fetchEpisodesWithFeedInfo(): Promise< audioPath: row.audioPath, duration: row.duration, fileSize: row.fileSize, + category: row.category, createdAt: row.createdAt, articleId: row.articleId, articleTitle: row.articleTitle, @@ -469,6 +482,7 @@ export async function fetchEpisodesByFeedId( e.audio_path as audioPath, e.duration, e.file_size as fileSize, + e.category, e.created_at as createdAt, e.article_id as articleId, a.title as articleTitle, @@ -494,6 +508,7 @@ export async function fetchEpisodesByFeedId( audioPath: row.audioPath, duration: row.duration, fileSize: row.fileSize, + category: row.category, createdAt: row.createdAt, articleId: row.articleId, articleTitle: row.articleTitle, @@ -523,6 +538,7 @@ export async function fetchEpisodeWithSourceInfo( e.audio_path as audioPath, e.duration, e.file_size as fileSize, + e.category, e.created_at as createdAt, e.article_id as articleId, a.title as articleTitle, @@ -548,6 +564,7 @@ export async function fetchEpisodeWithSourceInfo( audioPath: row.audioPath, duration: row.duration, fileSize: row.fileSize, + category: row.category, createdAt: row.createdAt, articleId: row.articleId, articleTitle: row.articleTitle, @@ -823,7 +840,7 @@ export async function saveEpisode( try { const stmt = db.prepare( - "INSERT INTO episodes (id, article_id, title, description, audio_path, duration, file_size, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO episodes (id, article_id, title, description, audio_path, duration, file_size, category, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ); stmt.run( id, @@ -833,6 +850,7 @@ export async function saveEpisode( episode.audioPath, episode.duration || null, episode.fileSize || null, + episode.category || null, createdAt, ); return id; @@ -876,6 +894,7 @@ export async function fetchAllEpisodes(): Promise { e.audio_path as audioPath, e.duration, e.file_size as fileSize, + e.category, e.created_at as createdAt FROM episodes e ORDER BY e.created_at DESC @@ -1289,6 +1308,278 @@ export async function deleteEpisode(episodeId: string): Promise { } } +// Episode category management functions +export async function getEpisodesByCategory(category?: string): Promise { + try { + let stmt; + let rows; + + if (category) { + stmt = db.prepare(` + SELECT + e.id, + e.title, + e.description, + e.audio_path as audioPath, + e.duration, + e.file_size as fileSize, + e.category, + e.created_at as createdAt, + e.article_id as articleId, + a.title as articleTitle, + a.link as articleLink, + a.pub_date as articlePubDate, + f.id as feedId, + f.title as feedTitle, + f.url as feedUrl, + f.category as feedCategory + FROM episodes e + JOIN articles a ON e.article_id = a.id + JOIN feeds f ON a.feed_id = f.id + WHERE e.category = ? AND f.active = 1 + ORDER BY e.created_at DESC + `); + rows = stmt.all(category) as any[]; + } else { + // If no category specified, return all episodes + stmt = db.prepare(` + SELECT + e.id, + e.title, + e.description, + e.audio_path as audioPath, + e.duration, + e.file_size as fileSize, + e.category, + e.created_at as createdAt, + e.article_id as articleId, + a.title as articleTitle, + a.link as articleLink, + a.pub_date as articlePubDate, + f.id as feedId, + f.title as feedTitle, + f.url as feedUrl, + f.category as feedCategory + FROM episodes e + JOIN articles a ON e.article_id = a.id + JOIN feeds f ON a.feed_id = f.id + WHERE f.active = 1 + ORDER BY e.created_at DESC + `); + rows = stmt.all() as any[]; + } + + return rows.map((row) => ({ + id: row.id, + title: row.title, + description: row.description, + audioPath: row.audioPath, + duration: row.duration, + fileSize: row.fileSize, + category: row.category, + createdAt: row.createdAt, + articleId: row.articleId, + articleTitle: row.articleTitle, + articleLink: row.articleLink, + articlePubDate: row.articlePubDate, + feedId: row.feedId, + feedTitle: row.feedTitle, + feedUrl: row.feedUrl, + feedCategory: row.feedCategory, + })); + } catch (error) { + console.error("Error getting episodes by category:", error); + throw error; + } +} + +export async function getAllEpisodeCategories(): Promise { + try { + const stmt = db.prepare( + "SELECT DISTINCT category FROM episodes WHERE category IS NOT NULL ORDER BY category", + ); + const rows = stmt.all() as any[]; + + return rows.map((row) => row.category).filter(Boolean); + } catch (error) { + console.error("Error getting all episode categories:", error); + throw error; + } +} + +export async function getEpisodesGroupedByCategory(): Promise<{ + [category: string]: EpisodeWithFeedInfo[]; +}> { + try { + const episodes = await fetchEpisodesWithFeedInfo(); + const grouped: { [category: string]: EpisodeWithFeedInfo[] } = {}; + + for (const episode of episodes) { + const category = episode.category || "未分類"; + if (!grouped[category]) { + grouped[category] = []; + } + grouped[category].push(episode); + } + + return grouped; + } catch (error) { + console.error("Error getting episodes grouped by category:", error); + throw error; + } +} + +export async function getEpisodeCategoryStats(): Promise<{ + [category: string]: number; +}> { + try { + const stmt = db.prepare(` + SELECT + COALESCE(e.category, '未分類') as category, + COUNT(*) as count + FROM episodes e + JOIN articles a ON e.article_id = a.id + JOIN feeds f ON a.feed_id = f.id + WHERE f.active = 1 + GROUP BY e.category + ORDER BY count DESC + `); + const rows = stmt.all() as any[]; + + const stats: { [category: string]: number } = {}; + for (const row of rows) { + stats[row.category] = row.count; + } + + return stats; + } catch (error) { + console.error("Error getting episode category stats:", error); + throw error; + } +} + +export async function updateEpisodeCategory(episodeId: string, category: string): Promise { + try { + const stmt = db.prepare("UPDATE episodes SET category = ? WHERE id = ?"); + const result = stmt.run(category, episodeId); + return result.changes > 0; + } catch (error) { + console.error("Error updating episode category:", error); + throw error; + } +} + +// Migration function to classify existing episodes without categories +export async function migrateEpisodesWithCategories(): Promise { + try { + console.log("🔄 Starting episode category migration..."); + + // Get all episodes without categories + const stmt = db.prepare( + "SELECT * FROM episodes WHERE category IS NULL OR category = ''", + ); + const episodesWithoutCategories = stmt.all() as any[]; + + if (episodesWithoutCategories.length === 0) { + console.log("✅ All episodes already have categories assigned"); + return; + } + + console.log( + `📋 Found ${episodesWithoutCategories.length} episodes without categories`, + ); + + // Import LLM service + const { openAI_ClassifyEpisode } = await import("./llm.js"); + + let processedCount = 0; + let errorCount = 0; + + for (const episode of episodesWithoutCategories) { + try { + console.log(`🔍 Classifying episode: ${episode.title}`); + + // Classify the episode using title and description + const category = await openAI_ClassifyEpisode( + episode.title, + episode.description, + ); + + // Update the episode with the category + const updateStmt = db.prepare( + "UPDATE episodes SET category = ? WHERE id = ?", + ); + updateStmt.run(category, episode.id); + + console.log( + `✅ Assigned category "${category}" to episode: ${episode.title}`, + ); + processedCount++; + + // Add a small delay to avoid rate limiting + await new Promise((resolve) => setTimeout(resolve, 1000)); + } catch (error) { + console.error( + `❌ Failed to classify episode ${episode.title}:`, + error, + ); + errorCount++; + + // Set a default category for failed classifications + const defaultCategory = "その他"; + const updateStmt = db.prepare( + "UPDATE episodes SET category = ? WHERE id = ?", + ); + updateStmt.run(defaultCategory, episode.id); + console.log( + `! Assigned default category "${defaultCategory}" to episode: ${episode.title}`, + ); + } + } + + console.log(`✅ Episode category migration completed`); + console.log( + `📊 Processed: ${processedCount}, Errors: ${errorCount}, Total: ${episodesWithoutCategories.length}`, + ); + } catch (error) { + console.error("❌ Error during episode category migration:", error); + throw error; + } +} + +// Function to get episode migration status +export async function getEpisodeCategoryMigrationStatus(): Promise<{ + totalEpisodes: number; + episodesWithCategories: number; + episodesWithoutCategories: number; + migrationComplete: boolean; +}> { + try { + const totalStmt = db.prepare("SELECT COUNT(*) as count FROM episodes"); + const totalResult = totalStmt.get() as any; + const totalEpisodes = totalResult.count; + + const withCategoriesStmt = db.prepare( + "SELECT COUNT(*) as count FROM episodes WHERE category IS NOT NULL AND category != ''", + ); + const withCategoriesResult = withCategoriesStmt.get() as any; + const episodesWithCategories = withCategoriesResult.count; + + const episodesWithoutCategories = totalEpisodes - episodesWithCategories; + const migrationComplete = episodesWithoutCategories === 0; + + return { + totalEpisodes, + episodesWithCategories, + episodesWithoutCategories, + migrationComplete, + }; + } catch (error) { + console.error("Error getting episode migration status:", error); + throw error; + } +} + export function closeDatabase(): void { db.close(); } diff --git a/services/llm.ts b/services/llm.ts index 29abda8..577f409 100644 --- a/services/llm.ts +++ b/services/llm.ts @@ -143,3 +143,69 @@ ${articleDetails} ); } } + +export async function openAI_ClassifyEpisode( + title: string, + description?: string, + content?: string, +): Promise { + if (!title || title.trim() === "") { + throw new Error("Episode title is required for classification"); + } + + // Build the text for classification based on available data + let textForClassification = `タイトル: ${title}`; + + if (description && description.trim()) { + textForClassification += `\n説明: ${description}`; + } + + if (content && content.trim()) { + const maxContentLength = 1500; + const truncatedContent = content.length > maxContentLength + ? content.substring(0, maxContentLength) + "..." + : content; + textForClassification += `\n内容: ${truncatedContent}`; + } + + const prompt = ` +以下のポッドキャストエピソードの情報を見て、適切なトピックカテゴリに分類してください。 + +${textForClassification} + +以下のカテゴリから1つを選択してください: +- テクノロジー +- ビジネス +- エンターテインメント +- スポーツ +- 科学 +- 健康 +- 政治 +- 環境 +- 教育 +- その他 + +エピソードの内容に最も適合するカテゴリを上記から1つだけ返してください。 +`; + + 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 episode category, using default"); + return "その他"; + } + + return category; + } catch (error) { + console.error("Error classifying episode:", error); + throw new Error( + `Failed to classify episode: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } +} diff --git a/services/podcast.ts b/services/podcast.ts index 96f9af4..46f1c63 100644 --- a/services/podcast.ts +++ b/services/podcast.ts @@ -3,7 +3,11 @@ import fsSync from "node:fs"; import path from "node:path"; import { dirname } from "path"; import { config } from "./config.js"; -import { fetchEpisodesWithFeedInfo } from "./database.js"; +import { + fetchEpisodesWithFeedInfo, + getEpisodesByCategory, + fetchEpisodesByFeedId +} from "./database.js"; function escapeXml(text: string): string { return text @@ -64,40 +68,38 @@ function createItemXml(episode: any): string { `; } -export async function updatePodcastRSS(): Promise { - try { - // Use episodes with feed info for enhanced descriptions - const episodesWithFeedInfo = await fetchEpisodesWithFeedInfo(); +// Filter episodes to only include those with valid audio files +function filterValidEpisodes(episodes: any[]): any[] { + return episodes.filter((episode) => { + try { + const audioPath = path.join( + config.paths.podcastAudioDir, + episode.audioPath, + ); + return fsSync.existsSync(audioPath); + } catch (error) { + console.warn(`Audio file not found for episode: ${episode.title}`); + return false; + } + }); +} - // Filter episodes to only include those with valid audio files - const validEpisodes = episodesWithFeedInfo.filter((episode) => { - try { - const audioPath = path.join( - config.paths.podcastAudioDir, - episode.audioPath, - ); - return fsSync.existsSync(audioPath); - } catch (error) { - console.warn(`Audio file not found for episode: ${episode.title}`); - return false; - } - }); - - console.log( - `Found ${episodesWithFeedInfo.length} episodes, ${validEpisodes.length} with valid audio files`, - ); - - const lastBuildDate = new Date().toUTCString(); - const itemsXml = validEpisodes.map(createItemXml).join("\n"); - const outputPath = path.join(config.paths.publicDir, "podcast.xml"); - - // Create RSS XML content - const rssXml = ` +// Generate RSS XML from episodes +function generateRSSXml( + episodes: any[], + title: string, + description: string, + link?: string +): string { + const lastBuildDate = new Date().toUTCString(); + const itemsXml = episodes.map(createItemXml).join("\n"); + + return ` - ${escapeXml(config.podcast.title)} - ${escapeXml(config.podcast.link)} - + ${escapeXml(title)} + ${escapeXml(link || config.podcast.link)} + ${config.podcast.language} ${lastBuildDate} ${config.podcast.ttl} @@ -105,6 +107,24 @@ export async function updatePodcastRSS(): Promise { ${escapeXml(config.podcast.categories)}${itemsXml} `; +} + +export async function updatePodcastRSS(): Promise { + try { + // Use episodes with feed info for enhanced descriptions + const episodesWithFeedInfo = await fetchEpisodesWithFeedInfo(); + const validEpisodes = filterValidEpisodes(episodesWithFeedInfo); + + console.log( + `Found ${episodesWithFeedInfo.length} episodes, ${validEpisodes.length} with valid audio files`, + ); + + const outputPath = path.join(config.paths.publicDir, "podcast.xml"); + const rssXml = generateRSSXml( + validEpisodes, + config.podcast.title, + config.podcast.description + ); // Ensure directory exists await fs.mkdir(dirname(outputPath), { recursive: true }); @@ -118,3 +138,78 @@ export async function updatePodcastRSS(): Promise { throw error; } } + +export async function generateCategoryRSS(category: string): Promise { + try { + // Get episodes for the specific category + const episodesWithFeedInfo = await getEpisodesByCategory(category); + const validEpisodes = filterValidEpisodes(episodesWithFeedInfo); + + console.log( + `Found ${episodesWithFeedInfo.length} episodes for category "${category}", ${validEpisodes.length} with valid audio files`, + ); + + const title = `${config.podcast.title} - ${category}`; + const description = `${config.podcast.description} カテゴリ: ${category}`; + + return generateRSSXml(validEpisodes, title, description); + } catch (error) { + console.error(`Error generating category RSS for "${category}":`, error); + throw error; + } +} + +export async function saveCategoryRSS(category: string): Promise { + try { + const rssXml = await generateCategoryRSS(category); + const safeCategory = category.replace(/[^a-zA-Z0-9\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/g, "_"); + const outputPath = path.join(config.paths.publicDir, `podcast_category_${safeCategory}.xml`); + + // Ensure directory exists + await fs.mkdir(dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, rssXml); + + console.log(`Category RSS saved for "${category}" at ${outputPath}`); + } catch (error) { + console.error(`Error saving category RSS for "${category}":`, error); + throw error; + } +} + +export async function generateFeedRSS(feedId: string): Promise { + try { + // Get episodes for the specific feed + const episodesWithFeedInfo = await fetchEpisodesByFeedId(feedId); + const validEpisodes = filterValidEpisodes(episodesWithFeedInfo); + + console.log( + `Found ${episodesWithFeedInfo.length} episodes for feed "${feedId}", ${validEpisodes.length} with valid audio files`, + ); + + // Use feed info for RSS metadata if available + const feedTitle = validEpisodes.length > 0 ? validEpisodes[0].feedTitle : "Unknown Feed"; + const title = `${config.podcast.title} - ${feedTitle}`; + const description = `${config.podcast.description} フィード: ${feedTitle}`; + + return generateRSSXml(validEpisodes, title, description); + } catch (error) { + console.error(`Error generating feed RSS for "${feedId}":`, error); + throw error; + } +} + +export async function saveFeedRSS(feedId: string): Promise { + try { + const rssXml = await generateFeedRSS(feedId); + const outputPath = path.join(config.paths.publicDir, `podcast_feed_${feedId}.xml`); + + // Ensure directory exists + await fs.mkdir(dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, rssXml); + + console.log(`Feed RSS saved for feed "${feedId}" at ${outputPath}`); + } catch (error) { + console.error(`Error saving feed RSS for "${feedId}":`, error); + throw error; + } +}