Skill: dz1922/dz-podcast (version 1.0.0)

--- name: dz-podcast description: Generate and publish a dual-host daily podcast. Fetches news, generates a conversational script between two hosts, synthesizes audio via Fish Audio or Edge TTS, publishes to S3 with RSS feed for Apple Podcasts, Spotify, etc. Fully automated with cron support. --- # Dual-Host Daily Podcast Generator Automated daily podcast with two AI hosts. Generates text brief + dual-voice audio, publishes to RSS, delivers via messaging. ## Concept - **Format**: Two hosts — one explains/analyzes, the other asks and transitions - **Duration**: Configurable, default ~7 minutes - **Style**: Casual, opinionated, conversational — like two friends chatting about the news - **Topics**: Customizable (default: AI/Tech, Stocks, Macro, Crypto) ## Architecture ``` Fetch News → Text Brief → Dual-Voice Script → TTS Audio → S3 Upload → RSS Update → Deliver ``` ## Configuration Set these in your environment: | Variable | Description | |----------|-------------| | `S3_BUCKET` | S3 bucket name | | `PODCAST_DOMAIN` | Custom domain or S3 URL | | `FISH_API_KEY` | Fish Audio API key (https://fish.audio) | | `FISH_VOICE_A` | Fish Audio voice ID for Host A | | `FISH_VOICE_B` | Fish Audio voice ID for Host B | ## Step 1: Fetch News Use `web_fetch` to scrape sources in parallel. Default sources: 1. `https://news.ycombinator.com/` — Tech 2. `https://www.coindesk.com/` — Crypto 3. `https://techcrunch.com/category/artificial-intelligence/` — AI 4. `https://finance.yahoo.com/` — Markets Customize sources to match your podcast topic. ## Step 2: Generate Text Brief Organize news into sections with emoji headers: ``` ☀️ Daily Brief | Mar 3, 2026 ━━━━━━━━━━━━━━━━━━ 🤖 Tech / AI ① Headline → One-line take ━━━━━━━━━━━━━━━━━━ 📈 Markets ① Headline → One-line take ━━━━━━━━━━━━━━━━━━ 🎯 Key Takeaway Summary paragraph ``` ## Step 3: Generate Dual-Voice Script Rewrite the brief as a dialogue. Prefix each line with speaker tag: ``` HostA: Welcome to today's episode... HostB: Some big stories today... HostA: Right, let's start with... ``` **Guidelines:** - Host A: Explains and analyzes, knowledgeable but casual - Host B: Asks, transitions, reacts - Substantial turns, not one-liners - Include analysis and discussion, not just headlines - End with a lighter topic + sign-off ## Step 4: Generate Audio **Fish Audio (recommended — natural, multi-voice):** ```bash python3 scripts/fish_dual_tts.py <script.txt> <output.mp3> ``` Parses speaker tags, sends each segment to Fish Audio, concatenates into final MP3. **Edge TTS (free fallback, single voice):** ```bash edge-tts --voice en-US-GuyNeural --rate "+5%" --file script.txt --write-media output.mp3 ``` ## Step 5: Publish ```bash bash scripts/generate_episode.sh <date> <EP-number> <title> <description> <mp3-file> ``` What it does: 1. Upload MP3 to S3 2. Get **actual duration** via ffprobe 3. Insert `<item>` into RSS feed (newest first) 4. Update `<lastBuildDate>` ## Step 6: Deliver Send text brief + audio via your preferred channel (Telegram, Discord, Slack, etc.) ## RSS Feed See `references/rss-format.md` for XML template. Key rules: - `<itunes:duration>` = actual duration from ffprobe (never hardcode) - `<enclosure length>` = actual file size in bytes - `<itunes:owner>` with email for Apple/Spotify verification - Cover: 3000x3000 JPEG minimum ## Hosting Options | Option | Notes | |--------|-------| | S3 + Cloudflare Worker | Free HTTPS, recommended | | S3 + CloudFront | Native AWS | | Any static host | Just serve MP3 + feed.xml | ## Cron (OpenClaw) ```bash openclaw cron add --task "Generate daily podcast" --cron "0 8 * * *" --tz "Your/Timezone" ``` ## Dependencies - `python3` + `requests` — Fish Audio TTS - `ffmpeg` / `ffprobe` — Audio processing - `aws` CLI — S3 upload - `edge-tts` (optional) — Free fallback TTS

