feat: SEO-optimize podcast config (titles, CTA, description) (#478)
* feat: SEO-optimize podcast config (titles, CTA, description) Closes #475, closes #476, closes #477 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address PR #478 review comments - Reorder closing_cues: Spotify follow CTA before AI disclosure repeat (#476) - Use short week suffix format '| W{week}' per issue #475 spec - Add HTML-safe truncation for Spotify description to maintain valid HTML when content exceeds 4000 chars (#477) - Update tests to validate HTML closure and new title format Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Juan Manuel Servera committed
Jun 14, 2026 at 12:24 UTC
8971025c9fc0f276393b90d7cef7dc925b4ecff2
3 files changed
+41
-10
config/podcast.json
+6
-4
@@ -27,9 +27,11 @@
27
},
28
"closing_cues": {
29
"corrections_path": "If we got something wrong, file an issue at https://github.com/jmservera/SquadScope/issues",
30
- "ai_disclosure_repeat": "Repeat AI voice disclosure in the outro.",
31
- "source_article_link": "Direct listeners to the full source article URL."
30
+ "source_article_link": "Direct listeners to the full source article URL.",
31
+ "spotify_follow_cta": "Before the AI disclosure, include a natural call-to-action: 'If you want to stay ahead of tech trends, follow Claracle on Spotify so you never miss a signal.' Make it conversational, not salesy.",
32
+ "ai_disclosure_repeat": "Repeat AI voice disclosure as the final element of the outro."
33
},
34
+ "episode_title": "Generate an SEO-friendly episode title that leads with the insight or listener benefit, not the date. Naturally include relevant keywords such as AI, GitHub, developer trends, software engineering trends, or tech weekly when they fit the story, and place any week/year reference at the end.",
35
"episode_style": {
36
"format": "Two-host conversational podcast, they interact between them, 8-10 minutes, 1200-1700 words.",
37
"tone": "Conversational, not performative. Opinionated, not reckless. Concise, not rushed. Funny, not cruel. Accessible, not dumbed-down.",
@@ -58,8 +60,8 @@
60
}
61
},
62
"spotify_publish": {
61
- "title_template": "{year}-W{week}: {article_title}",
62
- "description_template": "<p>{article_summary}</p><p>Read more at <a href=\"https://claracle.com\" target=\"_blank\" rel=\"ugc noopener noreferrer\">Claracle</a></p><p><br /></p><p>Intro and Outro: Summer Sport by AudioCoffee | https://www.audiocoffee.net/ Music promoted by https://www.chosic.com/free-music/all/ Creative Commons CC BY-SA 3.0 https://creativecommons.org/licenses/by-sa/3.0/</p>",
63
+ "title_template": "Why {article_title} Matters for AI, GitHub & Developer Trends | W{week}",
64
+ "description_template": "<p>{article_summary}</p><p>Claracle is your tech weekly for developer trends, AI news, GitHub insights, and software engineering trends—helping you understand what matters and why.</p><p>Read more at <a href=\"https://claracle.com\" target=\"_blank\" rel=\"ugc noopener noreferrer\">Claracle</a></p><p><br /></p><p>Intro and Outro: Summer Sport by AudioCoffee | https://www.audiocoffee.net/ Music promoted by https://www.chosic.com/free-music/all/ Creative Commons CC BY-SA 3.0 https://creativecommons.org/licenses/by-sa/3.0/</p>",
65
"season_number": "{year}",
66
"episode_number": "{week}",
67
"publish_mode": "draft"
scripts/podcaster_handoff.py
+27
-1
@@ -182,6 +182,32 @@ def _render_template_value(value: Any, context: dict[str, Any]) -> Any:
182
raise PodcasterHandoffError(f"spotify_publish template has invalid format syntax: {value!r}") from exc
183
184
185
+def _truncate_html(value: str, limit: int) -> str:
186
+ """Truncate HTML to *limit* chars while keeping tags properly closed."""
187
+ if len(value) <= limit:
188
+ return value
189
+ # Reserve space for potential closing tags; iteratively find a safe cut point
190
+ truncated = value[:limit]
191
+ # Remove any partial tag at the end
192
+ truncated = re.sub(r"<[^>]*$", "", truncated)
193
+ # Compute needed closing tags
194
+ while True:
195
+ open_tags: list[str] = []
196
+ for m in re.finditer(r"<(/?)(\w+)[^>]*>", truncated):
197
+ if m.group(1): # closing tag
198
+ if open_tags and open_tags[-1] == m.group(2):
199
+ open_tags.pop()
200
+ else:
201
+ if m.group(2).lower() not in ("br", "hr", "img", "input", "meta", "link"):
202
+ open_tags.append(m.group(2))
203
+ suffix = "".join(f"</{tag}>" for tag in reversed(open_tags))
204
+ if len(truncated) + len(suffix) <= limit:
205
+ return truncated + suffix
206
+ # Shrink content to make room for closing tags
207
+ truncated = truncated[: limit - len(suffix)]
208
+ truncated = re.sub(r"<[^>]*$", "", truncated)
209
+
210
+
211
def _truncate_text(value: str, limit: int) -> str:
212
return value[:limit]
213
@@ -210,7 +236,7 @@ def _resolve_spotify_publish(config: dict[str, Any], *, week: str, article_title
236
resolved["title"] = title
237
description = resolved.pop("description_template", None)
238
if isinstance(description, str):
213
- resolved["description"] = _truncate_text(description, MAX_SPOTIFY_DESCRIPTION_CHARS)
239
+ resolved["description"] = _truncate_html(description, MAX_SPOTIFY_DESCRIPTION_CHARS)
240
elif description is not None:
241
resolved["description"] = description
242
return resolved
tests/test_podcaster_handoff.py
+8
-5
@@ -360,7 +360,7 @@ class PodcasterHandoffTests(unittest.TestCase):
360
361
self.assertEqual(
362
payload["spotify_publish"]["title"],
363
- "2026-W24: Skills Go Vertical",
363
+ "Why Skills Go Vertical Matters for AI, GitHub & Developer Trends | W24",
364
)
365
self.assertIn("This week we explore agent skills.", payload["spotify_publish"]["description"])
366
self.assertEqual(payload["spotify_publish"]["season_number"], 2026)
@@ -387,10 +387,13 @@ class PodcasterHandoffTests(unittest.TestCase):
387
repo_root=base,
388
)
389
390
- self.assertEqual(len(payload["spotify_publish"]["title"]), 200)
391
- self.assertTrue(payload["spotify_publish"]["title"].startswith("2026-W24: "))
392
- self.assertEqual(len(payload["spotify_publish"]["description"]), 4000)
393
- self.assertTrue(payload["spotify_publish"]["description"].startswith("<p>"))
390
+ self.assertLessEqual(len(payload["spotify_publish"]["title"]), 200)
391
+ self.assertTrue(payload["spotify_publish"]["title"].startswith("Why "))
392
+ desc = payload["spotify_publish"]["description"]
393
+ self.assertLessEqual(len(desc), 4000)
394
+ self.assertTrue(desc.startswith("<p>"))
395
+ # Verify HTML is properly closed after truncation
396
+ self.assertTrue(desc.endswith("</p>"), "Truncated description must end with a closed tag")
397
398
def test_render_template_value_raises_on_malformed_format_string(self) -> None:
399
context = {"year": 2026, "week": 24}