Point internal links at clean episode URLs and retire the JS page

This commit is contained in:
2026-08-14 04:10:03 -05:00
parent 25b0015f62
commit fa5ab9aad2
7 changed files with 50 additions and 313 deletions
+3 -32
View File
@@ -974,34 +974,6 @@ def sync_episode_media_to_bunny(episode_id: int, already_uploaded: set):
Path(tmp_path).unlink(missing_ok=True)
def add_episode_to_sitemap(slug: str):
"""Add episode transcript page to sitemap.xml."""
sitemap_path = Path(__file__).parent / "website" / "sitemap.xml"
if not sitemap_path.exists():
return
url = f"https://lukeattheroost.com/episode.html?slug={slug}"
content = sitemap_path.read_text()
if url in content:
print(f" Episode already in sitemap")
return
today = datetime.now().strftime("%Y-%m-%d")
new_entry = f""" <url>
<loc>{url}</loc>
<lastmod>{today}</lastmod>
<changefreq>never</changefreq>
<priority>0.7</priority>
</url>
</urlset>"""
content = content.replace("</urlset>", new_entry)
sitemap_path.write_text(content)
print(f" Added episode to sitemap.xml")
def generate_social_image(episode_number: int, description: str, output_path: str) -> str:
"""Generate a social media image with cover art, episode number, and description."""
from PIL import Image, ImageDraw, ImageFont
@@ -1266,7 +1238,7 @@ def post_to_social(metadata: dict, episode_slug: str, image_path: str = None,
if media and media.get("id"):
image_ids = [{"id": media["id"], "path": media.get("path", "")}]
episode_url = f"https://lukeattheroost.com/episode.html?slug={episode_slug}"
episode_url = f"https://lukeattheroost.com/episode/{episode_slug}/"
yt_url = f"https://youtube.com/watch?v={yt_video_id}" if yt_video_id else None
now = datetime.now(timezone.utc)
@@ -1468,7 +1440,7 @@ def upload_to_youtube(audio_path: str, metadata: dict, chapters: list,
ts = f"{h}:{m:02d}:{s:02d}" if h > 0 else f"{m}:{s:02d}"
chapter_lines.append(f"{ts} {ch['title']}")
episode_url = f"https://lukeattheroost.com/episode.html?slug={episode_slug}"
episode_url = f"https://lukeattheroost.com/episode/{episode_slug}/"
description = (
f"{metadata['description']}\n\n"
+ "\n".join(chapter_lines) + "\n\n"
@@ -1845,8 +1817,7 @@ def main():
shutil.copy2(str(transcript_path), str(website_transcript_path))
print(f" Transcript copied to website/transcripts/")
# Add to sitemap
add_episode_to_sitemap(episode["slug"])
# Sitemap is regenerated wholesale by generate_episode_pages.py --sitemap
# Sync any remaining episode media to BunnyCDN (cover art, etc.)
print(" Syncing remaining episode media to CDN...")
+35
View File
@@ -0,0 +1,35 @@
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
LEGACY = "episode.html?slug="
def _files():
for pattern in ("website/*.html", "website/js/*.js", "website/llms.txt", "*.py"):
yield from ROOT.glob(pattern)
def test_no_source_file_builds_a_legacy_episode_url():
offenders = []
for f in _files():
if f.name == "_worker.js":
continue # the worker's reference is the redirect itself
if LEGACY in f.read_text(errors="replace"):
offenders.append(str(f.relative_to(ROOT)))
assert not offenders, f"legacy episode URLs still present: {offenders}"
def test_client_rendered_episode_page_is_gone():
assert not (ROOT / "website" / "episode.html").exists()
assert not (ROOT / "website" / "js" / "episode.js").exists()
def test_worker_still_redirects_legacy_urls():
worker = (ROOT / "website" / "_worker.js").read_text()
assert "/episode.html" in worker and "301" in worker
def test_redirects_file_has_no_dead_episode_target():
redirects = (ROOT / "website" / "_redirects").read_text()
assert "/episode 302" not in redirects
+1 -1
View File
@@ -1 +1 @@
/episodes.html /episode 302
/episodes.html / 302
-127
View File
@@ -1,127 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#1a1209">
<title id="page-title">Episode — Luke at the Roost</title>
<meta name="description" id="page-description" content="Full transcript of this episode of Luke at the Roost, the late-night call-in radio show.">
<link rel="canonical" id="page-canonical" href="https://lukeattheroost.com/episode.html">
<!-- OG / Social -->
<meta property="og:site_name" content="Luke at the Roost">
<meta property="og:title" id="og-title" content="Episode — Luke at the Roost">
<meta property="og:description" id="og-description" content="Full transcript of this episode of Luke at the Roost.">
<meta property="og:image" content="https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3">
<meta property="og:url" id="og-url" content="https://lukeattheroost.com/episode.html">
<meta property="og:type" content="article">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" id="tw-title" content="Episode — Luke at the Roost">
<meta name="twitter:description" id="tw-description" content="Full transcript of this episode of Luke at the Roost.">
<meta name="twitter:image" content="https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3">
<!-- Favicon -->
<link rel="icon" href="favicon.ico" sizes="48x48">
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="icon" type="image/png" sizes="192x192" href="favicon-192.png">
<link rel="icon" type="image/png" sizes="48x48" href="favicon-48.png">
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16.png">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<link rel="alternate" type="application/rss+xml" title="Luke at the Roost RSS Feed" href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml">
<link rel="stylesheet" href="css/style.css?v=7">
<!-- Structured Data (dynamically updated by JS) -->
<script type="application/ld+json" id="episode-jsonld">
{
"@context": "https://schema.org",
"@type": "PodcastEpisode",
"partOfSeries": {
"@type": "PodcastSeries",
"name": "Luke at the Roost",
"url": "https://lukeattheroost.com"
},
"name": "Episode — Luke at the Roost",
"url": "https://lukeattheroost.com/episode.html",
"description": "Full transcript of this episode of Luke at the Roost.",
"inLanguage": "en"
}
</script>
<script defer data-domain="lukeattheroost.com" data-api="/p/event" src="/p/script"></script>
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
</head>
<body>
<a href="#main-content" class="skip-link">Skip to content</a>
<nav class="site-nav">
<a href="/" class="site-nav-brand">Luke at the Roost</a>
<div class="site-nav-links">
<a href="/how-it-works">How It Works</a>
<a href="/clips">Clips</a>
<a href="/stats">Stats</a>
</div>
</nav>
<main id="main-content">
<!-- Episode Header -->
<section class="ep-header" id="ep-header">
<div class="ep-header-inner">
<div class="ep-meta" id="ep-meta"></div>
<h1 class="ep-title" id="ep-title">Loading...</h1>
<p class="ep-desc" id="ep-desc"></p>
<div class="ep-actions">
<button class="ep-play-btn" id="ep-play-btn" style="display:none" aria-label="Play Episode">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
<span>Play Episode</span>
</button>
</div>
</div>
</section>
<!-- Transcript -->
<section class="transcript-section" id="transcript-section">
<h2>Full Transcript</h2>
<div class="transcript-body" id="transcript-body">
<div class="episodes-loading">Loading transcript...</div>
</div>
</section>
</main>
<noscript>
<section class="transcript-section">
<p>This page requires JavaScript to load the episode transcript. Please enable JavaScript or listen on <a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF">Spotify</a>, <a href="https://podcasts.apple.com/us/podcast/luke-at-the-roost/id1875205848">Apple Podcasts</a>, or <a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-">YouTube</a>.</p>
</section>
</noscript>
<footer class="footer"></footer>
<!-- Sticky Audio Player -->
<div class="sticky-player" id="sticky-player">
<div class="player-inner">
<button class="player-play-btn" id="player-play-btn" aria-label="Play/Pause">
<svg class="icon-play" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
<svg class="icon-pause" viewBox="0 0 24 24" style="display:none"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
</button>
<div class="player-info">
<div class="player-title" id="player-title"></div>
<div class="player-progress-row">
<div class="player-progress" id="player-progress" role="slider" aria-label="Audio progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" tabindex="0">
<div class="player-progress-fill" id="player-progress-fill"></div>
</div>
<span class="player-time" id="player-time">0:00 / 0:00</span>
</div>
</div>
</div>
</div>
<audio id="audio-element" preload="none"></audio>
<script src="js/footer.js"></script>
<script src="js/player.js"></script>
<script src="js/episode.js"></script>
</body>
</html>
+4 -4
View File
@@ -107,7 +107,7 @@ function createFeaturedCard(ep) {
<button class="episode-play-btn featured-play-btn" aria-label="Play ${escapeAttr(ep.title)}">
${playSVG}
</button>
${epSlug ? `<a href="/episode.html?slug=${encodeURIComponent(epSlug)}" class="episode-transcript-link">Read Transcript</a>` : ''}
${epSlug ? `<a href="/episode/${encodeURIComponent(epSlug)}/" class="episode-transcript-link">Read Transcript</a>` : ''}
<button class="episode-share-btn" aria-label="Share episode">${shareSVG}</button>
</div>
`;
@@ -117,7 +117,7 @@ function createFeaturedCard(ep) {
const shareBtn = card.querySelector('.episode-share-btn');
const shareUrl = epSlug
? `${window.location.origin}/episode.html?slug=${encodeURIComponent(epSlug)}`
? `${window.location.origin}/episode/${encodeURIComponent(epSlug)}/`
: window.location.origin;
shareBtn.addEventListener('click', () => shareContent(ep.title, shareUrl, shareBtn));
@@ -207,7 +207,7 @@ function createEpisodeCard(ep) {
<div class="episode-meta">${metaParts}</div>
<div class="episode-title">${escapeAttr(ep.title)}</div>
<div class="episode-desc">${truncate(ep.description, 150)}</div>
${epSlug ? `<a href="/episode.html?slug=${encodeURIComponent(epSlug)}" class="episode-transcript-link">Read Transcript</a>` : ''}
${epSlug ? `<a href="/episode/${encodeURIComponent(epSlug)}/" class="episode-transcript-link">Read Transcript</a>` : ''}
<button class="episode-share-btn" aria-label="Share episode">${shareSVG}</button>
</div>
`;
@@ -217,7 +217,7 @@ function createEpisodeCard(ep) {
const shareBtn = card.querySelector('.episode-share-btn');
const shareUrl = epSlug
? `${window.location.origin}/episode.html?slug=${encodeURIComponent(epSlug)}`
? `${window.location.origin}/episode/${encodeURIComponent(epSlug)}/`
: window.location.origin;
shareBtn.addEventListener('click', () => shareContent(ep.title, shareUrl, shareBtn));
-142
View File
@@ -1,142 +0,0 @@
const FEED_URL = '/feed';
function formatDate(dateStr) {
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
function parseDuration(raw) {
if (!raw) return '';
if (raw.includes(':')) {
const parts = raw.split(':').map(Number);
let t = 0;
if (parts.length === 3) t = parts[0]*3600 + parts[1]*60 + parts[2];
else if (parts.length === 2) t = parts[0]*60 + parts[1];
return `${Math.round(t/60)} min`;
}
const sec = parseInt(raw, 10);
return isNaN(sec) ? '' : `${Math.round(sec/60)} min`;
}
function stripHtml(html) {
const div = document.createElement('div');
div.innerHTML = html || '';
return div.textContent || '';
}
function escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// Get slug from URL
const params = new URLSearchParams(window.location.search);
const slug = params.get('slug');
if (!slug) {
document.getElementById('ep-title').textContent = 'Episode not found';
document.getElementById('transcript-body').innerHTML = '<p>No episode specified. <a href="/">Go back to episodes.</a></p>';
} else {
loadEpisode(slug);
}
async function loadEpisode(slug) {
try {
const res = await fetch(FEED_URL);
const xml = await res.text();
const parser = new DOMParser();
const doc = parser.parseFromString(xml, 'text/xml');
const items = doc.querySelectorAll('item');
let episode = null;
for (const item of items) {
const link = item.querySelector('link')?.textContent || '';
const itemSlug = link.split('/episodes/').pop()?.replace(/\/$/, '');
if (itemSlug === slug) {
episode = {
title: item.querySelector('title')?.textContent || 'Untitled',
description: item.querySelector('description')?.textContent || '',
audioUrl: item.querySelector('enclosure')?.getAttribute('url') || '',
pubDate: item.querySelector('pubDate')?.textContent || '',
duration: item.getElementsByTagNameNS('http://www.itunes.com/dtds/podcast-1.0.dtd', 'duration')[0]?.textContent || '',
episodeNum: item.getElementsByTagNameNS('http://www.itunes.com/dtds/podcast-1.0.dtd', 'episode')[0]?.textContent || '',
};
break;
}
}
if (!episode) {
document.getElementById('ep-title').textContent = 'Episode not found';
document.getElementById('transcript-body').innerHTML = '<p>Could not find this episode. <a href="/">Go back to episodes.</a></p>';
return;
}
// Populate header
const metaParts = [
episode.episodeNum ? `Episode ${episode.episodeNum}` : '',
episode.pubDate ? formatDate(episode.pubDate) : '',
parseDuration(episode.duration),
].filter(Boolean).join(' \u00b7 ');
document.getElementById('ep-meta').textContent = metaParts;
document.getElementById('ep-title').textContent = episode.title;
document.getElementById('ep-desc').textContent = stripHtml(episode.description || '');
// Update page meta
document.title = `${episode.title} — Luke at the Roost`;
document.getElementById('page-description')?.setAttribute('content', `Full transcript of ${episode.title} from Luke at the Roost.`);
document.getElementById('og-title')?.setAttribute('content', episode.title);
document.getElementById('og-description')?.setAttribute('content', stripHtml(episode.description).slice(0, 200));
const canonicalUrl = `https://lukeattheroost.com/episode.html?slug=${slug}`;
document.getElementById('page-canonical')?.setAttribute('href', canonicalUrl);
document.getElementById('og-url')?.setAttribute('content', canonicalUrl);
document.getElementById('tw-title')?.setAttribute('content', episode.title);
document.getElementById('tw-description')?.setAttribute('content', stripHtml(episode.description).slice(0, 200));
// Update JSON-LD structured data
const jsonLd = document.getElementById('episode-jsonld');
if (jsonLd) {
const ld = JSON.parse(jsonLd.textContent);
ld.name = episode.title;
ld.url = canonicalUrl;
ld.description = stripHtml(episode.description).slice(0, 300);
if (episode.pubDate) ld.datePublished = new Date(episode.pubDate).toISOString().split('T')[0];
if (episode.episodeNum) ld.episodeNumber = parseInt(episode.episodeNum, 10);
if (episode.audioUrl) {
ld.associatedMedia = {
"@type": "MediaObject",
"contentUrl": episode.audioUrl
};
}
jsonLd.textContent = JSON.stringify(ld);
}
// Play button
if (episode.audioUrl) {
const playBtn = document.getElementById('ep-play-btn');
playBtn.style.display = 'inline-flex';
playBtn.addEventListener('click', () => {
audio.src = episode.audioUrl;
audio.play();
playerTitle.textContent = episode.title;
stickyPlayer.classList.add('active');
});
}
} catch (e) {
document.getElementById('ep-title').textContent = 'Error loading episode';
}
// Fetch transcript
try {
const txRes = await fetch(`/transcripts/${slug}.txt`);
if (!txRes.ok) throw new Error('Not found');
const text = await txRes.text();
const paragraphs = text.split(/\n\n+/).filter(Boolean);
const html = paragraphs.map(p => {
const escaped = escapeHtml(p);
const labeled = escaped.replace(/^([A-Z][A-Z\s'\-]+?):\s*/, '<span class="speaker-label">$1:</span> ');
return `<p>${labeled.replace(/\n/g, '<br>')}</p>`;
}).join('');
document.getElementById('transcript-body').innerHTML = html;
} catch (e) {
document.getElementById('transcript-body').innerHTML = '<p class="transcript-unavailable">Transcript not yet available for this episode.</p>';
}
}
+7 -7
View File
@@ -48,7 +48,7 @@ The show is entirely custom-built: callers are generated in real-time using larg
- **How It Works**: https://lukeattheroost.com/how-it-works — Technical deep dive into the AI caller generation, recording, and production pipeline
- **Clips**: https://lukeattheroost.com/clips — Best moments from the show as video clips
- **Stats**: https://lukeattheroost.com/stats — Download numbers, reviews, YouTube metrics
- **Episode transcripts**: https://lukeattheroost.com/episode.html?slug=EPISODE-SLUG — Full text transcripts of every episode
- **Episode transcripts**: https://lukeattheroost.com/episode/EPISODE-SLUG/ — Full text transcripts of every episode
## Community
@@ -121,16 +121,16 @@ Each episode (~30-60 minutes) follows a loose late-night radio format:
## Recent Episodes
Episodes are published daily. Each has a full transcript available at:
https://lukeattheroost.com/episode.html?slug=EPISODE-SLUG
https://lukeattheroost.com/episode/EPISODE-SLUG/
Episode transcript URLs follow the pattern: episode-N-title-slug
Recent episodes include:
- Episode 37: "Secrets, Lies, and Coffee Runs" — https://lukeattheroost.com/episode.html?slug=episode-37-secrets-lies-and-coffee-runs
- Episode 36: "Late Night Confessions and Unexpected Moments" — https://lukeattheroost.com/episode.html?slug=episode-36-late-night-confessions-and-unexpected-moments
- Episode 35: "Midnight Confessions and Unexpected Revelations" — https://lukeattheroost.com/episode.html?slug=episode-35-midnight-confessions-and-unexpected-revelations
- Episode 34: "Hidden Rooms, Potlucks, and Mysterious Notes" — https://lukeattheroost.com/episode.html?slug=episode-34-hidden-rooms-potlucks-and-mysterious-notes
- Episode 33: "Late Night Confessions and Cosmic Comedies" — https://lukeattheroost.com/episode.html?slug=episode-33-late-night-confessions-and-cosmic-comedies
- Episode 37: "Secrets, Lies, and Coffee Runs" — https://lukeattheroost.com/episode/episode-37-secrets-lies-and-coffee-runs/
- Episode 36: "Late Night Confessions and Unexpected Moments" — https://lukeattheroost.com/episode/episode-36-late-night-confessions-and-unexpected-moments/
- Episode 35: "Midnight Confessions and Unexpected Revelations" — https://lukeattheroost.com/episode/episode-35-midnight-confessions-and-unexpected-revelations/
- Episode 34: "Hidden Rooms, Potlucks, and Mysterious Notes" — https://lukeattheroost.com/episode/episode-34-hidden-rooms-potlucks-and-mysterious-notes/
- Episode 33: "Late Night Confessions and Cosmic Comedies" — https://lukeattheroost.com/episode/episode-33-late-night-confessions-and-cosmic-comedies/
## Clip Highlights