diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fd319e2..33e72c8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ import { Link, Route, Routes, useLocation } from "react-router-dom"; +import CategoryList from "./components/CategoryList"; import EpisodeDetail from "./components/EpisodeDetail"; import EpisodeList from "./components/EpisodeList"; import FeedDetail from "./components/FeedDetail"; @@ -7,7 +8,7 @@ import FeedManager from "./components/FeedManager"; function App() { const location = useLocation(); - const isMainPage = ["/", "/feeds", "/feed-requests"].includes( + const isMainPage = ["/", "/feeds", "/categories", "/feed-requests"].includes( location.pathname, ); @@ -35,6 +36,12 @@ function App() { > フィード一覧 + + カテゴリ一覧 + } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/CategoryList.tsx b/frontend/src/components/CategoryList.tsx new file mode 100644 index 0000000..9744ba1 --- /dev/null +++ b/frontend/src/components/CategoryList.tsx @@ -0,0 +1,397 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; + +interface Feed { + id: string; + url: string; + title?: string; + description?: string; + category?: string; + lastUpdated?: string; + createdAt: string; + active: boolean; +} + +interface CategoryGroup { + [category: string]: Feed[]; +} + +function CategoryList() { + const [groupedFeeds, setGroupedFeeds] = useState({}); + const [categories, setCategories] = useState([]); + const [selectedCategory, setSelectedCategory] = useState(null); + const [filteredFeeds, setFilteredFeeds] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + fetchCategoriesAndFeeds(); + }, []); + + useEffect(() => { + if (selectedCategory && groupedFeeds[selectedCategory]) { + setFilteredFeeds(groupedFeeds[selectedCategory]); + } else { + setFilteredFeeds([]); + } + }, [selectedCategory, groupedFeeds]); + + const fetchCategoriesAndFeeds = async () => { + try { + setLoading(true); + setError(null); + + // Fetch grouped feeds + const [groupedResponse, categoriesResponse] = await Promise.all([ + fetch("/api/feeds/grouped-by-category"), + fetch("/api/categories"), + ]); + + if (!groupedResponse.ok || !categoriesResponse.ok) { + throw new Error("カテゴリデータの取得に失敗しました"); + } + + const groupedData = await groupedResponse.json(); + const categoriesData = await categoriesResponse.json(); + + setGroupedFeeds(groupedData.groupedFeeds || {}); + setCategories(categoriesData.categories || []); + } catch (err) { + console.error("Category fetch error:", err); + setError(err instanceof Error ? err.message : "エラーが発生しました"); + } finally { + setLoading(false); + } + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleString("ja-JP"); + }; + + const getFeedCount = (category: string) => { + 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
読み込み中...
; + } + + if (error) { + return
{error}
; + } + + const availableCategories = Object.keys(groupedFeeds).filter( + (category) => groupedFeeds[category] && groupedFeeds[category].length > 0, + ); + + if (availableCategories.length === 0) { + return ( +
+

カテゴリ別のフィードがありません

+

+ フィードにカテゴリが設定されていないか、アクティブなフィードがない可能性があります +

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

カテゴリ一覧 ({availableCategories.length}件)

+ +
+ + {!selectedCategory ? ( +
+ {availableCategories.map((category) => ( +
+
+

setSelectedCategory(category)} + > + {category} +

+
+ + {getFeedCount(category)} フィード + +
+
+ +
+ {groupedFeeds[category]?.slice(0, 3).map((feed) => ( +
+ + {feed.title || feed.url} + +
+ ))} + {getFeedCount(category) > 3 && ( +
+ 他 {getFeedCount(category) - 3} フィード... +
+ )} +
+ +
+ +
+
+ ))} +
+ ) : ( +
+
+ +

カテゴリ: {selectedCategory}

+

{filteredFeeds.length} フィード

+
+ +
+ {filteredFeeds.map((feed) => ( +
+
+

+ + {feed.title || feed.url} + +

+ +
+ + {feed.description && ( +
{feed.description}
+ )} + +
+
作成日: {formatDate(feed.createdAt)}
+ {feed.lastUpdated && ( +
最終更新: {formatDate(feed.lastUpdated)}
+ )} +
+ +
+ + エピソード一覧を見る + +
+
+ ))} +
+
+ )} + + +
+ ); +} + +export default CategoryList; \ No newline at end of file diff --git a/frontend/src/components/EpisodeList.tsx b/frontend/src/components/EpisodeList.tsx index 89a746f..57e5f2b 100644 --- a/frontend/src/components/EpisodeList.tsx +++ b/frontend/src/components/EpisodeList.tsx @@ -27,10 +27,14 @@ interface EpisodeWithFeedInfo { feedId: string; feedTitle?: string; feedUrl: string; + feedCategory?: string; } function EpisodeList() { const [episodes, setEpisodes] = useState([]); + const [filteredEpisodes, setFilteredEpisodes] = useState([]); + const [categories, setCategories] = useState([]); + const [selectedCategory, setSelectedCategory] = useState(""); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [currentAudio, setCurrentAudio] = useState(null); @@ -38,8 +42,13 @@ function EpisodeList() { useEffect(() => { fetchEpisodes(); + fetchCategories(); }, [useDatabase]); + useEffect(() => { + filterEpisodesByCategory(); + }, [episodes, selectedCategory]); + const fetchEpisodes = async () => { try { setLoading(true); @@ -106,6 +115,29 @@ function EpisodeList() { } }; + const fetchCategories = async () => { + try { + const response = await fetch("/api/categories"); + if (response.ok) { + const data = await response.json(); + setCategories(data.categories || []); + } + } catch (err) { + console.error("Error fetching categories:", err); + } + }; + + const filterEpisodesByCategory = () => { + if (!selectedCategory) { + setFilteredEpisodes(episodes); + } else { + const filtered = episodes.filter(episode => + episode.feedCategory === selectedCategory + ); + setFilteredEpisodes(filtered); + } + }; + const formatDate = (dateString: string) => { return new Date(dateString).toLocaleString("ja-JP"); }; @@ -165,6 +197,21 @@ function EpisodeList() { return
{error}
; } + if (filteredEpisodes.length === 0 && episodes.length > 0 && selectedCategory) { + return ( +
+

カテゴリ「{selectedCategory}」のエピソードがありません

+ +
+ ); + } + if (episodes.length === 0) { return (
@@ -193,8 +240,27 @@ function EpisodeList() { alignItems: "center", }} > -

エピソード一覧 ({episodes.length}件)

+

エピソード一覧 ({selectedCategory ? `${filteredEpisodes.length}/${episodes.length}` : episodes.length}件)

+ {categories.length > 0 && ( + + )} データソース: {useDatabase ? "データベース" : "XML"} @@ -214,7 +280,7 @@ function EpisodeList() { - {episodes.map((episode) => ( + {filteredEpisodes.map((episode) => (
@@ -242,6 +308,11 @@ function EpisodeList() { > {episode.feedTitle} + {episode.feedCategory && ( + + ({episode.feedCategory}) + + )}
)} {episode.articleTitle && diff --git a/frontend/src/components/FeedList.tsx b/frontend/src/components/FeedList.tsx index 21f8235..133726b 100644 --- a/frontend/src/components/FeedList.tsx +++ b/frontend/src/components/FeedList.tsx @@ -6,6 +6,7 @@ interface Feed { url: string; title?: string; description?: string; + category?: string; lastUpdated?: string; createdAt: string; active: boolean; @@ -13,13 +14,21 @@ interface Feed { function FeedList() { const [feeds, setFeeds] = useState([]); + const [filteredFeeds, setFilteredFeeds] = useState([]); + const [categories, setCategories] = useState([]); + const [selectedCategory, setSelectedCategory] = useState(""); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { fetchFeeds(); + fetchCategories(); }, []); + useEffect(() => { + filterFeedsByCategory(); + }, [feeds, selectedCategory]); + const fetchFeeds = async () => { try { setLoading(true); @@ -38,6 +47,29 @@ function FeedList() { } }; + const fetchCategories = async () => { + try { + const response = await fetch("/api/categories"); + if (response.ok) { + const data = await response.json(); + setCategories(data.categories || []); + } + } catch (err) { + console.error("Error fetching categories:", err); + } + }; + + const filterFeedsByCategory = () => { + if (!selectedCategory) { + setFilteredFeeds(feeds); + } else { + const filtered = feeds.filter(feed => + feed.category === selectedCategory + ); + setFilteredFeeds(filtered); + } + }; + const formatDate = (dateString: string) => { return new Date(dateString).toLocaleString("ja-JP"); }; @@ -50,6 +82,21 @@ function FeedList() { return
{error}
; } + if (filteredFeeds.length === 0 && feeds.length > 0 && selectedCategory) { + return ( +
+

カテゴリ「{selectedCategory}」のフィードがありません

+ +
+ ); + } + if (feeds.length === 0) { return (
@@ -78,14 +125,35 @@ function FeedList() { alignItems: "center", }} > -

フィード一覧 ({feeds.length}件)

- +

フィード一覧 ({selectedCategory ? `${filteredFeeds.length}/${feeds.length}` : feeds.length}件)

+
+ {categories.length > 0 && ( + + )} + +
- {feeds.map((feed) => ( + {filteredFeeds.map((feed) => (

@@ -104,6 +172,12 @@ function FeedList() {
{feed.description}
)} + {feed.category && ( +
+ {feed.category} +
+ )} +
作成日: {formatDate(feed.createdAt)}
{feed.lastUpdated && ( @@ -187,6 +261,20 @@ function FeedList() { .feed-actions { text-align: right; } + + .feed-category { + margin-bottom: 15px; + } + + .category-badge { + display: inline-block; + background: #007bff; + color: white; + padding: 3px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 500; + } `}
); diff --git a/schema.sql b/schema.sql index 157fab1..dac68ba 100644 --- a/schema.sql +++ b/schema.sql @@ -4,6 +4,7 @@ CREATE TABLE IF NOT EXISTS feeds ( url TEXT NOT NULL UNIQUE, title TEXT, description TEXT, + category TEXT, last_updated TEXT, created_at TEXT NOT NULL, active BOOLEAN DEFAULT 1 diff --git a/services/batch-scheduler.ts b/services/batch-scheduler.ts index 744e1d9..cbedc1c 100644 --- a/services/batch-scheduler.ts +++ b/services/batch-scheduler.ts @@ -18,6 +18,7 @@ class BatchScheduler { }; private currentAbortController?: AbortController; + private migrationCompleted = false; private readonly SIX_HOURS_MS = 6 * 60 * 60 * 1000; // 6 hours in milliseconds @@ -84,6 +85,28 @@ class BatchScheduler { try { console.log("🔄 Running scheduled batch process..."); + + // Run migration for feeds without categories (only once) + if (!this.migrationCompleted) { + try { + const { migrateFeedsWithCategories, getFeedCategoryMigrationStatus } = await import("./database.js"); + const migrationStatus = await getFeedCategoryMigrationStatus(); + + if (!migrationStatus.migrationComplete) { + console.log("🔄 Running feed category migration..."); + await migrateFeedsWithCategories(); + this.migrationCompleted = true; + } else { + console.log("✅ Feed category migration already complete"); + this.migrationCompleted = true; + } + } catch (migrationError) { + console.error("❌ Error during feed category migration:", migrationError); + // Don't fail the entire batch process due to migration error + this.migrationCompleted = true; // Mark as completed to avoid retrying every batch + } + } + await batchProcess(this.currentAbortController.signal); console.log("✅ Scheduled batch process completed"); } catch (error) { diff --git a/services/database.ts b/services/database.ts index d1b457e..3c16de9 100644 --- a/services/database.ts +++ b/services/database.ts @@ -131,6 +131,7 @@ function initializeDatabase(): Database { url TEXT NOT NULL UNIQUE, title TEXT, description TEXT, + category TEXT, last_updated TEXT, created_at TEXT NOT NULL, active BOOLEAN DEFAULT 1 @@ -267,6 +268,7 @@ export interface EpisodeWithFeedInfo { feedId: string; feedTitle?: string; feedUrl: string; + feedCategory?: string; } // Feed management functions @@ -280,11 +282,12 @@ export async function saveFeed( if (existingFeed) { // Update existing feed const updateStmt = db.prepare( - "UPDATE feeds SET title = ?, description = ?, last_updated = ?, active = ? WHERE url = ?", + "UPDATE feeds SET title = ?, description = ?, category = ?, last_updated = ?, active = ? WHERE url = ?", ); updateStmt.run( feed.title || null, feed.description || null, + feed.category || null, feed.lastUpdated || null, feed.active !== undefined ? (feed.active ? 1 : 0) : 1, feed.url, @@ -296,13 +299,14 @@ export async function saveFeed( const createdAt = new Date().toISOString(); const insertStmt = db.prepare( - "INSERT INTO feeds (id, url, title, description, last_updated, created_at, active) VALUES (?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO feeds (id, url, title, description, category, last_updated, created_at, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ); insertStmt.run( id, feed.url, feed.title || null, feed.description || null, + feed.category || null, feed.lastUpdated || null, createdAt, feed.active !== undefined ? (feed.active ? 1 : 0) : 1, @@ -407,7 +411,8 @@ export async function fetchEpisodesWithFeedInfo(): Promise< a.pub_date as articlePubDate, f.id as feedId, f.title as feedTitle, - f.url as feedUrl + 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 @@ -432,6 +437,7 @@ export async function fetchEpisodesWithFeedInfo(): Promise< feedId: row.feedId, feedTitle: row.feedTitle, feedUrl: row.feedUrl, + feedCategory: row.feedCategory, })); } catch (error) { console.error("Error fetching episodes with feed info:", error); @@ -459,7 +465,8 @@ export async function fetchEpisodesByFeedId( a.pub_date as articlePubDate, f.id as feedId, f.title as feedTitle, - f.url as feedUrl + 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 @@ -484,6 +491,7 @@ export async function fetchEpisodesByFeedId( feedId: row.feedId, feedTitle: row.feedTitle, feedUrl: row.feedUrl, + feedCategory: row.feedCategory, })); } catch (error) { console.error("Error fetching episodes by feed ID:", error); @@ -511,7 +519,8 @@ export async function fetchEpisodeWithSourceInfo( a.pub_date as articlePubDate, f.id as feedId, f.title as feedTitle, - f.url as feedUrl + 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 @@ -536,6 +545,7 @@ export async function fetchEpisodeWithSourceInfo( feedId: row.feedId, feedTitle: row.feedTitle, feedUrl: row.feedUrl, + feedCategory: row.feedCategory, }; } catch (error) { console.error("Error fetching episode with source info:", error); @@ -1104,6 +1114,100 @@ export async function updateFeedRequestStatus( } } +// Migration function to classify existing feeds without categories +export async function migrateFeedsWithCategories(): Promise { + try { + console.log("🔄 Starting feed category migration..."); + + // Get all feeds without categories + const stmt = db.prepare("SELECT * FROM feeds WHERE category IS NULL OR category = ''"); + const feedsWithoutCategories = stmt.all() as any[]; + + if (feedsWithoutCategories.length === 0) { + console.log("✅ All feeds already have categories assigned"); + return; + } + + console.log(`📋 Found ${feedsWithoutCategories.length} feeds without categories`); + + // Import LLM service + const { openAI_ClassifyFeed } = await import("./llm.js"); + + let processedCount = 0; + let errorCount = 0; + + for (const feed of feedsWithoutCategories) { + try { + // Use title for classification, fallback to URL if no title + const titleForClassification = feed.title || feed.url; + + console.log(`🔍 Classifying feed: ${titleForClassification}`); + + // Classify the feed + const category = await openAI_ClassifyFeed(titleForClassification); + + // Update the feed with the category + const updateStmt = db.prepare("UPDATE feeds SET category = ? WHERE id = ?"); + updateStmt.run(category, feed.id); + + console.log(`✅ Assigned category "${category}" to feed: ${titleForClassification}`); + processedCount++; + + // Add a small delay to avoid rate limiting + await new Promise(resolve => setTimeout(resolve, 1000)); + + } catch (error) { + console.error(`❌ Failed to classify feed ${feed.title || feed.url}:`, error); + errorCount++; + + // Set a default category for failed classifications + const defaultCategory = "その他"; + const updateStmt = db.prepare("UPDATE feeds SET category = ? WHERE id = ?"); + updateStmt.run(defaultCategory, feed.id); + console.log(`⚠️ Assigned default category "${defaultCategory}" to feed: ${feed.title || feed.url}`); + } + } + + console.log(`✅ Feed category migration completed`); + console.log(`📊 Processed: ${processedCount}, Errors: ${errorCount}, Total: ${feedsWithoutCategories.length}`); + + } catch (error) { + console.error("❌ Error during feed category migration:", error); + throw error; + } +} + +// Function to get migration status +export async function getFeedCategoryMigrationStatus(): Promise<{ + totalFeeds: number; + feedsWithCategories: number; + feedsWithoutCategories: number; + migrationComplete: boolean; +}> { + try { + const totalStmt = db.prepare("SELECT COUNT(*) as count FROM feeds WHERE active = 1"); + const totalResult = totalStmt.get() as any; + const totalFeeds = totalResult.count; + + const withCategoriesStmt = db.prepare("SELECT COUNT(*) as count FROM feeds WHERE active = 1 AND category IS NOT NULL AND category != ''"); + const withCategoriesResult = withCategoriesStmt.get() as any; + const feedsWithCategories = withCategoriesResult.count; + + const feedsWithoutCategories = totalFeeds - feedsWithCategories; + const migrationComplete = feedsWithoutCategories === 0; + + return { + totalFeeds, + feedsWithCategories, + feedsWithoutCategories, + migrationComplete, + }; + } catch (error) { + console.error("Error getting migration status:", error); + throw error; + } +} + export function closeDatabase(): void { db.close(); }