import { sanitizeUrl } from '../study-helpers.js' const RSS_FEED_URL = 'https://anchor.fm/nmemmert/podcast/rss' let episodesCache = null let episodesCacheAt = 0 const EPISODES_CACHE_TTL = 30 * 60 * 1000 function extractCdata(raw) { const cdata = /^$/.exec(raw.trim()) return cdata ? cdata[1].trim() : raw.trim() } function parseRssItems(xml, limit = Infinity) { const items = [] const itemRegex = /([\s\S]*?)<\/item>/g let match while ((match = itemRegex.exec(xml)) !== null && items.length < limit) { const block = match[1] const titleRaw = /([\s\S]*?)<\/title>/.exec(block)?.[1] ?? '' const title = extractCdata(titleRaw) if (!title) continue const pubDate = (/<pubDate>([\s\S]*?)<\/pubDate>/.exec(block)?.[1] ?? '').trim() const guidRaw = /<guid[^>]*>([\s\S]*?)<\/guid>/.exec(block)?.[1] ?? '' const guid = extractCdata(guidRaw) const enclosureUrl = /<enclosure[^>]+url="([^"]+)"/.exec(block)?.[1] ?? '' const link = guid.startsWith('http') ? guid : enclosureUrl const descRaw = /<description>([\s\S]*?)<\/description>/.exec(block)?.[1] ?? '' const descText = extractCdata(descRaw).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim() const duration = (/<itunes:duration>([\s\S]*?)<\/itunes:duration>/.exec(block)?.[1] ?? '').trim() const episode = (/<itunes:episode>([\s\S]*?)<\/itunes:episode>/.exec(block)?.[1] ?? '').trim() items.push({ title, pubDate, link, audioUrl: enclosureUrl, description: descText.slice(0, 220) + (descText.length > 220 ? '…' : ''), duration, episode, }) } return items } async function fetchAllEpisodes() { const now = Date.now() if (episodesCache && (now - episodesCacheAt) < EPISODES_CACHE_TTL) { return episodesCache } const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 8000) const response = await fetch(RSS_FEED_URL, { signal: controller.signal }) clearTimeout(timeout) if (!response.ok) throw new Error(`RSS fetch failed: ${response.status}`) const xml = await response.text() const episodes = parseRssItems(xml) episodesCache = episodes episodesCacheAt = now return episodes } function toSpotifyEpisodeEmbedUrl(urlValue) { if (!urlValue) return '' try { const parsed = new URL(urlValue) if (parsed.protocol !== 'https:') return '' const host = parsed.hostname.toLowerCase() const parts = parsed.pathname.split('/').filter(Boolean) if (host === 'open.spotify.com') { if (parts[0] === 'embed' && parts[1] === 'episode' && parts[2]) { return `https://open.spotify.com/embed/episode/${parts[2]}?utm_source=generator` } if (parts[0] === 'episode' && parts[1]) { return `https://open.spotify.com/embed/episode/${parts[1]}?utm_source=generator` } } } catch { return '' } return '' } function decodeEscapedJsonUrl(value) { return String(value || '').replace(/\\u002F/g, '/').replace(/\\\//g, '/') } function extractSpotifyEpisodeIdFromCreatorHtml(html, sourceUrl) { const input = String(html || '') if (!input) return '' const sourceEpisodeSlug = /-([A-Za-z0-9]+)(?:\/|$)/.exec(sourceUrl)?.[1] ?? '' const blockRegex = /"episodeId":"([^"]+)"[\s\S]*?"spotifyUrl":"([^"]+)"/g let match let firstEpisodeId = '' while ((match = blockRegex.exec(input)) !== null) { const episodeSlug = match[1] const spotifyUrl = decodeEscapedJsonUrl(match[2]) const episodeId = /\/episode\/([A-Za-z0-9]+)/.exec(spotifyUrl)?.[1] if (!firstEpisodeId && episodeId) firstEpisodeId = episodeId if (sourceEpisodeSlug && episodeSlug === sourceEpisodeSlug && episodeId) return episodeId } if (firstEpisodeId) return firstEpisodeId const urlMatch = /"spotifyUrl":"(https:\\u002F\\u002Fopen\.spotify\.com\\u002Fepisode\\u002F([A-Za-z0-9]+))/.exec(input) return urlMatch ? (urlMatch[2] || '') : '' } function isAllowedSpotifyResolverHost(hostname) { const host = String(hostname || '').toLowerCase() return host === 'open.spotify.com' || host === 'creators.spotify.com' || host === 'anchor.fm' || host === 'podcasters.spotify.com' } export function register(app) { app.get('/api/spotify/embed-url', async (req, res) => { const incoming = typeof req.query.url === 'string' ? req.query.url.trim() : '' const safeInput = sanitizeUrl(incoming) if (!safeInput || safeInput.startsWith('/')) { res.status(400).json({ message: 'A valid episode URL is required.' }); return } let parsed try { parsed = new URL(safeInput) } catch { res.status(400).json({ message: 'Malformed URL.' }); return } if (parsed.protocol !== 'https:' || !isAllowedSpotifyResolverHost(parsed.hostname)) { res.status(400).json({ message: 'Unsupported episode URL host.' }); return } const directEmbed = toSpotifyEpisodeEmbedUrl(safeInput) if (directEmbed) { res.json({ embedUrl: directEmbed, resolvedFrom: 'direct' }); return } try { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 8000) const response = await fetch(safeInput, { signal: controller.signal, headers: { 'User-Agent': 'Siteforge/1.0 (+https://versebyversewithnate.us)', Accept: 'text/html' }, }) clearTimeout(timeout) if (!response.ok) { res.status(404).json({ message: 'Could not fetch episode page.' }); return } const html = await response.text() const spotifyEpisodeId = extractSpotifyEpisodeIdFromCreatorHtml(html, safeInput) if (!spotifyEpisodeId) { res.status(404).json({ message: 'Could not resolve Spotify episode ID from page.' }); return } const embedUrl = `https://open.spotify.com/embed/episode/${spotifyEpisodeId}?utm_source=generator` res.json({ embedUrl, resolvedFrom: 'page-fetch' }) } catch (err) { console.error('[spotify/embed-url] resolve error:', err.message) res.status(500).json({ message: 'Could not resolve Spotify embed URL right now.' }) } }) app.get('/api/episodes', async (_req, res) => { try { const episodes = await fetchAllEpisodes() res.json({ episodes: episodes.slice(0, 6) }) } catch (err) { console.error('[episodes] RSS fetch error:', err.message) res.json({ episodes: (episodesCache ?? []).slice(0, 6) }) } }) app.get('/api/episodes/all', async (_req, res) => { try { const episodes = await fetchAllEpisodes() res.json({ episodes }) } catch (err) { console.error('[episodes/all] RSS fetch error:', err.message) res.json({ episodes: episodesCache ?? [] }) } }) app.get('/api/episode-audio', async (_req, res) => { try { const episodes = await fetchAllEpisodes() res.json({ episodes: episodes.map(e => ({ title: e.title, audioUrl: e.audioUrl, duration: e.duration, episode: e.episode })) }) } catch (err) { console.error('[episode-audio] RSS fetch error:', err.message) res.json({ episodes: (episodesCache ?? []).map(e => ({ title: e.title, audioUrl: e.audioUrl, duration: e.duration, episode: e.episode })) }) } }) }