Update category management and RSS endpoint handling

This commit is contained in:
2025-06-08 21:50:31 +09:00
parent 4aa1b5c56a
commit cd0e4065fc
13 changed files with 1171 additions and 70 deletions

View File

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

View File

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

View File

@ -18,7 +18,6 @@ interface CategoryGroup {
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);
@ -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 <div className="loading">...</div>;

View File

@ -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<RSSEndpointsData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [copiedUrl, setCopiedUrl] = useState<string | null>(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 <div className="loading">...</div>;
if (error) return <div className="error">: {error}</div>;
if (!endpoints) return <div className="error"></div>;
return (
<div className="rss-endpoints">
<div className="rss-endpoints-header">
<h1>RSS </h1>
<p>RSSフィードURLをポッドキャストアプリに追加して音声コンテンツをお楽しみください</p>
</div>
{/* メインフィード */}
<section className="rss-section">
<h2>📻 </h2>
<div className="rss-endpoint-card main-feed">
<div className="endpoint-header">
<h3>{endpoints.main.title}</h3>
<span className="endpoint-badge main"></span>
</div>
<p className="endpoint-description">{endpoints.main.description}</p>
<div className="endpoint-url">
<code>{endpoints.main.url}</code>
<div className="endpoint-actions">
<button
onClick={() => copyToClipboard(endpoints.main.url)}
className={`copy-btn ${copiedUrl === endpoints.main.url ? "copied" : ""}`}
title="URLをコピー"
>
{copiedUrl === endpoints.main.url ? "✓" : "📋"}
</button>
<button
onClick={() => openInNewTab(endpoints.main.url)}
className="open-btn"
title="新しいタブで開く"
>
🔗
</button>
</div>
</div>
</div>
</section>
{/* カテゴリ別フィード */}
{endpoints.categories.length > 0 && (
<section className="rss-section">
<h2>🏷 </h2>
<div className="rss-endpoints-grid">
{endpoints.categories.map((endpoint, index) => (
<div key={index} className="rss-endpoint-card">
<div className="endpoint-header">
<h3>{endpoint.title}</h3>
<span className="endpoint-badge category"></span>
</div>
<p className="endpoint-description">{endpoint.description}</p>
<div className="endpoint-url">
<code>{endpoint.url}</code>
<div className="endpoint-actions">
<button
onClick={() => copyToClipboard(endpoint.url)}
className={`copy-btn ${copiedUrl === endpoint.url ? "copied" : ""}`}
title="URLをコピー"
>
{copiedUrl === endpoint.url ? "✓" : "📋"}
</button>
<button
onClick={() => openInNewTab(endpoint.url)}
className="open-btn"
title="新しいタブで開く"
>
🔗
</button>
</div>
</div>
</div>
))}
</div>
</section>
)}
{/* フィード別フィード */}
{endpoints.feeds.length > 0 && (
<section className="rss-section">
<h2>📡 </h2>
<div className="rss-endpoints-grid">
{endpoints.feeds.map((endpoint, index) => (
<div key={index} className="rss-endpoint-card">
<div className="endpoint-header">
<h3>{endpoint.title}</h3>
<div className="endpoint-badges">
<span className="endpoint-badge feed"></span>
{endpoint.feedCategory && (
<span className="endpoint-badge category-tag">
{endpoint.feedCategory}
</span>
)}
</div>
</div>
<p className="endpoint-description">{endpoint.description}</p>
<div className="endpoint-url">
<code>{endpoint.url}</code>
<div className="endpoint-actions">
<button
onClick={() => copyToClipboard(endpoint.url)}
className={`copy-btn ${copiedUrl === endpoint.url ? "copied" : ""}`}
title="URLをコピー"
>
{copiedUrl === endpoint.url ? "✓" : "📋"}
</button>
<button
onClick={() => openInNewTab(endpoint.url)}
className="open-btn"
title="新しいタブで開く"
>
🔗
</button>
</div>
</div>
</div>
))}
</div>
</section>
)}
{/* 使用方法の説明 */}
<section className="rss-section usage-info">
<h2>📱 使</h2>
<div className="usage-cards">
<div className="usage-card">
<h3>1. </h3>
<p>Apple PodcastsGoogle PodcastsSpotifyPocket Casts RSS 使</p>
</div>
<div className="usage-card">
<h3>2. RSS </h3>
<p>URLをコピーしてURLから購読使</p>
</div>
<div className="usage-card">
<h3>3. </h3>
<p></p>
</div>
</div>
</section>
</div>
);
}

View File

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

View File

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

View File

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

133
server.ts
View File

@ -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");

View File

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

View File

@ -230,7 +230,7 @@ export async function extractArticleContent(
}
export async function enhanceArticleContent(
originalTitle: string,
_originalTitle: string,
originalLink: string,
originalContent?: string,
originalDescription?: string,

View File

@ -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<Episode[]> {
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<boolean> {
}
}
// Episode category management functions
export async function getEpisodesByCategory(category?: string): Promise<EpisodeWithFeedInfo[]> {
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<string[]> {
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<boolean> {
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<void> {
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();
}

View File

@ -143,3 +143,69 @@ ${articleDetails}
);
}
}
export async function openAI_ClassifyEpisode(
title: string,
description?: string,
content?: string,
): Promise<string> {
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"}`,
);
}
}

View File

@ -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,13 +68,9 @@ function createItemXml(episode: any): string {
</item>`;
}
export async function updatePodcastRSS(): Promise<void> {
try {
// Use episodes with feed info for enhanced descriptions
const episodesWithFeedInfo = await fetchEpisodesWithFeedInfo();
// Filter episodes to only include those with valid audio files
const validEpisodes = episodesWithFeedInfo.filter((episode) => {
// 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,
@ -82,22 +82,24 @@ export async function updatePodcastRSS(): Promise<void> {
return false;
}
});
}
console.log(
`Found ${episodesWithFeedInfo.length} episodes, ${validEpisodes.length} with valid audio files`,
);
// Generate RSS XML from episodes
function generateRSSXml(
episodes: any[],
title: string,
description: string,
link?: string
): string {
const lastBuildDate = new Date().toUTCString();
const itemsXml = validEpisodes.map(createItemXml).join("\n");
const outputPath = path.join(config.paths.publicDir, "podcast.xml");
const itemsXml = episodes.map(createItemXml).join("\n");
// Create RSS XML content
const rssXml = `<?xml version="1.0" encoding="UTF-8"?>
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>${escapeXml(config.podcast.title)}</title>
<link>${escapeXml(config.podcast.link)}</link>
<description><![CDATA[${escapeXml(config.podcast.description)}]]></description>
<title>${escapeXml(title)}</title>
<link>${escapeXml(link || config.podcast.link)}</link>
<description><![CDATA[${escapeXml(description)}]]></description>
<language>${config.podcast.language}</language>
<lastBuildDate>${lastBuildDate}</lastBuildDate>
<ttl>${config.podcast.ttl}</ttl>
@ -105,6 +107,24 @@ export async function updatePodcastRSS(): Promise<void> {
<category>${escapeXml(config.podcast.categories)}</category>${itemsXml}
</channel>
</rss>`;
}
export async function updatePodcastRSS(): Promise<void> {
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<void> {
throw error;
}
}
export async function generateCategoryRSS(category: string): Promise<string> {
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<void> {
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<string> {
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<void> {
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;
}
}