@@ -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();
}
|