Resolve Spotify handoff config on main (#528)

Preserve main's Spotify metadata, historical context, and breaking-news handoff wiring while replacing regex HTML truncation with the parser-based implementation that keeps entities, char refs, and comments atomic. Keep WAV as the default Spotify upload format. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Juan Manuel Servera committed Jun 20, 2026 at 16:21 UTC 07b0c8d3255afc418307876b70f45ce07fba1225
3 files changed +176 -26
config/podcast.json
+2 -1
@@ -64,6 +64,7 @@
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"
67 + "publish_mode": "draft",
68 + "upload_format": "wav"
69 }
70 }
scripts/podcaster_handoff.py
+107 -23
@@ -6,6 +6,7 @@ import json
6 import os
7 import re
8 from datetime import date
9 +from html.parser import HTMLParser
10 from pathlib import Path
11 from typing import Any
12 from urllib import error, request
@@ -21,6 +22,9 @@ MAX_SPOTIFY_TITLE_CHARS = 200
22 MAX_SPOTIFY_DESCRIPTION_CHARS = 4_000
23 MAX_MONTH_SYNTHESIS_WORDS = 300
24 MAX_YEARLY_NARRATIVE_WORDS = 500
25 +_VOID_HTML_TAGS = frozenset(
26 + {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"}
27 +)
28
29
30 class PodcasterHandoffError(RuntimeError):
@@ -228,30 +232,110 @@ def _render_template_value(value: Any, context: dict[str, Any]) -> Any:
232 raise PodcasterHandoffError(f"spotify_publish template has invalid format syntax: {value!r}") from exc
233
234
231 -def _truncate_html(value: str, limit: int) -> str:
232 - """Truncate HTML to *limit* chars while keeping tags properly closed."""
235 +class _HTMLTruncator(HTMLParser):
236 + def __init__(self, max_length: int) -> None:
237 + super().__init__(convert_charrefs=False)
238 + self.max_length = max_length
239 + self.parts: list[str] = []
240 + self.open_tags: list[str] = []
241 + self.current_length = 0
242 + self.truncated = False
243 +
244 + def handle_starttag(self, tag: str, attrs) -> None: # type: ignore[override]
245 + self._append_tag(self.get_starttag_text(), tag, push=True)
246 +
247 + def handle_startendtag(self, tag: str, attrs) -> None: # type: ignore[override]
248 + self._append_tag(self.get_starttag_text(), tag, push=False)
249 +
250 + def handle_endtag(self, tag: str) -> None: # type: ignore[override]
251 + normalized = tag.lower()
252 + if self.truncated or normalized not in self.open_tags:
253 + return
254 +
255 + closings: list[str] = []
256 + while self.open_tags:
257 + open_tag = self.open_tags.pop()
258 + closings.append(f"</{open_tag}>")
259 + if open_tag == normalized:
260 + break
261 +
262 + for closing in closings:
263 + self._append(closing)
264 +
265 + def handle_data(self, data: str) -> None:
266 + self._append_text(data)
267 +
268 + def handle_entityref(self, name: str) -> None:
269 + self._append_atomic(f"&{name};")
270 +
271 + def handle_charref(self, name: str) -> None:
272 + self._append_atomic(f"&#{name};")
273 +
274 + def handle_comment(self, data: str) -> None:
275 + self._append_atomic(f"<!--{data}-->")
276 +
277 + def _append_tag(self, raw_tag: str | None, tag: str, *, push: bool) -> None:
278 + if self.truncated or not raw_tag:
279 + return
280 +
281 + normalized = tag.lower()
282 + budget = self._closing_budget(extra_tag=normalized if push else None)
283 + if self.current_length + len(raw_tag) + budget > self.max_length:
284 + self.truncated = True
285 + return
286 +
287 + self._append(raw_tag)
288 + if push and normalized not in _VOID_HTML_TAGS:
289 + self.open_tags.append(normalized)
290 +
291 + def _append_atomic(self, token: str) -> None:
292 + if self.truncated or not token:
293 + return
294 + available = self.max_length - self.current_length - self._closing_budget()
295 + if len(token) > available:
296 + self.truncated = True
297 + return
298 + self._append(token)
299 +
300 + def _append_text(self, text: str) -> None:
301 + if self.truncated or not text:
302 + return
303 +
304 + available = self.max_length - self.current_length - self._closing_budget()
305 + if available <= 0:
306 + self.truncated = True
307 + return
308 +
309 + piece = text[:available]
310 + if piece:
311 + self._append(piece)
312 + if len(piece) < len(text):
313 + self.truncated = True
314 +
315 + def _closing_budget(self, *, extra_tag: str | None = None) -> int:
316 + budget = sum(len(f"</{tag}>") for tag in self.open_tags)
317 + if extra_tag and extra_tag not in _VOID_HTML_TAGS:
318 + budget += len(f"</{extra_tag}>")
319 + return budget
320 +
321 + def _append(self, text: str) -> None:
322 + self.parts.append(text)
323 + self.current_length += len(text)
324 +
325 + def finish(self) -> str:
326 + for tag in reversed(self.open_tags):
327 + self._append(f"</{tag}>")
328 + return "".join(self.parts)
329 +
330 +
331 +def truncate_html(value: str, limit: int) -> str:
332 if len(value) <= limit:
333 return value
235 - # Reserve space for potential closing tags; iteratively find a safe cut point
236 - truncated = value[:limit]
237 - # Remove any partial tag at the end
238 - truncated = re.sub(r"<[^>]*$", "", truncated)
239 - # Compute needed closing tags
240 - while True:
241 - open_tags: list[str] = []
242 - for m in re.finditer(r"<(/?)(\w+)[^>]*>", truncated):
243 - if m.group(1): # closing tag
244 - if open_tags and open_tags[-1] == m.group(2):
245 - open_tags.pop()
246 - else:
247 - if m.group(2).lower() not in ("br", "hr", "img", "input", "meta", "link"):
248 - open_tags.append(m.group(2))
249 - suffix = "".join(f"</{tag}>" for tag in reversed(open_tags))
250 - if len(truncated) + len(suffix) <= limit:
251 - return truncated + suffix
252 - # Shrink content to make room for closing tags
253 - truncated = truncated[: limit - len(suffix)]
254 - truncated = re.sub(r"<[^>]*$", "", truncated)
334 +
335 + truncator = _HTMLTruncator(limit)
336 + truncator.feed(value)
337 + truncator.close()
338 + return truncator.finish()
339
340
341 def _truncate_text(value: str, limit: int) -> str:
@@ -361,7 +445,7 @@ def _resolve_spotify_publish(config: dict[str, Any], *, week: str, article_title
445 resolved["title"] = title
446 description = resolved.pop("description_template", None)
447 if isinstance(description, str):
364 - resolved["description"] = _truncate_html(description, MAX_SPOTIFY_DESCRIPTION_CHARS)
448 + resolved["description"] = truncate_html(description, MAX_SPOTIFY_DESCRIPTION_CHARS)
449 elif description is not None:
450 resolved["description"] = description
451 return resolved
tests/test_podcaster_handoff.py
+67 -2
@@ -3,6 +3,7 @@ import json
3 import sys
4 import tempfile
5 import unittest
6 +from html.parser import HTMLParser
7 from pathlib import Path
8 from unittest import mock
9 from urllib import error
@@ -24,6 +25,40 @@ class _FakeHTTPResponse(io.BytesIO):
25 return self.status
26
27
28 +class _BalancedHtmlParser(HTMLParser):
29 + _void_tags = {
30 + "area",
31 + "base",
32 + "br",
33 + "col",
34 + "embed",
35 + "hr",
36 + "img",
37 + "input",
38 + "link",
39 + "meta",
40 + "param",
41 + "source",
42 + "track",
43 + "wbr",
44 + }
45 +
46 + def __init__(self) -> None:
47 + super().__init__(convert_charrefs=False)
48 + self.stack: list[str] = []
49 + self.errors: list[str] = []
50 +
51 + def handle_starttag(self, tag: str, attrs) -> None: # type: ignore[override]
52 + if tag not in self._void_tags:
53 + self.stack.append(tag)
54 +
55 + def handle_endtag(self, tag: str) -> None: # type: ignore[override]
56 + if not self.stack or self.stack[-1] != tag:
57 + self.errors.append(tag)
58 + return
59 + self.stack.pop()
60 +
61 +
62 class PodcasterHandoffTests(unittest.TestCase):
63 def _write_manifest(self, base: Path, *, run_mode: str = "normal", ai_status: str = "ai") -> Path:
64 manifest = base / "publish-manifest.json"
@@ -437,6 +472,7 @@ class PodcasterHandoffTests(unittest.TestCase):
472 self.assertIn("music_mix", sent_payload["script_directions"])
473 self.assertIn("spotify_publish", sent_payload)
474 self.assertEqual(sent_payload["spotify_publish"]["publish_mode"], "draft")
475 + self.assertEqual(sent_payload["spotify_publish"]["upload_format"], "wav")
476 self.assertIsInstance(sent_payload["spotify_publish"]["season_number"], int)
477 self.assertIsInstance(sent_payload["spotify_publish"]["episode_number"], int)
478 self.assertNotIn("super-secret-value", stdout.getvalue())
@@ -693,8 +729,37 @@ class PodcasterHandoffTests(unittest.TestCase):
729 desc = payload["spotify_publish"]["description"]
730 self.assertLessEqual(len(desc), 4000)
731 self.assertTrue(desc.startswith("<p>"))
696 - # Verify HTML is properly closed after truncation
697 - self.assertTrue(desc.endswith("</p>"), "Truncated description must end with a closed tag")
732 + parser = _BalancedHtmlParser()
733 + parser.feed(desc)
734 + parser.close()
735 + self.assertEqual(parser.errors, [])
736 + self.assertEqual(parser.stack, [])
737 +
738 + def test_truncate_html_drops_partial_trailing_tag(self) -> None:
739 + html = "<p>" + ("x" * 3988) + '<a href="https://example.com/really/long/link">link</a></p>'
740 + truncated = podcaster_handoff.truncate_html(html, podcaster_handoff.MAX_SPOTIFY_DESCRIPTION_CHARS)
741 +
742 + parser = _BalancedHtmlParser()
743 + parser.feed(truncated)
744 + parser.close()
745 +
746 + self.assertLessEqual(len(truncated), podcaster_handoff.MAX_SPOTIFY_DESCRIPTION_CHARS)
747 + self.assertTrue(truncated.endswith("</p>"))
748 + self.assertNotIn("<a href", truncated)
749 + self.assertEqual(parser.errors, [])
750 + self.assertEqual(parser.stack, [])
751 +
752 + def test_truncate_html_entity_ref_atomic(self) -> None:
753 + self.assertEqual(podcaster_handoff.truncate_html("<p>&amp;z</p>", 11), "<p></p>")
754 + self.assertEqual(podcaster_handoff.truncate_html("<p>&amp;z</p>", 12), "<p>&amp;</p>")
755 +
756 + def test_truncate_html_charref_atomic(self) -> None:
757 + self.assertEqual(podcaster_handoff.truncate_html("<p>&#169;z</p>", 12), "<p></p>")
758 + self.assertEqual(podcaster_handoff.truncate_html("<p>&#169;z</p>", 13), "<p>&#169;</p>")
759 +
760 + def test_truncate_html_comment_atomic(self) -> None:
761 + self.assertEqual(podcaster_handoff.truncate_html("<p><!--ok-->z</p>", 15), "<p></p>")
762 + self.assertEqual(podcaster_handoff.truncate_html("<p><!--ok-->z</p>", 16), "<p><!--ok--></p>")
763
764 def test_render_template_value_raises_on_malformed_format_string(self) -> None:
765 context = {"year": 2026, "week": 24}