Skill: junweiren98-rgb/douyin-transcribe-skill (version 1.1.0)

--- name: douyin-transcribe description: | 抖音视频智能助手。用户发抖音链接或视频文件，自动转录并智能处理（总结/逐字稿/归档/讨论）。 触发词：抖音、douyin.com、转文字、转录、视频转文本、douyin、transcribe --- # 抖音视频智能助手 🎬🧠 用户发一个抖音链接，你就能"看到"视频内容——转录、总结、讨论、归档，一条龙。 --- ## 触发条件 以下情况触发此 Skill： 1. 用户消息包含 `douyin.com` 链接（短链或长链都算） 2. 用户发来视频文件 + 提到"转文字/转录"等 3. 用户发来类似 `7.94 复制打开抖音...` 的分享文本（提取其中链接） --- ## 第一步：判断用户意图 根据用户消息，判断属于以下哪种模式： | 模式 | 触发信号 | 用户说的话举例 | |------|---------|--------------| | **默认模式** | 只发了链接，没有额外指令 | `https://v.douyin.com/xxx` | | **逐字稿模式** | 明确要原文/逐字稿 | "转文字"、"逐字稿"、"转录出来" | | **总结模式** | 要总结/要点 | "总结一下"、"讲了啥"、"帮我看看" | | **归档模式** | 要保存/收藏 | "存到知识库"、"收藏一下"、"记下来" | | **讨论模式** | 要讨论/评价 | "你怎么看"、"有道理吗"、"值不值得做" | **判断不了时，用默认模式。** --- ## 第二步：转录视频 无论哪种模式，都需要先拿到逐字稿。 ### 方式 A：用户发来抖音链接 #### A1. 启动浏览器 ``` browser(action="start", profile="openclaw") ``` 如果浏览器已运行，跳过。 #### A2. 打开链接 ``` browser(action="navigate", url="<链接>", profile="openclaw") ``` #### A3. 等待播放，提取音频 等 3-5 秒让视频播放，然后执行： ``` browser(action="act", kind="evaluate", profile="openclaw", fn=下方代码) ``` ```javascript () => { const entries = performance.getEntriesByType('resource'); const audioEntry = entries.find(e => e.name.includes('media-audio')); const title = document.querySelector('h1')?.textContent?.trim() || document.querySelector('[data-e2e="video-desc"]')?.textContent?.trim() || document.title; const authorEl = document.querySelector('[data-e2e="video-account-link"]') || document.querySelector('.author-name'); const author = authorEl?.textContent?.trim(); return { audioUrl: audioEntry?.name || null, title: title || '未知标题', author: author || '未知作者' }; } ``` 如果 `audioUrl` 为 null，等 5 秒重试（最多 3 次）。仍然为 null 可能需要用户先登录抖音网页版。 #### A4. 运行转录脚本 `<skill目录>` 替换为这个 SKILL.md 所在的实际目录路径。 **Windows PowerShell：** ```powershell $env:DOUYIN_AUDIO_URL = "<audioUrl>" $env:DOUYIN_TITLE = "<title>" $env:DOUYIN_AUTHOR = "<author>" cd "<skill目录>" node scripts/transcribe.js "<原始链接>" ``` **Linux/Mac：** ```bash cd "<skill目录>" DOUYIN_AUDIO_URL="<audioUrl>" DOUYIN_TITLE="<title>" DOUYIN_AUTHOR="<author>" node scripts/transcribe.js "<原始链接>" ``` 设 timeout 120 秒。 #### A5. 读取逐字稿 脚本成功后，读取 `douyin-transcripts/` 目录下最新的 `.md` 文件，拿到完整逐字稿文本。 ### 方式 B：用户发来视频文件 保存文件到 `<skill目录>/temp/`，然后： ```bash cd "<skill目录>" node scripts/transcribe.js "<视频文件路径>" ``` 读取最新 `.md` 输出文件。 ### 方式 C：用户发来分享文本 从文本中提取 `https://v.douyin.com/xxxxx/` 链接，按方式 A 处理。 --- ## 第三步：根据模式输出结果 ### 🔹 默认模式（最常用） **用户只发了链接，没说要干嘛。给最有价值的输出。** 1. 读完逐字稿后，**你自己做一个总结**（不调脚本，你就是 LLM） 2. 总结格式： > **📹 {视频标题}** > 👤 {博主名} > > **要点总结：** > 1. {要点1} > 2. {要点2} > 3. {要点3} > ...（3-7个要点，视内容长度而定） > > **一句话概括：** {用一句话说清楚这个视频讲了什么} > > --- > 💡 逐字稿已保存。回复"看原文"查看完整逐字稿，"存起来"归档到知识库。 3. **不要**甩一大段逐字稿——用户扔链接过来是想快速了解内容，不是要读作文 ### 🔹 逐字稿模式 用户明确要逐字稿，直接给： > **📹 {标题}** | 👤 {博主} > > --- > {完整逐字稿内容} 如果逐字稿太长（>2000字），先给前 1000 字 + "内容较长，已保存完整版到 `{文件路径}`"。 ### 🔹 总结模式 比默认模式更详细的总结： > **📹 {标题}** | 👤 {博主} > > ## 核心观点 > {2-3段话概括视频主旨} > > ## 关键要点 > 1. **{要点标题}**：{展开说明} > 2. **{要点标题}**：{展开说明} > ... > > ## 适用场景 > {这个内容对用户有什么用，适合什么人看} ### 🔹 归档模式 1. 先做总结（同默认模式） 2. 保存到知识库目录 `<workspace>/douyin-knowledge/`（workspace 指 OpenClaw 工作目录） 3. 文件名格式：`{YYYY-MM-DD}-{简短标题}.md` 4. 文件内容包含：来源链接、博主、总结、完整逐字稿 ```markdown # {视频标题} **来源**: {抖音链接} **博主**: {博主名} **日期**: {归档日期} ## 总结 {AI 总结内容} ## 完整逐字稿 {逐字稿全文} ``` 5. 告诉用户： > ✅ 已归档到 `douyin-knowledge/{文件名}` > {简短总结} 如果用户提到飞书/Notion 等具体平台，用对应工具创建文档。 ### 🔹 讨论模式 1. 先做总结 2. 然后给出你的看法/分析： > **📹 {标题}** | 👤 {博主} > > **要点：** {3-5个要点} > > **我的看法：** > {你对内容的分析——同意什么、质疑什么、对用户有什么启发} > > {如果和用户的工作/计划相关，指出关联} --- ## 后续交互 转录完成后，用户可能会追问。常见追问及处理： | 用户说 | 你做什么 | |--------|---------| | "看原文" / "逐字稿" | 展示完整逐字稿 | | "存起来" / "归档" | 执行归档模式 | | "你怎么看" | 给出分析讨论 | | "他说的XX是什么意思" | 基于逐字稿回答 | | "帮我提取金句" | 从逐字稿中挑出精彩句子 | | "发到飞书" | 用飞书工具创建文档 | **关键**：逐字稿已经在你上下文里了，后续追问不需要重新转录。 --- ## 首次使用？先帮用户配置 当用户第一次触发时，按以下顺序检查环境。**缺什么补什么。** ### 检查 1：.env 文件 ``` read: <skill目录>/.env ``` 不存在则从 `.env.example` 复制。 ### 检查 2：Groq API Key 读 `.env`，检查 `GROQ_API_KEY` 是否已填（不是 `gsk_your_key_here`）。 如果未填，告诉用户： > 需要一个免费的 Groq API Key 来做语音识别。 > > 1. 打开 https://console.groq.com > 2. Google/GitHub 登录（免费，不要信用卡） > 3. API Keys → Create API Key > 4. 复制 Key（`gsk_` 开头）发给我 拿到 Key 后更新 `.env`。 ### 检查 3：ffmpeg ``` exec: ffmpeg -version ``` 没装则引导安装（Mac: `brew install ffmpeg`，Windows: gyan.dev 下载，Linux: `apt install ffmpeg`）。 --- ## 技术说明 ### 工作原理 ``` 抖音链接 → 浏览器打开 → DASH 音频流 URL → ffmpeg 下载（~1MB） → Groq Whisper large-v3（免费，3秒） → Groq LLM 标点分段（免费，1秒） → Agent 智能处理（总结/讨论/归档） ``` ### 依赖 | 依赖 | 必须？ | 费用 | |------|--------|------| | ffmpeg | ✅ | 免费 | | Groq API Key | ✅ | 免费 | | OpenClaw Browser | 推荐 | N/A | ### 文件结构 ``` douyin-transcribe/ ├── SKILL.md ← 你正在读的操作指南 ├── README.md ← 面向人类的说明 ├── _meta.json ← 元数据 + 触发词 ├── .env.example ← 配置模板 ├── .env ← 用户配置（不提交 git） ├── .gitignore ├── scripts/ │ └── transcribe.js ← 转录脚本 ├── douyin-transcripts/ ← 逐字稿输出（自动创建） └── temp/ ← 临时文件（自动清理） ``` ### 故障排查 | 问题 | 解决 | |------|------| | audioUrl 为 null | 等几秒重试；或登录抖音网页版 | | ffmpeg 未找到 | 引导安装 | | Groq 429 | 等 1 分钟再试 | | 音频 >25MB | 建议更短的视频 |

