216 lines
7.2 KiB
TypeScript
216 lines
7.2 KiB
TypeScript
import { promises as fs } from "fs";
|
|
import fsSync from "node:fs";
|
|
import path from "node:path";
|
|
import { dirname } from "path";
|
|
import { config } from "./config.js";
|
|
import {
|
|
fetchEpisodesWithFeedInfo,
|
|
getEpisodesByCategory,
|
|
fetchEpisodesByFeedId
|
|
} from "./database.js";
|
|
|
|
function escapeXml(text: string): string {
|
|
return text
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function createItemXml(episode: any): string {
|
|
const fileUrl = `${config.podcast.baseUrl}/podcast_audio/${path.basename(episode.audioPath)}`;
|
|
const pubDate = new Date(episode.createdAt).toUTCString();
|
|
|
|
let fileSize = 0;
|
|
try {
|
|
const audioPath = path.join(
|
|
config.paths.podcastAudioDir,
|
|
episode.audioPath,
|
|
);
|
|
if (fsSync.existsSync(audioPath)) {
|
|
fileSize = fsSync.statSync(audioPath).size;
|
|
}
|
|
} catch (error) {
|
|
console.warn(`Could not get file size for ${episode.audioPath}:`, error);
|
|
}
|
|
|
|
// Build enhanced description with feed and article info
|
|
let description = episode.title;
|
|
if (episode.feedTitle || episode.articleTitle || episode.articleLink) {
|
|
description += "\n\n";
|
|
if (episode.feedTitle) {
|
|
description += `フィード: ${episode.feedTitle}\n`;
|
|
}
|
|
if (episode.articleTitle && episode.articleTitle !== episode.title) {
|
|
description += `元記事: ${episode.articleTitle}\n`;
|
|
}
|
|
if (episode.articlePubDate) {
|
|
description += `記事公開日: ${new Date(episode.articlePubDate).toLocaleString("ja-JP")}\n`;
|
|
}
|
|
if (episode.articleLink) {
|
|
description += `元記事URL: ${episode.articleLink}`;
|
|
}
|
|
}
|
|
|
|
return `
|
|
<item>
|
|
<title><![CDATA[${escapeXml(episode.title)}]]></title>
|
|
<description><![CDATA[${escapeXml(description)}]]></description>
|
|
<author>${escapeXml(config.podcast.author)}</author>
|
|
<category>${escapeXml(config.podcast.categories)}</category>
|
|
<language>${config.podcast.language}</language>
|
|
<ttl>${config.podcast.ttl}</ttl>
|
|
<enclosure url="${escapeXml(fileUrl)}" length="${fileSize}" type="audio/mpeg" />
|
|
<guid>${escapeXml(fileUrl)}</guid>
|
|
<pubDate>${pubDate}</pubDate>
|
|
${episode.articleLink ? `<link>${escapeXml(episode.articleLink)}</link>` : ""}
|
|
</item>`;
|
|
}
|
|
|
|
// Filter episodes to only include those with valid audio files
|
|
function filterValidEpisodes(episodes: any[]): any[] {
|
|
return episodes.filter((episode) => {
|
|
try {
|
|
const audioPath = path.join(
|
|
config.paths.podcastAudioDir,
|
|
episode.audioPath,
|
|
);
|
|
return fsSync.existsSync(audioPath);
|
|
} catch (error) {
|
|
console.warn(`Audio file not found for episode: ${episode.title}`);
|
|
return false;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Generate RSS XML from episodes
|
|
function generateRSSXml(
|
|
episodes: any[],
|
|
title: string,
|
|
description: string,
|
|
link?: string
|
|
): string {
|
|
const lastBuildDate = new Date().toUTCString();
|
|
const itemsXml = episodes.map(createItemXml).join("\n");
|
|
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<rss version="2.0">
|
|
<channel>
|
|
<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>
|
|
<author>${escapeXml(config.podcast.author)}</author>
|
|
<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 });
|
|
await fs.writeFile(outputPath, rssXml);
|
|
|
|
console.log(
|
|
`RSS feed updated with ${validEpisodes.length} episodes (audio files verified)`,
|
|
);
|
|
} catch (error) {
|
|
console.error("Error updating podcast RSS:", error);
|
|
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;
|
|
}
|
|
}
|