fix: include article_content and article_title in podcaster handoff payload (#407)
Read the article file (resolved from repo-relative path) in build_payload() and include its content as 'article_content' in the payload dict. Extract the title from YAML front matter (title: field) or first # heading and include as 'article_title'. Truncate content at 50000 chars as a size guard. Closes #406 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Juan Manuel Servera committed
Jun 12, 2026 at 12:22 UTC
885cb5560cf9ce866738001540e28bc3507ffe85
2 files changed
+145
-1
scripts/podcaster_handoff.py
+54
-1
@@ -4,6 +4,7 @@ from __future__ import annotations
4
import argparse
5
import json
6
import os
7
+import re
8
from pathlib import Path
9
from typing import Any
10
from urllib import error, request
@@ -13,6 +14,8 @@ from urllib.parse import urljoin, urlparse
14
AUTH_HEADER = "x-podcaster-api-key"
15
DEFAULT_TIMEOUT_SECONDS = 30
16
DEFAULT_PODCAST_CONFIG_PATH = Path(__file__).resolve().parent.parent / "config" / "podcast.json"
17
+REPO_ROOT = Path(__file__).resolve().parent.parent
18
+MAX_ARTICLE_CONTENT_CHARS = 50_000
19
20
21
class PodcasterHandoffError(RuntimeError):
@@ -123,6 +126,46 @@ def _source_artifact_refs(manifest: dict[str, Any]) -> list[dict[str, str]]:
126
return refs
127
128
129
+def _extract_title(content: str) -> str | None:
130
+ """Extract article title from YAML front matter or first # heading."""
131
+ # Try YAML front matter first
132
+ if content.startswith("---"):
133
+ end = content.find("\n---", 3)
134
+ if end != -1:
135
+ frontmatter = content[3:end]
136
+ match = re.search(r"^title:\s*(.+)$", frontmatter, re.MULTILINE)
137
+ if match:
138
+ title = match.group(1).strip().strip("\"'")
139
+ if title:
140
+ return title
141
+ # Fall back to first # heading
142
+ match = re.search(r"^#\s+(.+)$", content, re.MULTILINE)
143
+ if match:
144
+ return match.group(1).strip()
145
+ return None
146
+
147
+
148
+def _read_article_content(article_path: str, repo_root: Path = REPO_ROOT) -> tuple[str | None, str | None]:
149
+ """Read article file content and extract title.
150
+
151
+ Returns (content, title). Content is truncated to MAX_ARTICLE_CONTENT_CHARS.
152
+ Returns (None, None) if the file does not exist or is empty.
153
+ """
154
+ resolved = repo_root / article_path
155
+ if not resolved.exists():
156
+ return None, None
157
+ try:
158
+ content = resolved.read_text(encoding="utf-8")
159
+ except OSError:
160
+ return None, None
161
+ if not content.strip():
162
+ return None, None
163
+ title = _extract_title(content)
164
+ if len(content) > MAX_ARTICLE_CONTENT_CHARS:
165
+ content = content[:MAX_ARTICLE_CONTENT_CHARS]
166
+ return content, title
167
+
168
+
169
def _manifest_allows_handoff(manifest: dict[str, Any], *, week: str, publish_mode: str) -> bool:
170
if not manifest:
171
return True
@@ -150,17 +193,27 @@ def build_payload(
193
manifest_path: Path | None = None,
194
podcast_config_path: Path | None = None,
195
podcaster_dry_run: bool = False,
196
+ repo_root: Path | None = None,
197
) -> dict[str, Any]:
198
manifest = _load_manifest(manifest_path)
199
if not _manifest_allows_handoff(manifest, week=week, publish_mode=publish_mode):
200
raise PodcasterHandoffError("Publish manifest is not eligible for Podcaster handoff.")
201
+ normalized_path = normalize_page_path(article_path)
202
payload: dict[str, Any] = {
203
"week": week,
204
"article_url": article_url,
160
- "article_path": normalize_page_path(article_path),
205
+ "article_path": normalized_path,
206
"publish_run_id": publish_run_id,
207
"publish_mode": publish_mode,
208
}
209
+
210
+ # Read article content and extract title
211
+ root = repo_root if repo_root is not None else REPO_ROOT
212
+ content, title = _read_article_content(normalized_path, repo_root=root)
213
+ if content:
214
+ payload["article_content"] = content
215
+ if title:
216
+ payload["article_title"] = title
217
article_sha = (
218
manifest.get("candidate", {}).get("summary_sha256")
219
if isinstance(manifest.get("candidate"), dict)
tests/test_podcaster_handoff.py
+91
@@ -49,6 +49,11 @@ class PodcasterHandoffTests(unittest.TestCase):
49
tests_root = Path(__file__).resolve().parent
50
with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
51
manifest = self._write_manifest(Path(tmpdir))
52
+ # Create article file so article_content is included
53
+ article_dir = Path(tmpdir) / "content" / "weekly" / "2026"
54
+ article_dir.mkdir(parents=True)
55
+ article_file = article_dir / "W23.md"
56
+ article_file.write_text("---\ntitle: Week 23 Report\n---\n# Heading\nBody content here.\n", encoding="utf-8")
57
58
payload = podcaster_handoff.build_payload(
59
week="2026-W23",
@@ -57,6 +62,7 @@ class PodcasterHandoffTests(unittest.TestCase):
62
publish_run_id="123456789",
63
publish_mode="normal",
64
manifest_path=manifest,
65
+ repo_root=Path(tmpdir),
66
)
67
68
self.assertEqual(payload["week"], "2026-W23")
@@ -74,6 +80,10 @@ class PodcasterHandoffTests(unittest.TestCase):
80
)
81
self.assertNotIn("force", payload)
82
self.assertNotIn("dry_run", payload)
83
+ # article_content and article_title from the article file
84
+ self.assertIn("article_content", payload)
85
+ self.assertIn("Week 23 Report", payload["article_title"])
86
+ self.assertIn("Body content here.", payload["article_content"])
87
88
def test_podcaster_dry_run_sets_payload_flag(self) -> None:
89
payload = podcaster_handoff.build_payload(
@@ -287,5 +297,86 @@ class PodcasterHandoffTests(unittest.TestCase):
297
)
298
299
300
+ def test_build_payload_includes_article_content_from_file(self) -> None:
301
+ with tempfile.TemporaryDirectory() as tmpdir:
302
+ base = Path(tmpdir)
303
+ article_dir = base / "content" / "weekly" / "2026"
304
+ article_dir.mkdir(parents=True)
305
+ article = article_dir / "W24.md"
306
+ article.write_text("---\ntitle: My Title\n---\n# Heading\nHello world.\n", encoding="utf-8")
307
+
308
+ payload = podcaster_handoff.build_payload(
309
+ week="2026-W24",
310
+ article_url="https://example.com/weekly/2026/w24/",
311
+ article_path="content/weekly/2026/W24.md",
312
+ publish_run_id="999",
313
+ publish_mode="normal",
314
+ podcaster_dry_run=True,
315
+ repo_root=base,
316
+ )
317
+
318
+ self.assertEqual(payload["article_title"], "My Title")
319
+ self.assertIn("Hello world.", payload["article_content"])
320
+ self.assertIn("---\ntitle: My Title\n---", payload["article_content"])
321
+
322
+ def test_build_payload_extracts_title_from_heading_when_no_frontmatter(self) -> None:
323
+ with tempfile.TemporaryDirectory() as tmpdir:
324
+ base = Path(tmpdir)
325
+ article_dir = base / "content" / "weekly" / "2026"
326
+ article_dir.mkdir(parents=True)
327
+ article = article_dir / "W24.md"
328
+ article.write_text("# My Heading Title\nSome content.\n", encoding="utf-8")
329
+
330
+ payload = podcaster_handoff.build_payload(
331
+ week="2026-W24",
332
+ article_url="https://example.com/weekly/2026/w24/",
333
+ article_path="content/weekly/2026/W24.md",
334
+ publish_run_id="999",
335
+ publish_mode="normal",
336
+ podcaster_dry_run=True,
337
+ repo_root=base,
338
+ )
339
+
340
+ self.assertEqual(payload["article_title"], "My Heading Title")
341
+
342
+ def test_build_payload_truncates_large_article_content(self) -> None:
343
+ with tempfile.TemporaryDirectory() as tmpdir:
344
+ base = Path(tmpdir)
345
+ article_dir = base / "content" / "weekly" / "2026"
346
+ article_dir.mkdir(parents=True)
347
+ article = article_dir / "W24.md"
348
+ large_content = "# Title\n" + "x" * 60_000
349
+ article.write_text(large_content, encoding="utf-8")
350
+
351
+ payload = podcaster_handoff.build_payload(
352
+ week="2026-W24",
353
+ article_url="https://example.com/weekly/2026/w24/",
354
+ article_path="content/weekly/2026/W24.md",
355
+ publish_run_id="999",
356
+ publish_mode="normal",
357
+ podcaster_dry_run=True,
358
+ repo_root=base,
359
+ )
360
+
361
+ self.assertEqual(len(payload["article_content"]), 50_000)
362
+ self.assertEqual(payload["article_title"], "Title")
363
+
364
+ def test_build_payload_missing_article_file_omits_content(self) -> None:
365
+ with tempfile.TemporaryDirectory() as tmpdir:
366
+ base = Path(tmpdir)
367
+ payload = podcaster_handoff.build_payload(
368
+ week="2026-W24",
369
+ article_url="https://example.com/weekly/2026/w24/",
370
+ article_path="content/weekly/2026/W24.md",
371
+ publish_run_id="999",
372
+ publish_mode="normal",
373
+ podcaster_dry_run=True,
374
+ repo_root=base,
375
+ )
376
+
377
+ self.assertNotIn("article_content", payload)
378
+ self.assertNotIn("article_title", payload)
379
+
380
+
381
if __name__ == "__main__":
382
unittest.main()