feat(skills): add YouTube and Google Maps Platform MCP skills

This commit is contained in:
Moritz Graf
2026-09-18 11:30:35 +02:00
parent ea079c9852
commit 6e98fd83b2
4 changed files with 1239 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
---
name: youtube
description: Search YouTube videos, retrieve video metadata, and extract full transcripts/subtitles with timestamps for instant AI summarization, recipes, or research.
---
# 📺 YouTube Integration (Search & Transcripts)
Full YouTube search and transcript extraction engine for OpenClaw:
1. 🔍 **`youtube_search(query, max_results=5, video_duration="any")`**:
- Searches YouTube via YouTube Data API v3.
- Returns title, channel, video ID, formatted duration, view count, published date, and direct link.
- Durations: `any`, `short` (< 4 min), `medium` (4-20 min), `long` (> 20 min).
2. 📜 **`youtube_get_transcript(video_url_or_id, languages=["de", "en"], include_timestamps=True)`**:
- **Unlimited & 100% Free**: Powered by `youtube-transcript-api` (zero GCP quota consumption).
- Fetches formatted subtitles and speech transcripts with `[MM:SS]` timestamps.
- Automatically prioritizes German (`de`) and English (`en`), including auto-generated subtitles.
- Perfect for:
- Summarizing interviews, podcasts, tech talks, and keynotes.
- Extracting cooking steps and recipes from YouTube food videos for `koch`.
- Extracting command-line instructions, code, or homelab setup guides for `itguy`.
3. ℹ️ **`youtube_video_details(video_url_or_id)`**:
- Fetches full description, tags, view count, like count, and publication details.
---
## 🧭 Prompt Routing & Best Practices
- **When the user pastes a YouTube URL** (e.g. *"Was wird in diesem Video erklärt? https://youtu.be/..."* or *"Fass mir das Video kurz zusammen"*):
- Do NOT waste a turn searching. Directly call `youtube_get_transcript` with the URL!
- Provide a concise executive summary with key takeaways, timestamps for notable moments, and actionable conclusions.
- **For Koch / Recipes**:
- When the user asks for a recipe from a video, pull the transcript via `youtube_get_transcript`, extract the ingredients and steps, and offer to add missing items to the Bring! shopping list.
- **For Research / Recherche**:
- Search relevant videos via `youtube_search` to find relevant lectures, documentaries, or news reports, then extract the transcript of the top hit for deep grounded analysis.
+568
View File
@@ -0,0 +1,568 @@
#!/usr/bin/env python3
"""
YouTube MCP Server & CLI Tool for OpenClaw.
Integrates YouTube search, video metadata, and free transcript extraction
(subtitles) via YouTube Data API v3 and youtube-transcript-api.
Features:
- youtube_search: Search videos by keyword with title, channel, duration, and views
- youtube_get_transcript: Fetch German and English subtitles with timestamps (100% free, zero quota)
- youtube_video_details: Fetch full video metadata and description
"""
import os
import sys
import re
import json
import argparse
from typing import Dict, Any, List, Optional
from urllib.parse import urlparse, parse_qs
# Environment
YOUTUBE_API_KEY = os.environ.get("YOUTUBE_API_KEY", "").strip()
def extract_video_id(url_or_id: str) -> Optional[str]:
"""Extract 11-character YouTube video ID from various URL formats or raw ID."""
if not url_or_id:
return None
url_or_id = url_or_id.strip()
# If already an 11-char alphanumeric ID
if re.match(r"^[a-zA-Z0-9_-]{11}$", url_or_id):
return url_or_id
# Common URL formats:
# https://www.youtube.com/watch?v=VIDEO_ID
# https://youtu.be/VIDEO_ID
# https://www.youtube.com/shorts/VIDEO_ID
# https://www.youtube.com/embed/VIDEO_ID
parsed = urlparse(url_or_id)
if "youtube.com" in parsed.netloc:
if parsed.path == "/watch":
qs = parse_qs(parsed.query)
if "v" in qs and qs["v"]:
return qs["v"][0]
elif parsed.path.startswith("/shorts/"):
parts = parsed.path.split("/")
if len(parts) >= 3 and len(parts[2]) == 11:
return parts[2]
elif parsed.path.startswith("/embed/"):
parts = parsed.path.split("/")
if len(parts) >= 3 and len(parts[2]) == 11:
return parts[2]
elif "youtu.be" in parsed.netloc:
path = parsed.path.lstrip("/")
# Path might have query params or /
video_id = path.split("?")[0].split("/")[0]
if len(video_id) == 11:
return video_id
# Regex fallback
match = re.search(r"[a-zA-Z0-9_-]{11}", url_or_id)
if match:
return match.group(0)
return None
def parse_iso_duration(duration_str: str) -> str:
"""Convert ISO 8601 duration (PT1H2M30S or PT5M12S) to human readable (01:02:30 or 05:12)."""
if not duration_str or not duration_str.startswith("PT"):
return duration_str or ""
hours = 0
minutes = 0
seconds = 0
pattern = re.compile(r"PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?")
match = pattern.match(duration_str)
if match:
h, m, s = match.groups()
hours = int(h) if h else 0
minutes = int(m) if m else 0
seconds = int(s) if s else 0
if hours > 0:
return f"{hours}:{minutes:02d}:{seconds:02d}"
return f"{minutes:02d}:{seconds:02d}"
return duration_str
def format_seconds(seconds: float) -> str:
"""Format seconds into HH:MM:SS or MM:SS."""
s = int(seconds)
hours = s // 3600
minutes = (s % 3600) // 60
secs = s % 60
if hours > 0:
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
return f"{minutes:02d}:{secs:02d}"
# ==============================================================================
# YouTube Operations
# ==============================================================================
def search_videos(
query: str,
max_results: int = 5,
video_duration: str = "any",
api_key: Optional[str] = None,
) -> Dict[str, Any]:
"""Search YouTube videos using YouTube Data API v3."""
key = api_key or YOUTUBE_API_KEY
if not key:
return {
"success": False,
"error": "YOUTUBE_API_KEY is not set. Please provide a valid Google Cloud API key.",
"videos": [],
}
try:
from googleapiclient.discovery import build
youtube = build("youtube", "v3", developerKey=key, cache_discovery=False)
search_kwargs = {
"q": query,
"part": "snippet",
"type": "video",
"maxResults": min(max(1, max_results), 20),
}
if video_duration in ["short", "medium", "long"]:
search_kwargs["videoDuration"] = video_duration
search_resp = youtube.search().list(**search_kwargs).execute()
items = search_resp.get("items", [])
if not items:
return {"success": True, "query": query, "count": 0, "videos": []}
video_ids = [it["id"]["videoId"] for it in items if "videoId" in it.get("id", {})]
# Batch fetch details (duration, view count, stats) - only 1 unit!
details_map = {}
if video_ids:
videos_resp = youtube.videos().list(
id=",".join(video_ids),
part="contentDetails,statistics,snippet"
).execute()
for v in videos_resp.get("items", []):
details_map[v["id"]] = v
videos = []
for item in items:
vid = item["id"].get("videoId")
if not vid:
continue
snip = item.get("snippet", {})
v_detail = details_map.get(vid, {})
cd = v_detail.get("contentDetails", {})
stats = v_detail.get("statistics", {})
duration_raw = cd.get("duration", "")
duration_formatted = parse_iso_duration(duration_raw)
videos.append({
"video_id": vid,
"title": snip.get("title", ""),
"channel": snip.get("channelTitle", ""),
"published_at": snip.get("publishedAt", ""),
"duration": duration_formatted,
"view_count": stats.get("viewCount", "N/A"),
"url": f"https://www.youtube.com/watch?v={vid}",
"description_snippet": snip.get("description", "")[:200],
"thumbnail": snip.get("thumbnails", {}).get("high", {}).get("url", ""),
})
return {
"success": True,
"query": query,
"count": len(videos),
"videos": videos,
}
except Exception as e:
return {
"success": False,
"error": f"YouTube search failed: {str(e)}",
"videos": [],
}
def get_transcript(
video_url_or_id: str,
languages: Optional[List[str]] = None,
include_timestamps: bool = True,
) -> Dict[str, Any]:
"""Fetch video transcript/subtitles using youtube-transcript-api."""
video_id = extract_video_id(video_url_or_id)
if not video_id:
return {
"success": False,
"error": f"Could not extract a valid YouTube video ID from '{video_url_or_id}'.",
}
lang_pref = languages or ["de", "en"]
try:
from youtube_transcript_api import YouTubeTranscriptApi
api = YouTubeTranscriptApi()
# Try fetching with preferred languages
transcript_obj = None
detected_lang = None
is_generated = False
# Use .list(video_id) to inspect available tracks and find best match
try:
transcript_list = api.list(video_id)
# Find best match or auto-generated
try:
transcript_obj = transcript_list.find_transcript(lang_pref)
except Exception:
# If no direct match in lang_pref, try any available transcript and translate if possible
for t in transcript_list:
transcript_obj = t
break
if transcript_obj:
detected_lang = transcript_obj.language_code
is_generated = transcript_obj.is_generated
snippets = transcript_obj.fetch()
else:
# Fallback to direct fetch
snippets = api.fetch(video_id, languages=lang_pref)
except Exception:
# Fallback to direct fetch
snippets = api.fetch(video_id, languages=lang_pref)
formatted_lines = []
full_text_chunks = []
total_duration = 0.0
for item in snippets:
# item may be an object (FetchedTranscriptSnippet) or dict
if hasattr(item, "text"):
text = item.text.strip()
start = getattr(item, "start", 0.0)
duration = getattr(item, "duration", 0.0)
elif isinstance(item, dict):
text = item.get("text", "").strip()
start = item.get("start", 0.0)
duration = item.get("duration", 0.0)
else:
text = str(item).strip()
start = 0.0
duration = 0.0
if not text:
continue
timestamp = format_seconds(start)
formatted_lines.append(f"[{timestamp}] {text}")
full_text_chunks.append(text)
total_duration = max(total_duration, start + duration)
full_text = " ".join(full_text_chunks)
return {
"success": True,
"video_id": video_id,
"url": f"https://www.youtube.com/watch?v={video_id}",
"language": detected_lang or "auto",
"is_generated": is_generated,
"duration_seconds": round(total_duration, 1),
"duration_formatted": format_seconds(total_duration),
"snippet_count": len(formatted_lines),
"formatted_transcript": "\n".join(formatted_lines) if include_timestamps else full_text,
"full_text": full_text,
}
except Exception as e:
err_msg = str(e)
if "TranscriptsDisabled" in err_msg or "Subtitles are disabled" in err_msg:
return {
"success": False,
"video_id": video_id,
"error": "Transcripts are disabled for this video by the uploader.",
}
elif "NoTranscriptFound" in err_msg:
return {
"success": False,
"video_id": video_id,
"error": f"No transcript found in requested languages ({lang_pref}).",
}
return {
"success": False,
"video_id": video_id,
"error": f"Failed to retrieve transcript: {err_msg}",
}
def get_video_details(
video_url_or_id: str,
api_key: Optional[str] = None,
) -> Dict[str, Any]:
"""Fetch complete video details and description."""
video_id = extract_video_id(video_url_or_id)
if not video_id:
return {
"success": False,
"error": f"Could not extract a valid YouTube video ID from '{video_url_or_id}'.",
}
key = api_key or YOUTUBE_API_KEY
if not key:
return {
"success": False,
"error": "YOUTUBE_API_KEY is not set.",
}
try:
from googleapiclient.discovery import build
youtube = build("youtube", "v3", developerKey=key, cache_discovery=False)
resp = youtube.videos().list(
id=video_id,
part="snippet,contentDetails,statistics"
).execute()
items = resp.get("items", [])
if not items:
return {
"success": False,
"error": f"Video '{video_id}' not found.",
}
item = items[0]
snip = item.get("snippet", {})
cd = item.get("contentDetails", {})
stats = item.get("statistics", {})
return {
"success": True,
"video_id": video_id,
"title": snip.get("title", ""),
"channel": snip.get("channelTitle", ""),
"published_at": snip.get("publishedAt", ""),
"description": snip.get("description", ""),
"duration": parse_iso_duration(cd.get("duration", "")),
"view_count": stats.get("viewCount", "0"),
"like_count": stats.get("likeCount", "0"),
"comment_count": stats.get("commentCount", "0"),
"tags": snip.get("tags", []),
"url": f"https://www.youtube.com/watch?v={video_id}",
"thumbnail": snip.get("thumbnails", {}).get("maxres", snip.get("thumbnails", {}).get("high", {})).get("url", ""),
}
except Exception as e:
return {
"success": False,
"error": f"Failed to get video details: {str(e)}",
}
# ==============================================================================
# MCP Protocol Handlers (JSON-RPC 2.0 stdio)
# ==============================================================================
TOOLS_DEFINITIONS = [
{
"name": "youtube_search",
"description": "Search YouTube for videos matching a query. Returns video title, channel, video ID, formatted duration, view count, and direct link.",
"inputSchema": {
"type": "object",
"required": ["query"],
"properties": {
"query": {
"type": "string",
"description": "The search term or topic (e.g. 'Ninja Creami Deluxe Rezepte', 'Kubernetes Helm Tutorial').",
},
"max_results": {
"type": "integer",
"description": "Maximum number of videos to return (default 5, max 20).",
"default": 5,
},
"video_duration": {
"type": "string",
"description": "Filter by duration: 'any' (default), 'short' (< 4m), 'medium' (4m-20m), 'long' (> 20m).",
"enum": ["any", "short", "medium", "long"],
"default": "any",
},
},
},
},
{
"name": "youtube_get_transcript",
"description": "Fetch video transcript / subtitles with timestamps. Free and unlimited. Essential for summarizing YouTube videos, extracting recipes, interview takeaways, or step-by-step instructions.",
"inputSchema": {
"type": "object",
"required": ["video_url_or_id"],
"properties": {
"video_url_or_id": {
"type": "string",
"description": "YouTube URL (e.g. 'https://youtu.be/...' or 'https://www.youtube.com/watch?v=...') or 11-char video ID.",
},
"languages": {
"type": "array",
"items": {"type": "string"},
"description": "Preferred language codes in priority order (default ['de', 'en']).",
"default": ["de", "en"],
},
"include_timestamps": {
"type": "boolean",
"description": "Whether to include [HH:MM:SS] timestamps for each segment (default True).",
"default": True,
},
},
},
},
{
"name": "youtube_video_details",
"description": "Fetch complete YouTube video details including full description, channel, publish date, duration, tags, and stats.",
"inputSchema": {
"type": "object",
"required": ["video_url_or_id"],
"properties": {
"video_url_or_id": {
"type": "string",
"description": "YouTube video URL or 11-character video ID.",
},
},
},
},
]
def run_mcp_server():
"""Run JSON-RPC 2.0 stdio loop for OpenClaw."""
while True:
try:
line = sys.stdin.readline()
if not line:
break
line = line.strip()
if not line:
continue
req = json.loads(line)
req_id = req.get("id")
method = req.get("method")
params = req.get("params", {})
if method == "initialize":
res = {
"jsonrpc": "2.0",
"id": req_id,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {
"name": "openclaw-youtube",
"version": "1.0.0",
},
},
}
print(json.dumps(res), flush=True)
elif method == "notifications/initialized":
pass
elif method == "tools/list":
res = {
"jsonrpc": "2.0",
"id": req_id,
"result": {"tools": TOOLS_DEFINITIONS},
}
print(json.dumps(res), flush=True)
elif method == "tools/call":
tool_name = params.get("name")
args = params.get("arguments", {})
content = []
if tool_name == "youtube_search":
result = search_videos(
query=args.get("query", ""),
max_results=args.get("max_results", 5),
video_duration=args.get("video_duration", "any"),
)
content.append({"type": "text", "text": json.dumps(result, indent=2, ensure_ascii=False)})
elif tool_name == "youtube_get_transcript":
result = get_transcript(
video_url_or_id=args.get("video_url_or_id", ""),
languages=args.get("languages", ["de", "en"]),
include_timestamps=args.get("include_timestamps", True),
)
content.append({"type": "text", "text": json.dumps(result, indent=2, ensure_ascii=False)})
elif tool_name == "youtube_video_details":
result = get_video_details(
video_url_or_id=args.get("video_url_or_id", "")
)
content.append({"type": "text", "text": json.dumps(result, indent=2, ensure_ascii=False)})
else:
content.append({"type": "text", "text": f"Error: Unknown tool '{tool_name}'"})
res = {
"jsonrpc": "2.0",
"id": req_id,
"result": {"content": content},
}
print(json.dumps(res), flush=True)
else:
res = {
"jsonrpc": "2.0",
"id": req_id,
"error": {"code": -32601, "message": f"Method '{method}' not found"},
}
print(json.dumps(res), flush=True)
except Exception as e:
err_res = {
"jsonrpc": "2.0",
"id": None,
"error": {"code": -32603, "message": f"Internal error: {str(e)}"},
}
print(json.dumps(err_res), flush=True)
def run_cli():
"""CLI testing interface."""
parser = argparse.ArgumentParser(description="YouTube MCP Server & CLI Tool")
subparsers = parser.add_subparsers(dest="command", help="Subcommand")
# Search
p_search = subparsers.add_parser("search", help="Search videos")
p_search.add_argument("query", help="Search query")
p_search.add_argument("--limit", "-n", type=int, default=5, help="Max results")
p_search.add_argument("--duration", choices=["any", "short", "medium", "long"], default="any")
# Transcript
p_trans = subparsers.add_parser("transcript", help="Get transcript")
p_trans.add_argument("video", help="URL or video ID")
p_trans.add_argument("--languages", "-l", default="de,en", help="Comma separated language preferences")
p_trans.add_argument("--no-timestamps", action="store_true", help="Omit timestamps")
# Details
p_details = subparsers.add_parser("details", help="Get video details")
p_details.add_argument("video", help="URL or video ID")
args = parser.parse_args()
if args.command == "search":
res = search_videos(query=args.query, max_results=args.limit, video_duration=args.duration)
print(json.dumps(res, indent=2, ensure_ascii=False))
elif args.command == "transcript":
langs = [l.strip() for l in args.languages.split(",") if l.strip()]
res = get_transcript(video_url_or_id=args.video, languages=langs, include_timestamps=not args.no_timestamps)
print(json.dumps(res, indent=2, ensure_ascii=False))
elif args.command == "details":
res = get_video_details(video_url_or_id=args.video)
print(json.dumps(res, indent=2, ensure_ascii=False))
else:
parser.print_help()
if __name__ == "__main__":
if len(sys.argv) == 1 or (len(sys.argv) == 2 and sys.argv[1] == "--stdio"):
run_mcp_server()
else:
run_cli()