Add searching feature

This commit is contained in:
2025-06-08 21:53:45 +09:00
parent cd0e4065fc
commit b7f3ca6a27
16 changed files with 564 additions and 194 deletions

View File

@ -91,7 +91,8 @@ function App() {
const loadData = async () => { const loadData = async () => {
setLoading(true); setLoading(true);
try { try {
const [feedsRes, statsRes, envRes, requestsRes, episodesRes] = await Promise.all([ const [feedsRes, statsRes, envRes, requestsRes, episodesRes] =
await Promise.all([
fetch("/api/admin/feeds"), fetch("/api/admin/feeds"),
fetch("/api/admin/stats"), fetch("/api/admin/stats"),
fetch("/api/admin/env"), fetch("/api/admin/env"),
@ -99,11 +100,18 @@ function App() {
fetch("/api/admin/episodes"), fetch("/api/admin/episodes"),
]); ]);
if (!feedsRes.ok || !statsRes.ok || !envRes.ok || !requestsRes.ok || !episodesRes.ok) { if (
!feedsRes.ok ||
!statsRes.ok ||
!envRes.ok ||
!requestsRes.ok ||
!episodesRes.ok
) {
throw new Error("Failed to load data"); throw new Error("Failed to load data");
} }
const [feedsData, statsData, envData, requestsData, episodesData] = await Promise.all([ const [feedsData, statsData, envData, requestsData, episodesData] =
await Promise.all([
feedsRes.json(), feedsRes.json(),
statsRes.json(), statsRes.json(),
envRes.json(), envRes.json(),
@ -580,20 +588,36 @@ function App() {
</p> </p>
<div style={{ marginBottom: "20px" }}> <div style={{ marginBottom: "20px" }}>
<div className="stats-grid" style={{ gridTemplateColumns: "repeat(3, 1fr)" }}> <div
className="stats-grid"
style={{ gridTemplateColumns: "repeat(3, 1fr)" }}
>
<div className="stat-card"> <div className="stat-card">
<div className="value">{episodes.length}</div> <div className="value">{episodes.length}</div>
<div className="label"></div> <div className="label"></div>
</div> </div>
<div className="stat-card"> <div className="stat-card">
<div className="value"> <div className="value">
{episodes.filter(ep => ep.feedCategory).map(ep => ep.feedCategory).filter((category, index, arr) => arr.indexOf(category) === index).length} {
episodes
.filter((ep) => ep.feedCategory)
.map((ep) => ep.feedCategory)
.filter(
(category, index, arr) =>
arr.indexOf(category) === index,
).length
}
</div> </div>
<div className="label"></div> <div className="label"></div>
</div> </div>
<div className="stat-card"> <div className="stat-card">
<div className="value"> <div className="value">
{episodes.reduce((acc, ep) => acc + (ep.fileSize || 0), 0) / (1024 * 1024) > 1 {episodes.reduce(
(acc, ep) => acc + (ep.fileSize || 0),
0,
) /
(1024 * 1024) >
1
? `${Math.round(episodes.reduce((acc, ep) => acc + (ep.fileSize || 0), 0) / (1024 * 1024))}MB` ? `${Math.round(episodes.reduce((acc, ep) => acc + (ep.fileSize || 0), 0) / (1024 * 1024))}MB`
: `${Math.round(episodes.reduce((acc, ep) => acc + (ep.fileSize || 0), 0) / 1024)}KB`} : `${Math.round(episodes.reduce((acc, ep) => acc + (ep.fileSize || 0), 0) / 1024)}KB`}
</div> </div>
@ -620,37 +644,79 @@ function App() {
<li key={episode.id} className="feed-item"> <li key={episode.id} className="feed-item">
<div className="feed-info"> <div className="feed-info">
<h3>{episode.title}</h3> <h3>{episode.title}</h3>
<div className="url" style={{ fontSize: "14px", color: "#666" }}> <div
className="url"
style={{ fontSize: "14px", color: "#666" }}
>
: {episode.feedTitle || episode.feedUrl} : {episode.feedTitle || episode.feedUrl}
{episode.feedCategory && ( {episode.feedCategory && (
<span style={{ marginLeft: "8px", padding: "2px 6px", background: "#e9ecef", borderRadius: "4px", fontSize: "12px" }}> <span
style={{
marginLeft: "8px",
padding: "2px 6px",
background: "#e9ecef",
borderRadius: "4px",
fontSize: "12px",
}}
>
{episode.feedCategory} {episode.feedCategory}
</span> </span>
)} )}
</div> </div>
<div className="url" style={{ fontSize: "14px", color: "#666" }}> <div
: <a href={episode.articleLink} target="_blank" rel="noopener noreferrer">{episode.articleTitle}</a> className="url"
style={{ fontSize: "14px", color: "#666" }}
>
:{" "}
<a
href={episode.articleLink}
target="_blank"
rel="noopener noreferrer"
>
{episode.articleTitle}
</a>
</div> </div>
{episode.description && ( {episode.description && (
<div style={{ fontSize: "14px", color: "#777", marginTop: "4px" }}> <div
style={{
fontSize: "14px",
color: "#777",
marginTop: "4px",
}}
>
{episode.description.length > 100 {episode.description.length > 100
? episode.description.substring(0, 100) + "..." ? episode.description.substring(0, 100) + "..."
: episode.description} : episode.description}
</div> </div>
)} )}
<div style={{ fontSize: "12px", color: "#999", marginTop: "8px" }}> <div
<span>: {new Date(episode.createdAt).toLocaleString("ja-JP")}</span> style={{
fontSize: "12px",
color: "#999",
marginTop: "8px",
}}
>
<span>
:{" "}
{new Date(episode.createdAt).toLocaleString(
"ja-JP",
)}
</span>
{episode.duration && ( {episode.duration && (
<> <>
<span style={{ margin: "0 8px" }}>|</span> <span style={{ margin: "0 8px" }}>|</span>
<span>: {Math.round(episode.duration / 60)}</span> <span>
: {Math.round(episode.duration / 60)}
</span>
</> </>
)} )}
{episode.fileSize && ( {episode.fileSize && (
<> <>
<span style={{ margin: "0 8px" }}>|</span> <span style={{ margin: "0 8px" }}>|</span>
<span> <span>
: {episode.fileSize > 1024 * 1024 :{" "}
{episode.fileSize > 1024 * 1024
? `${Math.round(episode.fileSize / (1024 * 1024))}MB` ? `${Math.round(episode.fileSize / (1024 * 1024))}MB`
: `${Math.round(episode.fileSize / 1024)}KB`} : `${Math.round(episode.fileSize / 1024)}KB`}
</span> </span>
@ -665,7 +731,7 @@ function App() {
style={{ style={{
fontSize: "12px", fontSize: "12px",
color: "#007bff", color: "#007bff",
textDecoration: "none" textDecoration: "none",
}} }}
> >
🎵 🎵
@ -675,7 +741,9 @@ function App() {
<div className="feed-actions"> <div className="feed-actions">
<button <button
className="btn btn-danger" className="btn btn-danger"
onClick={() => deleteEpisode(episode.id, episode.title)} onClick={() =>
deleteEpisode(episode.id, episode.title)
}
> >
</button> </button>

View File

@ -8,8 +8,8 @@ import { batchScheduler } from "./services/batch-scheduler.js";
import { config, validateConfig } from "./services/config.js"; import { config, validateConfig } from "./services/config.js";
import { closeBrowser } from "./services/content-extractor.js"; import { closeBrowser } from "./services/content-extractor.js";
import { import {
deleteFeed,
deleteEpisode, deleteEpisode,
deleteFeed,
fetchAllEpisodes, fetchAllEpisodes,
fetchEpisodesWithArticles, fetchEpisodesWithArticles,
getAllCategories, getAllCategories,
@ -414,7 +414,7 @@ app.get("/api/admin/db-diagnostic", async (c) => {
return c.json( return c.json(
{ {
error: "Failed to run database diagnostic", error: "Failed to run database diagnostic",
details: error instanceof Error ? error.message : String(error) details: error instanceof Error ? error.message : String(error),
}, },
500, 500,
); );
@ -702,14 +702,14 @@ app.get("/index.html", serveAdminIndex);
app.get("*", serveAdminIndex); app.get("*", serveAdminIndex);
// Graceful shutdown // Graceful shutdown
process.on('SIGINT', async () => { process.on("SIGINT", async () => {
console.log('\n🛑 Received SIGINT. Graceful shutdown...'); console.log("\n🛑 Received SIGINT. Graceful shutdown...");
await closeBrowser(); await closeBrowser();
process.exit(0); process.exit(0);
}); });
process.on('SIGTERM', async () => { process.on("SIGTERM", async () => {
console.log('\n🛑 Received SIGTERM. Graceful shutdown...'); console.log("\n🛑 Received SIGTERM. Graceful shutdown...");
await closeBrowser(); await closeBrowser();
process.exit(0); process.exit(0);
}); });

View File

@ -9,9 +9,13 @@ import RSSEndpoints from "./components/RSSEndpoints";
function App() { function App() {
const location = useLocation(); const location = useLocation();
const isMainPage = ["/", "/feeds", "/categories", "/feed-requests", "/rss-endpoints"].includes( const isMainPage = [
location.pathname, "/",
); "/feeds",
"/categories",
"/feed-requests",
"/rss-endpoints",
].includes(location.pathname);
return ( return (
<div className="container"> <div className="container">

View File

@ -70,7 +70,6 @@ function CategoryList() {
return groupedFeeds[category]?.length || 0; return groupedFeeds[category]?.length || 0;
}; };
if (loading) { if (loading) {
return <div className="loading">...</div>; return <div className="loading">...</div>;
} }
@ -138,7 +137,10 @@ function CategoryList() {
<div className="category-feeds-preview"> <div className="category-feeds-preview">
{groupedFeeds[category]?.slice(0, 3).map((feed) => ( {groupedFeeds[category]?.slice(0, 3).map((feed) => (
<div key={feed.id} className="feed-preview"> <div key={feed.id} className="feed-preview">
<Link to={`/feeds/${feed.id}`} className="feed-preview-link"> <Link
to={`/feeds/${feed.id}`}
className="feed-preview-link"
>
{feed.title || feed.url} {feed.title || feed.url}
</Link> </Link>
</div> </div>

View File

@ -182,11 +182,17 @@ function EpisodeDetail() {
<meta property="og:locale" content="ja_JP" /> <meta property="og:locale" content="ja_JP" />
{/* Image metadata */} {/* Image metadata */}
<meta property="og:image" content={`${window.location.origin}/default-thumbnail.svg`} /> <meta
property="og:image"
content={`${window.location.origin}/default-thumbnail.svg`}
/>
<meta property="og:image:type" content="image/svg+xml" /> <meta property="og:image:type" content="image/svg+xml" />
<meta property="og:image:width" content="1200" /> <meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" /> <meta property="og:image:height" content="630" />
<meta property="og:image:alt" content={`${episode.title} - Voice RSS Summary`} /> <meta
property="og:image:alt"
content={`${episode.title} - Voice RSS Summary`}
/>
{/* Twitter Card metadata */} {/* Twitter Card metadata */}
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
@ -195,8 +201,14 @@ function EpisodeDetail() {
name="twitter:description" name="twitter:description"
content={episode.description || `${episode.title}のエピソード詳細`} content={episode.description || `${episode.title}のエピソード詳細`}
/> />
<meta name="twitter:image" content={`${window.location.origin}/default-thumbnail.svg`} /> <meta
<meta name="twitter:image:alt" content={`${episode.title} - Voice RSS Summary`} /> name="twitter:image"
content={`${window.location.origin}/default-thumbnail.svg`}
/>
<meta
name="twitter:image:alt"
content={`${episode.title} - Voice RSS Summary`}
/>
{/* Audio-specific metadata */} {/* Audio-specific metadata */}
<meta <meta

View File

@ -32,9 +32,13 @@ interface EpisodeWithFeedInfo {
function EpisodeList() { function EpisodeList() {
const [episodes, setEpisodes] = useState<EpisodeWithFeedInfo[]>([]); const [episodes, setEpisodes] = useState<EpisodeWithFeedInfo[]>([]);
const [filteredEpisodes, setFilteredEpisodes] = useState<EpisodeWithFeedInfo[]>([]); const [filteredEpisodes, setFilteredEpisodes] = useState<
EpisodeWithFeedInfo[]
>([]);
const [categories, setCategories] = useState<string[]>([]); const [categories, setCategories] = useState<string[]>([]);
const [selectedCategory, setSelectedCategory] = useState<string>(""); const [selectedCategory, setSelectedCategory] = useState<string>("");
const [searchQuery, setSearchQuery] = useState<string>("");
const [isSearching, setIsSearching] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [currentAudio, setCurrentAudio] = useState<string | null>(null); const [currentAudio, setCurrentAudio] = useState<string | null>(null);
@ -46,8 +50,12 @@ function EpisodeList() {
}, [useDatabase]); }, [useDatabase]);
useEffect(() => { useEffect(() => {
if (searchQuery.trim()) {
performSearch();
} else {
filterEpisodesByCategory(); filterEpisodesByCategory();
}, [episodes, selectedCategory]); }
}, [episodes, selectedCategory, searchQuery]);
const fetchEpisodes = async () => { const fetchEpisodes = async () => {
try { try {
@ -127,12 +135,41 @@ function EpisodeList() {
} }
}; };
const performSearch = async () => {
try {
setIsSearching(true);
setError(null);
const searchParams = new URLSearchParams({
q: searchQuery.trim(),
});
if (selectedCategory) {
searchParams.append("category", selectedCategory);
}
const response = await fetch(`/api/episodes/search?${searchParams}`);
if (!response.ok) {
throw new Error("検索に失敗しました");
}
const data = await response.json();
setFilteredEpisodes(data.episodes || []);
} catch (err) {
console.error("Search error:", err);
setError(err instanceof Error ? err.message : "検索エラーが発生しました");
setFilteredEpisodes([]);
} finally {
setIsSearching(false);
}
};
const filterEpisodesByCategory = () => { const filterEpisodesByCategory = () => {
if (!selectedCategory) { if (!selectedCategory) {
setFilteredEpisodes(episodes); setFilteredEpisodes(episodes);
} else { } else {
const filtered = episodes.filter(episode => const filtered = episodes.filter(
episode.feedCategory === selectedCategory (episode) => episode.feedCategory === selectedCategory,
); );
setFilteredEpisodes(filtered); setFilteredEpisodes(filtered);
} }
@ -197,7 +234,40 @@ function EpisodeList() {
return <div className="error">{error}</div>; return <div className="error">{error}</div>;
} }
if (filteredEpisodes.length === 0 && episodes.length > 0 && selectedCategory) { if (filteredEpisodes.length === 0 && episodes.length > 0) {
if (searchQuery.trim()) {
return (
<div className="empty-state">
<p>{searchQuery}</p>
{selectedCategory && (
<p>{selectedCategory}</p>
)}
<div
style={{
marginTop: "10px",
display: "flex",
gap: "10px",
justifyContent: "center",
}}
>
<button
className="btn btn-secondary"
onClick={() => setSearchQuery("")}
>
</button>
{selectedCategory && (
<button
className="btn btn-secondary"
onClick={() => setSelectedCategory("")}
>
</button>
)}
</div>
</div>
);
} else if (selectedCategory) {
return ( return (
<div className="empty-state"> <div className="empty-state">
<p>{selectedCategory}</p> <p>{selectedCategory}</p>
@ -211,6 +281,7 @@ function EpisodeList() {
</div> </div>
); );
} }
}
if (episodes.length === 0) { if (episodes.length === 0) {
return ( return (
@ -235,22 +306,79 @@ function EpisodeList() {
<div <div
style={{ style={{
marginBottom: "20px", marginBottom: "20px",
}}
>
<div
style={{
display: "flex", display: "flex",
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
marginBottom: "15px",
}} }}
> >
<h2> ({selectedCategory ? `${filteredEpisodes.length}/${episodes.length}` : episodes.length})</h2> <h2>
(
{searchQuery
? `検索結果: ${filteredEpisodes.length}`
: selectedCategory
? `${filteredEpisodes.length}/${episodes.length}`
: `${episodes.length}`}
)
</h2>
<div style={{ display: "flex", gap: "10px", alignItems: "center" }}> <div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
<span style={{ fontSize: "12px", color: "#666" }}>
: {useDatabase ? "データベース" : "XML"}
</span>
<button className="btn btn-secondary" onClick={fetchEpisodes}>
</button>
</div>
</div>
<div
style={{
display: "flex",
gap: "10px",
alignItems: "center",
flexWrap: "wrap",
}}
>
<input
type="text"
placeholder="エピソードを検索..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{
flex: "1",
minWidth: "200px",
padding: "8px 12px",
fontSize: "14px",
border: "1px solid #ccc",
borderRadius: "4px",
}}
/>
{searchQuery && (
<button
className="btn btn-secondary"
onClick={() => setSearchQuery("")}
style={{
padding: "8px 12px",
fontSize: "14px",
}}
>
</button>
)}
{categories.length > 0 && ( {categories.length > 0 && (
<select <select
value={selectedCategory} value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)} onChange={(e) => setSelectedCategory(e.target.value)}
style={{ style={{
padding: "5px 10px", padding: "8px 12px",
fontSize: "14px", fontSize: "14px",
border: "1px solid #ccc", border: "1px solid #ccc",
borderRadius: "4px", borderRadius: "4px",
minWidth: "120px",
}} }}
> >
<option value=""></option> <option value=""></option>
@ -261,12 +389,9 @@ function EpisodeList() {
))} ))}
</select> </select>
)} )}
<span style={{ fontSize: "12px", color: "#666" }}> {isSearching && (
: {useDatabase ? "データベース" : "XML"} <span style={{ fontSize: "14px", color: "#666" }}>...</span>
</span> )}
<button className="btn btn-secondary" onClick={fetchEpisodes}>
</button>
</div> </div>
</div> </div>
@ -309,7 +434,13 @@ function EpisodeList() {
{episode.feedTitle} {episode.feedTitle}
</Link> </Link>
{episode.feedCategory && ( {episode.feedCategory && (
<span style={{ marginLeft: "8px", color: "#999", fontSize: "11px" }}> <span
style={{
marginLeft: "8px",
color: "#999",
fontSize: "11px",
}}
>
({episode.feedCategory}) ({episode.feedCategory})
</span> </span>
)} )}

View File

@ -63,8 +63,8 @@ function FeedList() {
if (!selectedCategory) { if (!selectedCategory) {
setFilteredFeeds(feeds); setFilteredFeeds(feeds);
} else { } else {
const filtered = feeds.filter(feed => const filtered = feeds.filter(
feed.category === selectedCategory (feed) => feed.category === selectedCategory,
); );
setFilteredFeeds(filtered); setFilteredFeeds(filtered);
} }
@ -125,7 +125,13 @@ function FeedList() {
alignItems: "center", alignItems: "center",
}} }}
> >
<h2> ({selectedCategory ? `${filteredFeeds.length}/${feeds.length}` : feeds.length})</h2> <h2>
(
{selectedCategory
? `${filteredFeeds.length}/${feeds.length}`
: feeds.length}
)
</h2>
<div style={{ display: "flex", gap: "10px", alignItems: "center" }}> <div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
{categories.length > 0 && ( {categories.length > 0 && (
<select <select

View File

@ -61,7 +61,9 @@ export default function RSSEndpoints() {
<div className="rss-endpoints"> <div className="rss-endpoints">
<div className="rss-endpoints-header"> <div className="rss-endpoints-header">
<h1>RSS </h1> <h1>RSS </h1>
<p>RSSフィードURLをポッドキャストアプリに追加して音声コンテンツをお楽しみください</p> <p>
RSSフィードURLをポッドキャストアプリに追加して音声コンテンツをお楽しみください
</p>
</div> </div>
{/* メインフィード */} {/* メインフィード */}
@ -182,15 +184,22 @@ export default function RSSEndpoints() {
<div className="usage-cards"> <div className="usage-cards">
<div className="usage-card"> <div className="usage-card">
<h3>1. </h3> <h3>1. </h3>
<p>Apple PodcastsGoogle PodcastsSpotifyPocket Casts RSS 使</p> <p>
Apple PodcastsGoogle PodcastsSpotifyPocket Casts RSS
使
</p>
</div> </div>
<div className="usage-card"> <div className="usage-card">
<h3>2. RSS </h3> <h3>2. RSS </h3>
<p>URLをコピーしてURLから購読使</p> <p>
URLをコピーしてURLから購読使
</p>
</div> </div>
<div className="usage-card"> <div className="usage-card">
<h3>3. </h3> <h3>3. </h3>
<p></p> <p>
</p>
</div> </div>
</div> </div>
</section> </section>

View File

@ -402,7 +402,7 @@ body {
background: none; background: none;
border: none; border: none;
color: #495057; color: #495057;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
font-size: 0.9rem; font-size: 0.9rem;
word-break: break-all; word-break: break-all;
flex: 1; flex: 1;
@ -419,7 +419,8 @@ body {
flex-shrink: 0; flex-shrink: 0;
} }
.copy-btn, .open-btn { .copy-btn,
.open-btn {
background: #3498db; background: #3498db;
color: white; color: white;
border: none; border: none;
@ -435,7 +436,8 @@ body {
justify-content: center; justify-content: center;
} }
.copy-btn:hover, .open-btn:hover { .copy-btn:hover,
.open-btn:hover {
background: #2980b9; background: #2980b9;
transform: scale(1.05); transform: scale(1.05);
} }

View File

@ -2,7 +2,10 @@ import crypto from "crypto";
import fs from "fs/promises"; import fs from "fs/promises";
import Parser from "rss-parser"; import Parser from "rss-parser";
import { config } from "../services/config.js"; import { config } from "../services/config.js";
import { enhanceArticleContent, closeBrowser } from "../services/content-extractor.js"; import {
closeBrowser,
enhanceArticleContent,
} from "../services/content-extractor.js";
import { import {
getFeedById, getFeedById,
getFeedByUrl, getFeedByUrl,
@ -13,8 +16,8 @@ import {
saveFeed, saveFeed,
} from "../services/database.js"; } from "../services/database.js";
import { import {
openAI_ClassifyFeed,
openAI_ClassifyEpisode, openAI_ClassifyEpisode,
openAI_ClassifyFeed,
openAI_GeneratePodcastContent, openAI_GeneratePodcastContent,
} from "../services/llm.js"; } from "../services/llm.js";
import { updatePodcastRSS } from "../services/podcast.js"; import { updatePodcastRSS } from "../services/podcast.js";

112
server.ts
View File

@ -136,7 +136,10 @@ app.get("/podcast/category/:category.xml", async (c) => {
"Cache-Control": "public, max-age=3600", // Cache for 1 hour "Cache-Control": "public, max-age=3600", // Cache for 1 hour
}); });
} catch (error) { } catch (error) {
console.error(`Error generating category RSS for "${c.req.param("category")}":`, error); console.error(
`Error generating category RSS for "${c.req.param("category")}":`,
error,
);
return c.notFound(); return c.notFound();
} }
}); });
@ -157,7 +160,10 @@ app.get("/podcast/feed/:feedId.xml", async (c) => {
"Cache-Control": "public, max-age=3600", // Cache for 1 hour "Cache-Control": "public, max-age=3600", // Cache for 1 hour
}); });
} catch (error) { } catch (error) {
console.error(`Error generating feed RSS for "${c.req.param("feedId")}":`, error); console.error(
`Error generating feed RSS for "${c.req.param("feedId")}":`,
error,
);
return c.notFound(); return c.notFound();
} }
}); });
@ -206,7 +212,9 @@ async function serveIndex(c: any) {
async function serveEpisodePage(c: any, episodeId: string) { async function serveEpisodePage(c: any, episodeId: string) {
try { try {
// First, try to get episode from database // First, try to get episode from database
const { fetchEpisodeWithSourceInfo } = await import("./services/database.js"); const { fetchEpisodeWithSourceInfo } = await import(
"./services/database.js"
);
let episode = null; let episode = null;
try { try {
@ -275,26 +283,24 @@ async function serveEpisodePage(c: any, episodeId: string) {
// Replace title and add OG metadata // Replace title and add OG metadata
const title = `${episode.title} - Voice RSS Summary`; const title = `${episode.title} - Voice RSS Summary`;
const description = episode.description || `${episode.title}のエピソード詳細`; const description =
episode.description || `${episode.title}のエピソード詳細`;
const episodeUrl = `${baseUrl}/episode/${episodeId}`; const episodeUrl = `${baseUrl}/episode/${episodeId}`;
const imageUrl = `${baseUrl}/default-thumbnail.svg`; const imageUrl = `${baseUrl}/default-thumbnail.svg`;
const audioUrl = episode.audioPath.startsWith('/') const audioUrl = episode.audioPath.startsWith("/")
? `${baseUrl}${episode.audioPath}` ? `${baseUrl}${episode.audioPath}`
: `${baseUrl}/podcast_audio/${episode.audioPath}`; : `${baseUrl}/podcast_audio/${episode.audioPath}`;
// Replace the title // Replace the title
html = html.replace( html = html.replace(/<title>.*?<\/title>/, `<title>${title}</title>`);
/<title>.*?<\/title>/,
`<title>${title}</title>`
);
// Add OG metadata before closing </head> // Add OG metadata before closing </head>
const ogMetadata = ` const ogMetadata = `
<meta name="description" content="${description.replace(/"/g, '&quot;')}" /> <meta name="description" content="${description.replace(/"/g, "&quot;")}" />
<!-- OpenGraph metadata --> <!-- OpenGraph metadata -->
<meta property="og:title" content="${episode.title.replace(/"/g, '&quot;')}" /> <meta property="og:title" content="${episode.title.replace(/"/g, "&quot;")}" />
<meta property="og:description" content="${description.replace(/"/g, '&quot;')}" /> <meta property="og:description" content="${description.replace(/"/g, "&quot;")}" />
<meta property="og:type" content="article" /> <meta property="og:type" content="article" />
<meta property="og:url" content="${episodeUrl}" /> <meta property="og:url" content="${episodeUrl}" />
<meta property="og:site_name" content="Voice RSS Summary" /> <meta property="og:site_name" content="Voice RSS Summary" />
@ -303,25 +309,28 @@ async function serveEpisodePage(c: any, episodeId: string) {
<meta property="og:image:type" content="image/svg+xml" /> <meta property="og:image:type" content="image/svg+xml" />
<meta property="og:image:width" content="1200" /> <meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" /> <meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="${episode.title.replace(/"/g, '&quot;')} - Voice RSS Summary" /> <meta property="og:image:alt" content="${episode.title.replace(/"/g, "&quot;")} - Voice RSS Summary" />
<meta property="og:audio" content="${audioUrl}" /> <meta property="og:audio" content="${audioUrl}" />
<meta property="og:audio:type" content="audio/mpeg" /> <meta property="og:audio:type" content="audio/mpeg" />
<!-- Twitter Card metadata --> <!-- Twitter Card metadata -->
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="${episode.title.replace(/"/g, '&quot;')}" /> <meta name="twitter:title" content="${episode.title.replace(/"/g, "&quot;")}" />
<meta name="twitter:description" content="${description.replace(/"/g, '&quot;')}" /> <meta name="twitter:description" content="${description.replace(/"/g, "&quot;")}" />
<meta name="twitter:image" content="${imageUrl}" /> <meta name="twitter:image" content="${imageUrl}" />
<meta name="twitter:image:alt" content="${episode.title.replace(/"/g, '&quot;')} - Voice RSS Summary" /> <meta name="twitter:image:alt" content="${episode.title.replace(/"/g, "&quot;")} - Voice RSS Summary" />
<!-- Article metadata --> <!-- Article metadata -->
<meta property="article:published_time" content="${episode.createdAt}" /> <meta property="article:published_time" content="${episode.createdAt}" />
${episode.articlePubDate && episode.articlePubDate !== episode.createdAt ? ${
`<meta property="article:modified_time" content="${episode.articlePubDate}" />` : ''} episode.articlePubDate && episode.articlePubDate !== episode.createdAt
${episode.feedTitle ? `<meta property="article:section" content="${episode.feedTitle.replace(/"/g, '&quot;')}" />` : ''} ? `<meta property="article:modified_time" content="${episode.articlePubDate}" />`
: ""
}
${episode.feedTitle ? `<meta property="article:section" content="${episode.feedTitle.replace(/"/g, "&quot;")}" />` : ""}
`; `;
html = html.replace('</head>', `${ogMetadata}</head>`); html = html.replace("</head>", `${ogMetadata}</head>`);
return c.body(html, 200, { "Content-Type": "text/html; charset=utf-8" }); return c.body(html, 200, { "Content-Type": "text/html; charset=utf-8" });
} catch (error) { } catch (error) {
@ -334,7 +343,6 @@ app.get("/", serveIndex);
app.get("/index.html", serveIndex); app.get("/index.html", serveIndex);
// API endpoints for frontend // API endpoints for frontend
app.get("/api/episodes", async (c) => { app.get("/api/episodes", async (c) => {
try { try {
@ -517,7 +525,9 @@ app.get("/api/feeds/by-category", async (c) => {
app.get("/api/feeds/grouped-by-category", async (c) => { app.get("/api/feeds/grouped-by-category", async (c) => {
try { try {
const { getFeedsGroupedByCategory } = await import("./services/database.js"); const { getFeedsGroupedByCategory } = await import(
"./services/database.js"
);
const groupedFeeds = await getFeedsGroupedByCategory(); const groupedFeeds = await getFeedsGroupedByCategory();
return c.json({ groupedFeeds }); return c.json({ groupedFeeds });
} catch (error) { } catch (error) {
@ -558,6 +568,26 @@ app.get("/api/episodes-with-feed-info", async (c) => {
} }
}); });
app.get("/api/episodes/search", async (c) => {
try {
const query = c.req.query("q");
const category = c.req.query("category");
if (!query || query.trim() === "") {
return c.json({ error: "Search query is required" }, 400);
}
const { searchEpisodesWithFeedInfo } = await import(
"./services/database.js"
);
const episodes = await searchEpisodesWithFeedInfo(query.trim(), category);
return c.json({ episodes, query, category });
} catch (error) {
console.error("Error searching episodes:", error);
return c.json({ error: "Failed to search episodes" }, 500);
}
});
app.get("/api/episode-with-source/:episodeId", async (c) => { app.get("/api/episode-with-source/:episodeId", async (c) => {
try { try {
const episodeId = c.req.param("episodeId"); const episodeId = c.req.param("episodeId");
@ -629,12 +659,17 @@ app.get("/api/episodes/by-category", async (c) => {
app.get("/api/episodes/grouped-by-category", async (c) => { app.get("/api/episodes/grouped-by-category", async (c) => {
try { try {
const { getEpisodesGroupedByCategory } = await import("./services/database.js"); const { getEpisodesGroupedByCategory } = await import(
"./services/database.js"
);
const groupedEpisodes = await getEpisodesGroupedByCategory(); const groupedEpisodes = await getEpisodesGroupedByCategory();
return c.json({ groupedEpisodes }); return c.json({ groupedEpisodes });
} catch (error) { } catch (error) {
console.error("Error fetching episodes grouped by category:", error); console.error("Error fetching episodes grouped by category:", error);
return c.json({ error: "Failed to fetch episodes grouped by category" }, 500); return c.json(
{ error: "Failed to fetch episodes grouped by category" },
500,
);
} }
}); });
@ -652,14 +687,13 @@ app.get("/api/episode-category-stats", async (c) => {
// RSS endpoints information API // RSS endpoints information API
app.get("/api/rss-endpoints", async (c) => { app.get("/api/rss-endpoints", async (c) => {
try { try {
const { const { getAllEpisodeCategories, fetchActiveFeeds } = await import(
getAllEpisodeCategories, "./services/database.js"
fetchActiveFeeds );
} = await import("./services/database.js");
const [episodeCategories, activeFeeds] = await Promise.all([ const [episodeCategories, activeFeeds] = await Promise.all([
getAllEpisodeCategories(), getAllEpisodeCategories(),
fetchActiveFeeds() fetchActiveFeeds(),
]); ]);
const protocol = c.req.header("x-forwarded-proto") || "http"; const protocol = c.req.header("x-forwarded-proto") || "http";
@ -670,19 +704,19 @@ app.get("/api/rss-endpoints", async (c) => {
main: { main: {
title: "全エピソード", title: "全エピソード",
url: `${baseUrl}/podcast.xml`, url: `${baseUrl}/podcast.xml`,
description: "すべてのエピソードを含むメインRSSフィード" description: "すべてのエピソードを含むメインRSSフィード",
}, },
categories: episodeCategories.map(category => ({ categories: episodeCategories.map((category) => ({
title: `カテゴリ: ${category}`, title: `カテゴリ: ${category}`,
url: `${baseUrl}/podcast/category/${encodeURIComponent(category)}.xml`, url: `${baseUrl}/podcast/category/${encodeURIComponent(category)}.xml`,
description: `${category}」カテゴリのエピソードのみ` description: `${category}」カテゴリのエピソードのみ`,
})), })),
feeds: activeFeeds.map(feed => ({ feeds: activeFeeds.map((feed) => ({
title: `フィード: ${feed.title || feed.url}`, title: `フィード: ${feed.title || feed.url}`,
url: `${baseUrl}/podcast/feed/${feed.id}.xml`, url: `${baseUrl}/podcast/feed/${feed.id}.xml`,
description: `${feed.title || feed.url}」からのエピソードのみ`, description: `${feed.title || feed.url}」からのエピソードのみ`,
feedCategory: feed.category feedCategory: feed.category,
})) })),
}; };
return c.json({ endpoints }); return c.json({ endpoints });
@ -705,14 +739,14 @@ app.get("*", serveIndex);
console.log("🔄 Batch scheduler initialized and ready"); console.log("🔄 Batch scheduler initialized and ready");
// Graceful shutdown // Graceful shutdown
process.on('SIGINT', async () => { process.on("SIGINT", async () => {
console.log('\n🛑 Received SIGINT. Graceful shutdown...'); console.log("\n🛑 Received SIGINT. Graceful shutdown...");
await closeBrowser(); await closeBrowser();
process.exit(0); process.exit(0);
}); });
process.on('SIGTERM', async () => { process.on("SIGTERM", async () => {
console.log('\n🛑 Received SIGTERM. Graceful shutdown...'); console.log("\n🛑 Received SIGTERM. Graceful shutdown...");
await closeBrowser(); await closeBrowser();
process.exit(0); process.exit(0);
}); });

View File

@ -103,9 +103,12 @@ class BatchScheduler {
} }
// Episode category migration // Episode category migration
const { migrateEpisodesWithCategories, getEpisodeCategoryMigrationStatus } = const {
await import("./database.js"); migrateEpisodesWithCategories,
const episodeMigrationStatus = await getEpisodeCategoryMigrationStatus(); getEpisodeCategoryMigrationStatus,
} = await import("./database.js");
const episodeMigrationStatus =
await getEpisodeCategoryMigrationStatus();
if (!episodeMigrationStatus.migrationComplete) { if (!episodeMigrationStatus.migrationComplete) {
console.log("🔄 Running episode category migration..."); console.log("🔄 Running episode category migration...");
@ -117,10 +120,7 @@ class BatchScheduler {
this.migrationCompleted = true; this.migrationCompleted = true;
} catch (migrationError) { } catch (migrationError) {
console.error( console.error("❌ Error during category migrations:", migrationError);
"❌ Error during category migrations:",
migrationError,
);
// Don't fail the entire batch process due to migration error // Don't fail the entire batch process due to migration error
this.migrationCompleted = true; // Mark as completed to avoid retrying every batch this.migrationCompleted = true; // Mark as completed to avoid retrying every batch
} }

View File

@ -67,7 +67,7 @@ export async function extractArticleContent(
} }
// Wait for potential dynamic content // Wait for potential dynamic content
await new Promise(resolve => setTimeout(resolve, 2000)); await new Promise((resolve) => setTimeout(resolve, 2000));
// Extract content using page.evaluate // Extract content using page.evaluate
const extractedData = await page.evaluate(() => { const extractedData = await page.evaluate(() => {
@ -112,7 +112,9 @@ export async function extractArticleContent(
""; "";
// Extract description // Extract description
const descriptionMeta = document.querySelector('meta[name="description"]'); const descriptionMeta = document.querySelector(
'meta[name="description"]',
);
const ogDescriptionMeta = document.querySelector( const ogDescriptionMeta = document.querySelector(
'meta[property="og:description"]', 'meta[property="og:description"]',
); );

View File

@ -218,7 +218,9 @@ function initializeDatabase(): Database {
// Ensure the category column exists in episodes // Ensure the category column exists in episodes
const episodeInfos = db.prepare("PRAGMA table_info(episodes);").all(); const episodeInfos = db.prepare("PRAGMA table_info(episodes);").all();
const hasEpisodeCategory = episodeInfos.some((col: any) => col.name === "category"); const hasEpisodeCategory = episodeInfos.some(
(col: any) => col.name === "category",
);
if (!hasEpisodeCategory) { if (!hasEpisodeCategory) {
db.exec("ALTER TABLE episodes ADD COLUMN category TEXT DEFAULT NULL;"); db.exec("ALTER TABLE episodes ADD COLUMN category TEXT DEFAULT NULL;");
@ -581,6 +583,88 @@ export async function fetchEpisodeWithSourceInfo(
} }
} }
// Search episodes with feed information
export async function searchEpisodesWithFeedInfo(
query: string,
category?: string,
): Promise<EpisodeWithFeedInfo[]> {
try {
let whereClause = `
WHERE f.active = 1
AND (
e.title LIKE ?
OR e.description LIKE ?
OR a.title LIKE ?
OR a.description LIKE ?
OR a.content LIKE ?
)
`;
const searchPattern = `%${query}%`;
const params = [
searchPattern,
searchPattern,
searchPattern,
searchPattern,
searchPattern,
];
if (category) {
whereClause += " AND f.category = ?";
params.push(category);
}
const 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
${whereClause}
ORDER BY e.created_at DESC
`);
const rows = stmt.all(...params) 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 searching episodes with feed info:", error);
throw error;
}
}
export async function getAllFeedsIncludingInactive(): Promise<Feed[]> { export async function getAllFeedsIncludingInactive(): Promise<Feed[]> {
try { try {
const stmt = db.prepare("SELECT * FROM feeds ORDER BY created_at DESC"); const stmt = db.prepare("SELECT * FROM feeds ORDER BY created_at DESC");
@ -1309,7 +1393,9 @@ export async function deleteEpisode(episodeId: string): Promise<boolean> {
} }
// Episode category management functions // Episode category management functions
export async function getEpisodesByCategory(category?: string): Promise<EpisodeWithFeedInfo[]> { export async function getEpisodesByCategory(
category?: string,
): Promise<EpisodeWithFeedInfo[]> {
try { try {
let stmt; let stmt;
let rows; let rows;
@ -1458,7 +1544,10 @@ export async function getEpisodeCategoryStats(): Promise<{
} }
} }
export async function updateEpisodeCategory(episodeId: string, category: string): Promise<boolean> { export async function updateEpisodeCategory(
episodeId: string,
category: string,
): Promise<boolean> {
try { try {
const stmt = db.prepare("UPDATE episodes SET category = ? WHERE id = ?"); const stmt = db.prepare("UPDATE episodes SET category = ? WHERE id = ?");
const result = stmt.run(category, episodeId); const result = stmt.run(category, episodeId);
@ -1519,10 +1608,7 @@ export async function migrateEpisodesWithCategories(): Promise<void> {
// Add a small delay to avoid rate limiting // Add a small delay to avoid rate limiting
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
} catch (error) { } catch (error) {
console.error( console.error(`❌ Failed to classify episode ${episode.title}:`, error);
`❌ Failed to classify episode ${episode.title}:`,
error,
);
errorCount++; errorCount++;
// Set a default category for failed classifications // Set a default category for failed classifications

View File

@ -162,7 +162,8 @@ export async function openAI_ClassifyEpisode(
if (content && content.trim()) { if (content && content.trim()) {
const maxContentLength = 1500; const maxContentLength = 1500;
const truncatedContent = content.length > maxContentLength const truncatedContent =
content.length > maxContentLength
? content.substring(0, maxContentLength) + "..." ? content.substring(0, maxContentLength) + "..."
: content; : content;
textForClassification += `\n内容: ${truncatedContent}`; textForClassification += `\n内容: ${truncatedContent}`;

View File

@ -4,9 +4,9 @@ import path from "node:path";
import { dirname } from "path"; import { dirname } from "path";
import { config } from "./config.js"; import { config } from "./config.js";
import { import {
fetchEpisodesByFeedId,
fetchEpisodesWithFeedInfo, fetchEpisodesWithFeedInfo,
getEpisodesByCategory, getEpisodesByCategory,
fetchEpisodesByFeedId
} from "./database.js"; } from "./database.js";
function escapeXml(text: string): string { function escapeXml(text: string): string {
@ -89,7 +89,7 @@ function generateRSSXml(
episodes: any[], episodes: any[],
title: string, title: string,
description: string, description: string,
link?: string link?: string,
): string { ): string {
const lastBuildDate = new Date().toUTCString(); const lastBuildDate = new Date().toUTCString();
const itemsXml = episodes.map(createItemXml).join("\n"); const itemsXml = episodes.map(createItemXml).join("\n");
@ -123,7 +123,7 @@ export async function updatePodcastRSS(): Promise<void> {
const rssXml = generateRSSXml( const rssXml = generateRSSXml(
validEpisodes, validEpisodes,
config.podcast.title, config.podcast.title,
config.podcast.description config.podcast.description,
); );
// Ensure directory exists // Ensure directory exists
@ -162,8 +162,14 @@ export async function generateCategoryRSS(category: string): Promise<string> {
export async function saveCategoryRSS(category: string): Promise<void> { export async function saveCategoryRSS(category: string): Promise<void> {
try { try {
const rssXml = await generateCategoryRSS(category); const rssXml = await generateCategoryRSS(category);
const safeCategory = category.replace(/[^a-zA-Z0-9\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/g, "_"); const safeCategory = category.replace(
const outputPath = path.join(config.paths.publicDir, `podcast_category_${safeCategory}.xml`); /[^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 // Ensure directory exists
await fs.mkdir(dirname(outputPath), { recursive: true }); await fs.mkdir(dirname(outputPath), { recursive: true });
@ -187,7 +193,8 @@ export async function generateFeedRSS(feedId: string): Promise<string> {
); );
// Use feed info for RSS metadata if available // Use feed info for RSS metadata if available
const feedTitle = validEpisodes.length > 0 ? validEpisodes[0].feedTitle : "Unknown Feed"; const feedTitle =
validEpisodes.length > 0 ? validEpisodes[0].feedTitle : "Unknown Feed";
const title = `${config.podcast.title} - ${feedTitle}`; const title = `${config.podcast.title} - ${feedTitle}`;
const description = `${config.podcast.description} フィード: ${feedTitle}`; const description = `${config.podcast.description} フィード: ${feedTitle}`;
@ -201,7 +208,10 @@ export async function generateFeedRSS(feedId: string): Promise<string> {
export async function saveFeedRSS(feedId: string): Promise<void> { export async function saveFeedRSS(feedId: string): Promise<void> {
try { try {
const rssXml = await generateFeedRSS(feedId); const rssXml = await generateFeedRSS(feedId);
const outputPath = path.join(config.paths.publicDir, `podcast_feed_${feedId}.xml`); const outputPath = path.join(
config.paths.publicDir,
`podcast_feed_${feedId}.xml`,
);
// Ensure directory exists // Ensure directory exists
await fs.mkdir(dirname(outputPath), { recursive: true }); await fs.mkdir(dirname(outputPath), { recursive: true });