Skill: chapati23/yt-summary (version 1.3.2)

--- name: youtube-summary version: "1.3.2" author: giskard description: "Summarize any YouTube video by dropping the link in chat. Supports custom prompts — paste the URL followed by your instructions (e.g. 'focus on the technical details'). Triggers on YouTube URLs." tags: [youtube, video, summary, transcript] license: MIT homepage: https://github.com/chapati23 metadata: openclaw: emoji: "📺" requires: bins: [python3] env: [TRANSCRIPT_API_KEY] primaryEnv: TRANSCRIPT_API_KEY --- # YouTube Summary Skill Summarize YouTube videos by extracting transcripts via [TranscriptAPI.com](https://transcriptapi.com) and generating structured summaries. ## Setup ### Prerequisites - Python 3.10+ - A TranscriptAPI.com account ($5/mo for 1,000 transcripts) - Optional: `pass` (Unix password manager) for secure key storage ### Installation 1. Sign up at [transcriptapi.com](https://transcriptapi.com) and get your API key 2. Provide the API key via **one** of these methods: - **Environment variable (simplest):** `export TRANSCRIPT_API_KEY="your-key-here"` - **`pass` password store (most secure):** `pass insert transcriptapi/api-key` 3. Install Python dependencies: ```bash pip install -r skills/youtube-summary/requirements.txt ``` ## Detection Trigger on messages containing YouTube URLs matching any of: - `youtube.com/watch?v=ID` - `youtu.be/ID` - `youtube.com/shorts/ID` - `m.youtube.com/watch?v=ID` - `youtube.com/live/ID` ## ⚠️ Critical Rules - **NEVER use web_search as a fallback.** If transcript extraction fails, report the error and stop. - **NEVER fabricate transcript content.** Only summarize what the extraction script returns. - **Always run the extraction script.** Do not skip it, even for well-known videos. ## Workflow ### Step 1: Extract transcript **If using `pass`:** ```bash _yt_key_file=$(mktemp) && pass transcriptapi/api-key > "$_yt_key_file" && python3 skills/youtube-summary/scripts/extract.py "YOUTUBE_URL_OR_ID" --api-key-file "$_yt_key_file"; rm -f "$_yt_key_file" ``` **If using env var:** ```bash python3 skills/youtube-summary/scripts/extract.py "YOUTUBE_URL_OR_ID" ``` (Reads `TRANSCRIPT_API_KEY` from the environment automatically.) **Security note:** The `pass` + temp file approach avoids exposing the key in `ps` output or shell history. The env var approach is simpler but the key is visible in the process environment. Parse stdout: - `PROGRESS:` lines → relay to user as status updates (optional) - `ERROR:` lines → relay error to user, stop - `RESULT:` line → parse the JSON after `RESULT: ` — contains: `header`, `transcript`, `language`, `tokens`, `title`, `channel`, `duration_str` ### Step 2: Summarize the transcript Use the extracted transcript to generate a summary. The summary language must match the transcript language (from the `language` field). **If tokens < 50000** — single-pass: summarize the full transcript in one request. **If tokens ≥ 50000** — tell the user it's a long video and summarize the first ~40K tokens with a note that it was truncated. **Default summary format** (use when no custom prompt given): ``` {header} **TL;DR:** 2-3 sentence summary. **Key Points:** • Point one • Point two • (3-7 total) **Notable Quotes:** (only if genuinely quotable lines exist) > "Quote here" ``` **Custom prompt** — if the user included text alongside the URL, append it as additional instructions for the summary. ### Step 3: Reply - Keep output under 4000 characters for Telegram - If the summary would exceed 4000 chars, send the TL;DR first, then the rest as a follow-up - Always include the header line from the extraction result ## Error Handling - `ERROR: API_ERROR: Invalid API key` → "TranscriptAPI key is invalid. Check `pass transcriptapi/api-key`." - `ERROR: No transcript available` → "This video doesn't have captions available." - `ERROR: Video not found` → "Couldn't find that video — double-check the URL." - Any other `ERROR:` → relay the message as-is. **Do NOT fall back to web_search.** ## Why TranscriptAPI? YouTube aggressively blocks datacenter/IPv6 ranges from accessing transcripts. Most cloud VPS (Hetzner, DigitalOcean, AWS, etc.) are blocked — direct transcript fetching fails for most videos when running from a server. TranscriptAPI.com proxies requests through residential IPs, bypassing these blocks reliably. The $5/mo plan covers 1,000 transcript fetches. 💡 **Tip:** Add instructions after the URL to customize the summary (e.g. "focus on the technical details").

