Skill: wahtungl/douyin-video-to-report (version 1.0.0)

--- version: "1.0.0" name: douyin-video-to-report description: "抖音视频链接 → 完整图文报告。自动完成：绕过验证抓取视频 → 下载 → 音频提取 → 语音转文字 → 炫酷HTML报告 → 飞书发送。触发词：抖音视频总结、抖音视频分析、这个视频说了什么、总结这个视频" override-tools: - douyin_report --- # Douyin Video → Report Pipeline 将抖音视频链接自动转换为可阅读的 HTML 报告，适合需要提炼视频内容的场景。 ## 完整工作流程 ``` 抖音短链 (v.douyin.com/xxx) │ ▼ 1. Playwright (headless Chrome) - 绕过 JS 验证 / 滑动验证码 - 提取视频直链 │ ▼ 2. curl 下载视频 (.mp4) │ ▼ 3. ffmpeg 提取音频 (.mp3) │ ▼ 4. miaoda-studio-cli speech-to-text - 中文普通话识别 - 100% 转录 │ ▼ 5. 生成炫酷 HTML 报告 - 深色渐变主题 - 核心数据卡片 - 洞察总结 - 完整转录（可展开） - Ctrl+P 可打印成 PDF │ ▼ 6. 飞书附件发送给用户 ``` ## 核心脚本 脚本位于：`/home/gem/workspace/agent/skills/douyin-video-to-report/douyin_pipeline.py` ### 使用方式 ```bash # 方式一：直接运行（传入抖音URL） python3 /home/gem/workspace/agent/skills/douyin-video-to-report/douyin_pipeline.py "https://v.douyin.com/xxxxx" # 方式二：分步执行（见下方分步说明） ``` ## 分步执行命令 ### Step 1：提取视频直链 ```python # douyin_fetch.py - Playwright 提取视频 URL import asyncio from playwright.async_api import async_playwright async def get_video_url(douyin_url): async with async_playwright() as p: browser = await p.chromium.launch(headless=True, args=[ '--no-sandbox', '--disable-setuid-sandbox', [REDACTED_SECRET], ]) context = await browser.new_context( user_agent='Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', viewport={'width': 390, 'height': 844}, locale='zh-CN', ) page = await context.new_page() await page.goto(douyin_url, timeout=30000, wait_until="networkidle") await asyncio.sleep(3) # 从 video 元素获取直链 video_info = await page.evaluate(""" () => { const v = document.querySelector('video'); if (v && v.src) return v.src; return null; } """) await browser.close() return video_info ``` ### Step 2：下载视频 ```bash curl -L -o /tmp/douyin_video.mp4 "视频直链" \ -H "User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" \ --max-time 60 -s ``` ### Step 3：提取音频 ```bash ffmpeg -i /tmp/douyin_video.mp4 -vn -acodec libmp3lame -q:a 2 /tmp/douyin_audio.mp3 -y ``` ### Step 4：语音转文字 ```bash miaoda-studio-cli speech-to-text --file /tmp/douyin_audio.mp3 --lang zh --output text ``` ### Step 5：生成报告 使用 `miaoda_coding` 或直接使用 `report_generator.py`（见下方） ## 依赖环境 | 依赖 | 安装命令 | |------|---------| | Playwright | `pip install playwright && python3 -m playwright install chromium` | | ffmpeg | 系统自带或 `apt install ffmpeg` | | yt-dlp | `pip install yt-dlp` | | miaoda-studio-cli | 系统自带 | ## 关键经验 1. **必须用 Playwright headless Chrome**，不能用 requests/httpx，抖音会拦截 2. **移动端 UA 更容易绕过**，用 iPhone UA 而非桌面 Chrome 3. **视频直链从 `document.querySelector('video').src` 获取**，不要从 network 请求里找 4. **下载视频时必须加 User-Agent header**，否则 403 5. **飞书附件发送**：文件必须放在 [REDACTED_SECRET] 或 `/tmp/openclaw/` 目录 ## 报告模板结构 - Hero：标题 + 视频元信息 - 数据卡片：关键数字高亮 - 洞察区：6个核心发现卡片 - 对比表：进门 vs 留存因素 - 案例区：引用高亮 - 行动指南：5项建议 - 完整转录：可展开/收起 ## 相关工具 - `miaoda-studio-cli speech-to-text`：音频转文字 - `miaoda-studio-cli doc-parse`：文档解析 - `miaoda-coding`：生成可视化报告

