DEV Community

Hermes Soulmate
Hermes Soulmate

Posted on Edited on AI-assisted

Turn podcast RSS feeds into transcripts for RAG, without speech-to-text

I wanted to ask questions about a podcast's back catalogue with an LLM. The obvious route is to download every MP3, run it through Whisper or a paid speech-to-text API, and then chunk the output. That works, but it is slow, it costs money per audio minute, and you end up with a machine transcript of a show whose creator may already have published a proper one.

A lot of podcasts do publish one. Podcasting 2.0 added a <podcast:transcript> tag to RSS, and hosts like Buzzsprout and Omny Studio put a link to an SRT, WebVTT, JSON or HTML transcript right in the feed. If the transcript is already there, you can skip the audio entirely.

This post shows how to check a feed for transcripts yourself, then how to pull them as clean JSON (plain text plus timestamped speaker segments) with a small Apify Actor I built, and how to turn the segments into RAG chunks.

Disclosure: I built the Actor used below (Podcast Transcripts from RSS) and I get paid when people run it. This article was written with AI assistance; the inputs, field names, prices and limits come from the Actor's README and build report.

Step 1: check whether a feed publishes transcripts

You don't need any tool for this. Fetch the feed and grep for the tag:

curl -sL https://feeds.buzzsprout.com/231452.rss | grep -o '<podcast:transcript[^>]*>' | head -4
Enter fullscreen mode Exit fullscreen mode

