Fetch the full transcript of any YouTube video or playlist by pasting a URL into Claude Code. Runs locally, no API keys.
⏱ ~10 seconds to install
What you get. Drop the skill in, paste a YouTube URL in your next message, Claude returns the transcript. Supports plain text, markdown with timestamps, SRT / VTT subtitle files, and JSON. Also handles whole playlists.
How it works. Cascading fallback — youtube-transcript-api first, yt-dlp as backup. Runs entirely on your machine, so there's no token cost for the fetch itself.
1
Install
Open Claude Code in any folder, paste the prompt below, hit enter. Claude will create the skill in your global skills folder (~/.claude/skills/youtube-transcript/), write both files, install Python deps, and verify the install.
Install a global Claude Code skill called "youtube-transcript". Do all of the following:
1. Create directory: ~/.claude/skills/youtube-transcript/scripts/
2. Write the file ~/.claude/skills/youtube-transcript/SKILL.md with EXACTLY this content (between the SKILL_START and SKILL_END markers, do not include the markers themselves):
===SKILL_START===
---
name: youtube-transcript
description: Fetch transcripts from any YouTube video or playlist using a local Python script (yt-dlp + youtube-transcript-api). Use whenever the user gives a YouTube URL and asks to get, fetch, download, extract, summarize, or analyze the transcript. Runs locally — no API keys, no cloud, no token cost for the fetch itself.
---
# YouTube Transcript Extractor
Extracts transcripts from YouTube videos and playlists using a bundled Python script with a cascading fallback (youtube-transcript-api → yt-dlp). Everything runs locally on the user's machine.
## When to use
Trigger this skill when the user:
- Pastes a YouTube URL and asks for the transcript
- Asks to summarize, analyze, or extract insights from a video
- Asks to download captions or subtitles
- Mentions a YouTube playlist they want transcripts from
## How to run it
The bundled script lives next to this `SKILL.md` at `scripts/yt_transcript.py`. From a Claude Code session, invoke it via Bash. The absolute path is:
```
~/.claude/skills/youtube-transcript/scripts/yt_transcript.py
```
### Default — plain text (best for feeding to Claude)
```bash
python3 ~/.claude/skills/youtube-transcript/scripts/yt_transcript.py "YOUTUBE_URL"
```
### Other formats
```bash
# Markdown with metadata header + timestamps
python3 ~/.claude/skills/youtube-transcript/scripts/yt_transcript.py "URL" --format markdown
# Timestamped plain text
python3 ~/.claude/skills/youtube-transcript/scripts/yt_transcript.py "URL" --format timestamped
# SRT / VTT subtitle files
python3 ~/.claude/skills/youtube-transcript/scripts/yt_transcript.py "URL" --format srt
python3 ~/.claude/skills/youtube-transcript/scripts/yt_transcript.py "URL" --format vtt
# Full structured JSON
python3 ~/.claude/skills/youtube-transcript/scripts/yt_transcript.py "URL" --format json
```
### Playlist (extracts every video)
```bash
python3 ~/.claude/skills/youtube-transcript/scripts/yt_transcript.py --playlist "PLAYLIST_URL" --format plain
```
### Save to file
```bash
python3 ~/.claude/skills/youtube-transcript/scripts/yt_transcript.py "URL" --format markdown -o transcript.md
```
### Non-English
```bash
python3 ~/.claude/skills/youtube-transcript/scripts/yt_transcript.py "URL" --lang es
```
## Format selection
| Goal | Format |
|------|--------|
| Feed to Claude for analysis/summary | `plain` (default) |
| Save a readable reference with timestamps | `markdown` |
| Quickly scan what's said when | `timestamped` |
| Generate subtitle files | `srt` or `vtt` |
| Programmatic processing | `json` |
## Behavior rules
- Default to `--format plain` when the user just wants the transcript or wants analysis.
- Use `-o filename.ext` when the user asks to save/download. Match the extension to the format (`.txt`, `.md`, `.srt`, `.vtt`, `.json`).
- For playlists, warn the user it may take a moment, then proceed.
- **Always present the full transcript.** Never truncate or summarize the raw output unless the user explicitly asks for a summary.
## Error handling
Relay the actual error message from stderr — do not use a generic failure response:
- **"Could not extract video ID from: …"** — URL is invalid. Ask the user to double-check it.
- **"All extraction methods failed for …"** — The video has no captions/subtitles available, or YouTube blocked the request.
- **Network timeout / connection error** — Ask the user to check their connection and retry.
- **`ImportError: No module named youtube_transcript_api` or `yt_dlp`** — Tell the user to install the missing dependency:
```bash
pip install youtube-transcript-api
brew install yt-dlp # or: pip install yt-dlp
```
## Dependencies
The script requires both packages to be installed on the user's machine:
- `youtube-transcript-api` (pip)
- `yt-dlp` (brew or pip)
If either is missing the script will print a clear `ImportError`. Walk the user through the install one-liner above.
===SKILL_END===
3. Write the file ~/.claude/skills/youtube-transcript/scripts/yt_transcript.py with EXACTLY this content (between SCRIPT_START and SCRIPT_END markers, do not include the markers themselves):
===SCRIPT_START===
#!/usr/bin/env python3
"""
yt_transcript.py — Lightweight YouTube transcript extractor for Claude Code.
Cascading fallback: youtube-transcript-api → yt-dlp subtitles → yt-dlp + innertube.
Based on YT Transcript Pro by BizzyForge.
Usage:
python3 yt_transcript.py <URL_or_ID> [--format markdown|plain|timestamped|srt|vtt|json] [--lang en]
python3 yt_transcript.py --playlist <PLAYLIST_URL> [--format markdown]
"""
import argparse
import json
import html
import os
import re
import sys
import tempfile
from pathlib import Path
# ---------------------------------------------------------------------------
# Video ID / URL helpers
# ---------------------------------------------------------------------------
def extract_video_id(url_or_id):
url_or_id = url_or_id.strip()
patterns = [
r'(?:youtube\.com/watch\?.*v=)([a-zA-Z0-9_-]{11})',
r'(?:youtu\.be/)([a-zA-Z0-9_-]{11})',
r'(?:youtube\.com/shorts/)([a-zA-Z0-9_-]{11})',
r'(?:youtube\.com/embed/)([a-zA-Z0-9_-]{11})',
r'(?:youtube\.com/live/)([a-zA-Z0-9_-]{11})',
]
for p in patterns:
m = re.search(p, url_or_id)
if m:
return m.group(1)
if re.match(r'^[a-zA-Z0-9_-]{11}$', url_or_id):
return url_or_id
raise ValueError(f"Could not extract video ID from: {url_or_id}")
def is_playlist_url(url):
return "playlist" in url or ("list=" in url and "watch" not in url)
# ---------------------------------------------------------------------------
# Timestamp formatting
# ---------------------------------------------------------------------------
def fmt_ts(seconds):
total = int(seconds)
h, rem = divmod(total, 3600)
m, s = divmod(rem, 60)
return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
def fmt_srt_ts(seconds):
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
s = int(seconds % 60)
ms = int((seconds - int(seconds)) * 1000)
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
def fmt_vtt_ts(seconds):
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
s = int(seconds % 60)
ms = int((seconds - int(seconds)) * 1000)
return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}"
# ---------------------------------------------------------------------------
# Layer 1: youtube-transcript-api
# ---------------------------------------------------------------------------
def extract_via_api(video_id, languages=None):
languages = languages or ["en"]
try:
from youtube_transcript_api import YouTubeTranscriptApi
except ImportError:
return None
try:
api = YouTubeTranscriptApi()
tl = api.list(video_id)
t = tl.find_transcript(languages)
fetched = t.fetch()
segments = [
{"text": s.text.strip(), "start": s.start, "duration": s.duration}
for s in fetched
]
return {
"video_id": video_id,
"title": "", # filled later
"language": t.language,
"language_code": t.language_code,
"is_generated": t.is_generated,
"method": "youtube-transcript-api",
"segments": segments,
}
except Exception as e:
print(f"[Layer 1 youtube-transcript-api] {e}", file=sys.stderr)
return None
# ---------------------------------------------------------------------------
# Layer 2: yt-dlp subtitle download
# ---------------------------------------------------------------------------
def extract_via_ytdlp(video_id, languages=None):
languages = languages or ["en"]
try:
import yt_dlp
except ImportError:
return None
try:
url = f"https://www.youtube.com/watch?v={video_id}"
with tempfile.TemporaryDirectory() as tmpdir:
ydl_opts = {
"writesubtitles": True,
"writeautomaticsub": True,
"subtitleslangs": languages,
"subtitlesformat": "json3",
"skip_download": True,
"outtmpl": os.path.join(tmpdir, "%(id)s.%(ext)s"),
"quiet": True,
"no_warnings": True,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=True)
title = info.get("title", "Unknown")
sub_files = list(Path(tmpdir).glob("*.json3"))
if not sub_files:
return None
with open(sub_files[0], "r", encoding="utf-8") as f:
sub_data = json.load(f)
segments = []
for event in sub_data.get("events", []):
if "segs" not in event:
continue
parts = [s.get("utf8", "").strip() for s in event["segs"] if s.get("utf8", "").strip() and s.get("utf8", "").strip() != "\n"]
if parts:
segments.append({
"text": " ".join(parts),
"start": event.get("tStartMs", 0) / 1000.0,
"duration": event.get("dDurationMs", 0) / 1000.0,
})
if not segments:
return None
lang_code = sub_files[0].stem.split(".")[-1] if "." in sub_files[0].stem else "en"
return {
"video_id": video_id,
"title": title,
"language": lang_code,
"language_code": lang_code,
"is_generated": True,
"method": "yt-dlp",
"segments": segments,
}
except Exception as e:
print(f"[Layer 2 yt-dlp] {e}", file=sys.stderr)
return None
# ---------------------------------------------------------------------------
# Metadata fetch (for title when layer 1 doesn't provide it)
# ---------------------------------------------------------------------------
def fetch_title(video_id):
try:
import yt_dlp
url = f"https://www.youtube.com/watch?v={video_id}"
with yt_dlp.YoutubeDL({"quiet": True, "no_warnings": True, "skip_download": True}) as ydl:
info = ydl.extract_info(url, download=False)
return info.get("title", "Unknown")
except Exception:
return "Unknown"
# ---------------------------------------------------------------------------
# Orchestrator
# ---------------------------------------------------------------------------
def extract_transcript(url_or_id, languages=None):
video_id = extract_video_id(url_or_id)
languages = languages or ["en"]
# Try Layer 1
result = extract_via_api(video_id, languages)
if result and result["segments"]:
if not result["title"]:
result["title"] = fetch_title(video_id)
return result
# Try Layer 2
result = extract_via_ytdlp(video_id, languages)
if result and result["segments"]:
return result
raise RuntimeError(f"All extraction methods failed for {video_id}")
def resolve_playlist(playlist_url):
import yt_dlp
ydl_opts = {"quiet": True, "no_warnings": True, "extract_flat": True, "skip_download": True}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(playlist_url, download=False)
entries = info.get("entries", [])
return [{"id": e["id"], "title": e.get("title", "Unknown")} for e in entries if e]
# ---------------------------------------------------------------------------
# Formatters
# ---------------------------------------------------------------------------
def format_result(result, fmt="plain"):
segs = result["segments"]
title = result["title"]
vid = result["video_id"]
url = f"https://www.youtube.com/watch?v={vid}"
full_text = " ".join(s["text"] for s in segs)
word_count = len(full_text.split())
duration = fmt_ts(segs[-1]["start"] + segs[-1]["duration"]) if segs else "0:00"
if fmt == "plain":
return full_text
elif fmt == "timestamped":
return "\n".join(f"[{fmt_ts(s['start'])}] {s['text']}" for s in segs)
elif fmt == "markdown":
lines = [
f"# {title}",
"",
f"**Video:** [{vid}]({url})",
f"**Language:** {result['language']} ({result['language_code']})",
f"**Auto-generated:** {'Yes' if result['is_generated'] else 'No'}",
f"**Method:** {result['method']}",
f"**Duration:** {duration}",
f"**Words:** {word_count:,}",
"",
"---",
"",
]
for s in segs:
lines.append(f"**[{fmt_ts(s['start'])}]** {s['text']}")
lines.append("")
return "\n".join(lines)
elif fmt == "srt":
lines = []
for i, s in enumerate(segs, 1):
end = s["start"] + s["duration"]
lines.extend([str(i), f"{fmt_srt_ts(s['start'])} --> {fmt_srt_ts(end)}", s["text"], ""])
return "\n".join(lines)
elif fmt == "vtt":
lines = ["WEBVTT", ""]
for s in segs:
end = s["start"] + s["duration"]
lines.extend([f"{fmt_vtt_ts(s['start'])} --> {fmt_vtt_ts(end)}", s["text"], ""])
return "\n".join(lines)
elif fmt == "json":
return json.dumps(result, indent=2, ensure_ascii=False)
else:
return full_text
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description="YouTube transcript extractor")
parser.add_argument("url", nargs="?", help="YouTube video URL or ID")
parser.add_argument("--playlist", help="YouTube playlist URL")
parser.add_argument("-f", "--format", default="plain",
choices=["plain", "markdown", "timestamped", "srt", "vtt", "json"])
parser.add_argument("-l", "--lang", default="en", help="Language code (default: en)")
parser.add_argument("-o", "--output", help="Output file path")
args = parser.parse_args()
if not args.url and not args.playlist:
parser.error("Provide a video URL/ID or --playlist URL")
languages = [l.strip() for l in args.lang.split(",")]
if args.playlist:
videos = resolve_playlist(args.playlist)
print(f"Found {len(videos)} videos in playlist", file=sys.stderr)
all_output = []
for i, v in enumerate(videos):
print(f"[{i+1}/{len(videos)}] {v['title']}", file=sys.stderr)
try:
result = extract_transcript(v["id"], languages)
all_output.append(format_result(result, args.format))
except Exception as e:
print(f" FAILED: {e}", file=sys.stderr)
all_output.append(f"[Error extracting {v['title']}]: {e}")
output = ("\n\n" + "=" * 60 + "\n\n").join(all_output)
else:
result = extract_transcript(args.url, languages)
output = format_result(result, args.format)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(output)
print(f"Saved to {args.output}", file=sys.stderr)
else:
print(output)
if __name__ == "__main__":
main()
===SCRIPT_END===
4. Make the script executable: chmod +x ~/.claude/skills/youtube-transcript/scripts/yt_transcript.py
5. Install Python dependencies on my machine:
pip3 install --user youtube-transcript-api yt-dlp
(If you have Homebrew, also run: brew install yt-dlp)
6. Verify by listing the installed files and running a quick test:
ls -la ~/.claude/skills/youtube-transcript/
python3 ~/.claude/skills/youtube-transcript/scripts/yt_transcript.py --help
When done, tell me the skill is installed and that I can restart Claude Code or start a new session to use it. The trigger phrase is anything like "transcript of [YouTube URL]" or I can invoke it explicitly with /youtube-transcript.
Paste once
Paste this single message into Claude Code. Takes ~10 seconds. After it finishes, restart Claude Code (or start a new session) so the skill loads.
Try it
Start a new Claude Code session and paste this:
Get the transcript of https://www.youtube.com/watch?v=dQw4w9WgXcQ
If the skill is installed correctly, Claude will return the full transcript of the video. If not, the troubleshooting section below covers the usual culprits.
2
How to use it
Once installed, just talk to Claude normally. The skill auto-triggers on YouTube URLs, or invoke it explicitly with /youtube-transcript.
Example prompts
"Grab the transcript of https://youtu.be/abc123"
"Summarize this video: https://www.youtube.com/watch?v=xyz"
"Save the transcript of this video as markdown with timestamps"
"Get transcripts for every video in this playlist: https://www.youtube.com/playlist?list=..."
"Pull the Spanish captions from this video"
Formats available
You want
Format flag
Plain text (best for Claude to analyze)
--format plain (default)
Markdown with metadata + timestamps
--format markdown
Timestamped plain text
--format timestamped
SRT subtitles
--format srt
WebVTT subtitles
--format vtt
Structured JSON
--format json
3
Troubleshooting
"Could not extract video ID from: ..."
The URL is malformed. Double-check it actually points to a YouTube video. Supported formats: youtube.com/watch?v=, youtu.be/, youtube.com/shorts/, youtube.com/embed/, or a bare 11-character video ID.
"All extraction methods failed for ..."
The video genuinely has no captions, or YouTube is rate-limiting your IP. Try a different video to confirm the skill is working; if it's just one video, that video has no transcript available.
"ImportError: No module named youtube_transcript_api"
You skipped the pip install. Run:
pip3 install --user youtube-transcript-api yt-dlp
Skill not found / doesn't auto-trigger
Run ls ~/.claude/skills/youtube-transcript/ — you should see SKILL.md and a scripts/ directory. If they're missing, the install didn't complete. Restart Claude Code after installing so it picks up the new skill. You can also force-invoke it with /youtube-transcript.