path: _meta.json
content: {
  "name": "douyin-video-to-report",
  "version": "1.0.0",
  "description": "抖音视频链接 → 完整图文报告。自动完成：绕过验证抓视频 → 下载 → 音频提取 → 语音转文字 → 炫酷HTML报告 → 飞书发送。",
  "triggers": [
    "抖音视频总结",
    "抖音视频分析",
    "这个视频说了什么",
    "总结这个视频",
    "帮我看这个抖音",
    "抖音链接"
  ],
  "author": "主机",
  "tags": ["douyin", "video", "transcription", "report", "automation"]
}


path: douyin_pipeline.py
content: #!/usr/bin/env python3
"""
抖音视频 → 完整报告（升级版）
流程: Playwright抓视频 → ffmpeg抽帧 → image-understanding画面分析 → speech-to-text语音转文字 → 两路合并 → 生成HTML报告
用法: python3 douyin_pipeline.py <抖音URL> [报告标题]
"""
import asyncio, sys, os, subprocess, base64
from playwright.async_api import async_playwright

TMP = "/tmp"
WORKSPACE = "/home/gem/workspace/agent/workspace"

def run(cmd, timeout=60):
    r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
    return r.returncode, r.stdout, r.stderr

async def get_video_info(url):
    async with async_playwright() as p:
        browser = await p.chromium.launch(
            headless=True,
            args=['--no-sandbox','--disable-setuid-sandbox','--disable-blink-features=AutomationControlled']
        )
        ctx = await browser.new_context(
            user_agent='Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
            viewport={'width':390,'height':844}, locale='zh-CN'
        )
        page = await ctx.new_page()
        print(f"🚀 访问: {url}")
        await page.goto(url, timeout=30000, wait_until="domcontentloaded")
        await asyncio.sleep(4)
        title = await page.title()
        video_url = await page.evaluate("""
            () => {
                const v = document.querySelector('video');
                if (v && v.src && v.src.includes('douyin')) return v.src;
                return null;
            }
        """)
        if not video_url:
            scripts = await page.query_selector_all('script')
            for s in scripts:
                txt = await s.inner_text() if hasattr(s,'inner_text') else ''
                if 'playwm' in txt or '.mp4' in txt:
                    import re
                    m = re.search(r'(https?://[^\s"\'\\]+\.mp4[^\s"\'\\]*)', txt)
                    if m: return m.group(1)
        await browser.close()
        return {'title': title, 'video_url': video_url}

def download_video(url, path):
    print(f"📥 下载视频...")
    code, _, err = run(
        f'curl -L -o "{path}" --max-time 120 -s '
        f'-H "User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" '
        f'-H "Referer: https://www.douyin.com/" '
        f'"{url}"', timeout=130
    )
    if code == 0:
        size = os.path.getsize(path)/(1024*1024)
        print(f"✅ 视频下载完成: {size:.1f} MB")
        return True
    print(f"❌ 下载失败: {err[-200:]}")
    return False

def extract_frames(video_path, frame_dir, interval=5):
    """每隔interval秒抽一帧，保存为jpg"""
    os.makedirs(frame_dir, exist_ok=True)
    # 获取视频时长
    code, out, _ = run(f'ffprobe -v quiet -show_entries format=duration -of csv=p=0 "{video_path}"')
    duration = float(out.strip()) if code == 0 else 60
    fps = 1.0 / interval
    cmd = f'ffmpeg -i "{video_path}" -vf "fps={fps},scale=720:-1" "{frame_dir}/frame_%03d.jpg" -y 2>&1 | tail -2'
    code, _, err = run(cmd, timeout=120)
    frames = sorted([f for f in os.listdir(frame_dir) if f.endswith('.jpg')])
    print(f"✅ 抽帧完成: {len(frames)} 帧")
    return frames

def extract_audio(video_path, audio_path):
    print(f"🎵 提取音频...")
    code, _, err = run(
        f'ffmpeg -i "{video_path}" -vn -acodec libmp3lame -q:a 2 "{audio_path}" -y',
        timeout=300
    )
    if code == 0:
        print(f"✅ 音频提取完成: {os.path.getsize(audio_path)/1024/1024:.1f} MB")
        return True
    print(f"❌ 音频失败: {err[-200:]}")
    return False

