Skill: mcdowell8023/wansan-video-summarizer (version 2.0.0)

--- name: video-summarizer description: Multi-platform video transcript extraction and AI-powered summarization (YouTube, Bilibili, extensible). Use when you need to summarize videos, extract transcripts, scan channels, or generate daily video digests. metadata: author: mcdowell8023 repo: https://github.com/mcdowell8023/oc-youtube-summarizer --- # Video Summarizer Skill 多平台视频摘要工具，支持 YouTube 和 B站（未来可扩展更多平台），支持单个视频、频道扫描、每日批量处理。 ## 功能 - ✅ 获取 YouTube 视频信息（yt-dlp） - ✅ 提取字幕/transcript（youtube-transcript-api） - ✅ **B站视频下载 + 语音转录**（yt-dlp + faster-whisper，本地，无需 API Key） - ✅ **B站关键帧提取**（ffmpeg，每 30 秒一帧，供 agent 视觉分析） - ✅ 生成深度摘要（LLM API） - ✅ 输出 JSON 格式（agent 自行处理发送） - ✅ 支持多频道配置 - ✅ 过滤 Shorts（< 5 分钟） - 🔮 架构可扩展：新增平台只需添加 extractor 模块 ## 安装 ```bash cd ~/.openclaw/skills/video-summarizer ./setup.sh ``` 安装完成后会引导你选择默认图文模式（可随时重新配置）： ``` 📋 选择默认图文模式: 1) text-only - 纯文字，不抽帧（最快） 2) auto-insert - 自动选帧插入文档（推荐平衡） 3) ai-review - AI 智能选图（默认，最佳效果，多消耗 ~5-8k token） 请选择 [1/2/3] (默认 3): ``` 配置写入 `config/settings.json`，可随时运行 `video-summarizer --setup` 重新配置。 ### 跨平台依赖安装 #### Linux（当前已支持） ```bash sudo apt install ffmpeg pip install faster-whisper yt-dlp youtube-transcript-api innertube ``` #### macOS ```bash brew install ffmpeg pip install faster-whisper yt-dlp youtube-transcript-api innertube ``` #### Windows ```bash # 安装 ffmpeg（推荐 chocolatey 或 scoop） choco install ffmpeg # 或 scoop install ffmpeg pip install faster-whisper yt-dlp youtube-transcript-api innertube ``` #### 注意事项 - **路径分隔符**：脚本已使用 `path.join()`，跨平台兼容 - **faster-whisper**：Windows 下需要 CUDA 或 CPU 模式（无 CUDA 时自动 fallback CPU，速度较慢） - **Chrome cookies**：各平台路径不同，yt-dlp 会自动处理 - **Python 版本**：需要 Python 3.9+ ### 核心依赖说明 | 依赖 | 用途 | |------|------| | `yt-dlp` | 视频下载、信息获取（YouTube + B站） | | `youtube-transcript-api` | YouTube 字幕提取 | | `innertube` | 绕过 YouTube API 限流 | | `faster-whisper` | B站语音转文字（本地，无需 API） | | `ffmpeg` | 音频提取 + 关键帧截取（系统级） | ## 工作原理 Skill 使用多种方法获取字幕，避免 YouTube 限流： 1. **innertube ANDROID client + Cloudflare proxy** - 主要方法，绕过限流 2. **youtube-transcript-api** - 备用方法 B站视频使用 yt-dlp 下载音频 → faster-whisper 本地转录，全程无需外部 API。 ## 使用 ### 三种图文模式 | 模式 | 说明 | 额外 token | 适用场景 | |------|------|-----------|---------| | `text-only` | 纯文字，不抽帧 | 0 | 快速摘要、纯文字需求 | | `auto-insert` | 固定规则选帧，按时间戳插入（帧偏移+5s避转场） | ~0 | 平衡速度与图文效果 | | `ai-review` ← **默认** | 基于文章结构反向选图，可补帧/删帧/替换 | ~5-8k | 最佳图文效果 | **ai-review 流程（文章驱动选图）：** 1. Skill 抽帧 → 固定规则多选（~15-20帧，比最终需要多50%） 2. 先写纯文字版文档（分好章节） 3. 逐章判断：需要配图？→ 从已选帧匹配 / 补帧 / 跳过 4. 输出精选帧 + 画面描述 + 对应章节 **auto-insert 流程：** 1. Skill 抽帧 → 固定规则选帧（多选留余量） 2. 帧时间偏移 +5s（避开转场） 3. 按时间戳匹配章节，全部插入 通过以下方式指定模式（优先级从高到低）： 1. CLI 参数 `--mode` 2. 环境变量 `SUMMARY_MODE` 3. `config/settings.json` 中的 `default_mode` ### 1. 单个视频摘要（YouTube） ```bash video-summarizer --url "https://www.youtube.com/watch?v=VIDEO_ID" ``` ### 2. B站视频摘要 ```bash video-summarizer --url "https://www.bilibili.com/video/BV1xxxxx" # 短链接也支持（自动跟随跳转） video-summarizer --url "https://b23.tv/xxxxx" # 指定图文模式 video-summarizer --url "https://www.bilibili.com/video/BV1xxxxx" --mode text-only video-summarizer --url "https://www.bilibili.com/video/BV1xxxxx" --mode auto-insert video-summarizer --url "https://www.bilibili.com/video/BV1xxxxx" --mode ai-review # 自定义 whisper 模型（精度更高，但更慢） video-summarizer --url "https://www.bilibili.com/video/BV1xxxxx" --whisper-model large-v3 # 跳过关键帧提取（等效 --mode text-only） video-summarizer --url "https://www.bilibili.com/video/BV1xxxxx" --no-frames # 自定义关键帧间隔（默认30秒一帧） video-summarizer --url "https://www.bilibili.com/video/BV1xxxxx" --frame-interval 60 ``` ### 3. 频道扫描（过去 24 小时） ```bash video-summarizer --channel "UC_x5XG1OV2P6uZZ5FSM9Ttw" --hours 24 ``` ### 4. 每日批量处理（Cron 用） ```bash video-summarizer --config /path/to/channels.json --daily --output /tmp/video_daily.json ``` ## 配置文件格式 `channels.json`: ```json { "channels": [ { "name": "Lex Fridman", "id": "UCSHZKyawb77ixDdsGog4iWA", "url": "https://www.youtube.com/@lexfridman" }, { "name": "Y Combinator", "id": "UCcefcZRL2oaA_uBNeo5UOWg", "url": "https://www.youtube.com/@ycombinator" } ], "hours_lookback": 24, "min_duration_seconds": 300, "max_videos_per_channel": 5 } ``` ## 输出格式 ```json { "generated_at": "2026-02-14T11:17:00Z", "items": [ { "title": "视频标题", "url": "https://youtube.com/watch?v=...", "video_id": "VIDEO_ID", "platform": "youtube", "channel": "频道名", "duration": "15:30", "published": "2026-02-14T08:00:00Z", "has_transcript": true, "summary": "# 摘要内容（markdown）\n\n### 🎯 核心问题..." }, { "video_id": "BV1xxxxx", "title": "B站视频标题", "url": "https://www.bilibili.com/video/BV1xxxxx", "platform": "bilibili", "has_transcript": true, "transcript_path": "/tmp/bili_BV1xxxxx_transcript.txt", "frame_files": ["/tmp/bili_BV1xxxxx_frames/frame_001.jpg", "..."], "frame_count": 12, "summary": "# 摘要内容（markdown）..." } ], "stats": { "total_videos": 5, "with_transcript": 4, "without_transcript": 1 } } ``` ## 环境变量 | 变量 | 用途 | 必须 | |------|------|------| | `LLM_API_URL` | 自定义 LLM API 端点 | 否 | | `LLM_API_KEY` | 自定义 LLM API Key | 否 | | `LLM_MODEL` | 自定义模型名 | 否 | | `OPENCLAW_GATEWAY_TOKEN` | OpenClaw Gateway token | 否 | | `GITHUB_TOKEN` | GitHub token（有 Copilot 订阅时可用） | 否 | | `POLLINATIONS_API_KEY` | Pollinations API Key | 否 | | `SUMMARY_MODE` | 图文模式：text-only / auto-insert / ai-review | 否 | | `WHISPER_MODEL` | B站 whisper 模型大小（默认 small） | 否 | | `FRAME_INTERVAL` | B站关键帧间隔秒数（默认 30） | 否 | | `FRAME_TIME_OFFSET` | 帧时间戳偏移秒数，避开转场（默认 5） | 否 | 无需任何 Key 也可运行：转录功能不需要 API Key；摘要功能会尝试 Pollinations 免费匿名调用。 ## 故障排查 ### 字幕获取失败 - 视频可能没有字幕 - 输出 JSON 中 `has_transcript: false` - Agent 应生成简短摘要（基于标题/描述） ### yt-dlp 限流 - 设置 `REQUEST_DELAY_SECONDS` (默认 3 秒) - 减少 `max_videos_per_channel`

