main
py 857 lines 31.2 KB
Raw
1 #!/usr/bin/env python3
2 from __future__ import annotations
3
4 import argparse
5 import hashlib
6 import json
7 import os
8 import re
9 from datetime import date
10 from html.parser import HTMLParser
11 from pathlib import Path
12 from typing import Any
13 from urllib import error, request
14 from urllib.parse import urljoin, urlparse
15
16 AUTH_HEADER = "x-podcaster-api-key"
17 DEFAULT_TIMEOUT_SECONDS = 180
18 DEFAULT_PODCAST_CONFIG_PATH = Path(__file__).resolve().parent.parent / "config" / "podcast.json"
19 REPO_ROOT = Path(__file__).resolve().parent.parent
20 MAX_ARTICLE_CONTENT_CHARS = 50_000
21 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 {
27 "area",
28 "base",
29 "br",
30 "col",
31 "embed",
32 "hr",
33 "img",
34 "input",
35 "link",
36 "meta",
37 "param",
38 "source",
39 "track",
40 "wbr",
41 }
42 )
43
44
45 class PodcasterHandoffError(RuntimeError):
46 pass
47
48
49 def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
50 parser = argparse.ArgumentParser(
51 description="Notify Podcaster after a SquadScope weekly article is published."
52 )
53 parser.add_argument("--week", required=True, help="ISO week slug, e.g. 2026-W23.")
54 parser.add_argument("--article-url", required=True, help="Published SquadScope article URL.")
55 parser.add_argument(
56 "--article-path", required=True, help="Published SquadScope article content path."
57 )
58 parser.add_argument(
59 "--publish-run-id", required=True, help="GitHub Actions run ID that published the article."
60 )
61 parser.add_argument(
62 "--publish-mode",
63 default="normal",
64 help="Publish mode; only normal is eligible for Podcaster handoff.",
65 )
66 parser.add_argument(
67 "--manifest",
68 type=Path,
69 help="Optional publish manifest used for article hash/source artifact metadata.",
70 )
71 parser.add_argument(
72 "--podcaster-dry-run",
73 action="store_true",
74 help="Ask Podcaster to validate without generating an episode; intended only for the manual smoke workflow.",
75 )
76 parser.add_argument(
77 "--podcast-config",
78 type=Path,
79 default=None,
80 help="Path to podcast config JSON (default: config/podcast.json relative to repo root).",
81 )
82 parser.add_argument(
83 "--breaking-news",
84 default=None,
85 help="Optional last-moment news or important information to include in this podcast episode.",
86 )
87 parser.add_argument(
88 "--require-merged",
89 action="store_true",
90 help="Fail closed unless the article file exists locally and its sha256 matches the "
91 "manifest candidate.content_sha256. Use after the weekly article is merged to main so "
92 "the podcaster is never triggered for an unpublished/stub article.",
93 )
94 parser.add_argument("--endpoint", default=os.environ.get("PODCASTER_ENDPOINT", ""))
95 parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT_SECONDS)
96 return parser.parse_args(argv)
97
98
99 WEEKLY_CONTENT_PREFIX = "content/weekly/"
100
101
102 def normalize_page_path(page_path: str) -> str:
103 """Reduce an absolute Actions page path to its repo-relative form.
104
105 The generate job emits page_path as an absolute runner path on GitHub
106 Actions (see scripts/generate_content.py); mirror the workflow's
107 GITHUB_WORKSPACE normalization by reducing any absolute path to the
108 repo-relative segment beginning at content/weekly/.
109 """
110 path = page_path.strip().replace("\\", "/")
111 index = path.find(WEEKLY_CONTENT_PREFIX)
112 if index != -1:
113 path = path[index:]
114 return path.lstrip("/")
115
116
117 def article_url_from_page_path(base_url: str, page_path: str) -> str:
118 base = base_url.rstrip("/") + "/"
119 path = normalize_page_path(page_path)
120 if not path.startswith(WEEKLY_CONTENT_PREFIX) or not path.endswith(".md"):
121 raise PodcasterHandoffError(f"Cannot derive weekly article URL from page path: {page_path}")
122 slug = path.removeprefix(WEEKLY_CONTENT_PREFIX).removesuffix(".md").lower()
123 return urljoin(base, f"weekly/{slug}/")
124
125
126 def _escape_gha_data(value: str) -> str:
127 """Escape data for the message portion of a GitHub Actions workflow command."""
128 return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
129
130
131 def validate_endpoint(endpoint: str) -> None:
132 parsed = urlparse(endpoint)
133 if parsed.scheme not in {"https", "http"} or not parsed.netloc:
134 raise PodcasterHandoffError("PODCASTER_ENDPOINT must be an absolute HTTP(S) URL.")
135 if parsed.scheme == "http" and parsed.hostname not in {"localhost", "127.0.0.1", "::1"}:
136 raise PodcasterHandoffError(
137 "PODCASTER_ENDPOINT may use HTTP only for localhost or loopback addresses (127.0.0.1, ::1)."
138 )
139
140
141 def _load_manifest(path: Path | None) -> dict[str, Any]:
142 if path is None:
143 return {}
144 if not path.exists():
145 raise PodcasterHandoffError(
146 f"Publish manifest path was provided but does not exist: {path}"
147 )
148 try:
149 payload = json.loads(path.read_text(encoding="utf-8"))
150 except (OSError, json.JSONDecodeError) as exc:
151 raise PodcasterHandoffError(f"Publish manifest could not be read: {path}") from exc
152 if not isinstance(payload, dict):
153 raise PodcasterHandoffError(f"Publish manifest must be a JSON object: {path}")
154 return payload
155
156
157 def _load_podcast_config(path: Path | None) -> dict[str, Any]:
158 """Load the podcast config file containing podcast_config and script_directions."""
159 config_path = path if path is not None else DEFAULT_PODCAST_CONFIG_PATH
160 if not config_path.exists():
161 return {}
162 try:
163 payload = json.loads(config_path.read_text(encoding="utf-8"))
164 except (OSError, json.JSONDecodeError) as exc:
165 raise PodcasterHandoffError(f"Podcast config could not be read: {config_path}") from exc
166 if not isinstance(payload, dict):
167 raise PodcasterHandoffError(f"Podcast config must be a JSON object: {config_path}")
168 return payload
169
170
171 def _source_artifact_refs(manifest: dict[str, Any]) -> list[dict[str, Any]]:
172 def _string_list(value: Any) -> list[str] | None:
173 if not isinstance(value, list):
174 return None
175 filtered = [item for item in value if isinstance(item, str) and item]
176 return filtered or None
177
178 refs: list[dict[str, Any]] = []
179 for artifact in manifest.get("source_artifacts", []):
180 if not isinstance(artifact, dict):
181 continue
182 ref: dict[str, Any] = {}
183 for key in (
184 "role",
185 "path",
186 "name",
187 "sha256",
188 "artifact_checksum",
189 "week",
190 "crawled_at",
191 "generated_at",
192 "source_status",
193 "source_config_checksum",
194 "schema_checksum",
195 ):
196 value = artifact.get(key)
197 if isinstance(value, str) and value:
198 ref[key] = value
199 exists = artifact.get("exists")
200 if isinstance(exists, bool):
201 ref["exists"] = exists
202 size_bytes = artifact.get("size_bytes")
203 if isinstance(size_bytes, int) and not isinstance(size_bytes, bool) and size_bytes >= 0:
204 ref["size_bytes"] = size_bytes
205 for key in ("url", "href", "uri"):
206 value = artifact.get(key)
207 if isinstance(value, str) and value.startswith(
208 ("https://", "http://localhost:", "http://127.0.0.1:")
209 ):
210 ref[key] = value
211 artifact_url = artifact.get("artifact_url")
212 if (
213 "url" not in ref
214 and isinstance(artifact_url, str)
215 and artifact_url.startswith(("https://", "http://localhost:", "http://127.0.0.1:"))
216 ):
217 ref["url"] = artifact_url
218 for key in (
219 "freshness",
220 "provenance",
221 "same_day_reuse",
222 "source_artifact_provenance",
223 "source_reuse_summary",
224 ):
225 value = artifact.get(key)
226 if isinstance(value, dict):
227 ref[key] = value
228 for key in ("sources_requested", "sources_succeeded", "sources_failed"):
229 filtered = _string_list(artifact.get(key))
230 if filtered is not None:
231 ref[key] = filtered
232 if ref:
233 refs.append(ref)
234 return refs
235
236
237 def _extract_title(content: str) -> str | None:
238 """Extract article title from YAML front matter or first # heading."""
239 # Try YAML front matter first
240 if content.startswith("---"):
241 end = content.find("\n---", 3)
242 if end != -1:
243 frontmatter = content[3:end]
244 match = re.search(r"^title:\s*(.+)$", frontmatter, re.MULTILINE)
245 if match:
246 title = match.group(1).strip().strip("\"'")
247 if title:
248 return title
249 # Fall back to first # heading
250 match = re.search(r"^#\s+(.+)$", content, re.MULTILINE)
251 if match:
252 return match.group(1).strip()
253 return None
254
255
256 def _extract_frontmatter_field(content: str, field_name: str) -> str | None:
257 if not content.startswith("---"):
258 return None
259 end = content.find("\n---", 3)
260 if end == -1:
261 return None
262 frontmatter = content[3:end]
263 match = re.search(rf"^{re.escape(field_name)}:\s*(.+)$", frontmatter, re.MULTILINE)
264 if not match:
265 return None
266 value = match.group(1).strip().strip("\"'")
267 return value or None
268
269
270 def _render_template_value(value: Any, context: dict[str, Any]) -> Any:
271 if isinstance(value, dict):
272 return {key: _render_template_value(item, context) for key, item in value.items()}
273 if isinstance(value, list):
274 return [_render_template_value(item, context) for item in value]
275 if not isinstance(value, str):
276 return value
277 exact_match = re.fullmatch(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}", value)
278 if exact_match:
279 key = exact_match.group(1)
280 if key in context:
281 return context[key]
282 try:
283 return value.format(**context)
284 except KeyError as exc:
285 missing = exc.args[0]
286 raise PodcasterHandoffError(
287 f"spotify_publish template references unknown field: {missing}"
288 ) from exc
289 except (ValueError, IndexError) as exc:
290 raise PodcasterHandoffError(
291 f"spotify_publish template has invalid format syntax: {value!r}"
292 ) from exc
293
294
295 class _HTMLTruncator(HTMLParser):
296 def __init__(self, max_length: int) -> None:
297 super().__init__(convert_charrefs=False)
298 self.max_length = max_length
299 self.parts: list[str] = []
300 self.open_tags: list[str] = []
301 self.current_length = 0
302 self.truncated = False
303
304 def handle_starttag(self, tag: str, attrs) -> None: # type: ignore[override]
305 self._append_tag(self.get_starttag_text(), tag, push=True)
306
307 def handle_startendtag(self, tag: str, attrs) -> None: # type: ignore[override]
308 self._append_tag(self.get_starttag_text(), tag, push=False)
309
310 def handle_endtag(self, tag: str) -> None: # type: ignore[override]
311 normalized = tag.lower()
312 if self.truncated or normalized not in self.open_tags:
313 return
314
315 closings: list[str] = []
316 while self.open_tags:
317 open_tag = self.open_tags.pop()
318 closings.append(f"</{open_tag}>")
319 if open_tag == normalized:
320 break
321
322 for closing in closings:
323 self._append(closing)
324
325 def handle_data(self, data: str) -> None:
326 self._append_text(data)
327
328 def handle_entityref(self, name: str) -> None:
329 self._append_atomic(f"&{name};")
330
331 def handle_charref(self, name: str) -> None:
332 self._append_atomic(f"&#{name};")
333
334 def handle_comment(self, data: str) -> None:
335 self._append_atomic(f"<!--{data}-->")
336
337 def _append_tag(self, raw_tag: str | None, tag: str, *, push: bool) -> None:
338 if self.truncated or not raw_tag:
339 return
340
341 normalized = tag.lower()
342 budget = self._closing_budget(extra_tag=normalized if push else None)
343 if self.current_length + len(raw_tag) + budget > self.max_length:
344 self.truncated = True
345 return
346
347 self._append(raw_tag)
348 if push and normalized not in _VOID_HTML_TAGS:
349 self.open_tags.append(normalized)
350
351 def _append_atomic(self, token: str) -> None:
352 if self.truncated or not token:
353 return
354 available = self.max_length - self.current_length - self._closing_budget()
355 if len(token) > available:
356 self.truncated = True
357 return
358 self._append(token)
359
360 def _append_text(self, text: str) -> None:
361 if self.truncated or not text:
362 return
363
364 available = self.max_length - self.current_length - self._closing_budget()
365 if available <= 0:
366 self.truncated = True
367 return
368
369 piece = text[:available]
370 if piece:
371 self._append(piece)
372 if len(piece) < len(text):
373 self.truncated = True
374
375 def _closing_budget(self, *, extra_tag: str | None = None) -> int:
376 budget = sum(len(f"</{tag}>") for tag in self.open_tags)
377 if extra_tag and extra_tag not in _VOID_HTML_TAGS:
378 budget += len(f"</{extra_tag}>")
379 return budget
380
381 def _append(self, text: str) -> None:
382 self.parts.append(text)
383 self.current_length += len(text)
384
385 def finish(self) -> str:
386 for tag in reversed(self.open_tags):
387 self._append(f"</{tag}>")
388 return "".join(self.parts)
389
390
391 def truncate_html(value: str, limit: int) -> str:
392 if len(value) <= limit:
393 return value
394
395 truncator = _HTMLTruncator(limit)
396 truncator.feed(value)
397 truncator.close()
398 return truncator.finish()
399
400
401 def _truncate_text(value: str, limit: int) -> str:
402 return value[:limit]
403
404
405 def _truncate_words(value: str, limit: int) -> str:
406 words = value.split()
407 return " ".join(words[:limit])
408
409
410 def _strip_frontmatter(content: str) -> str:
411 if not content.startswith("---"):
412 return content.strip()
413 end = content.find("\n---", 3)
414 if end == -1:
415 return content.strip()
416 return content[end + 4 :].strip()
417
418
419 def _extract_markdown_sections(content: str, headings: tuple[str, ...]) -> str | None:
420 body = _strip_frontmatter(content)
421 sections: list[str] = []
422 for heading in headings:
423 match = re.search(
424 rf"^##\s+{re.escape(heading)}\s*$\n?(.*?)(?=^##\s+|\Z)",
425 body,
426 re.MULTILINE | re.DOTALL,
427 )
428 if not match:
429 continue
430 section_body = match.group(1).strip()
431 section = f"## {heading}"
432 if section_body:
433 section += f"\n\n{section_body}"
434 sections.append(section)
435 combined = "\n\n".join(sections).strip()
436 return combined or None
437
438
439 def _read_historical_context(week: str, repo_root: Path) -> dict[str, str] | None:
440 match = re.fullmatch(r"(?P<year>\d{4})-W(?P<week>\d{1,2})", week)
441 if not match:
442 raise PodcasterHandoffError(
443 f"Week must use YYYY-WNN format for historical context lookup: {week}"
444 )
445
446 year = int(match.group("year"))
447 week_number = int(match.group("week"))
448 monday = date.fromisocalendar(year, week_number, 1)
449
450 month_synthesis_path = (
451 repo_root / "data" / "analyzed" / f"{year}-{monday.month:02d}-month-synthesis.md"
452 )
453 yearly_narrative_path = repo_root / "content" / "yearly" / f"{year}.md"
454
455 historical_context: dict[str, str] = {}
456
457 if month_synthesis_path.exists():
458 try:
459 month_synthesis = month_synthesis_path.read_text(encoding="utf-8")
460 except OSError as exc:
461 raise PodcasterHandoffError(
462 f"Month synthesis file exists but could not be read: {month_synthesis_path} ({exc})"
463 ) from exc
464 extracted_sections = _extract_markdown_sections(
465 month_synthesis, ("Month Synthesis", "Trend Arc")
466 )
467 if extracted_sections:
468 historical_context["month_synthesis"] = _truncate_words(
469 extracted_sections,
470 MAX_MONTH_SYNTHESIS_WORDS,
471 )
472
473 if yearly_narrative_path.exists():
474 try:
475 yearly_narrative = yearly_narrative_path.read_text(encoding="utf-8")
476 except OSError as exc:
477 raise PodcasterHandoffError(
478 f"Yearly narrative file exists but could not be read: {yearly_narrative_path} ({exc})"
479 ) from exc
480 extracted_yearly = _extract_markdown_sections(yearly_narrative, ("Year in Review",))
481 if extracted_yearly:
482 historical_context["yearly_narrative"] = _truncate_words(
483 extracted_yearly,
484 MAX_YEARLY_NARRATIVE_WORDS,
485 )
486
487 return historical_context or None
488
489
490 def _resolve_spotify_publish(
491 config: dict[str, Any], *, week: str, article_title: str | None, article_summary: str | None
492 ) -> dict[str, Any]:
493 """Render spotify_publish templates into concrete values for the Podcaster API.
494
495 Design: SquadScope resolves templates (title_template, description_template)
496 into final strings before sending. Podcaster receives ready-to-use metadata,
497 not raw templates — this keeps rendering logic in the source-of-truth repo.
498 """
499 match = re.fullmatch(r"(?P<year>\d{4})-W(?P<week>\d{1,2})", week)
500 if not match:
501 raise PodcasterHandoffError(
502 f"Week must use YYYY-WNN format for spotify_publish templating: {week}"
503 )
504 context: dict[str, Any] = {
505 "year": int(match.group("year")),
506 "week": int(match.group("week")),
507 "article_title": article_title or "",
508 "article_summary": article_summary or "",
509 }
510 resolved = _render_template_value(config, context)
511 title = resolved.pop("title_template", None)
512 if isinstance(title, str):
513 resolved["title"] = _truncate_text(title, MAX_SPOTIFY_TITLE_CHARS)
514 elif title is not None:
515 resolved["title"] = title
516 description = resolved.pop("description_template", None)
517 if isinstance(description, str):
518 resolved["description"] = truncate_html(description, MAX_SPOTIFY_DESCRIPTION_CHARS)
519 elif description is not None:
520 resolved["description"] = description
521 return resolved
522
523
524 def _read_article_content(
525 article_path: str, repo_root: Path = REPO_ROOT
526 ) -> tuple[str | None, str | None, str | None]:
527 """Read article file content and extract title.
528
529 Returns (content, title, summary). Content is truncated to MAX_ARTICLE_CONTENT_CHARS.
530 Returns (None, None, None) if the file does not exist.
531 Raises PodcasterHandoffError if the file exists but cannot be read, or if
532 the resolved path escapes the repo root (path traversal prevention).
533 """
534 resolved = (repo_root / article_path).resolve()
535 # Prevent path traversal — resolved path must stay within repo_root.
536 try:
537 resolved.relative_to(repo_root.resolve())
538 except ValueError:
539 raise PodcasterHandoffError(
540 f"article_path resolves outside the repository root: {article_path}"
541 )
542 if not resolved.exists():
543 return None, None, None
544 try:
545 content = resolved.read_text(encoding="utf-8")
546 except OSError as exc:
547 raise PodcasterHandoffError(
548 f"Article file exists but could not be read: {resolved} ({exc})"
549 )
550 if not content.strip():
551 return None, None, None
552 title = _extract_frontmatter_field(content, "title") or _extract_title(content)
553 summary = _extract_frontmatter_field(content, "summary")
554 if len(content) > MAX_ARTICLE_CONTENT_CHARS:
555 content = content[:MAX_ARTICLE_CONTENT_CHARS]
556 return content, title, summary
557
558
559 def verify_article_merged(
560 article_path: str,
561 manifest: dict[str, Any],
562 *,
563 repo_root: Path = REPO_ROOT,
564 ) -> str:
565 """Fail closed unless the merged article matches the manifest checksum.
566
567 Verifies the article file exists locally (i.e. the weekly article has been
568 merged to main) and that its sha256 matches the manifest
569 candidate.content_sha256. Returns the verified sha256. Raises
570 PodcasterHandoffError otherwise so the podcaster is never triggered for a
571 missing/stub article before the article merge is complete.
572 """
573 candidate = manifest.get("candidate") if isinstance(manifest, dict) else None
574 expected = candidate.get("content_sha256") if isinstance(candidate, dict) else None
575 if not isinstance(expected, str) or not re.fullmatch(r"[0-9a-f]{64}", expected):
576 raise PodcasterHandoffError(
577 "Manifest lacks a valid candidate.content_sha256; cannot verify the merged article."
578 )
579 resolved = (repo_root / article_path).resolve()
580 try:
581 resolved.relative_to(repo_root.resolve())
582 except ValueError:
583 raise PodcasterHandoffError(
584 f"article_path resolves outside the repository root: {article_path}"
585 )
586 if not resolved.is_file():
587 raise PodcasterHandoffError(
588 f"Article not merged yet: {article_path} is not present. "
589 "Trigger the handoff only after the weekly article is merged to main."
590 )
591 try:
592 actual = hashlib.sha256(resolved.read_bytes()).hexdigest()
593 except OSError as exc:
594 raise PodcasterHandoffError(f"Could not read merged article {article_path}: {exc}") from exc
595 if actual != expected:
596 raise PodcasterHandoffError(
597 f"Merged article sha256 mismatch for {article_path}: expected {expected}, got {actual}."
598 )
599 return actual
600
601
602 def _manifest_allows_handoff(manifest: dict[str, Any], *, week: str, publish_mode: str) -> bool:
603 if not manifest:
604 return True
605 analysis = manifest.get("analysis")
606 promotion = manifest.get("promotion")
607 return (
608 manifest.get("week") == week
609 and (manifest.get("run_mode") == "normal" or _is_audited_force_replace(manifest))
610 and publish_mode == "normal"
611 and isinstance(analysis, dict)
612 and analysis.get("ai_status") == "ai"
613 and isinstance(promotion, dict)
614 and promotion.get("eligible") is True
615 and promotion.get("decision") == "promote"
616 )
617
618
619 def _is_audited_force_replace(manifest: dict[str, Any]) -> bool:
620 promotion = manifest.get("promotion")
621 audit = manifest.get("audit")
622 return (
623 isinstance(promotion, dict)
624 and promotion.get("policy") == "force-replace"
625 and isinstance(audit, dict)
626 and bool(audit.get("actor"))
627 and bool(audit.get("reason"))
628 )
629
630
631 def _is_gated_replay(manifest: dict[str, Any], *, week: str) -> bool:
632 """Well-formed, promotion-eligible manifest that is deliberately excluded from
633 handoff (a plain, non-audited ``restore`` replay). Such a manifest is a clean
634 skip, not an error. Anything else -- malformed manifests, a missing/unknown
635 run_mode, or other non-normal modes -- returns False here and stays fail-closed
636 in build_payload, so a genuinely broken manifest is never silently skipped.
637 """
638 if not manifest:
639 return False
640 analysis = manifest.get("analysis")
641 promotion = manifest.get("promotion")
642 well_formed_promotable = (
643 manifest.get("week") == week
644 and isinstance(analysis, dict)
645 and analysis.get("ai_status") == "ai"
646 and isinstance(promotion, dict)
647 and promotion.get("eligible") is True
648 and promotion.get("decision") == "promote"
649 )
650 gated_mode = manifest.get("run_mode") == "restore" and not _is_audited_force_replace(manifest)
651 return well_formed_promotable and gated_mode
652
653
654 def build_payload(
655 *,
656 week: str,
657 article_url: str,
658 article_path: str,
659 publish_run_id: str,
660 publish_mode: str = "normal",
661 manifest_path: Path | None = None,
662 podcast_config_path: Path | None = None,
663 podcaster_dry_run: bool = False,
664 repo_root: Path | None = None,
665 breaking_news: str | None = None,
666 require_merged: bool = False,
667 manifest: dict[str, Any] | None = None,
668 ) -> dict[str, Any]:
669 if manifest is None:
670 manifest = _load_manifest(manifest_path)
671 if not _manifest_allows_handoff(manifest, week=week, publish_mode=publish_mode):
672 raise PodcasterHandoffError("Publish manifest is not eligible for Podcaster handoff.")
673 normalized_path = normalize_page_path(article_path)
674 root = repo_root if repo_root is not None else REPO_ROOT
675 if require_merged:
676 verify_article_merged(normalized_path, manifest, repo_root=root)
677 payload: dict[str, Any] = {
678 "week": week,
679 "article_url": article_url,
680 "article_path": normalized_path,
681 "publish_run_id": publish_run_id,
682 "publish_mode": publish_mode,
683 }
684
685 # Read article content and extract title
686 content, title, summary = _read_article_content(normalized_path, repo_root=root)
687 if content:
688 payload["article_content"] = content
689 if title:
690 payload["article_title"] = title
691 if summary:
692 payload["article_summary"] = summary
693 candidate = manifest.get("candidate")
694 article_sha = None
695 if isinstance(candidate, dict):
696 article_sha = candidate.get("content_sha256") or candidate.get("summary_sha256")
697 if (
698 isinstance(article_sha, str)
699 and len(article_sha) == 64
700 and article_sha.lower() == article_sha
701 ):
702 payload["article_sha256"] = article_sha
703 source_refs = _source_artifact_refs(manifest)
704 if source_refs:
705 payload["source_artifacts"] = source_refs
706
707 podcast_cfg = _load_podcast_config(podcast_config_path)
708 if "podcast_config" in podcast_cfg:
709 val = podcast_cfg["podcast_config"]
710 if not isinstance(val, dict):
711 raise PodcasterHandoffError("podcast_config must be a JSON object")
712 payload["podcast_config"] = val
713 if "script_directions" in podcast_cfg:
714 val = podcast_cfg["script_directions"]
715 if not isinstance(val, dict):
716 raise PodcasterHandoffError("script_directions must be a JSON object")
717 payload["script_directions"] = val
718 historical_context = _read_historical_context(week, root)
719 if historical_context:
720 if "script_directions" not in payload:
721 payload["script_directions"] = {}
722 payload["script_directions"]["historical_context"] = historical_context
723 if "spotify_publish" in podcast_cfg:
724 val = podcast_cfg["spotify_publish"]
725 if not isinstance(val, dict):
726 raise PodcasterHandoffError("spotify_publish must be a JSON object")
727 payload["spotify_publish"] = _resolve_spotify_publish(
728 val,
729 week=week,
730 article_title=title,
731 article_summary=summary,
732 )
733
734 if breaking_news:
735 payload["breaking_news"] = breaking_news
736
737 if podcaster_dry_run:
738 payload["dry_run"] = True
739 return payload
740
741
742 SUCCESS_RESPONSE_STATUSES = {"accepted", "dry_run"}
743
744
745 def validate_response(payload: Any) -> dict[str, Any]:
746 if not isinstance(payload, dict):
747 raise PodcasterHandoffError("Podcaster response must be a JSON object.")
748 status = payload.get("status")
749 errors = payload.get("errors", [])
750 if status not in SUCCESS_RESPONSE_STATUSES:
751 raise PodcasterHandoffError(
752 f"Podcaster response status was not a known success status "
753 f"(expected one of {sorted(SUCCESS_RESPONSE_STATUSES)}): {status!r}."
754 )
755 if isinstance(errors, list) and errors:
756 raise PodcasterHandoffError("Podcaster response contained errors.")
757 if errors not in ([], None) and not isinstance(errors, list):
758 raise PodcasterHandoffError("Podcaster response errors field must be a list when present.")
759 if not isinstance(payload.get("job_id"), str) or not payload["job_id"].strip():
760 raise PodcasterHandoffError("Podcaster response is missing job_id.")
761 return payload
762
763
764 def post_handoff(
765 endpoint: str, api_key: str, payload: dict[str, Any], *, timeout: int = DEFAULT_TIMEOUT_SECONDS
766 ) -> dict[str, Any]:
767 validate_endpoint(endpoint)
768 body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
769 req = request.Request(
770 endpoint,
771 data=body,
772 method="POST",
773 headers={
774 "Content-Type": "application/json",
775 AUTH_HEADER: api_key,
776 "User-Agent": "SquadScope-Podcaster-Handoff/1.0",
777 },
778 )
779 try:
780 with request.urlopen(req, timeout=timeout) as response: # nosec B310
781 status_code = getattr(response, "status", response.getcode())
782 response_body = response.read().decode("utf-8")
783 except error.HTTPError as exc:
784 try:
785 raw = exc.read(1024)
786 error_body = raw.decode("utf-8", errors="replace")
787 # Sanitize for GitHub Actions: strip workflow-command sequences and newlines
788 error_body = error_body.replace("::", "").replace("\r", " ").replace("\n", " ")
789 except Exception:
790 error_body = "<unreadable>"
791 raise PodcasterHandoffError(
792 f"Podcaster handoff failed with HTTP {exc.code}. Response body: {error_body}"
793 ) from exc
794 except error.URLError as exc:
795 raise PodcasterHandoffError(f"Podcaster handoff failed: {exc.reason}") from exc
796
797 if status_code < 200 or status_code >= 300:
798 raise PodcasterHandoffError(f"Podcaster handoff failed with HTTP {status_code}.")
799 try:
800 response_payload = json.loads(response_body)
801 except json.JSONDecodeError as exc:
802 raise PodcasterHandoffError("Podcaster response was not valid JSON.") from exc
803 return validate_response(response_payload)
804
805
806 def main(argv: list[str] | None = None) -> int:
807 args = parse_args(argv)
808 endpoint = args.endpoint.strip()
809 api_key = os.environ.get("PODCASTER_API_KEY", "").strip()
810 if not endpoint or not api_key:
811 print(
812 "::notice::Podcaster handoff skipped because PODCASTER_ENDPOINT and PODCASTER_API_KEY are not both configured."
813 )
814 return 0
815
816 if args.publish_mode != "normal":
817 print(f"::notice::Podcaster handoff skipped for publish mode {args.publish_mode}.")
818 return 0
819
820 try:
821 manifest = _load_manifest(args.manifest)
822 except PodcasterHandoffError as exc:
823 print(f"::error::Podcaster handoff failed: {exc}")
824 return 1
825 if _is_gated_replay(manifest, week=args.week):
826 print(
827 "::notice::Podcaster handoff skipped: publish manifest is a non-audited replay "
828 "(not eligible for handoff)."
829 )
830 return 0
831
832 try:
833 payload = build_payload(
834 week=args.week,
835 article_url=args.article_url,
836 article_path=args.article_path,
837 publish_run_id=args.publish_run_id,
838 publish_mode=args.publish_mode,
839 manifest_path=args.manifest,
840 podcast_config_path=args.podcast_config,
841 podcaster_dry_run=args.podcaster_dry_run,
842 breaking_news=args.breaking_news,
843 require_merged=args.require_merged,
844 manifest=manifest,
845 )
846 response = post_handoff(endpoint, api_key, payload, timeout=args.timeout)
847 except PodcasterHandoffError as exc:
848 print(f"::error::Podcaster handoff failed: {exc}")
849 return 1
850 job_id = _escape_gha_data(str(response.get("job_id", "")))
851 status = _escape_gha_data(str(response.get("status", "")))
852 print(f"::notice::Podcaster handoff completed (job_id={job_id}, status={status}).")
853 return 0
854
855
856 if __name__ == "__main__":
857 raise SystemExit(main())