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>&z</p>", 11), "<p></p>")
754
+ self.assertEqual(podcaster_handoff.truncate_html("<p>&z</p>", 12), "<p>&</p>")
755
+
756
+ def test_truncate_html_charref_atomic(self) -> None:
757
+ self.assertEqual(podcaster_handoff.truncate_html("<p>©z</p>", 12), "<p></p>")
758
+ self.assertEqual(podcaster_handoff.truncate_html("<p>©z</p>", 13), "<p>©</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}