path: README.md
content: # OpenClaw Skill - YouTube Transcript + Summary

> Forked and enhanced from [happynocode/openclaw-skill-youtube](https://github.com/happynocode/openclaw-skill-youtube)

Production-ready OpenClaw skill for YouTube video transcription and summarization.

[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
[![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/)
[![OpenClaw](https://img.shields.io/badge/openclaw-compatible-green.svg)](https://openclaw.ai)

## Features

- ✅ **Reliable Transcript Fetching** - Dual-method approach bypasses YouTube rate limiting
- ✅ **Batch Processing** - Process multiple channels in one run
- ✅ **AI-Powered Summaries** - Generate structured, insightful summaries
- ✅ **3 Output Modes** - text-only / auto-insert / ai-review (configurable)
- ✅ **Cron-Friendly** - Built for automated daily runs
- ✅ **JSON Output** - Flexible integration with any agent or platform
- ✅ **Filters Shorts** - Skip videos under 5 minutes
- ✅ **Zero-config** - Works without any API key (Pollinations free tier)

> **B站视频说明**：此 Skill 仅支持 YouTube。B站视频建议使用 yt-dlp + faster-whisper 本地方案，效果更稳定。

## Quick Start

```bash
# Install
cd ~/.openclaw/workspace/skills
git clone https://github.com/mcdowell8023/oc-youtube-summarizer.git youtube-summarizer
cd youtube-summarizer
./setup.sh
# ↑ 安装完成后会引导你选择默认图文模式

# Re-configure mode anytime
./youtube-summarizer --setup

# Test single video
./youtube-summarizer --url "https://www.youtube.com/watch?v=dQw4w9WgXcQ"

# With explicit mode
./youtube-summarizer --url "https://b23.tv/xxx" --mode ai-review

# Scan channel (last 24 hours)
./youtube-summarizer --channel "UCSHZKyawb77ixDdsGog4iWA" --hours 24

# Daily batch (for cron)
./youtube-summarizer --config channels.json --daily --output /tmp/youtube_summary.json
```

## Output Modes

| Mode | Description | Extra Tokens |
|------|-------------|-------------|
| `text-only` | Text only, no frames | 0 |
| `auto-insert` | Fixed-rule frame selection, insert by timestamp | ~0 |
| `ai-review` ← **default** | AI-driven image selection based on article structure | ~5-8k |

Select mode at install time, or override anytime:
- `--mode <mode>` CLI flag
- `SUMMARY_MODE` env var
- `config/settings.json` → `default_mode`

## How It Works

### Transcript Fetching

Uses dual-method approach to ensure reliability:

1. **Primary**: innertube ANDROID client
   - Bypasses YouTube's rate limiting
   - Works reliably from various environments

2. **Fallback**: youtube-transcript-api
   - Automatic fallback if primary method fails

### Summary Generation

LLM fallback chain (fully environment variable driven, no config files needed):

1. `LLM_API_URL` + `LLM_API_KEY` + `LLM_MODEL` → custom endpoint
2. `OPENCLAW_GATEWAY_TOKEN` → OpenClaw local gateway
3. `GITHUB_TOKEN` / `GH_TOKEN` → GitHub Copilot API
4. `POLLINATIONS_API_KEY` → Pollinations API (with key)
5. Pollinations free anonymous call (no key required)

## Environment Variables

| 变量 | 用途 | 必须 |
|------|------|------|
| `LLM_API_URL` | 自定义 LLM API 端点 | 否 |
| `LLM_API_KEY` | 自定义 LLM API Key | 否 |
| `LLM_MODEL` | 自定义模型名 | 否 |
| `OPENCLAW_GATEWAY_TOKEN` | OpenClaw Gateway token | 否 |
| `GITHUB_TOKEN` | GitHub token（有 Copilot 订阅时可用） | 否 |
| `POLLINATIONS_API_KEY` | Pollinations API Key | 否 |

无需任何 Key 也可运行：转录功能不需要 API Key；摘要功能会尝试 Pollinations 免费匿名调用。

## Configuration

Create `channels.json`:

```json
{
  "channels": [
    {
      "name": "Lex Fridman",
      "id": "UCSHZKyawb77ixDdsGog4iWA",
      "url": "https://www.youtube.com/@lexfridman"
    },
    {
      "name": "Y Combinator",
      "id": "UCcefcZRL2oaA_uBNeo5UOWg",
      "url": "https://www.youtube.com/@ycombinator"
    }
  ],
  "hours_lookback": 24,
  "min_duration_seconds": 300,
  "max_videos_per_channel": 5
}
```

## Output Format

```json
{
  "generated_at": "2026-02-14T11:17:00Z",
  "items": [
    {
      "video_id": "dQw4w9WgXcQ",
      "title": "Video Title",
      "url": "https://youtube.com/watch?v=...",
      "channel": "Channel Name",
      "duration": "15:30",
      "published": "20260214",
      "has_transcript": true,
      "summary": "# Markdown summary...",
      "metadata": {
        "view_count": 12345,
        "like_count": 678
      }
    }
  ],
  "stats": {
    "total_videos": 5,
    "with_transcript": 4,
    "without_transcript": 1
  }
}
```

## OpenClaw Integration

### Cron Job Example

```yaml
schedule:
  kind: cron
  expr: "0 8 * * *"  # 8 AM daily
payload:
  kind: agentTurn
  message: |
    Run YouTube daily summary:
    
    1. Execute skill:
       youtube-summarizer --config channels.json --daily --output /tmp/summary.json
    
    2. Read output and process each video
    
    3. Send to Discord / Feishu / Telegram
```

## Dependencies

- `yt-dlp` - Video metadata extraction
- `youtube-transcript-api` - Transcript fetching (fallback)
- `innertube` - YouTube API client (primary method)
- Python 3.9+

All dependencies are installed automatically by `setup.sh`.

## Troubleshooting

### Transcript fetch fails
- Video may not have captions
- Check `has_transcript: false` in output

### Rate limiting
- Reduce `max_videos_per_channel` in config

### yt-dlp errors
- Update yt-dlp: `pip install -U yt-dlp`
- Check video is publicly accessible

## License

MIT License - Copyright (c) Contributors

## Acknowledgments

- [happynocode/openclaw-skill-youtube](https://github.com/happynocode/openclaw-skill-youtube) - Original skill
- [OpenClaw](https://openclaw.ai) - AI agent framework
- [innertube](https://github.com/tombulled/innertube) - YouTube API client
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) - YouTube metadata extraction
- [youtube-transcript-api](https://github.com/jdepoix/youtube-transcript-api) - Transcript fetching

---

**Built for the OpenClaw community**


path: config/channels.example.json
content: {
  "channels": [
    {
      "name": "All About AI",
      "id": "UCyFqFYfTW2VoIQKylJ04Rtw",
      "url": "https://www.youtube.com/@AllAboutAI"
    },
    {
      "name": "Two Minute Papers",
      "id": "UCbfYPyITQ-7l4upoX8nvctg",
      "url": "https://www.youtube.com/@TwoMinutePapers"
    },
    {
      "name": "Lex Fridman",
      "id": "UCSHZKyawb77ixDdsGog4iWA",
      "url": "https://www.youtube.com/@lexfridman"
    }
  ],
  "hours_lookback": 24,
  "min_duration_seconds": 300,
  "max_videos_per_channel": 5
}


path: config/settings.json
content: default_mode: ai-review
max_frames: 15
frame_interval: 30
frame_time_offset: 5
whisper_model: small

path: scripts/summarize.py
content: #!/usr/bin/env python3
"""
YouTube / Bilibili Summarizer - Universal Video Summarization Tool

Usage:
  youtube-summarizer --url "https://youtube.com/watch?v=VIDEO_ID"
  youtube-summarizer --url "https://www.bilibili.com/video/BV1xxxxx"
  youtube-summarizer --channel "UC_x5XG1OV2P6uZZ5FSM9Ttw" --hours 24
  youtube-summarizer --config channels.json --daily --output /tmp/youtube_summary.json
"""

import os
import sys
import json
import glob
import argparse
import subprocess
from datetime import datetime, timezone, timedelta
from typing import Optional, List, Dict
from pathlib import Path

# Default config
DEFAULT_MIN_DURATION = 300  # 5 minutes (filter Shorts)
DEFAULT_HOURS_LOOKBACK = 24
DEFAULT_MAX_VIDEOS_PER_CHANNEL = 5
DEFAULT_OUTPUT = "/tmp/youtube_summary.json"
DEFAULT_WHISPER_MODEL = "small"
DEFAULT_FRAME_INTERVAL = 30  # seconds

# ─────────────────────────────────────────────
# Config loader
# ─────────────────────────────────────────────

def load_settings() -> dict:
    """Load settings from config/settings.json (relative to this script's skill root)."""
    skill_root = Path(__file__).parent.parent
    config_path = skill_root / "config" / "settings.json"
    if config_path.exists():
        try:
            with open(config_path, "r", encoding="utf-8") as f:
                return json.load(f)
        except Exception:
            pass
    return {}


SETTINGS = load_settings()


def resolve_mode(args_mode: str | None) -> str:
    """Resolve effective mode: CLI arg > env var > config file > default (ai-review)."""
    if args_mode:
        return args_mode
    env_mode = os.environ.get("SUMMARY_MODE")
    if env_mode:
        return env_mode
    return SETTINGS.get("default_mode", "ai-review")

SUMMARY_PROMPT_TEMPLATE = """You are a professional content analyst. Please generate an in-depth, practical summary (at least 300 words) for the following video transcript.

Video Title: {title}
Channel: {channel}
Duration: {duration}
Transcript: {transcript}

Please output strictly in the following format (no preamble):

### 🎯 Core Problem/Innovation
- Summarize in one sentence what problem the video addresses
- What novel perspectives or technical breakthroughs are presented

### 💡 Key Arguments (detailed, 2-3 sentences each)
1. **Argument 1**: Detailed explanation with specific data, examples, or evidence
2. **Argument 2**: ...
3. **Argument 3**: ...

### 🛠️ Practical Steps (if applicable)
1. Step 1: Specific instructions
2. Step 2: ...

### 💰 Value & Application
- Who would benefit from this content
- How to apply it to real work/life situations

### ⚠️ Considerations
- Risks, limitations, and important notes"""


# ─────────────────────────────────────────────
# Platform detection
# ─────────────────────────────────────────────

def detect_platform(url: str) -> str:
    """Detect video platform from URL."""
    if 'bilibili.com' in url or 'b23.tv' in url:
        return 'bilibili'
    elif 'youtube.com' in url or 'youtu.be' in url:
        return 'youtube'
    else:
        return 'unknown'


# ─────────────────────────────────────────────
# Bilibili helpers
# ─────────────────────────────────────────────

def extract_bilibili_id(url: str) -> str:
    """Extract BV number from Bilibili URL (follows b23.tv short links)."""
    import re
    if 'b23.tv' in url:
        try:
            import requests
            r = requests.head(url, allow_redirects=True, timeout=10)
            url = r.url
        except Exception:
            pass
    match = re.search(r'BV[\w]+', url)
    return match.group(0) if match else "unknown"


def select_key_frames(frame_files: list, target_count: int = 10, oversample_ratio: float = 1.5) -> list:
    """按固定规则从所有帧中选择关键帧

    规则：
    - 如果总帧数 <= actual_count + 3，全部使用
    - 否则均匀选取 actual_count 张（包含首帧和末帧）
    - actual_count = round(target_count * oversample_ratio)，多选留余量
    """
    total = len(frame_files)
    actual_count = min(round(target_count * oversample_ratio), total)

    if total <= actual_count + 3:
        return frame_files

    indices = [0]
    step = (total - 1) / (actual_count - 1)
    for i in range(1, actual_count - 1):
        indices.append(round(i * step))
    indices.append(total - 1)

    indices = sorted(set(indices))
    return [frame_files[i] for i in indices]


def process_bilibili_video(
    url: str,
    output_dir: str = "/tmp",
    whisper_model: str = DEFAULT_WHISPER_MODEL,
    frame_interval: int = DEFAULT_FRAME_INTERVAL,
    skip_frames: bool = False,
    max_frames: int = 10,
) -> Dict:
    """
    Process a Bilibili video:
      1. Download video via yt-dlp (Chrome cookies bypass 412)
      2. Extract audio with ffmpeg
      3. Transcribe with faster-whisper
      4. Extract keyframes with ffmpeg (optional)
    Returns a dict with paths and transcript text.
    """
    video_id = extract_bilibili_id(url)
    video_path = os.path.join(output_dir, f"bili_{video_id}.mp4")
    audio_path = os.path.join(output_dir, f"bili_{video_id}_audio.mp3")
    frames_dir = os.path.join(output_dir, f"bili_{video_id}_frames")
    transcript_path = os.path.join(output_dir, f"bili_{video_id}_transcript.txt")

    print(f"🎬 Bilibili: {video_id}", file=sys.stderr)

    # Step 1: Download video
    print("  📥 Downloading video...", file=sys.stderr)
    download_cmd = [
        "yt-dlp",
        "--cookies-from-browser", "chrome",
        "--no-check-certificates",
        "-f", "bestvideo[height<=720]+bestaudio/best[height<=720]",
        "--merge-output-format", "mp4",
        "-o", video_path,
        url,
    ]
    subprocess.run(download_cmd, check=True, timeout=300)

    # Step 2: Extract audio
    print("  🎵 Extracting audio...", file=sys.stderr)
    subprocess.run([
        "ffmpeg", "-y", "-i", video_path,
        "-vn", "-acodec", "libmp3lame", "-q:a", "2",
        audio_path,
    ], check=True, timeout=120, capture_output=True)

    # Step 3: Whisper transcription
    print(f"  🗣️  Transcribing (whisper {whisper_model})...", file=sys.stderr)
    from faster_whisper import WhisperModel
    model = WhisperModel(whisper_model, device="cpu", compute_type="int8")
    segments_iter, info = model.transcribe(audio_path, language="zh")

    segments_list = list(segments_iter)
    transcript_lines = [f"[{s.start:.1f}-{s.end:.1f}] {s.text}" for s in segments_list]
    transcript_with_timestamps = "\n".join(transcript_lines)
    transcript_plain = " ".join(s.text for s in segments_list)

    with open(transcript_path, "w", encoding="utf-8") as f:
        f.write(transcript_with_timestamps)
    print(f"  ✅ Transcript: {len(transcript_plain)} chars", file=sys.stderr)

    # Step 4: Extract keyframes
    frame_files = []
    if not skip_frames:
        print(f"  🖼️  Extracting keyframes (every {frame_interval}s)...", file=sys.stderr)
        os.makedirs(frames_dir, exist_ok=True)
        subprocess.run([
            "ffmpeg", "-y", "-i", video_path,
            "-vf", f"fps=1/{frame_interval}", "-q:v", "2",
            os.path.join(frames_dir, "frame_%03d.jpg"),
        ], check=True, timeout=120, capture_output=True)
        frame_files = sorted(glob.glob(os.path.join(frames_dir, "frame_*.jpg")))
        print(f"  ✅ Keyframes: {len(frame_files)}", file=sys.stderr)

    # Select key frames
    FRAME_TIME_OFFSET = int(os.environ.get("FRAME_TIME_OFFSET", "5"))
    selected_frames = select_key_frames(frame_files, max_frames) if frame_files else []
    frame_time_map = []
    for frame_path in selected_frames:
        frame_num = int(os.path.basename(frame_path).split('_')[1].split('.')[0])
        time_sec = (frame_num - 1) * frame_interval + FRAME_TIME_OFFSET  # +偏移避开转场
        frame_time_map.append({
            "file": os.path.basename(frame_path),
            "time_sec": time_sec
        })

    return {
        "video_id": video_id,
        "platform": "bilibili",
        "video_path": video_path,
        "audio_path": audio_path,
        "transcript_path": transcript_path,
        "transcript": transcript_plain,
        "transcript_with_timestamps": transcript_with_timestamps,
        "frame_files": frame_files,
        "frame_count": len(frame_files),
        "selected_frames": selected_frames,
        "selected_frame_count": len(selected_frames),
        "frame_interval": frame_interval,
        "frame_time_map": frame_time_map,
    }


# ─────────────────────────────────────────────
# YouTube helpers (unchanged)
# ─────────────────────────────────────────────

def get_channel_videos(channel_id: str, hours: int, max_videos: int) -> List[Dict]:
    """Get recent videos from a YouTube channel using yt-dlp"""
    videos = []

    if channel_id.startswith("UC") and len(channel_id) == 24:
        url = f"https://www.youtube.com/channel/{channel_id}/videos"
    elif channel_id.startswith("http"):
        url = channel_id.rstrip("/") + "/videos"
    else:
        url = f"https://www.youtube.com/@{channel_id}/videos"

    try:
        result = subprocess.run(
            [
                "yt-dlp",
                "--flat-playlist",
                "--no-warnings",
                "-J",
                "--playlist-end", str(max_videos * 2),
                url,
            ],
            capture_output=True,
            text=True,
            timeout=45,
        )

        if result.returncode != 0:
            print(f"⚠️ yt-dlp error for {channel_id}: {result.stderr[:100]}", file=sys.stderr)
            return []

        data = json.loads(result.stdout)
        entries = data.get("entries", [])

        for entry in entries:
            if not entry:
                continue
            video_id = entry.get("id")
            if not video_id:
                continue
            if entry.get("duration") and entry.get("duration") < DEFAULT_MIN_DURATION:
                continue
            videos.append({
                "id": video_id,
                "title": entry.get("title", "Unknown"),
                "channel": entry.get("channel", entry.get("uploader", "Unknown")),
                "url": f"https://www.youtube.com/watch?v={video_id}",
                "duration_hint": entry.get("duration"),
            })
            if len(videos) >= max_videos:
                break

    except Exception as e:
        print(f"⚠️ Error fetching channel {channel_id}: {e}", file=sys.stderr)

    return videos


def get_video_details(video_id: str) -> Optional[Dict]:
    """Get detailed video metadata using yt-dlp"""
    try:
        result = subprocess.run(
            [
                "yt-dlp",
                "--no-warnings",
                "-j",
                "--no-download",
                f"https://www.youtube.com/watch?v={video_id}",
            ],
            capture_output=True,
            text=True,
            timeout=20,
        )

        if result.returncode != 0:
            return None

        data = json.loads(result.stdout)
        duration = data.get("duration", 0)

        return {
            "duration_seconds": duration,
            "duration": f"{duration // 60}:{duration % 60:02d}",
            "description": data.get("description", "")[:1000],
            "published": data.get("upload_date", ""),
            "view_count": data.get("view_count", 0),
            "like_count": data.get("like_count", 0),
        }

    except Exception:
        return None


def get_transcript(video_id: str) -> Optional[str]:
    """Get video transcript using multiple methods to avoid rate limiting"""
    transcript = _get_transcript_innertube_proxy(video_id)
    if transcript:
        return transcript
    transcript = _get_transcript_ytapi(video_id)
    if transcript:
        return transcript
    return None


def _parse_caption_xml(xml_text: str) -> List[str]:
    import xml.etree.ElementTree as ET
    import html as html_mod

    try:
        root = ET.fromstring(xml_text)
        texts = []

        for p in root.findall('.//p'):
            words = []
            for s in p.findall('s'):
                if s.text:
                    words.append(html_mod.unescape(s.text.strip()))
            if words:
                texts.append(' '.join(words))
            elif p.text:
                texts.append(html_mod.unescape(p.text.strip()))

        if not texts:
            for elem in root.findall('.//text'):
                if elem.text:
                    texts.append(html_mod.unescape(elem.text.strip()))

        return texts
    except Exception:
        return []


def _download_caption(url: str) -> Optional[str]:
    try:
        import requests
        r = requests.get(url, timeout=15)
        if r.status_code == 200 and r.text.strip():
            return r.text
    except Exception:
        pass
    return None


def _get_transcript_innertube_proxy(video_id: str) -> Optional[str]:
    try:
        import innertube

        client = innertube.InnerTube('ANDROID')
        data = client.player(video_id=video_id)

        if 'captions' not in data:
            return None

        caps = data['captions']['playerCaptionsTracklistRenderer']['captionTracks']
        if not caps:
            return None

        cap_url = None
        for prefer in ['en', 'zh-Hans', 'zh']:
            for c in caps:
                if c.get('languageCode') == prefer:
                    cap_url = c['baseUrl']
                    break
            if cap_url:
                break
        if not cap_url:
            cap_url = caps[0]['baseUrl']

        xml_text = _download_caption(cap_url)
        if not xml_text:
            return None

        texts = _parse_caption_xml(xml_text)
        if not texts:
            return None

        result = ' '.join(texts).strip()
        return result if len(result) > 50 else None

    except Exception:
        return None


def _get_transcript_ytapi(video_id: str) -> Optional[str]:
    try:
        from youtube_transcript_api import YouTubeTranscriptApi

        api = YouTubeTranscriptApi()
        fetched = api.fetch(video_id, languages=["zh-Hans", "zh-Hant", "en"])
        transcript = " ".join([item.text if hasattr(item, 'text') else item["text"] for item in fetched])
        return transcript if len(transcript) > 50 else None

    except Exception:
        return None


# ─────────────────────────────────────────────
# LLM helpers (shared)
# ─────────────────────────────────────────────

def get_copilot_session_token(gh_token: str) -> Optional[str]:
    try:
        import requests
        r = requests.get(
            "https://api.github.com/copilot_internal/v2/token",
            headers={
                "Authorization": f"token {gh_token}",
                "Editor-Version": "vscode/1.95.0",
                "User-Agent": "GitHubCopilotChat/0.20.0"
            },
            timeout=10
        )
        if r.status_code == 200:
            return r.json().get("token")
        print(f"⚠️ Failed to get Copilot [REDACTED_SECRET]", file=sys.stderr)
    except Exception as e:
        print(f"⚠️ Copilot auth error: {e}", file=sys.stderr)
    return None


def _call_llm(api_url: str, api_key: str, model: str, prompt: str) -> Optional[str]:
    try:
        import requests
        headers = {
            "Content-Type": "application/json",
            "Editor-Version": "vscode/1.95.0",
            "User-Agent": "GitHubCopilotChat/0.20.0"
        }
        if [REDACTED_SECRET]"Authorization"] = f"Bearer {api_key}"
        response = requests.post(
            api_url,
            headers=headers,
            json={
                "model": model,
                "messages": [{"role": "user", "content": prompt}],
                "max_tokens": 2000
            },
            timeout=120
        )
        if response.status_code == 200:
            return response.json()["choices"][0]["message"]["content"]
        print(f"⚠️ LLM API error: {response.status_code} {response.text[:200]}", file=sys.stderr)
    except Exception as e:
        print(f"⚠️ LLM call error: {e}", file=sys.stderr)
    return None


def generate_summary(title: str, channel: str, duration: str, transcript: str) -> Optional[str]:
    """Generate summary using LLM API (multi-backend fallback chain)."""
    prompt = SUMMARY_PROMPT_TEMPLATE.format(
        title=title,
        channel=channel,
        duration=duration,
        transcript=transcript[:8000]
    )

    env_url = os.environ.get("LLM_API_URL")
    env_key = os.environ.get("LLM_API_KEY")
    env_model = os.environ.get("LLM_MODEL", "gpt-4o-mini")

    if env_url and env_key:
        print(f"  🔑 Using LLM_API_URL env var: {env_url}", file=sys.stderr)
        result = _call_llm(env_url, env_key, env_model, prompt)
        if result:
            return result

    oc_token = os.environ.get("OPENCLAW_GATEWAY_TOKEN")
    if oc_token:
        api_url = env_url or "http://localhost:18789/v1/chat/completions"
        print(f"  🔑 Using OPENCLAW_GATEWAY_TOKEN → {api_url}", file=sys.stderr)
        result = _call_llm(api_url, oc_token, env_model, prompt)
        if result:
            return result

    gh_token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
    if gh_token:
        print("  🔑 Trying GitHub Copilot API...", file=sys.stderr)
        copilot_token = get_copilot_session_token(gh_token)
        if copilot_token:
            copilot_model = os.environ.get("LLM_MODEL", "claude-haiku-4.5")
            result = _call_llm(
                "https://api.githubcopilot.com/chat/completions",
                copilot_token,
                copilot_model,
                prompt
            )
            if result:
                return result

    poll_key = os.environ.get("POLLINATIONS_API_KEY")
    if poll_key:
        print("  🔑 Trying Pollinations API (with key)...", file=sys.stderr)
        result = _call_llm(
            "https://gen.pollinations.ai/v1/chat/completions",
            poll_key,
            "openai",
            prompt
        )
        if result:
            return result

    print("  🌐 Trying Pollinations free anonymous call...", file=sys.stderr)
    result = _call_llm(
        "https://gen.pollinations.ai/v1/chat/completions",
        "",
        "openai",
        prompt
    )
    if result:
        return result

    print("⚠️ All LLM backends failed. No summary generated.", file=sys.stderr)
    return None


# ─────────────────────────────────────────────
# YouTube video processing
# ─────────────────────────────────────────────

def process_video(video_id: str, title: str = None, channel: str = None) -> Dict:
    """Process a single YouTube video: get details, transcript, and summary"""
    print(f"📹 Processing: {video_id}")

    details = get_video_details(video_id)
    if not details:
        return {
            "video_id": video_id,
            "title": title or "Unknown",
            "url": f"https://www.youtube.com/watch?v={video_id}",
            "error": "Failed to fetch video details"
        }

    transcript = get_transcript(video_id)
    has_transcript = transcript is not None

    result = {
        "video_id": video_id,
        "title": title or "Unknown",
        "url": f"https://www.youtube.com/watch?v={video_id}",
        "platform": "youtube",
        "channel": channel or "Unknown",
        "duration": details["duration"],
        "published": details["published"],
        "has_transcript": has_transcript,
        "metadata": {
            "view_count": details.get("view_count", 0),
            "like_count": details.get("like_count", 0),
        }
    }

    if has_transcript:
        print(f"  ✅ Transcript: {len(transcript)} chars")
        summary = generate_summary(title, channel, details["duration"], transcript)
        result["summary"] = summary or "⚠️ 摘要生成失败\n\n视频有字幕但 LLM 调用失败。"
        if summary:
            print(f"  ✅ Summary: {len(summary)} chars")
    else:
        result["summary"] = f"📺 **需观看获取详细内容**\n\n视频暂无字幕，无法生成详细摘要。\n\n基于标题推测：{title}"
        print(f"  ⚠️ No transcript available")

    return result


# ─────────────────────────────────────────────
# CLI entry point
# ─────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(description="YouTube / Bilibili Summarizer")
    parser.add_argument("--url", help="Single video URL (YouTube or Bilibili)")
    parser.add_argument("--channel", help="YouTube channel ID or handle")
    parser.add_argument("--config", help="Config file path (JSON)")
    parser.add_argument("--daily", action="store_true", help="Daily batch mode (requires --config)")
    parser.add_argument("--hours", type=int, default=DEFAULT_HOURS_LOOKBACK, help="Hours to look back")
    parser.add_argument("--output", default=DEFAULT_OUTPUT, help="Output JSON file")

    # Mode selection
    parser.add_argument("--mode",
                        choices=["text-only", "auto-insert", "ai-review"],
                        default=None,
                        help="Output mode: text-only / auto-insert / ai-review (overrides config & env)")

    # Bilibili-specific options
    parser.add_argument("--no-frames", action="store_true",
                        help="Skip keyframe extraction (Bilibili) — same as --mode text-only")
    parser.add_argument("--whisper-model",
                        default=os.environ.get("WHISPER_MODEL", SETTINGS.get("whisper_model", DEFAULT_WHISPER_MODEL)),
                        help="Whisper model size for Bilibili (default: small)")
    parser.add_argument("--frame-interval", type=int,
                        default=int(os.environ.get("FRAME_INTERVAL", SETTINGS.get("frame_interval", DEFAULT_FRAME_INTERVAL))),
                        help="Keyframe extraction interval in seconds (default: 30)")
    parser.add_argument("--max-frames", type=int,
                        default=int(os.environ.get("MAX_FRAMES", SETTINGS.get("max_frames", 15))),
                        help="Max keyframes to select (default: 15)")

    args = parser.parse_args()

    # Resolve effective mode
    effective_mode = resolve_mode(args.mode)
    # --no-frames is equivalent to text-only
    if args.no_frames:
        effective_mode = "text-only"
    skip_frames = (effective_mode == "text-only")

    print(f"🎨 图文模式: {effective_mode}", file=sys.stderr)

    results = {
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "items": [],
        "stats": {
            "total_videos": 0,
            "with_transcript": 0,
            "without_transcript": 0,
        }
    }

    # Mode 1: Single video (YouTube or Bilibili)
    if args.url:
        platform = detect_platform(args.url)

        if platform == 'bilibili':
            output_dir = str(Path(args.output).parent) if args.output != DEFAULT_OUTPUT else "/tmp"
            try:
                bili_result = process_bilibili_video(
                    args.url,
                    output_dir=output_dir,
                    whisper_model=args.whisper_model,
                    frame_interval=args.frame_interval,
                    skip_frames=skip_frames,
                    max_frames=args.max_frames,
                )
                summary = generate_summary(
                    title=bili_result.get("title", "B站视频"),
                    channel="Bilibili",
                    duration="unknown",
                    transcript=bili_result["transcript"],
                )
                result = {
                    "video_id": bili_result["video_id"],
                    "title": bili_result.get("title", "B站视频"),
                    "url": args.url,
                    "platform": "bilibili",
                    "mode": effective_mode,
                    "has_transcript": bool(bili_result["transcript"]),
                    "transcript_path": bili_result["transcript_path"],
                    "frame_files": bili_result["frame_files"],
                    "frame_count": bili_result["frame_count"],
                    "selected_frames": bili_result["selected_frames"],
                    "selected_frame_count": bili_result["selected_frame_count"],
                    "frame_interval": bili_result["frame_interval"],
                    "frame_time_map": bili_result["frame_time_map"],
                    "summary": summary or "摘要生成失败",
                }
                if effective_mode == "ai-review":
                    result["ai_review_hint"] = "⚠️ ai-review 模式：agent 需基于文章结构对帧进行审图（补帧/删帧/替换），再出图文版。"
                results["items"].append(result)
                results["stats"]["total_videos"] = 1
                results["stats"]["with_transcript"] = 1
            except Exception as e:
                print(f"❌ Bilibili processing failed: {e}", file=sys.stderr)
                results["items"].append({
                    "url": args.url,
                    "platform": "bilibili",
                    "error": str(e),
                })
                results["stats"]["total_videos"] = 1
                results["stats"]["without_transcript"] = 1

        elif platform == 'youtube':
            video_id = args.url.split("v=")[-1].split("&")[0]
            if "youtu.be/" in args.url:
                video_id = args.url.split("youtu.be/")[-1].split("?")[0]
            result = process_video(video_id)
            results["items"].append(result)
            results["stats"]["total_videos"] = 1
            if result.get("has_transcript"):
                results["stats"]["with_transcript"] = 1
            else:
                results["stats"]["without_transcript"] = 1

        else:
            print(f"❌ Unsupported URL platform: {args.url}", file=sys.stderr)
            sys.exit(1)

    # Mode 2: Channel scan
    elif args.channel:
        videos = get_channel_videos(args.channel, args.hours, DEFAULT_MAX_VIDEOS_PER_CHANNEL)
        print(f"📺 Found {len(videos)} videos from channel")

        for video in videos:
            result = process_video(video["id"], video["title"], video["channel"])
            results["items"].append(result)
            results["stats"]["total_videos"] += 1
            if result.get("has_transcript"):
                results["stats"]["with_transcript"] += 1
            else:
                results["stats"]["without_transcript"] += 1

    # Mode 3: Daily batch (config file)
    elif args.daily and args.config:
        with open(args.config, "r") as f:
            config = json.load(f)

        channels = config.get("channels", [])
        hours = config.get("hours_lookback", args.hours)
        max_videos = config.get("max_videos_per_channel", DEFAULT_MAX_VIDEOS_PER_CHANNEL)

        print(f"📺 Processing {len(channels)} channels")

        for ch in channels:
            channel_id = ch.get("id") or ch.get("url")
            channel_name = ch.get("name", "Unknown")

            print(f"\n🔍 Channel: {channel_name}")
            videos = get_channel_videos(channel_id, hours, max_videos)
            print(f"  Found {len(videos)} videos")

            for video in videos:
                result = process_video(video["id"], video["title"], channel_name)
                results["items"].append(result)
                results["stats"]["total_videos"] += 1
                if result.get("has_transcript"):
                    results["stats"]["with_transcript"] += 1
                else:
                    results["stats"]["without_transcript"] += 1

    else:
        parser.print_help()
        sys.exit(1)

    # Write output
    output_path = Path(args.output)
    output_path.parent.mkdir(parents=True, exist_ok=True)

    with open(output_path, "w", encoding="utf-8") as f:
        json.dump(results, f, indent=2, ensure_ascii=False)

    print(f"\n✅ Output written to: {output_path}")
    print(f"📊 Stats: {results['stats']}")


if __name__ == "__main__":
    main()


path: setup.sh
content: #!/bin/bash
# Setup script for YouTube Summarizer skill

set -e

SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VENV_DIR="$SKILL_DIR/venv"

echo "🔧 Setting up YouTube Summarizer skill..."

# Check Python version
if ! command -v python3 &> /dev/null; then
    echo "❌ Python 3 not found. Please install Python 3.9+"
    exit 1
fi

PYTHON_VERSION=$(python3 --version | cut -d' ' -f2 | cut -d'.' -f1,2)
echo "✅ Python version: $PYTHON_VERSION"

# Check/install yt-dlp
if ! command -v yt-dlp &> /dev/null; then
    echo "📦 Installing yt-dlp..."
    if command -v brew &> /dev/null; then
        brew install yt-dlp
    else
        echo "❌ Homebrew not found. Please install yt-dlp manually:"
        echo "   brew install yt-dlp"
        exit 1
    fi
else
    echo "✅ yt-dlp found"
fi

# Create virtual environment
if [ ! -d "$VENV_DIR" ]; then
    echo "📦 Creating virtual environment..."
    python3 -m venv "$VENV_DIR"
fi

# Activate and install dependencies
echo "📦 Installing Python dependencies..."
source "$VENV_DIR/bin/activate"
pip install --quiet youtube-transcript-api requests innertube faster-whisper

echo ""

# Check ffmpeg (required for Bilibili)
if ! command -v ffmpeg &> /dev/null; then
    echo "⚠️  ffmpeg not found. Required for Bilibili video processing."
    echo "   Install: sudo apt install ffmpeg (Linux) / brew install ffmpeg (macOS)"
else
    echo "✅ ffmpeg found"
fi

echo ""
echo "✅ 依赖安装完成！"
echo ""

# ─────────────────────────────────────────────
# 模式引导配置
# ─────────────────────────────────────────────
CONFIG_DIR="$SKILL_DIR/config"
CONFIG_FILE="$CONFIG_DIR/settings.json"
mkdir -p "$CONFIG_DIR"

configure_mode() {
    echo "📋 选择默认图文模式 (Select default output mode):"
    echo ""
    echo "  1) text-only    - 纯文字，不抽帧（最快）"
    echo "  2) auto-insert  - 自动选帧插入文档（推荐平衡）"
    echo "  3) ai-review    - AI 智能选图（默认，最佳效果，多消耗 ~5-8k token）"
    echo ""
    printf "请选择 [1/2/3] (默认 3): "
    read -r MODE_CHOICE

    case "$MODE_CHOICE" in
        1) DEFAULT_MODE="text-only" ;;
        2) DEFAULT_MODE="auto-insert" ;;
        *) DEFAULT_MODE="ai-review" ;;
    esac

    # Read existing config if present, else use defaults
    MAX_FRAMES=15
    FRAME_INTERVAL=30
    FRAME_TIME_OFFSET=5
    WHISPER_MODEL="small"

    if [ -f "$CONFIG_FILE" ]; then
        # Try to preserve existing values
        existing_max=$(python3 -c "import json; d=json.load(open('$CONFIG_FILE')); print(d.get('max_frames',15))" 2>/dev/null)
        existing_fi=$(python3 -c "import json; d=json.load(open('$CONFIG_FILE')); print(d.get('frame_interval',30))" 2>/dev/null)
        existing_fto=$(python3 -c "import json; d=json.load(open('$CONFIG_FILE')); print(d.get('frame_time_offset',5))" 2>/dev/null)
        existing_wm=$(python3 -c "import json; d=json.load(open('$CONFIG_FILE')); print(d.get('whisper_model','small'))" 2>/dev/null)
        [ -n "$existing_max" ] && MAX_FRAMES="$existing_max"
        [ -n "$existing_fi" ] && FRAME_INTERVAL="$existing_fi"
        [ -n "$existing_fto" ] && FRAME_TIME_OFFSET="$existing_fto"
        [ -n "$existing_wm" ] && WHISPER_MODEL="$existing_wm"
    fi

    cat > "$CONFIG_FILE" << JSONEOF
{
  "default_mode": "$DEFAULT_MODE",
  "max_frames": $MAX_FRAMES,
  "frame_interval": $FRAME_INTERVAL,
  "frame_time_offset": $FRAME_TIME_OFFSET,
  "whisper_model": "$WHISPER_MODEL"
}
JSONEOF

    echo ""
    echo "✅ 已设置默认模式：$DEFAULT_MODE"
    echo "   配置已写入 $CONFIG_FILE"
}

configure_mode

echo ""
echo "✅ Setup complete!"
echo ""
echo "Usage:"
echo "  youtube-summarizer --url 'https://youtube.com/watch?v=VIDEO_ID'"
echo "  youtube-summarizer --url 'https://www.bilibili.com/video/BV1xxxxx'"
echo "  youtube-summarizer --channel 'UC_x5XG1OV2P6uZZ5FSM9Ttw' --hours 24"
echo "  youtube-summarizer --config channels.json --daily --output /tmp/youtube_summary.json"
echo ""
echo "图文模式（可重新配置）:"
echo "  youtube-summarizer --setup"
echo ""
echo "Add to PATH:"
echo "  export PATH=\"\$PATH:$HOME/.openclaw/skills/youtube-summarizer\""


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