digest-skill

内容来源:SKILL.md(标准 Skill 格式) · 原始地址 · 查看安装指南

原始内容


name: digest description: Weekly digest of newsletter and podcast insights — novel, high-impact, contrarian ideas for business, marketing, and LinkedIn. Generates audio version via ElevenLabs.

Weekly Digest

Scan the past week's newsletters and podcast episodes, extract what matters, and deliver a single digest of insights that are novel, high-impact, contrarian, or directly useful for building your business, driving marketing success, and sharing on LinkedIn. Then generate an audio version.

Important: Writing Style

Follow the writing preferences in your project's CLAUDE.md. Direct. Specific. No filler.

Setup

Before using this skill, create a config.json file in the .claude/skills/digest/ directory. See config.example.json for the required structure. The skill reads API keys and service credentials from this file at runtime.

Step 1: Gather Sources (run in parallel)

Run all of the following simultaneously.

1A: Newsletters

Search Gmail for newsletters from the last 7 days:

Query Purpose maxResults
label:Substack newer_than:7d All Substack newsletters 100
label:Newsletter newer_than:7d Non-Substack newsletters (beehiiv, etc.) 100

Use your Gmail MCP's gmail_search_messages tool for each. (Load the schema first via ToolSearch if needed.)

Priority senders (always include if present) — customize this list with your own subscriptions:

  • Product Growth (Aakash Gupta) — aakashgupta@substack.com
  • Lenny's Newsletter — lenny@substack.com
  • Growth Unhinged (Kyle Poyar) — beehiiv / mail.beehiiv.com
  • Mostly Growth — substack
  • a16z — a16z@substack.com
  • Not Boring (Packy McCormick) — notboring@substack.com
  • MKT1 (Emily Kramer) — mkt1@substack.com
  • GTM Strategist (Maja Voje) — gtmstrategist@substack.com
  • OnlyCFO — onlycfo@substack.com
  • Wes Kao — weskao@substack.com
  • Tech Brew — techbrew@morningbrew.com
  • The Frontier (Product Hunt) — hi@deeperlearning.producthunt.com
  • Alex / basicarts — alex@basicarts.org

1B: Podcast Episodes

Use a hybrid approach to find episodes published in the last 7 days.

Source 1: RSS Transcript Feeds (preferred — gives full transcripts directly)

These podcasts have transcripts embedded in their RSS feeds. Curl the feed and extract <podcast:transcript> tags from recent episodes:

Podcast RSS Feed URL Transcript Format
Cheeky Pint https://feeds.transistor.fm/cheeky-pint-with-john-collison text/plain (.txt)
Think Fast, Talk Smart https://feeds.transistor.fm/think-fast-talk-smart-communication-techniques text/vtt (.vtt)

For each feed:

python3 -c "
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta, timezone
import sys, json

xml_data = sys.stdin.read()
# Register namespaces
ns = {
    'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd',
    'podcast': 'https://podcastindex.org/namespace/1.0',
}
root = ET.fromstring(xml_data)
channel = root.find('channel')
cutoff = datetime.now(timezone.utc) - timedelta(days=7)
results = []
for item in channel.findall('item'):
    pub_date_str = item.find('pubDate').text if item.find('pubDate') is not None else ''
    try:
        from email.utils import parsedate_to_datetime
        pub_date = parsedate_to_datetime(pub_date_str)
    except:
        continue
    if pub_date >= cutoff:
        title = item.find('title').text if item.find('title') is not None else ''
        transcript_el = item.find('podcast:transcript', ns)
        transcript_url = transcript_el.get('url') if transcript_el is not None else None
        results.append({'title': title, 'published': pub_date_str, 'transcript_url': transcript_url})
print(json.dumps(results, indent=2))
"

If a transcript URL is found, fetch it directly with curl. For VTT files, strip timestamps and keep just the text.

Source 2: YouTube RSS Feeds (for remaining podcasts)

Fetch YouTube RSS feeds for episodes published in the last 7 days:

