feat: daily podcast player (OpenAI TTS, 欣欣+建国 双主持)
This commit is contained in:
@@ -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
|
||||
+105
@@ -674,6 +674,111 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- PODCAST PLAYER -->
|
||||
<div class="podcast-bar" id="podcast-bar">
|
||||
<div class="container">
|
||||
<div class="podcast-inner">
|
||||
<div class="podcast-left">
|
||||
<span class="podcast-icon">🎙️</span>
|
||||
<div class="podcast-info">
|
||||
<div class="podcast-title">金融日报早报 · 今日播客</div>
|
||||
<div class="podcast-sub" id="podcast-date">欣欣 & 建国 · 约3分钟</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="podcast-controls">
|
||||
<audio id="podcast-audio" preload="none">
|
||||
<source src="audio/latest.mp3" type="audio/mpeg">
|
||||
</audio>
|
||||
<button class="pod-btn" id="pod-play" onclick="togglePodcast()">▶ 播放</button>
|
||||
<div class="pod-progress-wrap" onclick="seekPodcast(event)">
|
||||
<div class="pod-progress-bar" id="pod-progress"></div>
|
||||
</div>
|
||||
<span class="pod-time" id="pod-time">0:00</span>
|
||||
<button class="pod-btn pod-btn-sm" onclick="document.getElementById('podcast-audio').playbackRate=document.getElementById('podcast-audio').playbackRate===1?1.5:1;this.textContent=document.getElementById('podcast-audio').playbackRate+'x'">1x</button>
|
||||
<a class="pod-btn pod-btn-sm" href="audio/latest.mp3" download="金融日报早报.mp3" title="下载">⬇</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.podcast-bar {
|
||||
background: linear-gradient(90deg, rgba(0,212,170,.08), rgba(79,195,247,.05));
|
||||
border-bottom: 1px solid rgba(0,212,170,.2);
|
||||
padding: 10px 0;
|
||||
}
|
||||
.podcast-inner { display:flex; align-items:center; justify-content:space-between; gap:14px; flex-wrap:wrap; }
|
||||
.podcast-left { display:flex; align-items:center; gap:10px; }
|
||||
.podcast-icon { font-size:1.4rem; animation: pulse-pod 3s ease-in-out infinite; }
|
||||
@keyframes pulse-pod { 0%,100%{transform:scale(1)} 50%{transform:scale(1.1)} }
|
||||
.podcast-title { font-size:.82rem; font-weight:600; color:var(--text); }
|
||||
.podcast-sub { font-size:.65rem; color:var(--muted); font-family:var(--mono); margin-top:1px; }
|
||||
.podcast-controls { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
|
||||
.pod-btn {
|
||||
background: rgba(0,212,170,.15); color:var(--accent);
|
||||
border: 1px solid rgba(0,212,170,.3); border-radius:6px;
|
||||
padding: 5px 14px; font-size:.75rem; cursor:pointer;
|
||||
font-family:var(--sans); transition:background .15s; white-space:nowrap;
|
||||
}
|
||||
.pod-btn:hover { background:rgba(0,212,170,.25); }
|
||||
.pod-btn.playing { background:rgba(255,82,82,.12); color:var(--down); border-color:rgba(255,82,82,.3); }
|
||||
.pod-btn-sm { padding:5px 9px; font-size:.68rem; }
|
||||
.pod-btn-sm[download] { color:var(--muted); background:rgba(255,255,255,.05); border-color:var(--border); text-decoration:none; }
|
||||
.pod-progress-wrap { width:160px; height:4px; background:var(--border); border-radius:2px; cursor:pointer; }
|
||||
@media(max-width:640px){ .pod-progress-wrap { width:80px; } }
|
||||
.pod-progress-bar { height:100%; background:var(--accent); border-radius:2px; width:0%; transition:width .3s linear; }
|
||||
.pod-time { font-family:var(--mono); font-size:.65rem; color:var(--muted); min-width:32px; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var audio = document.getElementById('podcast-audio');
|
||||
var btn = document.getElementById('pod-play');
|
||||
var bar = document.getElementById('pod-progress');
|
||||
var time = document.getElementById('pod-time');
|
||||
if (!audio) return;
|
||||
|
||||
// Check if audio exists
|
||||
fetch('audio/latest-meta.json').then(r=>r.json()).then(m=>{
|
||||
document.getElementById('podcast-date').textContent = '欣欣 & 建国 · ' + m.date + ' · 约3分钟';
|
||||
}).catch(()=>{
|
||||
document.getElementById('podcast-bar').style.opacity = '0.4';
|
||||
btn.textContent = '暂无音频';
|
||||
btn.disabled = true;
|
||||
});
|
||||
|
||||
audio.addEventListener('timeupdate', function(){
|
||||
if (!audio.duration) return;
|
||||
bar.style.width = (audio.currentTime / audio.duration * 100) + '%';
|
||||
var m = Math.floor(audio.currentTime/60);
|
||||
var s = Math.floor(audio.currentTime%60);
|
||||
time.textContent = m + ':' + (s<10?'0':'')+s;
|
||||
});
|
||||
audio.addEventListener('ended', function(){
|
||||
btn.textContent = '▶ 播放';
|
||||
btn.classList.remove('playing');
|
||||
bar.style.width = '0%';
|
||||
});
|
||||
|
||||
window.togglePodcast = function(){
|
||||
if (audio.paused) {
|
||||
audio.play();
|
||||
btn.textContent = '⏸ 暂停';
|
||||
btn.classList.add('playing');
|
||||
} else {
|
||||
audio.pause();
|
||||
btn.textContent = '▶ 播放';
|
||||
btn.classList.remove('playing');
|
||||
}
|
||||
};
|
||||
window.seekPodcast = function(e){
|
||||
if (!audio.duration) return;
|
||||
var rect = e.currentTarget.getBoundingClientRect();
|
||||
audio.currentTime = ((e.clientX - rect.left) / rect.width) * audio.duration;
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- MAIN PAGE BODY -->
|
||||
<div class="page-body">
|
||||
<div class="container">
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user