path: scripts/generate_episode.sh
content: #!/bin/bash
# Upload podcast episode to S3 and update RSS feed
# Usage: ./generate_episode.sh <date> <ep-number> <title> <description> <mp3-file>
# Example: ./generate_episode.sh 2026-03-04 EP006 "标题" "描述" /tmp/2026-03-04-ep006.mp3

set -e

DATE="$1"
EP="$2"
TITLE="$3"
DESC="$4"
MP3_FILE="$5"

BUCKET="${S3_BUCKET:-my-podcast-bucket}"
S3_BASE="${PODCAST_DOMAIN:-https://my-podcast-bucket.s3.amazonaws.com}"
EP_LOWER=$(echo "$EP" | tr '[:upper:]' '[:lower:]')
S3_KEY="episodes/${DATE}-${EP_LOWER}.mp3"

if [ ! -f "$MP3_FILE" ]; then
  echo "❌ MP3 file not found: $MP3_FILE"
  exit 1
fi

FILESIZE=$(stat -c%s "$MP3_FILE")

# Get actual duration via ffprobe
DURATION_SEC=$(ffprobe -i "$MP3_FILE" -show_entries format=duration -v quiet -of csv="p=0" | cut -d. -f1)
DURATION_MIN=$((DURATION_SEC / 60))
DURATION_REM=$((DURATION_SEC % 60))
DURATION=$(printf "%02d:%02d" $DURATION_MIN $DURATION_REM)

echo "📊 File: ${FILESIZE} bytes, Duration: ${DURATION}"

# 1. Upload MP3
echo "☁️ Uploading ${S3_KEY}..."
aws s3 cp "$MP3_FILE" "s3://${BUCKET}/${S3_KEY}" --content-type "audio/mpeg"

# 2. Download current RSS
aws s3 cp "s3://${BUCKET}/feed.xml" /tmp/feed_current.xml 2>/dev/null

# 3. Build new item
PUBDATE=$(date -d "${DATE}" -u '+%a, %d %b %Y 08:00:00 +0800')
BUILDDATE=$(date -u '+%a, %d %b %Y %H:%M:%S +0000')
NEW_ITEM="<item>\n      <title>${TITLE}</title>\n      <description>${DESC}</description>\n      <enclosure url=\"${S3_BASE}/${S3_KEY}\" length=\"${FILESIZE}\" type=\"audio/mpeg\"/>\n      <pubDate>${PUBDATE}</pubDate>\n      <itunes:duration>${DURATION}</itunes:duration>\n      <itunes:summary>${DESC}</itunes:summary>\n      <itunes:image href=\"${S3_BASE}/cover.jpg\"/>\n      <guid isPermaLink=\"false\">podcast-${DATE}-${EP_LOWER}</guid>\n    </item>"

# 4. Insert new item after <lastBuildDate> line and update build date
sed -i "s|<lastBuildDate>.*</lastBuildDate>|<lastBuildDate>${BUILDDATE}</lastBuildDate>\n\n    ${NEW_ITEM}|" /tmp/feed_current.xml

# 5. Upload updated RSS
aws s3 cp /tmp/feed_current.xml "s3://${BUCKET}/feed.xml" --content-type "application/rss+xml"

echo "✅ Published: ${TITLE}"
echo "🔗 ${S3_BASE}/${S3_KEY}"
echo "📻 ${S3_BASE}/feed.xml"


path: scripts/fish_dual_tts.py
content: #!/usr/bin/env python3
"""
Dual-voice podcast TTS via Fish Audio API.
Usage: python3 fish_dual_tts.py <script.txt> <output.mp3>
"""

import sys, os, json, requests, tempfile

[REDACTED_SECRET]"
API_URL = "https://api.fish.audio/v1/tts"