def transcribe(audio_path):
    print(f"🎙️ 语音转文字...")
    code, out, err = run(
        f'miaoda-studio-cli speech-to-text --file "{audio_path}" --lang zh --output text',
        timeout=600
    )
    if code == 0 and out.strip():
        print(f"✅ 转录完成: {len(out)} 字符")
        return out.strip()
    print(f"❌ 转录失败: {err[-200:]}")
    return None

def analyze_frames(frame_dir, frames):
    """调用 image-understanding 分析每帧画面"""
    print(f"🖼️ 分析画面中（共{len(frames)}帧）...")
    results = []
    for i, fname in enumerate(frames):
        fpath = os.path.join(frame_dir, fname)
        cmd = f'miaoda-studio-cli image-understanding --image "{fpath}" --prompt "简述这张视频截图的画面内容，包括人物动作、屏幕文字、数据图表等所有视觉信息。" --output text'
        code, out, err = run(cmd, timeout=60)
        if code == 0 and out.strip():
            results.append(f"[帧{i+1}] {out.strip()}")
            print(f"  帧{i+1}/{len(frames)} 完成")
        else:
            print(f"  帧{i+1} 失败，尝试备用...")
            # 备用：用base64读取后通过文件方式
            with open(fpath, 'rb') as f:
                b64 = base64.b64encode(f.read()).decode()
            print(f"  帧{i+1}: (图片 {os.path.getsize(fpath)//1024}KB)")
            results.append(f"[帧{i+1}] {fname} ({os.path.getsize(fpath)//1024}KB)")
    return "\n".join(results)

async def main():
    if len(sys.argv) < 2:
        print("用法: python3 douyin_pipeline.py <抖音URL>")
        sys.exit(1)

    url = sys.argv[1].strip()
    report_title = sys.argv[2] if len(sys.argv) > 2 else "视频报告"

    video_path = f"{TMP}/douyin_v2_video.mp4"
    audio_path = f"{TMP}/douyin_v2_audio.mp3"
    frame_dir  = f"{TMP}/douyin_v2_frames"
    transcript_path = f"{WORKSPACE}/transcript.txt"

    # Step 1: 抓视频直链
    print("\n" + "="*50)
    print("STEP 1: Playwright 抓视频直链")
    print("="*50)
    info = await get_video_info(url)
    if not info['video_url']:
        print("❌ 无法获取视频直链")
        sys.exit(1)
    print(f"📌 标题: {info['title']}")

    # Step 2: 下载视频
    print("\n" + "="*50)
    print("STEP 2: 下载视频")
    print("="*50)
    if not download_video(info['video_url'], video_path):
        sys.exit(1)

    # Step 3: 提取音频
    print("\n" + "="*50)
    print("STEP 3: 提取音频")
    print("="*50)
    if not extract_audio(video_path, audio_path):
        sys.exit(1)

    # Step 4: 抽帧
    print("\n" + "="*50)
    print("STEP 4: ffmpeg 抽帧（每5秒1帧）")
    print("="*50)
    frames = extract_frames(video_path, frame_dir, interval=5)

    # Step 5: 语音转文字
    print("\n" + "="*50)
    print("STEP 5: 语音转文字")
    print("="*50)
    transcript = transcribe(audio_path)
    if not transcript:
        print("⚠️ 转录失败，继续画面分析")

    # Step 6: 画面分析
    print("\n" + "="*50)
    print("STEP 6: 逐帧画面 AI 分析")
    print("="*50)
    frame_analysis = analyze_frames(frame_dir, frames)

    # 保存分析结果
    with open(transcript_path.replace('transcript','analysis'), 'w') as f:
        f.write(f"标题: {info['title']}\n\n=== 画面分析 ===\n{frame_analysis}\n\n=== 语音转录 ===\n{transcript or '（语音转录失败）'}")

    print(f"\n✅ 全流程完成！")
    print(f"📄 画面分析: {len(frame_analysis)} 字符")
    if transcript:
        print(f"📄 语音转录: {len(transcript)} 字符")
    print(f"📁 输出目录: {frame_dir}")
    print("\n下一步: AI 根据画面分析+语音转录生成完整报告")

if __name__ == "__main__":
    asyncio.run(main())


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