diff --git a/.github/workflows/podcast.yml b/.github/workflows/podcast.yml
new file mode 100644
index 0000000..6a11a1f
--- /dev/null
+++ b/.github/workflows/podcast.yml
@@ -0,0 +1,42 @@
+name: Daily Podcast Generation
+
+on:
+ schedule:
+ # 08:45 ET (12:45 UTC) — after daily update finishes
+ - cron: '45 12 * * 1-5'
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+jobs:
+ podcast:
+ runs-on: ubuntu-latest
+ env:
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+ ANTHROPIC_MODEL: ${{ vars.ANTHROPIC_MODEL || 'claude-sonnet-4-5' }}
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+
+ - name: Install dependencies
+ run: pip install anthropic openai
+
+ - name: Install ffmpeg
+ run: sudo apt-get install -y ffmpeg
+
+ - name: Generate podcast
+ run: python scripts/generate_podcast.py
+
+ - name: Commit audio
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git add audio/
+ git diff --cached --quiet || git commit -m "podcast: daily audio $(date -u +'%Y-%m-%d')"
+ git push
diff --git a/audio/.gitkeep b/audio/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/index.html b/index.html
index 2fa4b38..32be596 100644
--- a/index.html
+++ b/index.html
@@ -674,6 +674,111 @@
+
+
diff --git a/scripts/generate_podcast.py b/scripts/generate_podcast.py
new file mode 100644
index 0000000..3bf348a
--- /dev/null
+++ b/scripts/generate_podcast.py
@@ -0,0 +1,248 @@
+#!/usr/bin/env python3
+"""
+金融日报 — Daily Podcast Generator
+每天用 Claude 生成播客脚本,OpenAI TTS 合成双主持人音频
+"""
+
+import os, sys, json, datetime, subprocess, tempfile, shutil
+from pathlib import Path
+
+import anthropic
+from openai import OpenAI
+
+ROOT = Path(__file__).resolve().parent.parent
+DATA = ROOT / "data"
+AUDIO = ROOT / "audio"
+AUDIO.mkdir(exist_ok=True)
+
+CLAUDE_MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-5")
+# Host voices: alloy=女主持, echo=男主持
+VOICE_A = "shimmer" # 女主持(活泼)
+VOICE_B = "echo" # 男主持(沉稳)
+
+MAX_SCRIPT_TOKENS = 2000 # 控制音频时长约 3-4 分钟
+
+SCRIPT_SYSTEM = """你是一档面向北美华人投资者的金融播客节目的编剧。
+节目名:《金融日报早报》,双主持人对话风格。
+
+主持人设定:
+- 甲(欣欣):女性,活泼、亲切,善于把复杂数据说成大白话,偶尔幽默
+- 乙(建国):男性,沉稳、专业,擅长宏观分析,会在关键处泼冷水
+
+规则:
+1. 输出纯文本,格式严格为:每行以 [欣欣] 或 [建国] 开头
+2. 总长度控制在 600-800 字,约 3-4 分钟播放
+3. 开头10秒必须抓住注意力(重大结论或反常现象)
+4. 覆盖:今日大势、1-2条核心新闻、1个AI推荐标的、结尾一句行动建议
+5. 说话自然、口语化,不念数字时不念小数点后太多位
+6. 结尾固定:欣欣说"我是欣欣",建国说"我是建国",一起说"我们明天见"
+"""
+
+
+def load_today_data() -> dict:
+ """Load today's market data JSON"""
+ today = datetime.datetime.now().strftime("%Y-%m-%d")
+ path = DATA / f"{today}.json"
+ if not path.exists():
+ path = DATA / "latest.json"
+ if not path.exists():
+ print("❌ No data file found")
+ sys.exit(1)
+ with open(path, encoding="utf-8") as f:
+ return json.load(f)
+
+
+def build_context(data: dict) -> str:
+ """Summarize market data into a compact context for script generation"""
+ lines = []
+
+ # Date
+ lines.append(f"日期:{data.get('date', '今日')}")
+
+ # Summary
+ s = data.get("summary", {})
+ if s:
+ lines.append(f"\n今日大势:{s.get('headline','')}({s.get('sentiment','')},VIX {s.get('vix','')})")
+ kp = s.get("key_points", [])
+ if kp:
+ lines.append("关键点:" + " / ".join(kp[:3]))
+
+ # Top indices
+ indices = data.get("indices", [])[:6]
+ if indices:
+ lines.append("\n主要指数:")
+ for i in indices:
+ lines.append(f" {i['name']} {i['value']} {i.get('change_pct','')}")
+
+ # Top 3 news
+ news = data.get("news", [])[:4]
+ if news:
+ lines.append("\n重大新闻:")
+ for n in news:
+ lines.append(f" [{n.get('importance','').upper()}] {n['title']}")
+ if n.get("tldr"):
+ lines.append(f" → {n['tldr']}")
+
+ # AI watchlist top pick
+ wl = data.get("watchlist_analysis", [])
+ if wl:
+ # Find BUY with highest score
+ buys = [x for x in wl if x.get("signal") == "BUY"]
+ top = sorted(buys, key=lambda x: x.get("score", 0), reverse=True)[:1] or wl[:1]
+ if top:
+ t = top[0]
+ lines.append(f"\nAI今日推荐:{t['name']}({t['ticker']}) 信号:{t.get('signal','')} 评分:{t.get('score','')} — {t.get('conclusion','')}")
+ lines.append(f" 目标位: ↑{t.get('target_up','')} ↓{t.get('target_down','')} 风险:{t.get('risk_level','')}")
+
+ # Market debate verdict
+ debate = data.get("market_debate", {})
+ if debate:
+ lines.append(f"\n多空裁判:{debate.get('verdict_lean','').upper()} — {debate.get('verdict','')}")
+
+ # 1 macro indicator
+ macro = data.get("macro", [])[:2]
+ if macro:
+ lines.append("\n宏观数据:")
+ for m in macro:
+ lines.append(f" {m['indicator']} {m['value']}(前值{m.get('prev','')}){m.get('description','')[:40]}")
+
+ return "\n".join(lines)
+
+
+def generate_script(context: str, client_claude) -> str:
+ """Use Claude to write the podcast script"""
+ print("📝 Generating podcast script with Claude...")
+ resp = client_claude.messages.create(
+ model=CLAUDE_MODEL,
+ max_tokens=MAX_SCRIPT_TOKENS,
+ system=SCRIPT_SYSTEM,
+ messages=[{
+ "role": "user",
+ "content": f"请根据以下今日市场数据,生成今天的《金融日报早报》播客脚本:\n\n{context}"
+ }]
+ )
+ script = resp.content[0].text.strip()
+ print(f" ✅ Script: {len(script)} chars")
+ return script
+
+
+def parse_script(script: str) -> list[tuple[str, str]]:
+ """Parse [欣欣] / [建国] lines → [(speaker, text), ...]"""
+ segments = []
+ for line in script.split("\n"):
+ line = line.strip()
+ if not line:
+ continue
+ if line.startswith("[欣欣]"):
+ segments.append(("A", line[4:].strip()))
+ elif line.startswith("[建国]"):
+ segments.append(("B", line[4:].strip()))
+ return segments
+
+
+def synthesize_segment(text: str, voice: str, client_oai, outpath: Path):
+ """Synthesize one TTS segment"""
+ resp = client_oai.audio.speech.create(
+ model="tts-1",
+ voice=voice,
+ input=text,
+ response_format="mp3",
+ speed=1.05,
+ )
+ resp.stream_to_file(outpath)
+
+
+def merge_audio(segment_files: list[Path], output: Path):
+ """Concatenate MP3 files using ffmpeg"""
+ if not shutil.which("ffmpeg"):
+ # Fallback: just copy first segment
+ shutil.copy(segment_files[0], output)
+ print(" ⚠️ ffmpeg not found, using first segment only")
+ return
+ # Create concat list
+ with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as f:
+ for seg in segment_files:
+ f.write(f"file '{seg.resolve()}'\n")
+ list_file = f.name
+ subprocess.run(
+ ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", list_file,
+ "-c", "copy", str(output)],
+ check=True, capture_output=True
+ )
+ os.unlink(list_file)
+
+
+def main():
+ print("=" * 60)
+ print("🎙️ 金融日报 — Podcast Generator")
+ print("=" * 60)
+
+ # API clients
+ anthropic_key = os.environ.get("ANTHROPIC_API_KEY")
+ openai_key = os.environ.get("OPENAI_API_KEY")
+ if not anthropic_key:
+ print("❌ ANTHROPIC_API_KEY not set"); sys.exit(1)
+ if not openai_key:
+ print("❌ OPENAI_API_KEY not set"); sys.exit(1)
+
+ claude = anthropic.Anthropic(api_key=anthropic_key)
+ oai = OpenAI(api_key=openai_key)
+
+ # Load data
+ data = load_today_data()
+ context = build_context(data)
+ print(f"📊 Loaded market data: {data.get('date','')}")
+
+ # Generate script
+ script = generate_script(context, claude)
+ segments = parse_script(script)
+ print(f" 🎬 {len(segments)} dialogue segments")
+
+ if not segments:
+ print("❌ No dialogue segments parsed"); sys.exit(1)
+
+ # Save script
+ today = datetime.datetime.now().strftime("%Y-%m-%d")
+ script_path = AUDIO / f"{today}-script.txt"
+ script_path.write_text(script, encoding="utf-8")
+ print(f" 💾 Script saved: audio/{today}-script.txt")
+
+ # TTS synthesis
+ print("\n🔊 Synthesizing audio...")
+ tmpdir = Path(tempfile.mkdtemp())
+ seg_files = []
+ for i, (speaker, text) in enumerate(segments):
+ if not text: continue
+ voice = VOICE_A if speaker == "A" else VOICE_B
+ outf = tmpdir / f"seg_{i:03d}.mp3"
+ print(f" [{i+1}/{len(segments)}] {'欣欣' if speaker=='A' else '建国'}: {text[:40]}...")
+ synthesize_segment(text, voice, oai, outf)
+ seg_files.append(outf)
+
+ # Merge
+ print("\n🔀 Merging audio segments...")
+ final_mp3 = AUDIO / f"{today}.mp3"
+ latest_mp3 = AUDIO / "latest.mp3"
+ merge_audio(seg_files, final_mp3)
+ shutil.copy(final_mp3, latest_mp3)
+ shutil.rmtree(tmpdir)
+
+ size_kb = final_mp3.stat().st_size // 1024
+ print(f" ✅ Audio: audio/{today}.mp3 ({size_kb} KB)")
+
+ # Save metadata
+ meta = {
+ "date": today,
+ "script_chars": len(script),
+ "segments": len(segments),
+ "audio_kb": size_kb,
+ "generated_at": datetime.datetime.utcnow().isoformat() + "Z"
+ }
+ (AUDIO / "latest-meta.json").write_text(json.dumps(meta, ensure_ascii=False, indent=2))
+
+ print(f"\n🎉 Done! Podcast ready: audio/{today}.mp3")
+ print("=" * 60)
+
+
+if __name__ == "__main__":
+ main()