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

132
server.ts
View File

@ -128,15 +128,18 @@ app.get("/podcast/category/:category.xml", async (c) => {
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);
console.error(
`Error generating category RSS for "${c.req.param("category")}":`,
error,
);
return c.notFound();
}
});
@ -149,15 +152,18 @@ app.get("/podcast/feed/:feedId.xml", async (c) => {
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);
console.error(
`Error generating feed RSS for "${c.req.param("feedId")}":`,
error,
);
return c.notFound();
}
});
@ -206,9 +212,11 @@ async function serveIndex(c: any) {
async function serveEpisodePage(c: any, episodeId: string) {
try {
// First, try to get episode from database
const { fetchEpisodeWithSourceInfo } = await import("./services/database.js");
const { fetchEpisodeWithSourceInfo } = await import(
"./services/database.js"
);
let episode = null;
try {
episode = await fetchEpisodeWithSourceInfo(episodeId);
} catch (error) {
@ -267,34 +275,32 @@ async function serveEpisodePage(c: any, episodeId: string) {
}
let html = await file.text();
// Extract the base URL
const protocol = c.req.header("x-forwarded-proto") || "http";
const host = c.req.header("host") || "localhost:3000";
const baseUrl = `${protocol}://${host}`;
// Replace title and add OG metadata
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 imageUrl = `${baseUrl}/default-thumbnail.svg`;
const audioUrl = episode.audioPath.startsWith('/')
? `${baseUrl}${episode.audioPath}`
const audioUrl = episode.audioPath.startsWith("/")
? `${baseUrl}${episode.audioPath}`
: `${baseUrl}/podcast_audio/${episode.audioPath}`;
// Replace the title
html = html.replace(
/<title>.*?<\/title>/,
`<title>${title}</title>`
);
html = html.replace(/<title>.*?<\/title>/, `<title>${title}</title>`);
// Add OG metadata before closing </head>
const ogMetadata = `
<meta name="description" content="${description.replace(/"/g, '&quot;')}" />
<meta name="description" content="${description.replace(/"/g, "&quot;")}" />
<!-- OpenGraph metadata -->
<meta property="og:title" content="${episode.title.replace(/"/g, '&quot;')}" />
<meta property="og:description" content="${description.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:type" content="article" />
<meta property="og:url" content="${episodeUrl}" />
<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:width" content="1200" />
<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:type" content="audio/mpeg" />
<!-- Twitter Card metadata -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="${episode.title.replace(/"/g, '&quot;')}" />
<meta name="twitter:description" content="${description.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: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 -->
<meta property="article:published_time" content="${episode.createdAt}" />
${episode.articlePubDate && episode.articlePubDate !== episode.createdAt ?
`<meta property="article:modified_time" content="${episode.articlePubDate}" />` : ''}
${episode.feedTitle ? `<meta property="article:section" content="${episode.feedTitle.replace(/"/g, '&quot;')}" />` : ''}
${
episode.articlePubDate && episode.articlePubDate !== episode.createdAt
? `<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" });
} catch (error) {
@ -334,7 +343,6 @@ app.get("/", serveIndex);
app.get("/index.html", serveIndex);
// API endpoints for frontend
app.get("/api/episodes", async (c) => {
try {
@ -517,7 +525,9 @@ app.get("/api/feeds/by-category", async (c) => {
app.get("/api/feeds/grouped-by-category", async (c) => {
try {
const { getFeedsGroupedByCategory } = await import("./services/database.js");
const { getFeedsGroupedByCategory } = await import(
"./services/database.js"
);
const groupedFeeds = await getFeedsGroupedByCategory();
return c.json({ groupedFeeds });
} 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) => {
try {
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) => {
try {
const { getEpisodesGroupedByCategory } = await import("./services/database.js");
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);
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
app.get("/api/rss-endpoints", async (c) => {
try {
const {
getAllEpisodeCategories,
fetchActiveFeeds
} = await import("./services/database.js");
const { getAllEpisodeCategories, fetchActiveFeeds } = await import(
"./services/database.js"
);
const [episodeCategories, activeFeeds] = await Promise.all([
getAllEpisodeCategories(),
fetchActiveFeeds()
getAllEpisodeCategories(),
fetchActiveFeeds(),
]);
const protocol = c.req.header("x-forwarded-proto") || "http";
@ -670,19 +704,19 @@ app.get("/api/rss-endpoints", async (c) => {
main: {
title: "全エピソード",
url: `${baseUrl}/podcast.xml`,
description: "すべてのエピソードを含むメインRSSフィード"
description: "すべてのエピソードを含むメインRSSフィード",
},
categories: episodeCategories.map(category => ({
categories: episodeCategories.map((category) => ({
title: `カテゴリ: ${category}`,
url: `${baseUrl}/podcast/category/${encodeURIComponent(category)}.xml`,
description: `${category}」カテゴリのエピソードのみ`
description: `${category}」カテゴリのエピソードのみ`,
})),
feeds: activeFeeds.map(feed => ({
feeds: activeFeeds.map((feed) => ({
title: `フィード: ${feed.title || feed.url}`,
url: `${baseUrl}/podcast/feed/${feed.id}.xml`,
description: `${feed.title || feed.url}」からのエピソードのみ`,
feedCategory: feed.category
}))
feedCategory: feed.category,
})),
};
return c.json({ endpoints });
@ -705,14 +739,14 @@ app.get("*", serveIndex);
console.log("🔄 Batch scheduler initialized and ready");
// Graceful shutdown
process.on('SIGINT', async () => {
console.log('\n🛑 Received SIGINT. Graceful shutdown...');
process.on("SIGINT", async () => {
console.log("\n🛑 Received SIGINT. Graceful shutdown...");
await closeBrowser();
process.exit(0);
});
process.on('SIGTERM', async () => {
console.log('\n🛑 Received SIGTERM. Graceful shutdown...');
process.on("SIGTERM", async () => {
console.log("\n🛑 Received SIGTERM. Graceful shutdown...");
await closeBrowser();
process.exit(0);
});