This commit is contained in:
2025-06-08 17:48:07 +09:00
parent 41a433893b
commit f34c601ec0
7 changed files with 705 additions and 13 deletions

View File

@ -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() {
>
</Link>
<Link
to="/categories"
className={`tab ${location.pathname === "/categories" ? "active" : ""}`}
>
</Link>
<Link
to="/feed-requests"
className={`tab ${location.pathname === "/feed-requests" ? "active" : ""}`}
@ -49,6 +56,7 @@ function App() {
<Routes>
<Route path="/" element={<EpisodeList />} />
<Route path="/feeds" element={<FeedList />} />
<Route path="/categories" element={<CategoryList />} />
<Route path="/feed-requests" element={<FeedManager />} />
<Route path="/episode/:episodeId" element={<EpisodeDetail />} />
<Route path="/feeds/:feedId" element={<FeedDetail />} />

View File

@ -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<CategoryGroup>({});
const [categories, setCategories] = useState<string[]>([]);
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [filteredFeeds, setFilteredFeeds] = useState<Feed[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 <div className="loading">...</div>;
}
if (error) {
return <div className="error">{error}</div>;
}
const availableCategories = Object.keys(groupedFeeds).filter(
(category) => groupedFeeds[category] && groupedFeeds[category].length > 0,
);
if (availableCategories.length === 0) {
return (
<div className="empty-state">
<p></p>
<p>
</p>
<button
className="btn btn-secondary"
onClick={fetchCategoriesAndFeeds}
style={{ marginTop: "10px" }}
>
</button>
</div>
);
}
return (
<div>
<div
style={{
marginBottom: "20px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<h2> ({availableCategories.length})</h2>
<button className="btn btn-secondary" onClick={fetchCategoriesAndFeeds}>
</button>
</div>
{!selectedCategory ? (
<div className="category-grid">
{availableCategories.map((category) => (
<div key={category} className="category-card">
<div className="category-header">
<h3
className="category-title"
onClick={() => setSelectedCategory(category)}
>
{category}
</h3>
<div className="category-stats">
<span className="feed-count">
{getFeedCount(category)}
</span>
</div>
</div>
<div className="category-feeds-preview">
{groupedFeeds[category]?.slice(0, 3).map((feed) => (
<div key={feed.id} className="feed-preview">
<Link to={`/feeds/${feed.id}`} className="feed-preview-link">
{feed.title || feed.url}
</Link>
</div>
))}
{getFeedCount(category) > 3 && (
<div className="more-feeds">
{getFeedCount(category) - 3} ...
</div>
)}
</div>
<div className="category-actions">
<button
className="btn btn-primary"
onClick={() => setSelectedCategory(category)}
>
</button>
</div>
</div>
))}
</div>
) : (
<div>
<div className="category-detail-header">
<button
className="btn btn-secondary"
onClick={() => setSelectedCategory(null)}
>
</button>
<h3>: {selectedCategory}</h3>
<p>{filteredFeeds.length} </p>
</div>
<div className="feed-grid">
{filteredFeeds.map((feed) => (
<div key={feed.id} className="feed-card">
<div className="feed-card-header">
<h4 className="feed-title">
<Link to={`/feeds/${feed.id}`} className="feed-link">
{feed.title || feed.url}
</Link>
</h4>
<div className="feed-url">
<a
href={feed.url}
target="_blank"
rel="noopener noreferrer"
>
{feed.url}
</a>
</div>
</div>
{feed.description && (
<div className="feed-description">{feed.description}</div>
)}
<div className="feed-meta">
<div>: {formatDate(feed.createdAt)}</div>
{feed.lastUpdated && (
<div>: {formatDate(feed.lastUpdated)}</div>
)}
</div>
<div className="feed-actions">
<Link to={`/feeds/${feed.id}`} className="btn btn-primary">
</Link>
</div>
</div>
))}
</div>
</div>
)}
<style>{`
.category-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.category-card {
border: 1px solid #e9ecef;
border-radius: 12px;
padding: 20px;
background: white;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.category-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
}
.category-header {
margin-bottom: 15px;
}
.category-title {
margin: 0 0 8px 0;
font-size: 20px;
color: #2c3e50;
cursor: pointer;
transition: color 0.2s ease;
}
.category-title:hover {
color: #007bff;
}
.category-stats {
display: flex;
gap: 15px;
font-size: 14px;
color: #6c757d;
}
.feed-count {
background: #f8f9fa;
padding: 4px 8px;
border-radius: 4px;
}
.category-feeds-preview {
margin-bottom: 15px;
}
.feed-preview {
margin-bottom: 6px;
font-size: 14px;
}
.feed-preview-link {
color: #007bff;
text-decoration: none;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.feed-preview-link:hover {
text-decoration: underline;
}
.more-feeds {
font-size: 12px;
color: #6c757d;
font-style: italic;
}
.category-actions {
text-align: right;
}
.category-detail-header {
margin-bottom: 25px;
padding-bottom: 15px;
border-bottom: 1px solid #e9ecef;
}
.category-detail-header h3 {
margin: 10px 0 5px 0;
color: #2c3e50;
}
.category-detail-header p {
margin: 0;
color: #6c757d;
}
.feed-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.feed-card {
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 20px;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.feed-card-header {
margin-bottom: 15px;
}
.feed-title {
margin: 0 0 8px 0;
font-size: 16px;
}
.feed-link {
text-decoration: none;
color: #007bff;
}
.feed-link:hover {
text-decoration: underline;
}
.feed-url {
font-size: 12px;
color: #666;
word-break: break-all;
}
.feed-url a {
color: #666;
text-decoration: none;
}
.feed-url a:hover {
color: #007bff;
}
.feed-description {
margin-bottom: 15px;
color: #333;
line-height: 1.5;
font-size: 14px;
}
.feed-meta {
margin-bottom: 15px;
font-size: 12px;
color: #666;
}
.feed-meta div {
margin-bottom: 4px;
}
.feed-actions {
text-align: right;
}
`}</style>
</div>
);
}
export default CategoryList;

View File

@ -27,10 +27,14 @@ interface EpisodeWithFeedInfo {
feedId: string;
feedTitle?: string;
feedUrl: string;
feedCategory?: string;
}
function EpisodeList() {
const [episodes, setEpisodes] = useState<EpisodeWithFeedInfo[]>([]);
const [filteredEpisodes, setFilteredEpisodes] = useState<EpisodeWithFeedInfo[]>([]);
const [categories, setCategories] = useState<string[]>([]);
const [selectedCategory, setSelectedCategory] = useState<string>("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentAudio, setCurrentAudio] = useState<string | null>(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 <div className="error">{error}</div>;
}
if (filteredEpisodes.length === 0 && episodes.length > 0 && selectedCategory) {
return (
<div className="empty-state">
<p>{selectedCategory}</p>
<button
className="btn btn-secondary"
onClick={() => setSelectedCategory("")}
style={{ marginTop: "10px" }}
>
</button>
</div>
);
}
if (episodes.length === 0) {
return (
<div className="empty-state">
@ -193,8 +240,27 @@ function EpisodeList() {
alignItems: "center",
}}
>
<h2> ({episodes.length})</h2>
<h2> ({selectedCategory ? `${filteredEpisodes.length}/${episodes.length}` : episodes.length})</h2>
<div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
{categories.length > 0 && (
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
style={{
padding: "5px 10px",
fontSize: "14px",
border: "1px solid #ccc",
borderRadius: "4px",
}}
>
<option value=""></option>
{categories.map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
)}
<span style={{ fontSize: "12px", color: "#666" }}>
: {useDatabase ? "データベース" : "XML"}
</span>
@ -214,7 +280,7 @@ function EpisodeList() {
</tr>
</thead>
<tbody>
{episodes.map((episode) => (
{filteredEpisodes.map((episode) => (
<tr key={episode.id}>
<td>
<div style={{ marginBottom: "8px" }}>
@ -242,6 +308,11 @@ function EpisodeList() {
>
{episode.feedTitle}
</Link>
{episode.feedCategory && (
<span style={{ marginLeft: "8px", color: "#999", fontSize: "11px" }}>
({episode.feedCategory})
</span>
)}
</div>
)}
{episode.articleTitle &&

View File

@ -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<Feed[]>([]);
const [filteredFeeds, setFilteredFeeds] = useState<Feed[]>([]);
const [categories, setCategories] = useState<string[]>([]);
const [selectedCategory, setSelectedCategory] = useState<string>("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 <div className="error">{error}</div>;
}
if (filteredFeeds.length === 0 && feeds.length > 0 && selectedCategory) {
return (
<div className="empty-state">
<p>{selectedCategory}</p>
<button
className="btn btn-secondary"
onClick={() => setSelectedCategory("")}
style={{ marginTop: "10px" }}
>
</button>
</div>
);
}
if (feeds.length === 0) {
return (
<div className="empty-state">
@ -78,14 +125,35 @@ function FeedList() {
alignItems: "center",
}}
>
<h2> ({feeds.length})</h2>
<h2> ({selectedCategory ? `${filteredFeeds.length}/${feeds.length}` : feeds.length})</h2>
<div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
{categories.length > 0 && (
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
style={{
padding: "5px 10px",
fontSize: "14px",
border: "1px solid #ccc",
borderRadius: "4px",
}}
>
<option value=""></option>
{categories.map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
)}
<button className="btn btn-secondary" onClick={fetchFeeds}>
</button>
</div>
</div>
<div className="feed-grid">
{feeds.map((feed) => (
{filteredFeeds.map((feed) => (
<div key={feed.id} className="feed-card">
<div className="feed-card-header">
<h3 className="feed-title">
@ -104,6 +172,12 @@ function FeedList() {
<div className="feed-description">{feed.description}</div>
)}
{feed.category && (
<div className="feed-category">
<span className="category-badge">{feed.category}</span>
</div>
)}
<div className="feed-meta">
<div>: {formatDate(feed.createdAt)}</div>
{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;
}
`}</style>
</div>
);

View File

@ -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

View File

@ -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) {

View File

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