path: README.md
content: # 📺 YouTube Summary

**Drop a YouTube link in chat → get an instant, structured summary.**

An [OpenClaw](https://openclaw.com) skill that extracts video transcripts via [TranscriptAPI.com](https://transcriptapi.com) and generates concise summaries with key points and notable quotes. Supports custom prompts — just add your instructions after the URL.

## ✨ Features

- **Any YouTube URL** — `youtube.com/watch`, `youtu.be`, `/shorts/`, `/live/`, `m.youtube.com`
- **Custom prompts** — "focus on the technical details", "list action items", "explain like I'm 5"
- **Long video support** — handles videos of any length with smart truncation for very long transcripts
- **Telegram-optimized** — output fits Telegram's formatting and character limits

## 🚀 Quick Example

You:
> https://www.youtube.com/watch?v=dQw4w9WgXcQ summarize the key arguments

Giskard:
> 📺 **Never Gonna Give You Up** — Rick Astley (3min)
>
> **TL;DR:** Rick makes an impassioned case for commitment and loyalty...
>
> **Key Points:**
> • Never going to give you up
> • Never going to let you down
> ...

## 📦 Setup

### Prerequisites

- Python 3.10+
- A [TranscriptAPI.com](https://transcriptapi.com) account ($5/mo for 1,000 transcripts)

### 1. Get a TranscriptAPI key

Sign up at [transcriptapi.com](https://transcriptapi.com) and copy your API key.

### 2. Provide the API key (choose one method)

**Option A — Environment variable (simplest):**
```bash
export TRANSCRIPT_API_KEY="your-key-here"
```

**Option B — `pass` password store (most secure):**
```bash
pass insert transcriptapi/api-key
```

The script reads `--api-key-file` first (used by the `pass` workflow), then falls back to the `TRANSCRIPT_API_KEY` environment variable.

### 3. Install Python dependencies

```bash
pip install -r requirements.txt
```

That's it. Drop a YouTube link in chat and the skill kicks in automatically.

## 🔧 Why TranscriptAPI?

YouTube aggressively blocks transcript requests from datacenter IP ranges. If your OpenClaw instance runs on a VPS (Hetzner, DigitalOcean, AWS, Linode, etc.), direct transcript fetching will fail for most videos.

[TranscriptAPI.com](https://transcriptapi.com) proxies requests through residential IPs, making transcript extraction reliable from any host. At $5/mo for 1,000 transcripts, it's a simple and cost-effective solution.

## 🎨 Custom Prompt Examples

| You type | You get |
|---|---|
| `<url>` | Default structured summary with TL;DR + key points |
| `<url> focus on the technical details` | Technical deep-dive |
| `<url> list all action items` | Actionable takeaways |
| `<url> what are the main arguments for and against?` | Balanced pro/con analysis |
| `<url> explain like I'm 5` | Simplified summary |
| `<url> auf Deutsch zusammenfassen` | Summary in German |

## 🔍 Troubleshooting

| Error | Cause | Fix |
|---|---|---|
| `Invalid API key` | TranscriptAPI key is wrong or expired | Check your key |
| `No transcript available` | Video has no captions | Nothing to do — video has no transcript |
| `Video not found` | Bad URL or private video | Double-check the URL |
| `Rate limited` | Too many requests | Wait a moment, try again |
| `No API key provided` | Neither env var nor `--api-key-file` set | Set `TRANSCRIPT_API_KEY` or use `pass` |

## 📁 Files

```
youtube-summary/
├── SKILL.md              # Agent instructions (how the AI uses this skill)
├── README.md             # This file (for humans)
├── requirements.txt      # Python dependencies
├── scripts/
│   ├── extract.py        # Transcript extraction + metadata
│   ├── utils.py          # URL parsing, token counting, text helpers
│   └── prompts.py        # Summary prompt templates (reference)
└── LICENSE               # MIT
```

## 📄 License

MIT — see [LICENSE](LICENSE).


path: requirements.txt
content: requests
yt-dlp


path: scripts/extract.py
content: #!/usr/bin/env python3
"""Extract YouTube video transcript and metadata via TranscriptAPI.com."""

import argparse
import json
import os
import subprocess
import sys

import requests
from utils import extract_video_id, count_tokens_approx, format_duration


def progress(msg: str):
    print(f"PROGRESS: {msg}", flush=True)


def error(msg: str):
    print(f"ERROR: {msg}", flush=True)
    sys.exit(1)


def get_metadata(video_id: str) -> dict:
    """Fetch video metadata via oEmbed (primary) or yt-dlp (fallback)."""
    # Validate video_id is strictly alphanumeric + dash/underscore, 11 chars
    assert len(video_id) == 11 and all(
        c.isascii() and (c.isalnum() or c in "-_") for c in video_id
    ), f"Invalid video ID: {video_id}"

    # Primary: oEmbed (no auth, works from any IP)
    try:
        r = requests.get(
            "https://www.youtube.com/oembed",
            params={"url": f"https://www.youtube.com/watch?v={video_id}", "format": "json"},
            timeout=10,
        )
        if r.status_code == 200:
            data = r.json()
            return {
                "title": data.get("title", "Unknown"),
                "channel": data.get("author_name", "Unknown"),
                "duration": 0,  # oEmbed doesn't provide duration
            }
    except Exception:
        pass

    # Fallback: yt-dlp (may fail on VPS with blocked IP)
    try:
        r = subprocess.run(
            ["yt-dlp", "--dump-json", "--no-download",
             f"https://www.youtube.com/watch?v={video_id}"],
            capture_output=True, text=True, timeout=30,
        )
        if r.returncode == 0:
            data = json.loads(r.stdout)
            return {
                "title": data.get("title", "Unknown"),
                "channel": data.get("channel", data.get("uploader", "Unknown")),
                "duration": data.get("duration", 0),
            }
    except (subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError):
        pass

    return {"title": None, "channel": None, "duration": 0}


def get_transcript(video_id: str, api_key: str) -> tuple[str, str]:
    """Fetch transcript via TranscriptAPI.com."""
    url = "https://transcriptapi.com/api/v2/youtube/transcript"
    params = {
        "video_url": video_id,
        "format": "json",
        "include_timestamp": "false",
    }
    headers = {"Authorization": f"Bearer {api_key}"}

    try:
        response = requests.get(url, params=params, headers=headers, timeout=30)

        if response.status_code == 401:
            error("API_ERROR: Invalid API key. Check your TranscriptAPI key.")
        elif response.status_code == 429:
            error("API_ERROR: Rate limited. Wait a moment and try again.")
        elif response.status_code != 200:
            error(f"API_ERROR: HTTP {response.status_code}")

        data = response.json()
        transcript_array = data.get("transcript", [])
        transcript_text = " ".join(seg.get("text", "") for seg in transcript_array)
        language = "en"  # TranscriptAPI doesn't expose language; default to English

        return transcript_text, language

    except requests.exceptions.Timeout:
        error("API_ERROR: Request timed out. Try again.")
    except requests.exceptions.RequestException as e:
        error(f"API_ERROR: Network error — could not reach TranscriptAPI.")
    except json.JSONDecodeError:
        error("API_ERROR: Invalid response from TranscriptAPI.")


def main():
    parser = argparse.ArgumentParser(description="Extract YouTube video transcript and metadata")
    parser.add_argument("video", help="YouTube URL or video ID")
    parser.add_argument("--api-key-file", help="Path to file containing TranscriptAPI key", default=None)
    args = parser.parse_args()

    # Priority: --api-key-file > TRANSCRIPT_API_KEY env var
    api_key = None
    if args.api_key_file:
        try:
            with open(args.api_key_file) as f:
                [REDACTED_SECRET]).strip()
        except OSError as e:
            error(f"Cannot read API key file: {e}")
    else:
        [REDACTED_SECRET]"TRANSCRIPT_API_KEY", "").strip()

    if not [REDACTED_SECRET]"No API key provided. Either set TRANSCRIPT_API_KEY env var or pass --api-key-file.")

    try:
        video_id = extract_video_id(args.video)
    except ValueError as e:
        error(str(e))

    progress(f"🔍 Fetching video info for {video_id}...")

    meta = get_metadata(video_id)
    duration_s = meta["duration"]
    duration_str = format_duration(duration_s) if duration_s else "unknown length"

    if duration_s > 7200:
        progress("⚠️ This video is over 2 hours — processing may take a while.")

    progress("📝 Extracting transcript...")
    transcript, detected_lang = get_transcript(video_id, api_key)
    tokens = count_tokens_approx(transcript)

    progress(f"📺 Got transcript ({duration_str}, ~{tokens} tokens).")

    # Build header
    header_parts = []
    if meta["title"]:
        header_parts.append(f'📺 **{meta["title"]}**')
        if meta["channel"]:
            header_parts.append(f' — {meta["channel"]}')
        if duration_s:
            header_parts.append(f' ({duration_str})')
        header = "".join(header_parts)
    else:
        header = f"📺 Video {video_id}"

    output = {
        "video_id": video_id,
        "title": meta["title"],
        "channel": meta["channel"],
        "duration": duration_s,
        "duration_str": duration_str,
        "language": detected_lang,
        "tokens": tokens,
        "transcript": transcript,
        "header": header,
    }

    print(f"RESULT: {json.dumps(output)}", flush=True)


if __name__ == "__main__":
    main()


path: scripts/prompts.py
content: """Prompt templates for YouTube summary skill."""

DEFAULT_SYSTEM_PROMPT = """You are summarizing a YouTube video transcript. Follow these rules:
- Write the summary in the SAME LANGUAGE as the transcript
- Be concise, factual, and well-structured
- Use Telegram-compatible Markdown (bold with **, no headers with #)
- Keep the total output under 3500 characters"""

DEFAULT_USER_PROMPT = """Summarize this video transcript.

Format your response EXACTLY as:

**TL;DR:** 2-3 sentence summary.

**Key Points:**
• Point one
• Point two
• (3-7 points total)

Only add a **Notable Quotes:** section if there are genuinely memorable or quotable lines. If not, omit it entirely.

Transcript:
{transcript}"""

CUSTOM_USER_PROMPT = """Summarize this video transcript.

Default format (adjust based on user instructions):

**TL;DR:** 2-3 sentence summary.

**Key Points:**
• Point one
• Point two

ADDITIONAL USER INSTRUCTIONS: {custom_prompt}

Transcript:
{transcript}"""

MAP_SYSTEM_PROMPT = """You are summarizing a section of a YouTube video transcript. Be concise and factual. Write in the same language as the transcript. Output max 600 words."""

MAP_USER_PROMPT = """Summarize this transcript section. Include key points, claims, and any notable quotes.

Section:
{chunk}"""

REDUCE_SYSTEM_PROMPT = """You are combining section summaries of a YouTube video into one coherent summary. Write in the same language as the content. Use Telegram-compatible Markdown. Keep total output under 3500 characters."""

REDUCE_USER_PROMPT = """Combine these section summaries into one coherent summary.

Format:
**TL;DR:** 2-3 sentences.

**Key Points:**
• (3-7 key points)

Only add **Notable Quotes:** if genuinely quotable lines appear.

{custom_instruction}

Section summaries:
{summaries}"""


def build_prompt(transcript: str, custom_prompt: str | None = None, language: str | None = None):
    """Build system and user prompts for summarization."""
    system = DEFAULT_SYSTEM_PROMPT
    if language:
        system += f"\nWrite the summary in {language}."

    if custom_prompt:
        user = CUSTOM_USER_PROMPT.format(transcript=transcript, custom_prompt=custom_prompt)
    else:
        user = DEFAULT_USER_PROMPT.format(transcript=transcript)

    return system, user


def build_map_prompt(chunk: str):
    """Build prompts for map phase."""
    return MAP_SYSTEM_PROMPT, MAP_USER_PROMPT.format(chunk=chunk)


def build_reduce_prompt(summaries: str, custom_prompt: str | None = None):
    """Build prompts for reduce phase."""
    custom_instruction = f"ADDITIONAL USER INSTRUCTIONS: {custom_prompt}" if custom_prompt else ""
    return REDUCE_SYSTEM_PROMPT, REDUCE_USER_PROMPT.format(
        summaries=summaries, custom_instruction=custom_instruction
    )


path: scripts/utils.py
content: """Utility functions for YouTube summary skill."""

import re
from urllib.parse import urlparse, parse_qs


def extract_video_id(url_or_id: str) -> str:
    """Extract YouTube video ID from various URL formats or a bare ID."""
    url_or_id = url_or_id.strip()

    # Already a bare ID (11 chars, alphanumeric + dash/underscore)
    if re.match(r'^[a-zA-Z0-9_-]{11}$', url_or_id):
        return url_or_id

    # youtu.be/ID
    m = re.search(r'youtu\.be/([\w-]{11})', url_or_id)
    if m:
        return m.group(1)

    # youtube.com/shorts/ID or youtube.com/live/ID
    m = re.search(r'youtube\.com/(?:shorts|live)/([\w-]{11})', url_or_id)
    if m:
        return m.group(1)

    # youtube.com/watch?v=ID (including m.youtube.com)
    parsed = urlparse(url_or_id)
    if parsed.hostname and 'youtube.com' in parsed.hostname:
        qs = parse_qs(parsed.query)
        if 'v' in qs:
            vid = qs['v'][0]
            if not re.match(r'^[a-zA-Z0-9_-]{11}$', vid):
                raise ValueError(f"Invalid video ID in URL: {vid}")
            return vid

    # Last resort: find 11-char ID pattern
    m = re.search(r'[\?&]v=([\w-]{11})', url_or_id)
    if m:
        return m.group(1)

    raise ValueError(f"Could not extract video ID from: {url_or_id}")


def count_tokens_approx(text: str) -> int:
    """Cheap token estimate: words * 1.3."""
    return int(len(text.split()) * 1.3)


def chunk_text(text: str, chunk_size: int = 3000, overlap: int = 100) -> list[str]:
    """Split text into chunks of approximately chunk_size tokens with overlap."""
    words = text.split()
    # Convert token counts to word counts (reverse the 1.3 factor)
    words_per_chunk = int(chunk_size / 1.3)
    words_overlap = int(overlap / 1.3)

    chunks = []
    start = 0
    while start < len(words):
        end = start + words_per_chunk
        chunk = ' '.join(words[start:end])
        chunks.append(chunk)
        start = end - words_overlap
        if start >= len(words):
            break
    return chunks


def truncate_for_telegram(text: str, max_chars: int = 3900) -> str:
    """Truncate text at a sentence boundary to fit Telegram limits."""
    if len(text) <= max_chars:
        return text

    truncated = text[:max_chars]
    # Find last sentence boundary
    for sep in ['\n\n', '.\n', '. ', '\n']:
        idx = truncated.rfind(sep)
        if idx > max_chars * 0.5:
            return truncated[:idx + len(sep)].rstrip() + '\n\n… _(truncated)_'

    return truncated.rstrip() + '\n\n… _(truncated)_'


def format_duration(seconds: int) -> str:
    """Format seconds into human-readable duration."""
    h, remainder = divmod(seconds, 3600)
    m, s = divmod(remainder, 60)
    if h:
        return f"{h}h {m}min"
    return f"{m}min"


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)