python3 -c "
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta, timezone
import sys, json

xml_data = sys.stdin.read()
ns = {'atom': 'http://www.w3.org/2005/Atom', 'yt': 'http://www.youtube.com/xml/schemas/2015', 'media': 'http://search.yahoo.com/mrss/'}
root = ET.fromstring(xml_data)
cutoff = datetime.now(timezone.utc) - timedelta(days=7)
results = []
for entry in root.findall('atom:entry', ns):
    published = entry.find('atom:published', ns).text
    pub_date = datetime.fromisoformat(published.replace('Z', '+00:00'))
    if pub_date >= cutoff:
        vid_id = entry.find('yt:videoId', ns).text
        title = entry.find('atom:title', ns).text
        results.append({'id': vid_id, 'title': title, 'published': published})
print(json.dumps(results))
"

YouTube feeds — customize this list with your preferred podcasts, in priority order:

Priority Podcast Feed URL
1 Founders (David Senra) https://www.youtube.com/feeds/videos.xml?channel_id=UCy2FPslt0LLPsIV0iukvHpQ
2 Lenny's Podcast https://www.youtube.com/feeds/videos.xml?channel_id=UC6t1O76G0jYXOAoYCm153dA
3 20VC https://www.youtube.com/feeds/videos.xml?channel_id=UCf0PBRjhf0rF8fWBIxTuoWA
4 Mostly Growth https://www.youtube.com/feeds/videos.xml?channel_id=UC6WQpg2OxjHSpYRHnsM90bw
5 a16z https://www.youtube.com/feeds/videos.xml?channel_id=UCQ1VQj-37kl2yS_VUhfQHsw
6 Masters of Scale https://www.youtube.com/feeds/videos.xml?channel_id=UCiemDAS1bXMBTx3jIIOukFg
7 This Week in Startups https://www.youtube.com/feeds/videos.xml?channel_id=UCkkhmBWfS7pILYIk0izkc3A

Curl all feeds in parallel (feed XML fetches are fine in parallel — rate limiting only happens during transcript fetches in Step 3).

IMPORTANT — Filter out YouTube Shorts: Before fetching transcripts, check video duration using yt-dlp:

yt-dlp --print duration VIDEO_ID 2>/dev/null

Skip any video shorter than 180 seconds (3 minutes) — these are Shorts/clips, not full episodes.

Step 2: Read Newsletter Content

For each newsletter email from Step 1A, use your Gmail MCP's gmail_read_message tool to get the full content.

Skip these — don't read or include:

  • Substack verification codes
  • "Welcome" / subscription confirmation emails
  • Substack "Weekly Stack" digest emails (from no-reply@substack.com)
  • Newsletters clearly outside your business interests (geopolitics, poetry, macro econ, faith/devotional, general news)

Keep and read everything about: product, growth, marketing, GTM, startups, AI/tech, SaaS, PLG, building businesses, fundraising, leadership.

If there are more than 15 relevant newsletters, prioritize the priority senders listed in Step 1A.

Step 3: Get Podcast Transcripts

For RSS transcript podcasts:

Transcripts were already fetched in Step 1B. Use them directly.

For YouTube-sourced podcasts:

For each new episode from Step 1B (that passed the duration filter), fetch the English transcript using youtube-transcript-api:

python3 -c "
from youtube_transcript_api import YouTubeTranscriptApi
api = YouTubeTranscriptApi()
transcript = api.fetch('VIDEO_ID')
text = ' '.join([t.text for t in transcript.snippets])
print(text)
" 2>&1

Replace VIDEO_ID with each video's ID. Fetch in priority order, not all in parallel — YouTube rate-limits after ~8 transcript requests in quick succession. If you hit a rate limit (IpBlocked error), stop fetching more YouTube transcripts and work with what you have.

If a transcript is very long (60+ minute episode), read just the first 15,000 words.

If the transcript fetch fails due to rate limiting, note the title as "transcript unavailable — rate limited" and move on. Do not retry.