VOICES = {
    "HostA": "YOUR_VOICE_A_ID",  # Voice A
    "HostB": "YOUR_VOICE_B_ID",  # Voice B
}

def parse_script(path):
    segments = []
    speaker = None
    lines = []
    import re
    with open(path, 'r', encoding='utf-8') as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            m = re.match(r'^\[(\w+)\]\s*(.*)', line)
            if m:
                if speaker and lines:
                    segments.append((speaker, ' '.join(lines)))
                speaker = m.group(1)
                lines = []
                if m.group(2):
                    lines.append(m.group(2))
            else:
                lines.append(line)
    if speaker and lines:
        segments.append((speaker, ' '.join(lines)))
    return segments

def tts(text, voice_id, output):
    resp = requests.post(API_URL,
        headers={
            "Authorization": f"Bearer {API_KEY}",
            "Content-Type": "application/json"
        },
        json={
            "text": text,
            "reference_id": voice_id,
            "format": "mp3"
        })
    if resp.status_code != 200:
        print(f"  ❌ Error: {resp.status_code} {resp.text[:100]}")
        return False
    with open(output, 'wb') as f:
        f.write(resp.content)
    return True

def main():
    script_path = sys.argv[1]
    output_path = sys.argv[2]
    
    segments = parse_script(script_path)
    print(f"Parsed {len(segments)} segments")
    
    tmpdir = tempfile.mkdtemp()
    seg_files = []
    
    for i, (speaker, text) in enumerate(segments):
        voice = VOICES.get(speaker, VOICES["HostA"])
        seg_file = os.path.join(tmpdir, f"seg_{i:03d}.mp3")
        print(f"  [{speaker}] {text[:40]}...")
        if tts(text, voice, seg_file):
            seg_files.append(seg_file)
        else:
            print(f"  ⚠️ Skipping segment {i}")
    
    # Concatenate
    with open(output_path, 'wb') as outf:
        for sf in seg_files:
            with open(sf, 'rb') as inf:
                outf.write(inf.read())
    
    # Cleanup
    for sf in seg_files:
        os.remove(sf)
    os.rmdir(tmpdir)
    
    size = os.path.getsize(output_path)
    print(f"✅ Output: {output_path} ({size} bytes)")

if __name__ == "__main__":
    main()


path: references/rss-format.md
content: # RSS Feed Format Reference

## Feed Template

```xml
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" 
  xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
  xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Your Podcast Name</title>
    <link>https://your-domain.com</link>
    <language>en</language>
    <description>Your podcast description</description>
    <itunes:owner>
      <itunes:name>Your Name</itunes:name>
      <itunes:email>[REDACTED_SECRET]</itunes:email>
    </itunes:owner>
    <itunes:author>Host A &amp; Host B</itunes:author>
    <itunes:summary>Short summary</itunes:summary>
    <itunes:category text="Technology"/>
    <itunes:explicit>false</itunes:explicit>
    <itunes:image href="https://your-domain.com/cover.jpg"/>
    <lastBuildDate>RFC 2822 date</lastBuildDate>

    <item>
      <title>EP001 | Episode Title</title>
      <description>Episode description</description>
      <enclosure url="https://your-domain.com/episodes/YYYY-MM-DD-ep001.mp3" length="ACTUAL_BYTES" type="audio/mpeg"/>
      <pubDate>Tue, 03 Mar 2026 08:00:00 +0000</pubDate>
      <itunes:duration>MM:SS</itunes:duration>
      <itunes:summary>Episode description</itunes:summary>
      <itunes:image href="https://your-domain.com/cover.jpg"/>
      <guid isPermaLink="false">your-podcast-YYYY-MM-DD-ep001</guid>
    </item>
  </channel>
</rss>
```

## Key Rules

- Newest episodes first
- `<guid>` unique per episode, `isPermaLink="false"`
- `<enclosure length>` = **actual file size** in bytes
- `<itunes:duration>` = **actual audio duration** via ffprobe
- `<itunes:owner>` with email required for Apple/Spotify
- Cover image: 3000x3000 JPEG minimum
- `<pubDate>` in RFC 2822 format


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)