Update category management and RSS endpoint handling
This commit is contained in:
@ -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";
|
||||
|
@ -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>
|
||||
|
@ -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>;
|
||||
|
199
frontend/src/components/RSSEndpoints.tsx
Normal file
199
frontend/src/components/RSSEndpoints.tsx
Normal 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 Podcasts、Google Podcasts、Spotify、Pocket 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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
);
|
||||
|
@ -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
133
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");
|
||||
|
@ -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
|
||||
|
@ -230,7 +230,7 @@ export async function extractArticleContent(
|
||||
}
|
||||
|
||||
export async function enhanceArticleContent(
|
||||
originalTitle: string,
|
||||
_originalTitle: string,
|
||||
originalLink: string,
|
||||
originalContent?: string,
|
||||
originalDescription?: string,
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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) => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user