- Multi-provider TTS service (OpenAI, Piper, macOS say) - Supabase Storage integration for audio files - RSS 2.0 feed with iTunes extensions for podcast distribution - Web audio player at /podcast page - Integration with daily digest workflow - Manual TTS generation script - Complete documentation in PODCAST_SETUP.md
204 lines
6.4 KiB
TypeScript
204 lines
6.4 KiB
TypeScript
/**
|
|
* Podcast RSS Feed Generation Utilities
|
|
* Generates RSS 2.0 with iTunes extensions for podcast distribution
|
|
*/
|
|
|
|
import { createClient } from "@supabase/supabase-js";
|
|
|
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
|
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
|
|
|
const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
|
|
|
export interface PodcastEpisode {
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
content: string;
|
|
date: string;
|
|
timestamp: number;
|
|
audioUrl?: string;
|
|
audioDuration?: number;
|
|
tags?: string[];
|
|
}
|
|
|
|
export interface PodcastConfig {
|
|
title: string;
|
|
description: string;
|
|
author: string;
|
|
email: string;
|
|
category: string;
|
|
language: string;
|
|
websiteUrl: string;
|
|
imageUrl: string;
|
|
explicit: boolean;
|
|
}
|
|
|
|
// Default podcast configuration
|
|
export const DEFAULT_CONFIG: PodcastConfig = {
|
|
title: "OpenClaw Daily Digest",
|
|
description: "Daily curated tech news covering AI coding assistants, iOS development, and digital entrepreneurship. AI-powered summaries delivered as audio.",
|
|
author: "OpenClaw",
|
|
email: "podcast@openclaw.ai",
|
|
category: "Technology",
|
|
language: "en-US",
|
|
websiteUrl: "https://blog-backup-two.vercel.app",
|
|
imageUrl: "https://blog-backup-two.vercel.app/podcast-cover.png",
|
|
explicit: false,
|
|
};
|
|
|
|
/**
|
|
* Parse title from markdown content
|
|
*/
|
|
export function extractTitle(content: string): string {
|
|
const lines = content.split("\n");
|
|
const titleLine = lines.find((l) => l.startsWith("# ") || l.startsWith("## "));
|
|
return titleLine?.replace(/#{1,2}\s/, "").trim() || "Daily Digest";
|
|
}
|
|
|
|
/**
|
|
* Extract plain text excerpt from markdown
|
|
*/
|
|
export function extractExcerpt(content: string, maxLength: number = 300): string {
|
|
const plainText = content
|
|
.replace(/#{1,6}\s/g, "")
|
|
.replace(/(\*\*|__|\*|_)/g, "")
|
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
.replace(/```[\s\S]*?```/g, "")
|
|
.replace(/`([^`]+)`/g, "$1")
|
|
.replace(/<[^>]+>/g, "")
|
|
.replace(/\n+/g, " ")
|
|
.trim();
|
|
|
|
if (plainText.length <= maxLength) return plainText;
|
|
return plainText.substring(0, maxLength).trim() + "...";
|
|
}
|
|
|
|
/**
|
|
* Format duration in seconds to HH:MM:SS or MM:SS
|
|
*/
|
|
export function formatDuration(seconds: number): string {
|
|
const hours = Math.floor(seconds / 3600);
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
|
const secs = seconds % 60;
|
|
|
|
if (hours > 0) {
|
|
return `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
|
}
|
|
return `${minutes}:${secs.toString().padStart(2, "0")}`;
|
|
}
|
|
|
|
/**
|
|
* Format date to RFC 2822 format for RSS
|
|
*/
|
|
export function formatRFC2822(date: Date | string | number): string {
|
|
const d = new Date(date);
|
|
return d.toUTCString();
|
|
}
|
|
|
|
/**
|
|
* Escape XML special characters
|
|
*/
|
|
export function escapeXml(text: string): string {
|
|
return text
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
/**
|
|
* Generate unique GUID for episode
|
|
*/
|
|
export function generateGuid(episodeId: string): string {
|
|
return `openclaw-digest-${episodeId}`;
|
|
}
|
|
|
|
/**
|
|
* Fetch episodes from database
|
|
*/
|
|
export async function fetchEpisodes(limit: number = 50): Promise<PodcastEpisode[]> {
|
|
const { data, error } = await supabase
|
|
.from("blog_messages")
|
|
.select("id, date, content, timestamp, audio_url, audio_duration, tags")
|
|
.not("audio_url", "is", null) // Only episodes with audio
|
|
.order("timestamp", { ascending: false })
|
|
.limit(limit);
|
|
|
|
if (error) {
|
|
console.error("Error fetching episodes:", error);
|
|
throw error;
|
|
}
|
|
|
|
return (data || []).map(item => ({
|
|
id: item.id,
|
|
title: extractTitle(item.content),
|
|
description: extractExcerpt(item.content),
|
|
content: item.content,
|
|
date: item.date,
|
|
timestamp: item.timestamp,
|
|
audioUrl: item.audio_url,
|
|
audioDuration: item.audio_duration || 300, // Default 5 min if not set
|
|
tags: item.tags || [],
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Generate RSS feed XML
|
|
*/
|
|
export function generateRSS(episodes: PodcastEpisode[], config: PodcastConfig = DEFAULT_CONFIG): string {
|
|
const now = new Date();
|
|
const lastBuildDate = episodes.length > 0
|
|
? new Date(episodes[0].timestamp)
|
|
: now;
|
|
|
|
const itemsXml = episodes.map(episode => {
|
|
const guid = generateGuid(episode.id);
|
|
const pubDate = formatRFC2822(episode.timestamp);
|
|
const duration = formatDuration(episode.audioDuration || 300);
|
|
const enclosureUrl = escapeXml(episode.audioUrl || "");
|
|
const title = escapeXml(episode.title);
|
|
const description = escapeXml(episode.description);
|
|
const keywords = episode.tags?.join(", ") || "technology, ai, programming";
|
|
|
|
return `
|
|
<item>
|
|
<title>${title}</title>
|
|
<description>${description}</description>
|
|
<pubDate>${pubDate}</pubDate>
|
|
<guid isPermaLink="false">${guid}</guid>
|
|
<link>${escapeXml(`${config.websiteUrl}/?post=${episode.id}`)}</link>
|
|
<enclosure url="${enclosureUrl}" length="${episode.audioDuration || 300}" type="audio/mpeg"/>
|
|
<itunes:title>${title}</itunes:title>
|
|
<itunes:author>${escapeXml(config.author)}</itunes:author>
|
|
<itunes:summary>${description}</itunes:summary>
|
|
<itunes:duration>${duration}</itunes:duration>
|
|
<itunes:explicit>${config.explicit ? "yes" : "no"}</itunes:explicit>
|
|
<itunes:keywords>${escapeXml(keywords)}</itunes:keywords>
|
|
</item>`;
|
|
}).join("\n");
|
|
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<rss xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
|
|
<channel>
|
|
<title>${escapeXml(config.title)}</title>
|
|
<link>${escapeXml(config.websiteUrl)}</link>
|
|
<description>${escapeXml(config.description)}</description>
|
|
<language>${config.language}</language>
|
|
<lastBuildDate>${formatRFC2822(lastBuildDate)}</lastBuildDate>
|
|
<atom:link href="${escapeXml(`${config.websiteUrl}/api/podcast/rss`)}" rel="self" type="application/rss+xml"/>
|
|
<itunes:author>${escapeXml(config.author)}</itunes:author>
|
|
<itunes:summary>${escapeXml(config.description)}</itunes:summary>
|
|
<itunes:category text="${escapeXml(config.category)}"/>
|
|
<itunes:explicit>${config.explicit ? "yes" : "no"}</itunes:explicit>
|
|
<itunes:image href="${escapeXml(config.imageUrl)}"/>
|
|
<itunes:owner>
|
|
<itunes:name>${escapeXml(config.author)}</itunes:name>
|
|
<itunes:email>${escapeXml(config.email)}</itunes:email>
|
|
</itunes:owner>
|
|
${itemsXml}
|
|
</channel>
|
|
</rss>`;
|
|
}
|