Scrape audio sources: $ARGUMENTS
$ARGUMENTS should be one of:
- A person/topic name to search (e.g.
"Alan Hirsch") — discovers podcasts via iTunes
--rss https://feed.url/rss to scrape a specific RSS feed
--episode-url https://example.com/episode.mp3 --title "Episode Title" to scrape a single audio file
--refresh to re-transcribe episodes that don't have transcripts yet
- Empty — ask the user for the search term
Before Starting
- Confirm
OPENAI_API_KEY is set in .env.local — needed for Whisper transcription
- Read
src/lib/database/schema.ts for the podcastEpisodes, podcastSeries table definitions
- Read
scripts/audio-scrape.ts to understand the existing scrape script
- Optionally confirm
ffmpeg is installed (which ffmpeg) — needed for splitting large audio files (>25MB)
Pipeline Stages
Stage 1 — Discover Podcasts
- iTunes Search API (free, no key needed): Search
https://itunes.apple.com/search?term={query}&media=podcast
- Multiple query variations:
"{name}", "{name} podcast", "{name} interview", "{name} sermon"
- Deduplicate feeds: Track by
feedUrl to avoid duplicate processing
- Delta check: Compare fetched episodes against existing
external_id values in podcast_episodes
Stage 2 — Parse RSS Feeds
For each discovered feed:
- Fetch the RSS/XML feed with a User-Agent header
- Parse
<item> elements extracting:
<title> → episode title
<enclosure url="..."> → audio URL (required, skip items without it)
<itunes:duration> → duration in seconds
<itunes:episode>, <itunes:season> → numbering
<pubDate> → published date (convert to ISO 8601)
<itunes:summary> or <description> → episode description
<itunes:image href="..."> → thumbnail
- Filter relevance: Only keep episodes where the search term appears in title, description, or series name
Stage 3 — Download & Transcribe
For each new episode (not already in DB with a transcript):
- Download audio via
curl to data/audio/{external_id}.mp3 with caching
- Transcribe via OpenAI Whisper API (
whisper-1 model):
- Files ≤ 25MB: single API call
- Files > 25MB: split into 20-minute chunks via
ffmpeg, transcribe each, concatenate
- If transcription fails, set transcript to
null and continue
Stage 4 — Chunk Transcripts for Search
Split each transcript into chunks for embedding/search:
- Target: 500 words per chunk
- Overlap: 75 words
- Stride: 425 words (500 - 75)
- Each chunk gets:
chunk_index, text, start_offset
- Absorb tiny final chunks (< 75 words) into the previous chunk
Stage 5 — Database Upsert
Upsert into podcast_episodes and podcast_series tables.
Database Mapping
Map RSS/audio fields to the podcast_episodes table:
| RSS Field |
DB Column |
<title> |
title |
| title (slugified + external_id suffix) |
slug |
<itunes:summary> or <description> |
description |
<enclosure url> |
audio_url |
<itunes:duration> (parsed) |
duration_seconds |
<itunes:episode> |
episode_number |
<itunes:season> |
season_number |
<pubDate> (ISO 8601) |
published_at |
<itunes:image> or channel image |
thumbnail_url |
| hash of audio_url |
external_id |
<link> |
external_url |
| Whisper transcript |
transcript |
'rss' |
hosting_provider |
'podcast' / 'sermon' / 'audio' |
source_type |
'published' |
status |
Key Design Rules
- Idempotent — Use upserts keyed on
external_id (hash of audio URL). Safe to re-run.
- Resume-safe — Skip audio download if file already exists in
data/audio/
- Incremental — Only download/transcribe new episodes
- Graceful degradation — Per-episode failures don't crash the pipeline
- All timestamps UTC — ISO 8601 throughout
- Pace API calls — 1-second delay between Whisper calls, 200ms between RSS fetches
Running
pnpm audio:scrape -- --search "Alan Hirsch"
pnpm audio:scrape -- --rss https://feed.example.com/rss
pnpm audio:scrape -- --episode-url https://example.com/episode.mp3 --title "My Episode"
pnpm audio:scrape -- --refresh
pnpm audio:scrape -- --search "Alan Hirsch" --max 10
pnpm audio:scrape -- --search "Alan Hirsch" --skip-transcribe
pnpm audio:scrape -- --search "Alan Hirsch" --source-type sermon
Output Format
## Audio Scrape Report
### Search: [query]
### Podcasts found: N feeds, M relevant episodes
### Stage 1 — Discovery: OK
- Feeds found: N
- Episodes parsed: M
### Stage 2 — Relevance filter: OK
- Relevant episodes: N
### Stage 3 — Transcripts: OK
- Transcribed: X
- Skipped (already exists): Y
- Failed: Z
### Stage 4 — Chunks: OK
- Total chunks: N across M episodes
### Stage 5 — Database:
- Series upserted: N
- Episodes inserted: X, updated: Y
### Warnings:
- [any non-blocking issues]
Cost Notes
- iTunes Search API: Free, no key needed
- RSS feeds: Free, public
- OpenAI Whisper API: ~$0.006/minute of audio
- A 1-hour episode costs ~$0.36
- 100 episodes averaging 45 min each = ~$27
- Audio downloads: bandwidth only; files cached in
data/audio/
- Large files (>25MB) require
ffmpeg for splitting
Error Handling
- Missing
OPENAI_API_KEY → stop immediately with instructions
- Individual episode download failure → log warning, continue pipeline
- Whisper API error → log warning, set transcript null, continue
- RSS parse failure → log warning, skip feed, continue
- Large file without
ffmpeg → attempt direct transcription (may fail for >25MB)
1---2name: audio-scrape-23description: Scrape podcast episodes, sermons, and audio sources — discovers via iTunes Search API, parses RSS feeds, downloads audio, transcribes via OpenAI Whisper, chunks for search, and upserts into the podcast_episodes table.4---56Scrape audio sources: $ARGUMENTS78$ARGUMENTS should be one of:9- A person/topic name to search (e.g. `"Alan Hirsch"`) — discovers podcasts via iTunes10- `--rss https://feed.url/rss` to scrape a specific RSS feed11- `--episode-url https://example.com/episode.mp3 --title "Episode Title"` to scrape a single audio file12- `--refresh` to re-transcribe episodes that don't have transcripts yet13- Empty — ask the user for the search term1415## Before Starting16171. Confirm `OPENAI_API_KEY` is set in `.env.local` — needed for Whisper transcription182. Read `src/lib/database/schema.ts` for the `podcastEpisodes`, `podcastSeries` table definitions193. Read `scripts/audio-scrape.ts` to understand the existing scrape script204. Optionally confirm `ffmpeg` is installed (`which ffmpeg`) — needed for splitting large audio files (>25MB)2122## Pipeline Stages2324### Stage 1 — Discover Podcasts25261. **iTunes Search API** (free, no key needed): Search `https://itunes.apple.com/search?term={query}&media=podcast`272. **Multiple query variations**: `"{name}"`, `"{name} podcast"`, `"{name} interview"`, `"{name} sermon"`283. **Deduplicate feeds**: Track by `feedUrl` to avoid duplicate processing294. **Delta check**: Compare fetched episodes against existing `external_id` values in `podcast_episodes`3031### Stage 2 — Parse RSS Feeds3233For each discovered feed:34351. Fetch the RSS/XML feed with a User-Agent header362. Parse `<item>` elements extracting:37 - `<title>` → episode title38 - `<enclosure url="...">` → audio URL (required, skip items without it)39 - `<itunes:duration>` → duration in seconds40 - `<itunes:episode>`, `<itunes:season>` → numbering41 - `<pubDate>` → published date (convert to ISO 8601)42 - `<itunes:summary>` or `<description>` → episode description43 - `<itunes:image href="...">` → thumbnail443. **Filter relevance**: Only keep episodes where the search term appears in title, description, or series name4546### Stage 3 — Download & Transcribe4748For each **new** episode (not already in DB with a transcript):49501. Download audio via `curl` to `data/audio/{external_id}.mp3` with caching512. Transcribe via OpenAI Whisper API (`whisper-1` model):52 - Files ≤ 25MB: single API call53 - Files > 25MB: split into 20-minute chunks via `ffmpeg`, transcribe each, concatenate543. If transcription fails, set transcript to `null` and continue5556### Stage 4 — Chunk Transcripts for Search5758Split each transcript into chunks for embedding/search:5960- **Target**: 500 words per chunk61- **Overlap**: 75 words62- **Stride**: 425 words (500 - 75)63- Each chunk gets: `chunk_index`, `text`, `start_offset`64- Absorb tiny final chunks (< 75 words) into the previous chunk6566### Stage 5 — Database Upsert6768Upsert into `podcast_episodes` and `podcast_series` tables.6970## Database Mapping7172Map RSS/audio fields to the `podcast_episodes` table:7374| RSS Field | DB Column |75|---|---|76| `<title>` | `title` |77| title (slugified + external_id suffix) | `slug` |78| `<itunes:summary>` or `<description>` | `description` |79| `<enclosure url>` | `audio_url` |80| `<itunes:duration>` (parsed) | `duration_seconds` |81| `<itunes:episode>` | `episode_number` |82| `<itunes:season>` | `season_number` |83| `<pubDate>` (ISO 8601) | `published_at` |84| `<itunes:image>` or channel image | `thumbnail_url` |85| hash of audio_url | `external_id` |86| `<link>` | `external_url` |87| Whisper transcript | `transcript` |88| `'rss'` | `hosting_provider` |89| `'podcast'` / `'sermon'` / `'audio'` | `source_type` |90| `'published'` | `status` |9192## Key Design Rules9394- **Idempotent** — Use upserts keyed on `external_id` (hash of audio URL). Safe to re-run.95- **Resume-safe** — Skip audio download if file already exists in `data/audio/`96- **Incremental** — Only download/transcribe new episodes97- **Graceful degradation** — Per-episode failures don't crash the pipeline98- **All timestamps UTC** — ISO 8601 throughout99- **Pace API calls** — 1-second delay between Whisper calls, 200ms between RSS fetches100101## Running102103```bash104pnpm audio:scrape -- --search "Alan Hirsch"105pnpm audio:scrape -- --rss https://feed.example.com/rss106pnpm audio:scrape -- --episode-url https://example.com/episode.mp3 --title "My Episode"107pnpm audio:scrape -- --refresh108pnpm audio:scrape -- --search "Alan Hirsch" --max 10109pnpm audio:scrape -- --search "Alan Hirsch" --skip-transcribe110pnpm audio:scrape -- --search "Alan Hirsch" --source-type sermon111```112113## Output Format114115```116## Audio Scrape Report117118### Search: [query]119### Podcasts found: N feeds, M relevant episodes120121### Stage 1 — Discovery: OK122- Feeds found: N123- Episodes parsed: M124125### Stage 2 — Relevance filter: OK126- Relevant episodes: N127128### Stage 3 — Transcripts: OK129- Transcribed: X130- Skipped (already exists): Y131- Failed: Z132133### Stage 4 — Chunks: OK134- Total chunks: N across M episodes135136### Stage 5 — Database:137- Series upserted: N138- Episodes inserted: X, updated: Y139140### Warnings:141- [any non-blocking issues]142```143144## Cost Notes145146- iTunes Search API: Free, no key needed147- RSS feeds: Free, public148- OpenAI Whisper API: ~$0.006/minute of audio149 - A 1-hour episode costs ~$0.36150 - 100 episodes averaging 45 min each = ~$27151- Audio downloads: bandwidth only; files cached in `data/audio/`152- Large files (>25MB) require `ffmpeg` for splitting153154## Error Handling155156- Missing `OPENAI_API_KEY` → stop immediately with instructions157- Individual episode download failure → log warning, continue pipeline158- Whisper API error → log warning, set transcript null, continue159- RSS parse failure → log warning, skip feed, continue160- Large file without `ffmpeg` → attempt direct transcription (may fail for >25MB)