Step 4: Synthesize the Digest

Analyze all newsletter content and podcast transcripts. Extract and organize insights.

What counts as signal:

  • Novel frameworks or mental models people haven't seen before
  • Contrarian takes that challenge conventional wisdom
  • Specific, tactical plays for growing a business (not generic advice)
  • Marketing strategies, GTM plays, or positioning insights
  • Data points or trends that reveal where markets are heading
  • Ideas that would make strong LinkedIn content (provocative, specific, backed by reasoning)
  • Concrete examples with names, numbers, and outcomes

What to filter out:

  • Obvious takes everyone already agrees on
  • Self-promotional content from the newsletter author
  • Generic advice ("focus on your customer," "iterate fast," "build trust")
  • Content outside your domain (pure engineering, policy, macro politics)
  • Anything that sounds like a LinkedIn influencer cliche

Also generate the episode title here. Format:

[Mon DD–DD] | [Strongest claim] · [Topic 2] · [Topic 3]

Rules:

  • Date range: abbreviated month + day range of the period covered (e.g., Mar 12–18)
  • First segment: the single sharpest claim from The Signal — short declarative, 4–7 words, no hedging
  • Two more topic slugs: specific and concrete, 3–5 words each
  • Total title: under 80 characters
  • No "weekly digest", no "episode", no generic filler — every word earns its place

Example: Mar 12–18 | Growth is a trust problem · Kalshi vs. the CFTC · The 8% conversion floor

This title is used in Step 7B for the RSS feed. Carry it forward.

Step 5: Present the Written Digest

## Weekly Digest — [Date Range]

**Sources scanned:** X newsletters, X podcast episodes

---

### The Signal