path: README.md
content: # 抖音视频智能助手 🎬🧠

一个 [OpenClaw](https://openclaw.ai) Skill，让你的 AI 助手能"看"抖音视频——转录、总结、讨论、归档。

## ✨ 效果

> 你：（发一个抖音链接）
>
> AI：
> 📹 **如何用AI做副业月入过万**
> 👤 张三说AI
>
> **要点总结：**
> 1. 选择一个垂直细分领域
> 2. 用AI工具做最小可行产品
> 3. 通过短视频引流测试市场
>
> **一句话概括：** 讲了AI时代个人副业的起步方法，核心是"小步快跑、快速验证"。
>
> 💡 逐字稿已保存。回复"看原文"查看，"存起来"归档到知识库。

### 五种模式

| 你说什么 | AI做什么 |
|---------|---------|
| 只发链接 | 🧠 AI总结要点（默认） |
| "转文字" | 📝 给完整逐字稿 |
| "总结一下" | 📋 详细总结+适用场景 |
| "存到知识库" | 📚 转录+总结+归档 |
| "你怎么看" | 💬 分析讨论+给建议 |

- ⚡ 3分钟视频 → **6秒**转完
- 💰 **完全免费**（Groq 免费 API）
- 🎯 中文优化，自动加标点分段
- 🧠 AI智能处理，不只是逐字稿

## 🚀 安装

### 1. 安装 ffmpeg

**Mac：** `brew install ffmpeg`

**Windows：** 从 [gyan.dev](https://www.gyan.dev/ffmpeg/builds/) 下载 release full 版本，解压后将 `bin` 目录加入 PATH。

**Linux：** `sudo apt install ffmpeg`

### 2. 获取 Groq API Key（免费）

1. 打开 [console.groq.com](https://console.groq.com)
2. Google 或 GitHub 登录（不需要信用卡）
3. **API Keys** → **Create API Key** → 复制（`gsk_` 开头）

### 3. 配置

```bash
cd ~/.openclaw/workspace/skills/douyin-transcribe
cp .env.example .env
# 编辑 .env，填入 GROQ_API_KEY
```

### 4. 完成！

对你的 OpenClaw AI 发抖音链接就行。

## 🔧 工作原理

```
抖音链接 → 浏览器打开 → 提取DASH音频流（~1MB，不下载视频）
         → Groq Whisper large-v3 语音识别（免费，3秒）
         → Groq LLM 标点分段（免费，1秒）
         → AI 智能处理（总结/讨论/归档）
```

**为什么不用 yt-dlp？** 抖音反爬太严。通过 OpenClaw 浏览器直接提取音频流，100% 可靠。

**为什么用 Groq？** 免费 + 快（硬件加速，比 OpenAI 快 10 倍）。

## 📋 依赖

| 依赖 | 费用 | 用途 |
|------|------|------|
| ffmpeg | 免费 | 音频处理 |
| Groq API | 免费 | 语音识别 + 标点 |
| OpenClaw Browser | N/A | 打开抖音页面 |

## 🐛 常见问题

**Q: 支持多长的视频？**
A: 10 分钟以内没问题（Groq 限制 25MB 音频）。

**Q: 支持其他平台吗？**
A: 本地视频文件都支持。在线平台目前只优化了抖音。

**Q: 浏览器没登录抖音？**
A: 手动在 OpenClaw 浏览器里登录一次即可，状态会保持。

## 📜 License

MIT

## 🙏 致谢

- [OpenClaw](https://openclaw.ai) — AI Agent 平台
- [Groq](https://groq.com) — 免费高速 Whisper API
- [FFmpeg](https://ffmpeg.org) — 音频处理


path: scripts/transcribe.js
content: #!/usr/bin/env node
/**
 * 抖音视频转文字脚本
 * 用法: node transcribe.js <抖音链接或本地文件路径> [--output <dir>] [--model <model>]
 */

const { execSync, execFileSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const https = require('https');

// 加载 .env 文件
function loadEnvFile() {
  const envPath = path.join(__dirname, '..', '.env');
  if (fs.existsSync(envPath)) {
    const envContent = fs.readFileSync(envPath, 'utf-8');
    envContent.split('\n').forEach(line => {
      const match = line.match(/^([A-Z_]+)=(.*)$/);
      if (match && !process.env[match[1]]) {
        process.env[match[1]] = match[2].replace(/^["']|["']$/g, '');
      }
    });
  }
}
loadEnvFile();

// 自动检测命令路径（跨平台）
function findCommand(cmd) {
  // 1. 先试 which/where
  try {
    const which = process.platform === 'win32' ? 'where.exe' : 'which';
    const result = execSync(`${which} ${cmd}`, { stdio: 'pipe', encoding: 'utf-8' }).trim().split('\n')[0].trim();
    if (result && !result.includes('not found') && !result.includes('Could not find')) return result;
  } catch (e) {}
  
  // 2. Windows 常见安装路径
  if (process.platform === 'win32') {
    const windowsPaths = [
      `C:\\ffmpeg\\bin\\${cmd}.exe`,
      `C:\\Program Files\\ffmpeg\\bin\\${cmd}.exe`,
      path.join(process.env.LOCALAPPDATA || '', 'Programs', 'yt-dlp', `${cmd}.exe`),
      path.join(process.env.LOCALAPPDATA || '', 'Programs', cmd, `${cmd}.exe`),
      path.join(process.env.USERPROFILE || '', 'scoop', 'shims', `${cmd}.exe`),
      path.join(process.env.USERPROFILE || '', 'AppData', 'Local', 'Microsoft', 'WinGet', 'Links', `${cmd}.exe`),
    ];
    for (const p of windowsPaths) {
      if (fs.existsSync(p)) return p;
    }
  }
  
  // 3. Mac/Linux 常见路径
  const unixPaths = [
    `/usr/local/bin/${cmd}`,
    `/usr/bin/${cmd}`,
    `/opt/homebrew/bin/${cmd}`,
  ];
  for (const p of unixPaths) {
    if (fs.existsSync(p)) return p;
  }
  
  return cmd; // fallback，让后续报错处理
}

// 配置
const CONFIG = {
  sttProvider: process.env.STT_PROVIDER || 'groq',  // groq 或 openai
  whisperModel: process.env.WHISPER_MODEL || 'whisper-large-v3',
  outputDir: process.env.OUTPUT_DIR || path.join(__dirname, '..', 'douyin-transcripts'),
  tempDir: process.env.TEMP_DIR || path.join(__dirname, '..', 'temp'),
  groqApiKey: process.env.GROQ_API_KEY,
  openaiApiKey: process.env.OPENAI_API_KEY,
  ffmpegPath: process.env.FFMPEG_PATH || findCommand('ffmpeg'),
  ffprobePath: process.env.FFPROBE_PATH || findCommand('ffprobe'),
  ytdlpPath: process.env.YTDLP_PATH || findCommand('yt-dlp')
};

// 颜色输出
const colors = {
  green: '\x1b[32m',
  yellow: '\x1b[33m',
  red: '\x1b[31m',
  cyan: '\x1b[36m',
  reset: '\x1b[0m'
};

function log(msg, color = 'reset') {
  console.log(`${colors[color]}${msg}${colors.reset}`);
}

function error(msg) {
  console.error(`${colors.red}❌ ${msg}${colors.reset}`);
  process.exit(1);
}

// 获取命令路径
function getCommandPath(cmd) {
  if (cmd === 'ffmpeg') return CONFIG.ffmpegPath;
  if (cmd === 'ffprobe') return CONFIG.ffprobePath;
  if (cmd === 'yt-dlp') return CONFIG.ytdlpPath;
  return cmd;
}

// 运行命令
function runCommand(cmd, args, options = {}) {
  const fullPath = getCommandPath(cmd);
  const executable = fs.existsSync(fullPath) ? fullPath : cmd;
  return execSync(`"${executable}" ${args}`, { stdio: 'pipe', ...options });
}

// 检查依赖
function checkDependencies(isLocalFile = false) {
  log('🔍 检查依赖...', 'cyan');
  
  // 检查 ffmpeg
  try {
    runCommand('ffmpeg', '-version');
    log('  ✓ ffmpeg 已安装', 'green');
  } catch (e) {
    error(`ffmpeg 未安装或未找到\n路径: ${CONFIG.ffmpegPath}\n请先安装 ffmpeg: https://ffmpeg.org/download.html`);
  }
  
  // yt-dlp 是可选的（浏览器模式不需要）
  if (!isLocalFile) {
    try {
      runCommand('yt-dlp', '--version');
      log('  ✓ yt-dlp 已安装 (备选下载方式)', 'green');
    } catch (e) {
      log('  ℹ yt-dlp 未安装 (可选，浏览器模式不需要)', 'yellow');
    }
  }
  
  // 检查 API Key
  if (CONFIG.sttProvider === 'groq') {
    if (!CONFIG.groqApiKey) {
      error('未设置 Groq API Key，请在 .env 文件中配置 GROQ_API_KEY');
    }
    log(`  ✓ Groq API Key 已配置 (模型: ${CONFIG.whisperModel})`, 'green');
  } else {
    if (!CONFIG.openaiApiKey || CONFIG.openaiApiKey === 'sk-your-api-key-here') {
      error('未设置 OpenAI API Key，请在 .env 文件中配置 OPENAI_API_KEY');
    }
    log(`  ✓ OpenAI API Key 已配置 (模型: ${CONFIG.whisperModel})`, 'green');
  }
}

// 检查是否是本地文件
function checkIsLocalFile(input) {
  // URL 协议开头的不是本地文件
  if (/^https?:\/\//.test(input)) return false;
  // Windows 绝对路径: C:\... or D:\...
  if (/^[a-zA-Z]:\\/.test(input)) return true;
  // Unix 绝对路径: /home/... or /Users/...
  if (input.startsWith('/') && !input.startsWith('//')) return true;
  // 相对路径: ./... or ../... 或直接文件名/子目录
  if (input.startsWith('./') || input.startsWith('../')) return true;
  // 包含文件扩展名的（.mp4, .mov, .avi, .mkv, .webm, .flv）
  if (/\.(mp4|mov|avi|mkv|webm|flv|m4a|mp3|wav)$/i.test(input)) return true;
  // 包含路径分隔符的（Windows 反斜杠或 Unix 正斜杠 + 不是 URL）
  if ((input.includes('\\') || (input.includes('/') && !input.includes('://'))) && !input.includes('douyin.com')) return true;
  return false;
}

// 准备媒体文件（本地文件 / 浏览器提取 / yt-dlp 下载）
async function prepareMedia(url) {
  // 如果是本地文件，直接返回
  if (checkIsLocalFile(url)) {
    const resolvedPath = path.resolve(url);
    if (!fs.existsSync(resolvedPath)) {
      error(`本地文件不存在: ${resolvedPath}`);
    }
    
    log(`\n📁 检测到本地文件，跳过下载...`, 'cyan');
    const stats = fs.statSync(resolvedPath);
    const sizeMB = (stats.size / 1024 / 1024).toFixed(2);
    log(`  ✓ 文件大小: ${sizeMB} MB`, 'green');
    
    return {
      videoPath: resolvedPath,
      audioPath: null,  // 需要后续提取
      title: path.basename(resolvedPath, path.extname(resolvedPath)),
      author: '本地文件',
      isLocal: true,
      directAudio: false
    };
  }
  
  // 抖音链接：尝试用浏览器提取音频 URL
  if (url.includes('douyin.com')) {
    try {
      return await browserExtract(url);
    } catch (e) {
      log(`  ⚠️ 浏览器提取失败: ${e.message}`, 'yellow');
      log(`  回退到 yt-dlp...`, 'yellow');
    }
  }
  
  // Fallback: yt-dlp
  return await ytdlpDownload(url);
}

// 方案 A：通过浏览器提取音频流（推荐，不需要 cookies/yt-dlp）
async function browserExtract(url) {
  log(`\n🌐 通过浏览器提取音频...`, 'cyan');
  
  const tempDir = path.resolve(CONFIG.tempDir);
  if (!fs.existsSync(tempDir)) {
    fs.mkdirSync(tempDir, { recursive: true });
  }
  
  // 调用 OpenClaw 浏览器打开抖音页面
  // 这个函数被 OpenClaw agent 调用时，由 agent 负责浏览器操作
  // 独立运行时，需要通过 CDP 连接浏览器
  
  // 检查是否有 OpenClaw 提供的音频信息（通过环境变量传入）
  if (process.env.DOUYIN_AUDIO_URL) {
    const audioUrl = process.env.DOUYIN_AUDIO_URL;
    const title = process.env.DOUYIN_TITLE || '未知标题';
    const author = process.env.DOUYIN_AUTHOR || '未知作者';
    
    log(`  标题: ${title}`, 'cyan');
    log(`  作者: ${author}`, 'cyan');
    
    const timestamp = Date.now();
    const audioPath = path.join(tempDir, `audio_${timestamp}.mp3`);
    
    // 用 ffmpeg 直接从音频 URL 下载转码（用 execFileSync 避免 shell 转义问题）
    log(`  📥 下载音频流...`);
    try {
      const ffmpegPath = fs.existsSync(CONFIG.ffmpegPath) ? CONFIG.ffmpegPath : 'ffmpeg';
      execFileSync(ffmpegPath, [
        '-y',
        '-headers', 'Referer: https://www.douyin.com/\r\n',
        '-i', audioUrl,
        '-vn', '-ar', '16000', '-ac', '1',
        '-c:a', 'libmp3lame', '-q:a', '2',
        audioPath
      ], { stdio: 'pipe', timeout: 60000 });
    } catch (e) {
      // ffmpeg 经常返回非零退出码但实际成功了
      if (!fs.existsSync(audioPath) || fs.statSync(audioPath).size < 1000) {
        throw new Error('ffmpeg 音频下载失败: ' + (e.stderr ? e.stderr.toString().slice(-200) : e.message));
      }
    }
    
    if (!fs.existsSync(audioPath) || fs.statSync(audioPath).size < 1000) {
      throw new Error('音频文件下载失败或为空');
    }
    
    const sizeMB = (fs.statSync(audioPath).size / 1024 / 1024).toFixed(2);
    
    // 获取时长
    let duration = 0;
    try {
      const durationOutput = runCommand('ffprobe', `-v error -show_entries format=duration -of csv=p=0 "${audioPath}"`, { encoding: 'utf-8' });
      duration = parseFloat(durationOutput.trim());
    } catch (e) {}
    
    const minutes = Math.floor(duration / 60);
    const seconds = Math.floor(duration % 60);
    log(`  ✓ 音频就绪 (${minutes}分${seconds}秒, ${sizeMB} MB)`, 'green');
    
    return {
      videoPath: null,
      audioPath,
      title,
      author,
      isLocal: false,
      directAudio: true,
      duration
    };
  }
  
  throw new Error('需要通过 OpenClaw agent 使用浏览器模式，或设置 DOUYIN_AUDIO_URL 环境变量');
}

// 方案 B：yt-dlp 下载（需要 cookies）
async function ytdlpDownload(url) {
  log(`\n📥 使用 yt-dlp 下载视频...`, 'cyan');
  
  const tempDir = path.resolve(CONFIG.tempDir);
  if (!fs.existsSync(tempDir)) {
    fs.mkdirSync(tempDir, { recursive: true });
  }
  
  const timestamp = Date.now();
  const videoPath = path.join(tempDir, `video_${timestamp}.mp4`);
  
  const cookiesPath = path.join(__dirname, '..', 'temp', 'douyin-cookies.txt');
  let cookiesArg = '';
  if (fs.existsSync(cookiesPath)) {
    log('  🍪 使用 cookies 文件', 'cyan');
    cookiesArg = `--cookies "${cookiesPath}"`;
  }
  
  try {
    let videoInfo = {};
    try {
      const infoJson = runCommand('yt-dlp', `${cookiesArg} --dump-json "${url}"`, { encoding: 'utf-8', timeout: 30000 });
      videoInfo = JSON.parse(infoJson);
    } catch (e) {
      log('  ⚠️ 无法获取视频信息，继续下载...', 'yellow');
    }
    
    runCommand('yt-dlp', `${cookiesArg} -o "${videoPath}" "${url}"`, { timeout: 120000 });
    
    if (!fs.existsSync(videoPath)) {
      const files = fs.readdirSync(tempDir);
      const videoFile = files.find(f => f.startsWith(`video_${timestamp}`));
      if (videoFile) {
        return { 
          videoPath: path.join(tempDir, videoFile),
          audioPath: null,
          title: videoInfo.title || '未知标题',
          author: videoInfo.uploader || '未知作者',
          isLocal: false,
          directAudio: false
        };
      }
      error('视频下载失败');
    }
    
    log(`  ✓ 下载完成`, 'green');
    return {
      videoPath,
      audioPath: null,
      title: videoInfo.title || '未知标题',
      author: videoInfo.uploader || '未知作者',
      isLocal: false,
      directAudio: false
    };
  } catch (e) {
    error(`下载失败: ${e.message}\n\n💡 解决方案:\n1. 手动下载视频文件: node transcribe.js "C:\\path\\to\\video.mp4"\n2. 通过 OpenClaw agent 使用浏览器模式自动提取`);
  }
}

// 提取音频
function extractAudio(videoPath) {
  log(`\n🎵 提取音频...`, 'cyan');
  
  const audioPath = videoPath.replace(/\.[^.]+$/, '.mp3');
  
  try {
    runCommand('ffmpeg', `-i "${videoPath}" -vn -ar 16000 -ac 1 -c:a libmp3lame -q:a 2 "${audioPath}" -y`);
    
    if (!fs.existsSync(audioPath)) {
      error('音频提取失败');
    }
    
    const durationOutput = runCommand('ffprobe', `-v error -show_entries format=duration -of csv=p=0 "${audioPath}"`, { encoding: 'utf-8' });
    const duration = parseFloat(durationOutput.trim());
    const minutes = Math.floor(duration / 60);
    const seconds = Math.floor(duration % 60);
    
    log(`  ✓ 音频提取完成 (${minutes}分${seconds}秒)`, 'green');
    return { audioPath, duration };
  } catch (e) {
    error(`音频提取失败: ${e.message}`);
  }
}

// 调用 Whisper API（支持 Groq 和 OpenAI）
async function transcribeAudio(audioPath) {
  const provider = CONFIG.sttProvider === 'groq' ? 'Groq' : 'OpenAI';
  log(`\n📝 语音识别中... (${provider} ${CONFIG.whisperModel})`, 'cyan');
  
  const fileSize = fs.statSync(audioPath).size;
  const fileSizeMB = (fileSize / 1024 / 1024).toFixed(2);
  log(`  音频大小: ${fileSizeMB} MB`);
  
  // Groq 限制 25MB，OpenAI 也是 25MB
  if (fileSize > 25 * 1024 * 1024) {
    error(`音频文件过大 (${fileSizeMB} MB)，API 限制 25MB。请用更短的视频。`);
  }
  
  const isGroq = CONFIG.sttProvider === 'groq';
  const [REDACTED_SECRET] ? CONFIG.groqApiKey : CONFIG.openaiApiKey;
  const hostname = isGroq ? 'api.groq.com' : 'api.openai.com';
  const apiPath = isGroq ? '/openai/v1/audio/transcriptions' : '/v1/audio/transcriptions';
  const model = isGroq ? CONFIG.whisperModel : 'whisper-1';
  
  return new Promise((resolve, reject) => {
    const audioData = fs.readFileSync(audioPath);
    const boundary = '----FormBoundary' + Math.random().toString(36).substring(2);
    
    const parts = [];
    
    // file 字段
    parts.push(Buffer.from(
      `--${boundary}\r\n` +
      `Content-Disposition: form-data; name="file"; filename="audio.mp3"\r\n` +
      `Content-Type: audio/mpeg\r\n\r\n`
    ));
    parts.push(audioData);
    
    // model 字段
    parts.push(Buffer.from(
      `\r\n--${boundary}\r\n` +
      `Content-Disposition: form-data; name="model"\r\n\r\n` +
      `${model}\r\n`
    ));
    
    // language 字段（提示中文，提高准确度）
    parts.push(Buffer.from(
      `--${boundary}\r\n` +
      `Content-Disposition: form-data; name="language"\r\n\r\n` +
      `zh\r\n`
    ));
    
    // 结束
    parts.push(Buffer.from(`--${boundary}--\r\n`));
    
    const body = Buffer.concat(parts);
    
    const startTime = Date.now();
    
    const options = {
      hostname,
      path: apiPath,
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${apiKey}`,
        'Content-Type': `multipart/form-data; boundary=${boundary}`,
        'Content-Length': body.length
      }
    };
    
    const req = https.request(options, (res) => {
      let data = '';
      res.on('data', chunk => data += chunk);
      res.on('end', () => {
        const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
        try {
          const result = JSON.parse(data);
          if (result.error) {
            reject(new Error(`${provider} API 错误: ${result.error.message}`));
          } else {
            log(`  ✓ 识别完成 (耗时 ${elapsed}s)`, 'green');
            resolve(result.text);
          }
        } catch (e) {
          reject(new Error('API 返回解析失败: ' + data.substring(0, 500)));
        }
      });
    });
    
    req.on('error', reject);
    req.write(body);
    req.end();
  });
}

// 用 LLM 加标点分段
async function formatTranscript(rawText) {
  log(`\n✍️  添加标点和分段...`, 'cyan');
  
  // 只有配了 Groq Key 才能用免费 LLM
  const [REDACTED_SECRET];
  if (!apiKey) {
    log('  ⚠️ 未配置 Groq Key，跳过格式化', 'yellow');
    return rawText;
  }
  
  const startTime = Date.now();
  
  // 文本太长需要分片（Groq 上下文限制），每片约 2000 字
  const CHUNK_SIZE = 2000;
  const chunks = [];
  for (let i = 0; i < rawText.length; i += CHUNK_SIZE) {
    chunks.push(rawText.slice(i, i + CHUNK_SIZE));
  }
  
  const formatted = [];
  for (let i = 0; i < chunks.length; i++) {
    if (chunks.length > 1) {
      log(`  处理第 ${i + 1}/${chunks.length} 段...`);
    }
    const result = await callGroqLLM(apiKey, chunks[i]);
    formatted.push(result);
  }
  
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
  log(`  ✓ 格式化完成 (耗时 ${elapsed}s)`, 'green');
  return formatted.join('\n\n');
}

function callGroqLLM(apiKey, text) {
  return new Promise((resolve, reject) => {
    const body = JSON.stringify({
      model: 'llama-3.3-70b-versatile',
      messages: [
        {
          role: 'system',
          content: '你是一个中文文本格式化助手。你的任务是给没有标点的语音转录文本添加标点符号和合理分段。\n\n规则：\n1. 添加中文标点（，。！？、：""）\n2. 按语义和话题切换分段（每段之间空一行）\n3. 不要改变任何原文内容，不要增删字词\n4. 不要添加标题、总结或任何额外内容\n5. 只输出格式化后的文本，不要任何解释'
        },
        {
          role: 'user',
          content: text
        }
      ],
      temperature: 0,
      max_tokens: 4096
    });
    
    const options = {
      hostname: 'api.groq.com',
      path: '/openai/v1/chat/completions',
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${apiKey}`,
        'Content-Type': 'application/json',
        'Content-Length': Buffer.byteLength(body)
      }
    };
    
    const req = https.request(options, (res) => {
      let data = '';
      res.on('data', chunk => data += chunk);
      res.on('end', () => {
        try {
          const result = JSON.parse(data);
          if (result.error) {
            log(`  ⚠️ LLM 格式化失败: ${result.error.message}，使用原始文本`, 'yellow');
            resolve(text);
          } else {
            resolve(result.choices[0].message.content.trim());
          }
        } catch (e) {
          log('  ⚠️ LLM 返回解析失败，使用原始文本', 'yellow');
          resolve(text);
        }
      });
    });
    
    req.on('error', () => {
      log('  ⚠️ LLM 请求失败，使用原始文本', 'yellow');
      resolve(text);
    });
    
    req.write(body);
    req.end();
  });
}

// 保存到 Markdown
function saveTranscript(source, info, transcript) {
  log(`\n💾 保存转录结果...`, 'cyan');
  
  const outputDir = path.resolve(CONFIG.outputDir);
  if (!fs.existsSync(outputDir)) {
    fs.mkdirSync(outputDir, { recursive: true });
  }
  
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
  const filename = `${timestamp}-douyin.md`;
  const filepath = path.join(outputDir, filename);
  
  const sourceType = info.isLocal ? '本地文件' : '来源';
  
  const content = `# 抖音视频转录

**${sourceType}**: ${source}
**转录时间**: ${new Date().toLocaleString('zh-CN')}
**视频标题**: ${info.title}
**博主**: ${info.author}
**Whisper 模型**: ${CONFIG.whisperModel}

---

${transcript}
`;
  
  fs.writeFileSync(filepath, content, 'utf-8');
  log(`  ✓ 已保存到: ${filepath}`, 'green');
  return filepath;
}

// 清理临时文件
function cleanup(videoPath, audioPath, isLocal) {
  log(`\n🧹 清理临时文件...`, 'cyan');
  
  try {
    // 本地文件不删除原视频
    if (!isLocal && videoPath && fs.existsSync(videoPath)) {
      fs.unlinkSync(videoPath);
    }
    if (audioPath && fs.existsSync(audioPath)) {
      fs.unlinkSync(audioPath);
    }
    log('  ✓ 清理完成', 'green');
  } catch (e) {
    log('  ⚠️ 清理失败 (可忽略)', 'yellow');
  }
}

// 主函数
async function main() {
  const args = process.argv.slice(2);
  
  if (args.length === 0 || args[0].startsWith('--')) {
    console.log(`
用法: node transcribe.js <抖音链接或本地文件> [选项]

选项:
  --output <目录>    输出目录 (默认: ./douyin-transcripts)
  --model <模型>     Whisper 模型 (tiny/base/small/medium/large, 默认: small)

示例:
  # 下载抖音视频并转录
  node transcribe.js "https://v.douyin.com/xxxxx"
  
  # 转录本地视频文件
  node transcribe.js "C:\\Users\\Videos\\douyin.mp4"
  
  # 指定输出目录和模型
  node transcribe.js "https://v.douyin.com/xxxxx" --output ./my-notes --model base

💡 提示:
  如果抖音下载失败(需要cookies), 可以手动下载视频后用本地文件路径运行
`);
    process.exit(1);
  }
  
  const input = args[0];
  const inputIsLocal = checkIsLocalFile(input);
  
  // 解析参数
  for (let i = 1; i < args.length; i++) {
    if (args[i] === '--output' && args[i + 1]) {
      CONFIG.outputDir = args[i + 1];
      i++;
    } else if (args[i] === '--model' && args[i + 1]) {
      CONFIG.whisperModel = args[i + 1];
      i++;
    }
  }
  
  // 验证输入
  if (!inputIsLocal && !input.includes('douyin.com')) {
    error('请输入有效的抖音链接或本地视频文件路径');
  }
  
  log('🦞 抖音视频转文字工具\n', 'cyan');
  log(`${inputIsLocal ? '文件' : '链接'}: ${input}`);
  log(`输出目录: ${path.resolve(CONFIG.outputDir)}`);
  log(`Whisper 模型: ${CONFIG.whisperModel}\n`);
  
  try {
    // 1. 检查依赖
    checkDependencies(inputIsLocal);
    
    // 2. 准备媒体（本地文件/浏览器提取/yt-dlp）
    const media = await prepareMedia(input);
    
    // 3. 提取音频（如果浏览器模式已经直接拿到音频，跳过此步）
    let audioPath, duration;
    if (media.directAudio) {
      audioPath = media.audioPath;
      duration = media.duration || 0;
    } else {
      const extracted = extractAudio(media.videoPath);
      audioPath = extracted.audioPath;
      duration = extracted.duration;
    }
    
    // 4. 转录
    const rawTranscript = await transcribeAudio(audioPath);
    
    // 5. 加标点分段
    const transcript = await formatTranscript(rawTranscript);
    
    // 6. 保存
    const savedPath = saveTranscript(input, { title: media.title, author: media.author, isLocal: media.isLocal }, transcript);
    
    // 7. 清理
    cleanup(media.videoPath, audioPath, media.isLocal);
    
    // 输出结果
    log(`\n✅ 转录完成!`, 'green');
    log(`\n📄 文件: ${savedPath}`);
    log(`📝 字数: ${transcript.length} 字`);
    
    // 显示预览
    const preview = transcript.slice(0, 500);
    log(`\n--- 内容预览 ---\n${preview}${transcript.length > 500 ? '...' : ''}\n`, 'cyan');
    
  } catch (e) {
    error(e.message);
  }
}

main();


path: _meta.json
content: {
  "name": "douyin-transcribe",
  "version": "1.1.0",
  "description": "抖音视频智能助手 — 发链接自动转录，AI总结/逐字稿/归档/讨论，一条龙",
  "author": "junweiren98-rgb",
  "license": "MIT",

  "keywords": ["douyin", "抖音", "transcribe", "whisper", "语音转文字", "视频转文本", "AI总结", "知识库"],
  "repository": "https://github.com/junweiren98-rgb/douyin-transcribe-skill",
  "requires": {
    "tools": ["browser", "exec", "read"],
    "external": ["ffmpeg"],
    "apis": ["groq"]
  },
  "triggers": ["抖音", "douyin.com", "转文字", "转录", "视频转文本", "douyin", "transcribe", "v.douyin.com"]
}


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