
Youtube Transcript
- 11 installs
- 3 repo stars
- Updated February 28, 2026
- fabriqaai/fabriqaai-youtube-transcript
Helps with ai & agent building tasks.
About
youtube-transcript is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- youtube-transcript
- AI & Agent Building
- AI-coding skill
Youtube Transcript by the numbers
- 11 all-time installs (skills.sh)
- Ranked #11,740 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 31, 2026 (Skillselion catalog sync)
npx skills add https://github.com/fabriqaai/fabriqaai-youtube-transcript --skill youtube-transcriptAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 11 |
|---|---|
| repo stars | ★ 3 |
| Last updated | February 28, 2026 |
| Repository | fabriqaai/fabriqaai-youtube-transcript ↗ |
What it does
Helps with ai & agent building tasks.
Files
YouTube Transcript Skill
Use this skill to fetch transcripts and transcript languages with a standalone Python CLI tool (no MCP server).
Prerequisites
Install dependency:
python3 -m pip install youtube-transcript-apiPackage Knowledge: youtube-transcript-api
Teach and apply these package facts when reasoning about failures or writing code:
- Package role:
- fetch transcript text for a YouTube video
- list available transcript languages
- detect generated vs manual captions
- Common API patterns across versions:
- static-style:
YouTubeTranscriptApi.get_transcript(video_id, languages=[...]) - class list:
YouTubeTranscriptApi.list_transcripts(video_id) - instance-style (newer variants):
api = YouTubeTranscriptApi(); api.list(video_id); api.fetch(video_id, languages=[...])
- Typical transcript item fields:
textstartduration
- Typical language fields:
language_codelanguageis_generated
- Common exception names you should expect and map:
InvalidVideoIdTranscriptsDisabledNoTranscriptFoundVideoUnavailableTooManyRequestsCouldNotRetrieveTranscript
- Reliability guidance:
- try requested language first, then fallback to first available language
- for AI summary workflows, prefer transcript retrieval with
--include-timestamps false - keep tool output structured JSON so downstream AI agents can parse reliably
Core Contract
This skill provides transcript retrieval. Summary and analysis must be done by AI.
- Use the CLI for data retrieval (
transcript,languages). - Do summary/key points/quotes using an AI agent.
- Prefer sub-agent summarization when available to preserve main-context space.
Workflow
1. Extract a YouTube URL or 11-character video ID. 2. Decide output type:
- Full transcript requested: fetch transcript and return it.
- Summary requested: fetch transcript first, then summarize with AI.
3. Context-saving rule for summary:
- If sub-agents are available, run transcript retrieval + summarization in a sub-agent.
- If sub-agents are unavailable, summarize in the current agent after retrieval.
4. For long videos, prefer transcript fetch with --include-timestamps false unless timestamps are explicitly requested.
Sub-Agent Guidance
When available, use this flow:
1. Sub-agent runs transcript fetch command. 2. Sub-agent summarizes transcript based on user intent. 3. Sub-agent returns concise summary (and optional key quotes). 4. Main agent returns the synthesized result without dumping full raw transcript unless the user asks.
Commands
Get transcript with timestamps:
python3 youtube_transcript_tool.py \
--mode transcript \
--url "https://www.youtube.com/watch?v=VIDEO_ID" \
--lang en \
--include-timestamps trueGet transcript as plain text (recommended for summarization):
python3 youtube_transcript_tool.py \
--mode transcript \
--url "VIDEO_ID" \
--include-timestamps falseList available transcript languages:
python3 youtube_transcript_tool.py \
--mode languages \
--url "https://youtu.be/VIDEO_ID"Output Contract
Default output is JSON (--json true).
Success payload:
ok: true- includes
mode,video_id, and mode-specific payload
Error payload:
ok: falseerror.codeerror.messageerror.hint
Use --json false for readable text output.
Error Handling Guidance
When the tool fails, surface the hint directly and suggest one practical next step:
invalid_video_id: check URL format or use raw 11-char IDtranscripts_disabled: captions disabled by publisherno_transcript: try another language or auto-generated captionsvideo_unavailable: video is private/deleted/restrictedrate_limited: retry later or from a different networkdependency_missing: installyoutube-transcript-api
Defaults
--lang en--include-timestamps true--json true
fabriqaai-youtube-transcript
Standalone YouTube transcript skill and CLI toolkit (no MCP server).
This repository is designed for agent workflows where transcript retrieval is handled by Python, and summary/insight generation is handled by AI (preferably in a sub-agent to save context).
Compatibility
This skill works with any agent host that can:
- load
skills.sh-style skills (SKILL.md) - execute local Python commands
- optionally run an AI sub-agent/tool for summarization
What This Repo Provides
SKILL.md: agent instructions for transcript workflowsyoutube_transcript_tool.py: Python CLI for transcript/language retrievaltests/: unit tests for parsing, mode routing, and payload shape
Why No MCP
This project intentionally avoids MCP transport.
- Retrieval: done locally with Python +
youtube-transcript-api - Summarization: done by AI agents after retrieval
- Context optimization: use sub-agents when available so raw transcript tokens do not bloat the main conversation context
Requirements
- Python 3.10+
youtube-transcript-api
Setup
Recommended (virtual environment)
python3 -m venv .venv
./.venv/bin/pip install youtube-transcript-apiIf your shell supports activation, you can also do:
source .venv/bin/activate
pip install youtube-transcript-apiInstall As a Skill (skills.sh / Skills CLI)
This repository is for skills.sh-compatible agent environments.
Install from GitHub:
npx skills add https://github.com/fabriqaai/fabriqaai-youtube-transcript --skill youtube-transcriptAfter install, restart your agent host so the skill is reloaded.
Expected result:
- skill is installed under your host's skills directory
SKILL.mdis discoverable by the agent
Examples by host:
- Codex:
~/.codex/skills/youtube-transcript - Agent setups that use project-local skills:
.agents/skills/youtube-transcript
CLI Usage
1) Get transcript with timestamps
python3 youtube_transcript_tool.py \
--mode transcript \
--url "https://www.youtube.com/watch?v=VIDEO_ID" \
--lang en \
--include-timestamps true2) Get transcript as plain text (recommended for AI summarization)
python3 youtube_transcript_tool.py \
--mode transcript \
--url "VIDEO_ID" \
--include-timestamps false3) List available transcript languages
python3 youtube_transcript_tool.py \
--mode languages \
--url "https://youtu.be/VIDEO_ID"AI Summary Workflow (Recommended)
Use this two-step flow for high-quality results and better context control:
1. Retrieve transcript text:
python3 youtube_transcript_tool.py \
--mode transcript \
--url "VIDEO_ID" \
--include-timestamps false \
--json true2. Summarize with AI:
- Preferred: delegate to a sub-agent if available.
- Fallback: summarize in the current agent.
Sub-agent-first approach keeps large transcript content out of your main context window.
CLI Arguments
--mode(required):transcript,languages,analyze--url(required): YouTube URL or 11-char video ID--lang(defaulten): preferred transcript language--include-timestamps(defaulttrue): timestamped entries in transcript mode--analysis-type(defaultsummary): only foranalyzemode--max-items(default5): output limit for analysis mode--json(defaulttrue): JSON output for machine-friendly pipelines
Note: this skill's recommended summary path is AI summarization after transcript retrieval (preferably in a sub-agent).
Output Contract
Success
{
"ok": true,
"mode": "transcript",
"video_id": "dQw4w9WgXcQ"
}Error
{
"ok": false,
"error": {
"code": "no_transcript",
"message": "...",
"hint": "Try another language or use --mode languages"
}
}Error Codes
invalid_video_idtranscripts_disabledno_transcriptvideo_unavailablerate_limiteddependency_missingtranscript_fetch_failedapi_compat_errortranscript_parse_error
Package Notes: youtube-transcript-api
The script supports multiple package API styles to remain resilient across versions:
- static-style calls such as
YouTubeTranscriptApi.get_transcript(...) - list calls such as
YouTubeTranscriptApi.list_transcripts(...) - instance-style variants such as
api.list(...)/api.fetch(...)
This compatibility layer helps avoid breakage when package internals evolve.
Testing
Run tests:
python3 -m unittest discover -s tests -vCurrent tests cover:
- boolean parsing
- YouTube ID normalization
- mode routing logic
- fallback language selection
- summary payload shape
- error payload shape
Repository Structure
.
├── README.md
├── SKILL.md
├── tests/
│ └── test_youtube_transcript_tool.py
└── youtube_transcript_tool.pyimport sys
import unittest
from argparse import Namespace
from pathlib import Path
from unittest.mock import patch
import importlib.util
REPO_ROOT = Path(__file__).resolve().parent.parent
MODULE_PATH = REPO_ROOT / "youtube_transcript_tool.py"
module_name = "youtube_transcript_tool_under_test"
spec = importlib.util.spec_from_file_location(module_name, MODULE_PATH)
module = importlib.util.module_from_spec(spec)
assert spec is not None and spec.loader is not None
sys.modules[module_name] = module
spec.loader.exec_module(module)
class YouTubeTranscriptToolTests(unittest.TestCase):
def test_parse_bool_true_false(self):
self.assertTrue(module.parse_bool("true"))
self.assertTrue(module.parse_bool("1"))
self.assertFalse(module.parse_bool("false"))
self.assertFalse(module.parse_bool("0"))
def test_normalize_video_id_from_watch_url(self):
video_id = module.normalize_video_id("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
self.assertEqual(video_id, "dQw4w9WgXcQ")
def test_normalize_video_id_from_short_url(self):
video_id = module.normalize_video_id("https://youtu.be/dQw4w9WgXcQ?t=5")
self.assertEqual(video_id, "dQw4w9WgXcQ")
def test_normalize_video_id_invalid_raises(self):
with self.assertRaises(module.ToolError) as exc:
module.normalize_video_id("https://example.com/not-youtube")
self.assertEqual(exc.exception.code, "invalid_video_id")
@patch.object(module, "list_languages")
@patch.object(module, "fetch_entries")
def test_run_transcript_uses_fallback_language(self, mock_fetch_entries, mock_list_languages):
mock_list_languages.return_value = [
{"language_code": "es", "language_name": "Spanish", "is_generated": False},
{"language_code": "en", "language_name": "English", "is_generated": False},
]
mock_fetch_entries.return_value = [
{"text": "Hello world", "start": 0.0, "duration": 1.0},
{"text": "Second line", "start": 1.0, "duration": 1.0},
]
args = Namespace(
mode="transcript",
url="dQw4w9WgXcQ",
lang="fr",
include_timestamps=False,
analysis_type="summary",
max_items=5,
json=True,
)
result = module.run(args)
self.assertTrue(result["ok"])
self.assertEqual(result["mode"], "transcript")
self.assertEqual(result["language_requested"], "fr")
self.assertEqual(result["language_used"], "es")
self.assertIn("Hello world", result["transcript"])
@patch.object(module, "list_languages")
@patch.object(module, "fetch_entries")
def test_run_languages_mode(self, mock_fetch_entries, mock_list_languages):
mock_fetch_entries.return_value = []
mock_list_languages.return_value = [
{"language_code": "en", "language_name": "English", "is_generated": False}
]
args = Namespace(
mode="languages",
url="dQw4w9WgXcQ",
lang="en",
include_timestamps=True,
analysis_type="summary",
max_items=5,
json=True,
)
result = module.run(args)
self.assertTrue(result["ok"])
self.assertEqual(result["mode"], "languages")
self.assertEqual(result["count"], 1)
self.assertEqual(result["languages"][0]["language_code"], "en")
def test_analyze_summary_returns_expected_shape(self):
entries = [
{"text": "AI can automate repetitive tasks effectively.", "start": 0.0, "duration": 1.0},
{"text": "Teams should validate outputs before production use.", "start": 1.0, "duration": 1.0},
{"text": "Monitoring and feedback loops improve model quality over time.", "start": 2.0, "duration": 1.0},
]
analysis = module.analyze_summary(entries, max_items=3)
self.assertEqual(analysis["analysis_type"], "summary")
self.assertIn("summary", analysis)
self.assertIn("key_takeaways", analysis)
self.assertGreaterEqual(len(analysis["key_takeaways"]), 1)
def test_error_payload_shape(self):
payload = module.error_payload(module.ToolError("rate_limited", "Too many requests", "Retry later"))
self.assertFalse(payload["ok"])
self.assertEqual(payload["error"]["code"], "rate_limited")
if __name__ == "__main__":
unittest.main()
#!/usr/bin/env python3
"""Local YouTube transcript toolkit (no MCP).
Modes:
- transcript: fetch transcript text or timestamped entries
- languages: list available transcript languages
- analyze: extractive summary, key points, or quotes
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from collections import Counter
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Sequence, Tuple
from urllib.parse import parse_qs, urlparse
try:
from youtube_transcript_api import YouTubeTranscriptApi
except Exception as import_error: # pragma: no cover - import is environment-dependent
YouTubeTranscriptApi = None
IMPORT_ERROR = import_error
else:
IMPORT_ERROR = None
VIDEO_ID_RE = re.compile(r"^[A-Za-z0-9_-]{11}$")
SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?])\s+")
WORD_RE = re.compile(r"[A-Za-z0-9']+")
STOPWORDS = {
"a",
"an",
"and",
"are",
"as",
"at",
"be",
"by",
"for",
"from",
"has",
"he",
"in",
"is",
"it",
"its",
"of",
"on",
"or",
"that",
"the",
"to",
"was",
"were",
"will",
"with",
"you",
"your",
"this",
"they",
"we",
"i",
"our",
}
@dataclass
class ToolError(Exception):
code: str
message: str
hint: str
def parse_bool(value: str) -> bool:
lowered = value.strip().lower()
if lowered in {"true", "1", "yes", "y"}:
return True
if lowered in {"false", "0", "no", "n"}:
return False
raise argparse.ArgumentTypeError(f"Invalid boolean value: {value}")
def normalize_video_id(url_or_id: str) -> str:
candidate = url_or_id.strip()
if VIDEO_ID_RE.match(candidate):
return candidate
parsed = urlparse(candidate)
host = parsed.netloc.lower().replace("www.", "").replace("m.", "")
path = parsed.path.strip("/")
video_id = None
if host == "youtu.be" and path:
video_id = path.split("/")[0]
elif "youtube.com" in host:
if path == "watch":
video_id = parse_qs(parsed.query).get("v", [None])[0]
else:
parts = [p for p in path.split("/") if p]
if len(parts) >= 2 and parts[0] in {"embed", "v", "shorts", "live", "e"}:
video_id = parts[1]
elif parts and VIDEO_ID_RE.match(parts[0]):
video_id = parts[0]
if not video_id and (not host or host == "youtu.be" or "youtube.com" in host):
match = re.search(r"(?:v=|/)([A-Za-z0-9_-]{11})(?:[?&/#]|$)", candidate)
if match:
video_id = match.group(1)
if video_id and VIDEO_ID_RE.match(video_id):
return video_id
raise ToolError(
"invalid_video_id",
"Could not extract a valid YouTube video ID from the provided input.",
"Use a full YouTube URL or a raw 11-character ID like dQw4w9WgXcQ.",
)
def ensure_dependency() -> None:
if YouTubeTranscriptApi is None:
detail = str(IMPORT_ERROR) if IMPORT_ERROR else "youtube-transcript-api missing"
raise ToolError(
"dependency_missing",
f"youtube-transcript-api is not available: {detail}",
"Run: python3 -m pip install youtube-transcript-api",
)
def map_exception(error: Exception) -> ToolError:
name = error.__class__.__name__.lower()
message = str(error)
lowered = message.lower()
if "invalidvideoid" in name or ("invalid" in lowered and "video" in lowered and "id" in lowered):
return ToolError(
"invalid_video_id",
message,
"Check your URL format or pass a raw 11-character YouTube video ID.",
)
if "transcriptsdisabled" in name or "disabled" in lowered:
return ToolError(
"transcripts_disabled",
message,
"Captions are disabled for this video. Try a different video.",
)
if "notranscriptfound" in name or "no transcript" in lowered:
return ToolError(
"no_transcript",
message,
"Try another language or use --mode languages to inspect available transcripts.",
)
if "videounavailable" in name or ("video" in lowered and "unavailable" in lowered):
return ToolError(
"video_unavailable",
message,
"The video may be private, deleted, or region/age restricted.",
)
if "toomanyrequests" in name or "429" in lowered or "rate" in lowered:
return ToolError(
"rate_limited",
message,
"Retry later or from a different network.",
)
if "couldnotretrievetranscript" in name:
return ToolError(
"transcript_fetch_failed",
message,
"Retry once, then try --mode languages to inspect availability.",
)
return ToolError(
"transcript_fetch_failed",
message or "Failed to retrieve transcript data.",
"Retry with a different video or language.",
)
def iter_transcript_objects(transcript_list_obj: Any) -> Iterable[Any]:
try:
for item in transcript_list_obj:
yield item
return
except TypeError:
pass
for attr in ("transcripts", "_manually_created_transcripts", "_generated_transcripts"):
if not hasattr(transcript_list_obj, attr):
continue
value = getattr(transcript_list_obj, attr)
if isinstance(value, dict):
for item in value.values():
yield item
elif isinstance(value, list):
for item in value:
yield item
def list_languages(video_id: str) -> List[Dict[str, Any]]:
ensure_dependency()
try:
api = YouTubeTranscriptApi() if callable(YouTubeTranscriptApi) else None
if hasattr(YouTubeTranscriptApi, "list_transcripts"):
transcript_list_obj = YouTubeTranscriptApi.list_transcripts(video_id)
elif api is not None and hasattr(api, "list_transcripts"):
transcript_list_obj = api.list_transcripts(video_id)
elif api is not None and hasattr(api, "list"):
transcript_list_obj = api.list(video_id)
else:
raise ToolError(
"api_compat_error",
"Unable to find a compatible list_transcripts() API.",
"Upgrade youtube-transcript-api to the latest version.",
)
languages = []
for transcript in iter_transcript_objects(transcript_list_obj):
code = getattr(transcript, "language_code", None)
name = getattr(transcript, "language", None)
is_generated = bool(getattr(transcript, "is_generated", False))
if not code:
continue
languages.append(
{
"language_code": code,
"language_name": name or code,
"is_generated": is_generated,
}
)
if not languages:
raise ToolError(
"no_transcript",
"No transcript languages were found for this video.",
"The video may not have captions enabled.",
)
# Deterministic order: human captions first, then generated; then by code.
languages.sort(key=lambda x: (x["is_generated"], x["language_code"]))
return languages
except ToolError:
raise
except Exception as error: # pragma: no cover - depends on third-party exceptions
raise map_exception(error) from error
def normalize_entries(raw_entries: Any) -> List[Dict[str, Any]]:
if hasattr(raw_entries, "to_raw_data"):
raw_entries = raw_entries.to_raw_data()
if not isinstance(raw_entries, list):
raise ToolError(
"transcript_parse_error",
"Transcript API returned an unsupported data shape.",
"Update youtube-transcript-api and retry.",
)
entries: List[Dict[str, Any]] = []
for item in raw_entries:
if isinstance(item, dict):
text = str(item.get("text", "")).strip()
start = float(item.get("start", 0.0) or 0.0)
duration = float(item.get("duration", 0.0) or 0.0)
else:
text = str(getattr(item, "text", "")).strip()
start = float(getattr(item, "start", 0.0) or 0.0)
duration = float(getattr(item, "duration", 0.0) or 0.0)
if not text:
continue
entries.append({"text": text, "start": start, "duration": duration})
if not entries:
raise ToolError(
"no_transcript",
"Transcript payload was empty.",
"Try another language or a different video.",
)
return entries
def fetch_entries(video_id: str, language_code: str) -> List[Dict[str, Any]]:
ensure_dependency()
api = YouTubeTranscriptApi() if callable(YouTubeTranscriptApi) else None
calls = []
if hasattr(YouTubeTranscriptApi, "get_transcript"):
calls.append(lambda: YouTubeTranscriptApi.get_transcript(video_id, languages=[language_code]))
if api is not None and hasattr(api, "get_transcript"):
calls.append(lambda: api.get_transcript(video_id, languages=[language_code]))
if api is not None and hasattr(api, "fetch"):
calls.append(lambda: api.fetch(video_id, languages=[language_code]))
if hasattr(YouTubeTranscriptApi, "fetch"):
calls.append(lambda: YouTubeTranscriptApi.fetch(video_id, languages=[language_code]))
if not calls:
raise ToolError(
"api_compat_error",
"Unable to find a compatible transcript fetch API.",
"Upgrade youtube-transcript-api to the latest version.",
)
last_error: Exception | None = None
for call in calls:
try:
return normalize_entries(call())
except ToolError:
raise
except Exception as error: # pragma: no cover - depends on third-party exceptions
last_error = error
if last_error is not None:
raise map_exception(last_error) from last_error
raise ToolError(
"transcript_fetch_failed",
"Transcript fetch failed for an unknown reason.",
"Retry with a different language or video.",
)
def build_plain_text(entries: Sequence[Dict[str, Any]]) -> str:
return " ".join(entry["text"].strip() for entry in entries if entry.get("text")).strip()
def split_sentences(text: str) -> List[str]:
if not text:
return []
sentences = [s.strip() for s in SENTENCE_SPLIT_RE.split(text) if s.strip()]
return sentences
def tokenize(text: str) -> List[str]:
return [w.lower() for w in WORD_RE.findall(text)]
def sentence_scores(sentences: Sequence[str]) -> List[Tuple[float, int, str]]:
corpus_tokens = [w for sentence in sentences for w in tokenize(sentence) if w not in STOPWORDS and len(w) > 2]
if not corpus_tokens:
return [(1.0, idx, sentence) for idx, sentence in enumerate(sentences)]
frequencies = Counter(corpus_tokens)
scored = []
for idx, sentence in enumerate(sentences):
tokens = [w for w in tokenize(sentence) if w not in STOPWORDS and len(w) > 2]
if not tokens:
scored.append((0.0, idx, sentence))
continue
unique_tokens = set(tokens)
score = sum(frequencies[token] for token in unique_tokens) / max(len(tokens), 1)
scored.append((score, idx, sentence))
return scored
def analyze_summary(entries: Sequence[Dict[str, Any]], max_items: int) -> Dict[str, Any]:
text = build_plain_text(entries)
sentences = split_sentences(text)
if not sentences:
return {
"analysis_type": "summary",
"summary": "No usable transcript text was found.",
"key_takeaways": [],
}
ranked = sorted(sentence_scores(sentences), key=lambda x: x[0], reverse=True)
pick_count = min(max(3, min(max_items, 5)), len(sentences))
selected_idxs = sorted(idx for _, idx, _ in ranked[:pick_count])
selected = [sentences[idx] for idx in selected_idxs]
summary = " ".join(selected[: min(3, len(selected))]).strip()
return {
"analysis_type": "summary",
"summary": summary,
"key_takeaways": selected[:max_items],
}
def analyze_key_points(entries: Sequence[Dict[str, Any]], max_items: int) -> Dict[str, Any]:
text = build_plain_text(entries)
sentences = split_sentences(text)
if not sentences:
return {"analysis_type": "key_points", "key_points": []}
ranked = sorted(sentence_scores(sentences), key=lambda x: x[0], reverse=True)
top = ranked[: min(max_items, len(ranked))]
return {
"analysis_type": "key_points",
"key_points": [sentence for _, _, sentence in top],
}
def analyze_quotes(entries: Sequence[Dict[str, Any]], max_items: int) -> Dict[str, Any]:
candidates = []
seen = set()
for entry in entries:
text = entry["text"].strip()
if len(tokenize(text)) < 6:
continue
normalized = text.lower()
if normalized in seen:
continue
seen.add(normalized)
candidates.append((len(text), text, entry["start"]))
if not candidates:
return {"analysis_type": "quotes", "quotes": []}
candidates.sort(key=lambda x: x[0], reverse=True)
selected = candidates[:max_items]
return {
"analysis_type": "quotes",
"quotes": [
{
"text": text,
"start": start,
}
for _, text, start in selected
],
}
def format_timestamp(seconds: float) -> str:
whole = int(seconds)
hours = whole // 3600
minutes = (whole % 3600) // 60
secs = whole % 60
if hours > 0:
return f"{hours}:{minutes:02d}:{secs:02d}"
return f"{minutes}:{secs:02d}"
def emit(data: Dict[str, Any], as_json: bool) -> None:
if as_json:
print(json.dumps(data, ensure_ascii=False))
return
if not data.get("ok"):
error = data["error"]
print(f"Error [{error['code']}]: {error['message']}")
print(f"Hint: {error['hint']}")
return
mode = data.get("mode")
print(f"Mode: {mode}")
print(f"Video ID: {data.get('video_id')}")
if mode == "languages":
print("Languages:")
for item in data.get("languages", []):
suffix = " (auto-generated)" if item.get("is_generated") else ""
print(f"- {item['language_code']}: {item['language_name']}{suffix}")
if mode == "transcript":
transcript = data.get("transcript")
if isinstance(transcript, str):
print("\nTranscript:\n")
print(transcript)
else:
print("\nTranscript entries:\n")
for entry in transcript:
print(f"[{format_timestamp(entry['start'])}] {entry['text']}")
if mode == "analyze":
analysis = data.get("analysis", {})
print("\nAnalysis:\n")
print(json.dumps(analysis, ensure_ascii=False, indent=2))
def success_payload(mode: str, video_id: str, **kwargs: Any) -> Dict[str, Any]:
payload = {"ok": True, "mode": mode, "video_id": video_id}
payload.update(kwargs)
return payload
def error_payload(error: ToolError) -> Dict[str, Any]:
return {
"ok": False,
"error": {
"code": error.code,
"message": error.message,
"hint": error.hint,
},
}
def run(args: argparse.Namespace) -> Dict[str, Any]:
video_id = normalize_video_id(args.url)
if args.mode == "languages":
languages = list_languages(video_id)
return success_payload(
"languages",
video_id,
count=len(languages),
languages=languages,
)
languages = list_languages(video_id)
available_codes = [item["language_code"] for item in languages]
preferred = args.lang.strip() if args.lang else "en"
language_used = preferred if preferred in available_codes else available_codes[0]
entries = fetch_entries(video_id, language_used)
if args.mode == "transcript":
transcript_data: Any
if args.include_timestamps:
transcript_data = entries
else:
transcript_data = build_plain_text(entries)
return success_payload(
"transcript",
video_id,
language_requested=preferred,
language_used=language_used,
include_timestamps=args.include_timestamps,
entry_count=len(entries),
transcript=transcript_data,
)
if args.analysis_type == "summary":
analysis = analyze_summary(entries, args.max_items)
elif args.analysis_type == "key_points":
analysis = analyze_key_points(entries, args.max_items)
else:
analysis = analyze_quotes(entries, args.max_items)
return success_payload(
"analyze",
video_id,
language_requested=preferred,
language_used=language_used,
entry_count=len(entries),
analysis=analysis,
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="YouTube transcript toolkit (no MCP)")
parser.add_argument("--mode", choices=["transcript", "languages", "analyze"], required=True)
parser.add_argument("--url", required=True, help="YouTube URL or 11-character video ID")
parser.add_argument("--lang", default="en", help="Preferred language code (default: en)")
parser.add_argument(
"--include-timestamps",
type=parse_bool,
default=True,
help="Include timestamps in transcript mode (true/false, default: true)",
)
parser.add_argument(
"--analysis-type",
choices=["summary", "key_points", "quotes"],
default="summary",
help="Analysis style for analyze mode (default: summary)",
)
parser.add_argument(
"--max-items",
type=int,
default=5,
help="Maximum items in analysis output (default: 5)",
)
parser.add_argument(
"--json",
type=parse_bool,
default=True,
help="Emit JSON output (true/false, default: true)",
)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
if args.max_items < 1:
error = ToolError("invalid_argument", "--max-items must be >= 1", "Use a value like 3, 5, or 10.")
emit(error_payload(error), as_json=args.json)
return 1
try:
payload = run(args)
emit(payload, as_json=args.json)
return 0
except ToolError as error:
emit(error_payload(error), as_json=args.json)
return 1
except Exception as error: # pragma: no cover - defensive fallback
mapped = map_exception(error)
emit(error_payload(mapped), as_json=args.json)
return 1
if __name__ == "__main__":
sys.exit(main())