[3-7 of the most important insights from this week. Each one gets 2-3 sentences max.
State the insight directly — don't build up to it. Attribute the source.]

---

### Contrarian / Provocative

[1-3 takes that challenge conventional wisdom. These are the ones worth
turning into LinkedIn posts. State why they're contrarian.]

---

### Tactical Takeaways

[3-5 specific, actionable ideas you could apply to your business or share.
The more concrete, the better. "Do X" not "consider X."]

---

### LinkedIn Angles

[2-3 specific content angles from this week's sources that could become a post or carousel.
For each: the hook line, the core argument, and which source it draws from.]

---

### Worth a Full Read

[Any newsletter or podcast episode that was especially dense with insight this week.
One-line reason why.]

---

### Skipped / Thin This Week

[List source names that had nothing relevant. No detail needed.]

Do NOT pad the digest. If there's only one contrarian take, list one. If no podcasts published, skip that section. Empty sections get cut entirely.

Step 6: Generate Audio Digest

After presenting the written digest, generate an audio version covering Signal + Contrarian + Tactical sections only.

6A: Format for spoken delivery

Reformat those three sections into a spoken script. Rules:

  • Remove markdown formatting (headers become spoken transitions like "Here's what mattered this week." / "Now, the contrarian takes." / "Tactical takeaways.")
  • Remove attribution brackets — weave source names naturally ("Elena Verna on 20VC made the case that...")
  • Keep sentences short and punchy — same voice as the written digest but optimized for ear, not eye
  • No bullet points — convert to flowing paragraphs with natural transitions
  • Total script should be under 10,000 characters (fits in one ElevenLabs Turbo v2 request)
  • If over 10,000 characters, trim the least important tactical takeaway

6B: Generate audio via ElevenLabs

Read the config file to get the ElevenLabs API key and voice settings:

python3 -c "
import json, urllib.request, sys

config = json.load(open('CONFIG_PATH'))
api_key = config['elevenlabs_api_key']

# Read script from stdin
script = sys.stdin.read()

voice_id = config.get('elevenlabs_voice_id', 'NNl6r8mD7vthiJatiJt1')
url = f'https://api.elevenlabs.io/v1/text-to-speech/{voice_id}/stream'

payload = json.dumps({
    'text': script,
    'model_id': 'eleven_turbo_v2',
    'output_format': 'mp3_44100_128',
    'voice_settings': {
        'stability': 0.6,
        'similarity_boost': 0.8,
        'style': 0.15,
        'use_speaker_boost': True,
    },
}).encode()

req = urllib.request.Request(url, data=payload)
req.add_header('xi-api-key', api_key)
req.add_header('Content-Type', 'application/json')
req.add_header('Accept', 'audio/mpeg')

from datetime import date
output_dir = config.get('output_dir', './digests')
output_path = f'{output_dir}/digest-{date.today().isoformat()}.mp3'

import os
os.makedirs(os.path.dirname(output_path), exist_ok=True)

with urllib.request.urlopen(req) as resp:
    with open(output_path, 'wb') as f:
        while True:
            chunk = resp.read(1024)
            if not chunk:
                break
            f.write(chunk)

print(f'Audio saved: {output_path}')
char_count = len(script)
print(f'Characters used: {char_count} (~{char_count} credits)')
" <<'SCRIPT'
INSERT_SPOKEN_SCRIPT_HERE
SCRIPT

Replace CONFIG_PATH with the path to your config.json and INSERT_SPOKEN_SCRIPT_HERE with the actual spoken script from Step 6A.

After generating, report:

  • File path of the MP3
  • Character count / credits used
  • Approximate duration (estimate ~150 words per minute)

Step 7: Publish to Podcast RSS Feed

Upload the MP3 to Cloudflare R2 and update the podcast RSS feed so Spotify and Apple Podcasts auto-ingest the new episode.

7A: Upload MP3 to R2

python3 -c "
import boto3, json
from datetime import date

config = json.load(open('CONFIG_PATH'))
r2 = config['r2']

s3 = boto3.client('s3',
    endpoint_url=r2['endpoint'],
    aws_access_key_id=r2['access_key_id'],
    aws_secret_access_key=r2['secret_access_key'],
    region_name='auto'
)

output_dir = config.get('output_dir', './digests')
today = date.today().isoformat()
local_path = f'{output_dir}/digest-{today}.mp3'
r2_key = f'episodes/digest-{today}.mp3'

s3.upload_file(local_path, r2['bucket'], r2_key, ExtraArgs={'ContentType': 'audio/mpeg'})

import os
file_size = os.path.getsize(local_path)
print(f'Uploaded: {r2[\"public_url\"]}/{r2_key}')
print(f'Size: {file_size} bytes')
"

7B: Update RSS feed

Download the existing feed.xml from R2, add the new episode entry, and re-upload. The feed uses raw string templating (not xml.etree) to avoid the duplicate xmlns:itunes bug that breaks Spotify/Apple ingestion.

python3 -c "
import boto3, json, os, re
from datetime import date, datetime, timezone
from email.utils import format_datetime

config = json.load(open('CONFIG_PATH'))
r2 = config['r2']
podcast = config['podcast']

s3 = boto3.client('s3',
    endpoint_url=r2['endpoint'],
    aws_access_key_id=r2['access_key_id'],
    aws_secret_access_key=r2['secret_access_key'],
    region_name='auto'
)

output_dir = config.get('output_dir', './digests')
today = date.today().isoformat()
mp3_key = f'episodes/digest-{today}.mp3'
pub_url = r2['public_url']
mp3_url = f'{pub_url}/{mp3_key}'
local_mp3 = f'{output_dir}/digest-{today}.mp3'
file_size = os.path.getsize(local_mp3)

duration_seconds = int(file_size / 16000)
minutes = duration_seconds // 60
seconds = duration_seconds % 60

pub_date = format_datetime(datetime.now(timezone.utc))

# Build new <item> block
new_item = f'''    <item>
      <title>INSERT_EPISODE_TITLE_HERE</title>
      <description>INSERT_EPISODE_DESCRIPTION_HERE</description>
      <pubDate>{pub_date}</pubDate>
      <guid isPermaLink=\"true\">{mp3_url}</guid>
      <enclosure url=\"{mp3_url}\" length=\"{file_size}\" type=\"audio/mpeg\"/>
      <itunes:duration>{minutes}:{seconds:02d}</itunes:duration>
      <itunes:explicit>false</itunes:explicit>
    </item>'''

# Download existing feed and insert new item after opening <channel> metadata
feed_path = '/tmp/feed.xml'
try:
    s3.download_file(r2['bucket'], 'feed.xml', feed_path)
    with open(feed_path, 'r') as f:
        feed = f.read()
    # Insert new item before the first existing <item>, or before </channel>
    if '<item>' in feed:
        feed = feed.replace('<item>', new_item + '\n    <item>', 1)
    else:
        feed = feed.replace('</channel>', new_item + '\n  </channel>')
except:
    # Create new feed from scratch
    feed = f'''<?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/\" xmlns:atom=\"http://www.w3.org/2005/Atom\">
  <channel>
    <title>{podcast['title']}</title>
    <link>{pub_url}</link>
    <atom:link href=\"https://pubsubhubbub.appspot.com\" rel=\"hub\"/>
    <atom:link href=\"{pub_url}/feed.xml\" rel=\"self\" type=\"application/rss+xml\"/>
    <description>{podcast['description']}</description>
    <language>en-us</language>
    <itunes:author>{podcast['author']}</itunes:author>
    <itunes:owner>
      <itunes:name>{podcast['author']}</itunes:name>
      <itunes:email>{podcast['email']}</itunes:email>
    </itunes:owner>
    <itunes:explicit>false</itunes:explicit>
    <itunes:image href=\"{pub_url}/cover.jpg\"/>
    <itunes:category text=\"Business\"/>
    <itunes:type>episodic</itunes:type>
{new_item}
  </channel>
</rss>'''

with open(feed_path, 'w') as f:
    f.write(feed)

s3.upload_file(feed_path, r2['bucket'], 'feed.xml', ExtraArgs={'ContentType': 'application/rss+xml'})
print(f'RSS feed updated: {pub_url}/feed.xml')
print(f'New episode: {today} ({minutes}:{seconds:02d})')
"

Replace CONFIG_PATH with the path to your config.json. Replace INSERT_EPISODE_TITLE_HERE with the episode title generated in Step 4. Replace INSERT_EPISODE_DESCRIPTION_HERE with a 1-2 sentence episode description from the Signal and Contrarian sections. Keep it under 250 characters.

7C: Ping WebSub hub

After uploading the feed, ping Google's PubSubHubbub hub so subscribers (Spotify, Apple, YouTube Music) get notified immediately instead of waiting for their next poll cycle.

curl -s -o /dev/null -w "HTTP %{http_code}" -X POST https://pubsubhubbub.appspot.com/ \
  -d "hub.mode=publish" \
  -d "hub.url=YOUR_FEED_URL"

Replace YOUR_FEED_URL with the public URL to your feed.xml on R2. A 204 response means the hub accepted the ping. Any other code means the ping failed — log it but don't block.

After publishing, report:

  • RSS feed URL
  • Episode MP3 URL
  • WebSub ping status (204 = success)

Notes

  • boto3 is used for R2 uploads. If missing, install with pip3 install --user boto3.
  • youtube-transcript-api is installed via pip3. If missing, install with pip3 install --user youtube-transcript-api.
  • yt-dlp is used to check video duration. If missing, install with pip3 install --user yt-dlp.
  • Some podcast channels (especially 20VC and This Week in Startups) publish multiple times per week. Include all episodes but keep summaries tight.
  • If a YouTube feed returns a 404 or empty result, the channel ID may have changed. Flag it and move on.
  • For a weekly digest with more sources, focus synthesis on quality over quantity. A week with 20 newsletters should still produce the same tight digest format — just with better signal extracted.
  • Audio generation costs ~11,000 ElevenLabs credits per episode. At once per week, that's ~44,000 credits/month on the Creator plan.