For Buzzcast (Buzzsprout's own show) that prints four alternatives for the newest episode:

<podcast:transcript url="https://www.buzzsprout.com/231452/20042854/transcript" type="text/html" />
<podcast:transcript url="https://www.buzzsprout.com/231452/20042854/transcript.json" type="application/json" />
<podcast:transcript url="https://www.buzzsprout.com/231452/20042854/transcript.srt" type="application/x-subrip" />
<podcast:transcript url="https://www.buzzsprout.com/231452/20042854/transcript.vtt" type="text/vtt" />
Enter fullscreen mode Exit fullscreen mode

If you get nothing back, that feed doesn't publish transcripts and you'll need speech-to-text after all. Be ready for that: when I sampled 59 popular feeds (top Apple Podcasts results for 20 topics) on 2026-11-04, only 10 of them (17%) carried the tag, and 44 of their 286 newest episodes (15%) had a transcript. Omny Studio shows (iHeart, Bloomberg, Fox Sports and others) and Buzzsprout creators who switch transcripts on were the main sources. Most Megaphone, Simplecast, Acast, Art19 and Libsyn shows in that sample had none.

Step 2: why not just parse it yourself?

You can, and for one feed you probably should. It gets tedious across many feeds because:

  • each episode can list several formats, and you have to pick one and fall back when a file fails;
  • SRT, WebVTT, Podcast Index JSON (word level), HTML and plain text all need different parsers;
  • speaker names come as WebVTT <v> voices, Name: prefixes, or not at all;
  • some feeds are huge (one sports show's feed is over 15 MB), so you want to stop reading early.

That is the part the Actor does. It reads the feed, picks the richest format (WebVTT, then SRT, then JSON, then plain text, then HTML), downloads that one file and returns one row per episode.

Step 3: run it

Input:

{
  "feedUrls": ["https://feeds.buzzsprout.com/231452.rss"],
  "maxEpisodesPerFeed": 2,
  "onlyWithTranscripts": true,
  "includeSegments": true,
  "includeSpeakerLabels": true
}
Enter fullscreen mode Exit fullscreen mode

maxEpisodesPerFeed counts newest first (default 5). onlyWithTranscripts is on by default; turn it off if you also want metadata-only rows for episodes without a transcript (those rows are free). There's also publishedSince (a date) and maxResults as a total cap.

You can paste that into the Actor page and press Start, or call it from code. With the Python client:

from apify_client import ApifyClient

client = ApifyClient("YOUR_APIFY_TOKEN")
run = client.actor("ahmed.fouaad82/podcast-published-transcripts").call(run_input={
    "feedUrls": ["https://feeds.buzzsprout.com/231452.rss"],
    "maxEpisodesPerFeed": 2,
})

episodes = list(client.dataset(run.default_dataset_id).iterate_items())
for ep in episodes:
    print(ep["episodeTitle"], ep["transcriptFormat"], ep["wordCount"])
Enter fullscreen mode Exit fullscreen mode

Or in one HTTP request that waits for the run and returns the dataset:

curl -s -X POST \
  "https://api.apify.com/v2/acts/ahmed.fouaad82~podcast-published-transcripts/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"feedUrls":["https://feeds.buzzsprout.com/231452.rss"],"maxEpisodesPerFeed":2}'
Enter fullscreen mode Exit fullscreen mode

What comes back

One row per episode. Shortened example from the README:

{
  "podcastTitle": "Buzzcast",
  "episodeTitle": "Your Podcast Idea Doesn’t Have To Be Perfect To Get Started",
  "publishedAt": "2026-10-30T12:00:00.000Z",
  "durationSeconds": 751,
  "hasTranscript": true,
  "transcriptFormat": "vtt",
  "language": "en-us",
  "text": "Jordan: Alban, do you remember when we did that Buzzcast episode...\n\nAlban: No. I have no recollection of it...",
  "segments": [{ "start": 0.08, "end": 8.08, "speaker": "Jordan", "text": "Alban, do you remember when we did that Buzzcast episode..." }],
  "wordCount": 2478,
  "speakers": ["Jordan", "Alban", "Kevin"]
}
Enter fullscreen mode Exit fullscreen mode

Other fields: feedUrl, guid, episodeUrl, audioUrl, episodeNumber, season, transcriptUrl, transcriptType, segmentCount, availableTranscripts (every alternative the feed listed) and transcriptError (why a metadata-only row has no transcript).

text has one paragraph per speaker turn. segments keeps the timing in seconds, which is what you want for RAG, because a retrieved chunk can then point back to a timestamp in the episode.

Step 4: chunk the segments for RAG

Here's a simple chunker that groups consecutive segments into roughly 300-word pieces and keeps the start time and speakers as metadata. It works on the episodes list from the Python example above:

def chunk_episode(ep, max_words=300):
    chunks, buf, words = [], [], 0
    for seg in ep.get("segments") or []:
        buf.append(seg)
        words += len(seg["text"].split())
        if words >= max_words:
            chunks.append(buf)
            buf, words = [], 0
    if buf:
        chunks.append(buf)

    for i, segs in enumerate(chunks):
        yield {
            "id": f'{ep["guid"]}-{i}',
            "text": "\n".join(f'{s.get("speaker", "")}: {s["text"]}'.lstrip(": ") for s in segs),
            "metadata": {
                "podcast": ep["podcastTitle"],
                "episode": ep["episodeTitle"],
                "published": ep["publishedAt"],
                "start": segs[0]["start"],
                "end": segs[-1]["end"],
                "url": ep["episodeUrl"],
            },
        }

docs = [c for ep in episodes for c in chunk_episode(ep)]
Enter fullscreen mode Exit fullscreen mode

From there, embed text with whatever you already use and store metadata next to it. When the model cites a chunk, you can link to the episode and say "around 12:40" instead of "somewhere in this hour".

If a transcript has no timing (plain text or some HTML), segments can be empty; fall back to splitting text on blank lines.

Price

$2.00 per 1,000 transcripts, so $0.002 per episode that actually had a transcript. Episodes without one are not charged, and neither are failed downloads. A one-hour episode costs a fraction of a cent here, while speech-to-text is usually priced per audio minute. New Apify accounts get a monthly free credit, which covers a lot of test runs at this price. The Pricing tab on the Actor page is the authoritative number.

Limits, honestly

  • It only returns transcripts the publisher already put in the feed. It does no speech-to-text and never makes one up. With roughly 15-17% coverage in my sample, plenty of shows you care about won't have one.
  • You give it RSS feed URLs. There's no search by podcast name, because Apple's robots.txt disallows automated use of its search pages. The RSS link is usually on the show's website or host page.
  • It respects robots.txt on every feed and transcript host. Some hosts block all bots (changelog.com does), and those feeds are skipped and listed in the run summary.
  • Omny transcripts label speakers generically (Speaker 1, Speaker 2). Buzzsprout usually has real names.
  • RSS only (Atom podcast feeds are rare), public feeds only, no private or paywalled feeds. Files above 15 MB are refused by default (maxResponseMb).
  • The text belongs to the publisher. Using it for your own search or analysis is one thing; check the show's terms before you republish it.

If you try it on a feed and something parses badly, open an issue on the Actor page with the feed URL and I'll look at it.

Top comments (0)