Publish Container / build (push) Successful in 16m16s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
168 lines
5.9 KiB
JavaScript
168 lines
5.9 KiB
JavaScript
import { sanitizeUrl } from '../study-helpers.js'
|
|
import { parseRssItems } from '../rss-helpers.js'
|
|
|
|
const RSS_FEED_URL = 'https://anchor.fm/s/11068d290/podcast/rss'
|
|
let episodesCache = null
|
|
let episodesCacheAt = 0
|
|
let rawXmlCache = null
|
|
const EPISODES_CACHE_TTL = 10 * 60 * 1000
|
|
|
|
export 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)
|
|
try {
|
|
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()
|
|
rawXmlCache = xml
|
|
const episodes = parseRssItems(xml)
|
|
episodesCache = episodes
|
|
episodesCacheAt = now
|
|
return episodes
|
|
} catch (err) {
|
|
clearTimeout(timeout)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
export async function fetchRawFeedXml() {
|
|
await fetchAllEpisodes()
|
|
return rawXmlCache
|
|
}
|
|
|
|
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 })) })
|
|
}
|
|
})
|
|
}
|