Scrape YouTube videos: $ARGUMENTS
$ARGUMENTS should be one of:
- A YouTube channel handle (e.g.
@AlanHirsch) or channel ID (e.g. UCxVxcTULO9cFU6SB9qVaisQ)
--video VIDEO_ID to scrape a single video
--refresh to re-fetch stats for all existing videos without re-downloading transcripts
- Empty — ask the user for the channel handle
Before Starting
- Confirm
YOUTUBE_API_KEY is set in .env.local — if not, tell the user to add it
- Confirm
yt-dlp is installed (which yt-dlp) — if not, tell the user to install it (pip install yt-dlp)
- Read
src/lib/database/schema.ts for the videos, videoSeries table definitions
- Read
scripts/youtube-scrape.ts to understand the existing scrape script
Pipeline Stages
Stage 1 — Fetch All Videos from Channel
- Resolve channel ID: If given a handle (
@Name), call the YouTube Data API v3 channels?part=id&forHandle=@Name to get the UC... channel ID
- Derive uploads playlist: Replace
UC prefix with UU to get the uploads playlist ID
- Paginate playlist: Call
playlistItems.list with maxResults=50, following nextPageToken until exhausted
- Enrich with details: Call
videos.list in batches of 50 (part=snippet,contentDetails,statistics) to get full metadata
- Delta check: Compare fetched
video_id list against existing external_id values in the videos table — only download transcripts for new videos
Stage 2 — Download & Parse Transcripts
For each new video (not already in DB with a transcript):
- Run
yt-dlp --write-auto-subs --sub-lang en --skip-download -o "data/vtt/%(id)s" "https://youtube.com/watch?v=VIDEO_ID" with a 60-second timeout
- Parse the resulting
.en.vtt file:
- Strip VTT headers, cue IDs, positioning metadata
- Deduplicate repeated lines (auto-subs repeat heavily)
- Extract
{ start, end, text } segments
- Concatenate into
fullText
- If no VTT file produced, set transcript to
null and continue
Stage 3 — 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_time, end_time, timestamp_url (deep-link to that point in the video)
- Absorb tiny final chunks (< 75 words) into the previous chunk
Store chunks in a JSON field or separate table as appropriate.
Stage 4 — Fetch Comments (Optional)
Call commentThreads.list with maxResults=100&order=relevance, paginating with nextPageToken:
- Handle 403 gracefully (comments disabled) — return
[]
- Extract:
comment_id, author, text, like_count, published_at
- Per-video errors should not crash the pipeline
Database Mapping
Map YouTube fields to the existing videos table:
| YouTube Field |
DB Column |
item.id |
external_id |
item.snippet.title |
title |
item.snippet.title (slugified) |
slug |
item.snippet.description |
description |
https://youtube.com/watch?v={id} |
video_url |
item.snippet.thumbnails.maxres?.url |
thumbnail_url |
item.contentDetails.duration (parsed) |
duration_seconds |
item.statistics.viewCount |
view_count |
item.statistics.likeCount |
like_count |
item.statistics.commentCount |
comment_count |
item.snippet.tags |
tags (jsonb) |
item.snippet.publishedAt |
published_at |
| Parsed transcript fullText |
transcript |
'youtube' |
hosting_provider |
<iframe> embed string |
embed_code |
'published' |
status |
Key Design Rules
- Idempotent — Use upserts keyed on
external_id. Safe to re-run.
- Resume-safe — Skip transcript download if VTT file already exists in
data/vtt/
- Incremental — Only download transcripts for new videos. Always refresh stats.
- Graceful degradation — 403 returns
[]; per-video errors don't crash the pipeline
- All timestamps UTC — ISO 8601 throughout
- Batch operations — 50 per YouTube API call; small delays between yt-dlp downloads
Running
pnpm youtube:scrape -- --channel @AlanHirsch
pnpm youtube:scrape -- --video dQw4w9WgXcQ
pnpm youtube:scrape -- --refresh
Output Format
## YouTube Scrape Report
### Channel: [name] ([handle])
### Videos found: N total, M new
### Stage 1 — Metadata: OK
- Fetched: N videos
- New (not in DB): M
### Stage 2 — Transcripts: OK
- Downloaded: X
- Skipped (already exists): Y
- Failed (no subs): Z
### Stage 3 — Chunks: OK
- Total chunks: N across M videos
### Stage 4 — Comments: OK / SKIPPED
- Fetched: N comments across M videos
### Database:
- Upserted: N videos
- Stats refreshed: M videos
### Warnings:
- [any non-blocking issues]
Quota Notes
- YouTube Data API v3 free tier: 10,000 units/day
playlistItems.list = 1 unit, videos.list = 1 unit, commentThreads.list = 1 unit
- A channel with 100 videos uses ~104 units per full run
- yt-dlp has no official rate limit but add small delays between downloads
Error Handling
- Missing
YOUTUBE_API_KEY → stop immediately with instructions
- Missing
yt-dlp → stop immediately with install instructions
- Individual video transcript failure → log warning, continue pipeline
- Comments disabled (403) → return empty array, continue
- API quota exceeded (403 with reason
quotaExceeded) → stop and report progress so far
1---2name: youtube-scrape-23description: Scrape YouTube channel videos — fetches metadata, downloads transcripts via yt-dlp, chunks for search, and pulls comments. Upserts into the videos table.4---56Scrape YouTube videos: $ARGUMENTS78$ARGUMENTS should be one of:9- A YouTube channel handle (e.g. `@AlanHirsch`) or channel ID (e.g. `UCxVxcTULO9cFU6SB9qVaisQ`)10- `--video VIDEO_ID` to scrape a single video11- `--refresh` to re-fetch stats for all existing videos without re-downloading transcripts12- Empty — ask the user for the channel handle1314## Before Starting15161. Confirm `YOUTUBE_API_KEY` is set in `.env.local` — if not, tell the user to add it172. Confirm `yt-dlp` is installed (`which yt-dlp`) — if not, tell the user to install it (`pip install yt-dlp`)183. Read `src/lib/database/schema.ts` for the `videos`, `videoSeries` table definitions194. Read `scripts/youtube-scrape.ts` to understand the existing scrape script2021## Pipeline Stages2223### Stage 1 — Fetch All Videos from Channel24251. **Resolve channel ID**: If given a handle (`@Name`), call the YouTube Data API v3 `channels?part=id&forHandle=@Name` to get the `UC...` channel ID262. **Derive uploads playlist**: Replace `UC` prefix with `UU` to get the uploads playlist ID273. **Paginate playlist**: Call `playlistItems.list` with `maxResults=50`, following `nextPageToken` until exhausted284. **Enrich with details**: Call `videos.list` in batches of 50 (`part=snippet,contentDetails,statistics`) to get full metadata295. **Delta check**: Compare fetched `video_id` list against existing `external_id` values in the `videos` table — only download transcripts for new videos3031### Stage 2 — Download & Parse Transcripts3233For each **new** video (not already in DB with a transcript):34351. Run `yt-dlp --write-auto-subs --sub-lang en --skip-download -o "data/vtt/%(id)s" "https://youtube.com/watch?v=VIDEO_ID"` with a 60-second timeout362. Parse the resulting `.en.vtt` file:37 - Strip VTT headers, cue IDs, positioning metadata38 - Deduplicate repeated lines (auto-subs repeat heavily)39 - Extract `{ start, end, text }` segments40 - Concatenate into `fullText`413. If no VTT file produced, set transcript to `null` and continue4243### Stage 3 — Chunk Transcripts for Search4445Split each transcript into chunks for embedding/search:4647- **Target**: 500 words per chunk48- **Overlap**: 75 words49- **Stride**: 425 words (500 - 75)50- Each chunk gets: `chunk_index`, `text`, `start_time`, `end_time`, `timestamp_url` (deep-link to that point in the video)51- Absorb tiny final chunks (< 75 words) into the previous chunk5253Store chunks in a JSON field or separate table as appropriate.5455### Stage 4 — Fetch Comments (Optional)5657Call `commentThreads.list` with `maxResults=100&order=relevance`, paginating with `nextPageToken`:5859- Handle 403 gracefully (comments disabled) — return `[]`60- Extract: `comment_id`, `author`, `text`, `like_count`, `published_at`61- Per-video errors should not crash the pipeline6263## Database Mapping6465Map YouTube fields to the existing `videos` table:6667| YouTube Field | DB Column |68|---|---|69| `item.id` | `external_id` |70| `item.snippet.title` | `title` |71| `item.snippet.title` (slugified) | `slug` |72| `item.snippet.description` | `description` |73| `https://youtube.com/watch?v={id}` | `video_url` |74| `item.snippet.thumbnails.maxres?.url` | `thumbnail_url` |75| `item.contentDetails.duration` (parsed) | `duration_seconds` |76| `item.statistics.viewCount` | `view_count` |77| `item.statistics.likeCount` | `like_count` |78| `item.statistics.commentCount` | `comment_count` |79| `item.snippet.tags` | `tags` (jsonb) |80| `item.snippet.publishedAt` | `published_at` |81| Parsed transcript fullText | `transcript` |82| `'youtube'` | `hosting_provider` |83| `<iframe>` embed string | `embed_code` |84| `'published'` | `status` |8586## Key Design Rules8788- **Idempotent** — Use upserts keyed on `external_id`. Safe to re-run.89- **Resume-safe** — Skip transcript download if VTT file already exists in `data/vtt/`90- **Incremental** — Only download transcripts for new videos. Always refresh stats.91- **Graceful degradation** — 403 returns `[]`; per-video errors don't crash the pipeline92- **All timestamps UTC** — ISO 8601 throughout93- **Batch operations** — 50 per YouTube API call; small delays between yt-dlp downloads9495## Running9697```bash98pnpm youtube:scrape -- --channel @AlanHirsch99pnpm youtube:scrape -- --video dQw4w9WgXcQ100pnpm youtube:scrape -- --refresh101```102103## Output Format104105```106## YouTube Scrape Report107108### Channel: [name] ([handle])109### Videos found: N total, M new110111### Stage 1 — Metadata: OK112- Fetched: N videos113- New (not in DB): M114115### Stage 2 — Transcripts: OK116- Downloaded: X117- Skipped (already exists): Y118- Failed (no subs): Z119120### Stage 3 — Chunks: OK121- Total chunks: N across M videos122123### Stage 4 — Comments: OK / SKIPPED124- Fetched: N comments across M videos125126### Database:127- Upserted: N videos128- Stats refreshed: M videos129130### Warnings:131- [any non-blocking issues]132```133134## Quota Notes135136- YouTube Data API v3 free tier: 10,000 units/day137- `playlistItems.list` = 1 unit, `videos.list` = 1 unit, `commentThreads.list` = 1 unit138- A channel with 100 videos uses ~104 units per full run139- yt-dlp has no official rate limit but add small delays between downloads140141## Error Handling142143- Missing `YOUTUBE_API_KEY` → stop immediately with instructions144- Missing `yt-dlp` → stop immediately with install instructions145- Individual video transcript failure → log warning, continue pipeline146- Comments disabled (403) → return empty array, continue147- API quota exceeded (403 with reason `quotaExceeded`) → stop and report progress so far