Skill: junweiren98-rgb/douyin-transcribe (version 1.0.1)

--- name: douyin-transcribe description: | 抖音视频转文字。用户发抖音链接或视频文件，自动转录为带标点的中文文本。 触发词：抖音、转文字、转录、视频转文本、douyin、transcribe --- # 抖音视频转文字 🎬➡️📝 将抖音视频自动转录为带标点分段的中文文本。 用户只需要发一个抖音链接或视频文件，你来完成所有工作。 --- ## 首次使用？先帮用户完成配置 当用户第一次触发这个 Skill 时，你需要先检查环境是否就绪。 按以下顺序检查，**缺什么就引导用户补什么**： ### 检查 1：.env 文件是否存在 检查 Skill 目录下是否有 `.env` 文件： ``` read: <skill目录>/.env ``` 如果不存在，从 `.env.example` 复制一份： ``` exec: cp <skill目录>/.env.example <skill目录>/.env ``` ### 检查 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. 名字随便填，点 Submit > 5. 复制生成的 Key（以 `gsk_` 开头），发给我 > > 拿到 Key 后发给我，我帮你配好。 用户发来 Key 后，更新 `.env` 文件中的 `GROQ_API_KEY=<用户的key>`。 ### 检查 3：ffmpeg 运行以下命令检查 ffmpeg 是否安装： ``` exec: ffmpeg -version ``` 如果报错（未找到命令），**告诉用户**： > 还需要安装 ffmpeg（音频处理工具）： > > **Mac 用户：** 打开终端，运行 `brew install ffmpeg` > > **Windows 用户：** > 1. 打开 https://www.gyan.dev/ffmpeg/builds/ > 2. 下载 "release full" 版本（.zip 文件） > 3. 解压到 `C:\ffmpeg` > 4. 把 `C:\ffmpeg\bin` 添加到系统环境变量 PATH > 5. 重启终端，运行 `ffmpeg -version` 验证 > > **Linux 用户：** 运行 `sudo apt install ffmpeg` > > 装好了告诉我！ ### 检查通过后 告诉用户： > ✅ 配置完成！以后你可以： > - 直接发抖音链接给我，我自动转成文字 > - 发视频文件给我，我也能转 > > 试试看？发个抖音链接过来吧！ --- ## 使用方式 1：用户发来抖音链接 当用户发来包含 `douyin.com` 的链接时，按以下步骤操作： ### 步骤 1：启动浏览器 ``` browser(action="start", profile="openclaw") ``` 如果浏览器已经在运行，跳过此步。 ### 步骤 2：打开抖音链接 ``` browser(action="navigate", url="<用户发的链接>", profile="openclaw") ``` 抖音短链接会自动跳转到完整页面。 ### 步骤 3：等待视频播放，提取音频信息 等待 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 秒后再执行一次。 如果重试 2-3 次仍然为 null，可能需要用户先在浏览器中登录抖音网页版。 ### 步骤 4：运行转录脚本 设置环境变量并调用脚本。注意 `<skill目录>` 替换为这个 SKILL.md 所在的实际目录路径。 **Windows PowerShell：** ```powershell $env:DOUYIN_AUDIO_URL = "<步骤3拿到的audioUrl>" $env:DOUYIN_TITLE = "<步骤3拿到的title>" $env:DOUYIN_AUTHOR = "<步骤3拿到的author>" cd "<skill目录>" node scripts/transcribe.js "<用户的原始链接>" ``` **Linux/Mac Bash：** ```bash cd "<skill目录>" DOUYIN_AUDIO_URL="<audioUrl>" \ DOUYIN_TITLE="<title>" \ DOUYIN_AUTHOR="<author>" \ node scripts/transcribe.js "<用户的原始链接>" ``` 设置 timeout 为 120 秒（长视频可能需要更多时间）。 ### 步骤 5：返回结果 脚本成功后，读取 `douyin-transcripts/` 目录下最新的 `.md` 文件，把**文字内容**发给用户。 不需要发整个 Markdown 文件头，只发标题和正文部分即可。格式示例： > **转录完成** ✅ > > 📹 标题：xxx > 👤 博主：xxx > ⏱️ 耗时：x秒 > > --- > （正文内容） --- ## 使用方式 2：用户发来视频文件 当用户通过飞书/Telegram/Discord 等发来视频文件时： ### 步骤 1：保存文件到本地 如果平台提供了文件路径，直接使用。如果需要下载（如飞书），保存到 `<skill目录>/temp/` 下。 ### 步骤 2：运行脚本 ```bash cd "<skill目录>" node scripts/transcribe.js "<视频文件的完整路径>" ``` ### 步骤 3：返回结果 同上，读取最新的 `.md` 文件，发正文给用户。 --- ## 使用方式 3：用户发来文字描述的链接 用户有时会发类似这样的分享文本： > 7.94 复制打开抖音，看看【xxx的作品】标题内容 https://v.douyin.com/xxxxx/ 06/13 从中提取 `https://v.douyin.com/xxxxx/` 部分，然后按方式 1 处理。 --- ## 技术说明 ### 工作原理 ``` 抖音链接 → 浏览器打开页面 → 从 DASH 流提取音频 URL（不下载视频，只下音频） → ffmpeg 下载音频流（~1MB，1秒） → Groq Whisper large-v3 语音识别（免费，3秒） → Groq LLM 标点分段（免费，1秒） → 保存 Markdown 文件 ``` ### 为什么这么做 - **不用 yt-dlp**：抖音反爬严格，yt-dlp 需要 cookies 且经常失败 - **只下音频**：不需要下载整个视频，音频只有 ~1MB，比视频小 20-50 倍 - **用 Groq 不用 OpenAI**：Groq 完全免费且速度快 10 倍 ### 依赖 | 依赖 | 必须？ | 费用 | 用途 | |------|--------|------|------| | Node.js | ✅ | 免费 | 运行脚本（OpenClaw 自带） | | ffmpeg | ✅ | 免费 | 音频处理 | | Groq API Key | ✅ | 免费 | 语音识别 + 标点分段 | | OpenClaw Browser | 推荐 | N/A | 打开抖音页面提取音频 | ### 配置文件 (.env) ```env # 必填 GROQ_API_KEY=gsk_xxxxx # 可选（如果想用 OpenAI 替代 Groq） # STT_PROVIDER=openai # OPENAI_API_KEY=sk-xxxxx # 可选（自定义路径） # FFMPEG_PATH=/usr/local/bin/ffmpeg # OUTPUT_DIR=./douyin-transcripts ``` ### 文件结构 ``` douyin-transcribe/ ├── SKILL.md ← 你正在读的操作指南 ├── README.md ← 给人看的说明 ├── _meta.json ← Skill 元数据 ├── .env.example ← 配置模板 ├── .env ← 用户的配置（不提交 git） ├── .gitignore ├── scripts/ │ └── transcribe.js ← 核心脚本 ├── douyin-transcripts/ ← 输出目录（自动创建） └── temp/ ← 临时文件（自动清理） ``` --- ## 故障排查 | 问题 | 原因 | 解决 | |------|------|------| | `audioUrl` 为 null | 视频没开始播放 | 等几秒重试；或用户需要登录抖音网页版 | | ffmpeg 未找到 | 没装或不在 PATH | 引导用户安装 ffmpeg | | Groq API 429 错误 | 频率限制 | 等 1 分钟再试 | | 音频文件过大 >25MB | 视频太长 | 建议用户发短一点的视频 | | 浏览器打开后是登录页 | 未登录抖音 | 让用户在浏览器中手动登录一次即可 |

path: README.md
content: # 抖音视频转文字 🎬➡️📝

一个 [OpenClaw](https://openclaw.ai) Skill，让你的 AI 助手能把抖音视频自动转成文字。

## ✨ 效果

> 你：（发一个抖音链接）
>
> AI：转录完成！以下是视频内容：
>
> 大家好，我是xxx，今天给大家分享一个关于……

- ⚡ 3分钟视频 → **6秒**转完（音频提取 + 语音识别 + 标点分段）
- 💰 **完全免费**（使用 Groq 免费 API）
- 🎯 支持中文，自动加标点和分段
- 📝 输出 Markdown 文件，方便存入知识库

## 🚀 安装

### 1. 安装 ffmpeg（音频处理）

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

**Mac：**
```bash
brew install ffmpeg
```

**Linux：**
```bash
sudo apt install ffmpeg   # Debian/Ubuntu
sudo yum install ffmpeg   # CentOS/RHEL
```

验证安装：
```bash
ffmpeg -version
```

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

1. 打开 [console.groq.com](https://console.groq.com)
2. 用 Google 或 GitHub 登录（不需要信用卡）
3. 点击 **API Keys** → **Create API Key**
4. 填个名字（如 `douyin`），点 Submit
5. 复制生成的 Key（以 `gsk_` 开头）

### 3. 配置

```bash
cd ~/.openclaw/workspace/skills/douyin-transcribe
cp .env.example .env
```

编辑 `.env`，填入你的 Groq API Key：
```env
GROQ_API_KEY=gsk_你的key
```

### 4. 完成！

在任何 OpenClaw 对话中发送抖音链接，AI 会自动识别并转录。

## 📖 使用方式

### 发抖音链接（最简单）

直接把抖音分享链接发给你的 OpenClaw AI：

```
https://v.douyin.com/xxxxx
```

AI 会自动：打开链接 → 提取音频 → 转文字 → 加标点 → 返回结果。

### 发视频文件

也可以直接发视频文件（MP4、MOV 等），AI 会提取音频并转录。

### 命令行使用

```bash
# 转录本地视频
node scripts/transcribe.js "/path/to/video.mp4"

# 通过环境变量传入音频 URL（由 AI agent 调用）
DOUYIN_AUDIO_URL="https://..." DOUYIN_TITLE="标题" node scripts/transcribe.js "https://v.douyin.com/xxx"
```

## 🔧 工作原理

```
抖音链接 → 浏览器打开页面 → 从 DASH 流提取音频 URL
         → ffmpeg 下载音频（~1MB）
         → Groq Whisper large-v3 语音识别（免费，3秒完成）
         → Groq LLM 添加标点和分段（免费，1秒完成）
         → 输出 Markdown 文件
```

**为什么不用 yt-dlp？**

抖音反爬非常严格，yt-dlp 需要 fresh cookies 且经常失败。我们的方案通过 OpenClaw 浏览器直接打开页面（复用已登录的会话），从播放中的视频提取音频流 URL，100% 可靠。而且只下载音频不下载视频，更快更省流量。

**为什么用 Groq？**

Groq 提供免费的 Whisper large-v3 语音识别和 LLM 推理，速度极快（硬件加速），日常使用完全免费。

## 📋 依赖

| 依赖 | 用途 | 必须？ | 费用 |
|------|------|--------|------|
| ffmpeg | 音频处理 | ✅ 是 | 免费开源 |
| Groq API | 语音识别 + 标点 | ✅ 是 | 免费 |
| OpenClaw Browser | 打开抖音页面 | 推荐 | N/A |
| yt-dlp | 备选下载方式 | ❌ 可选 | 免费开源 |

## 🐛 常见问题

**Q: 转录结果有错别字怎么办？**
A: Whisper 对中文的识别准确率约 95%，专有名词（如 OpenClaw → "open call"）可能识别不准。这是语音识别的固有局限，不影响整体可读性。

**Q: 支持多长的视频？**
A: Groq Whisper API 限制音频 25MB。一般 10 分钟以内的视频没问题。超长视频建议裁剪后分段转录。

**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.0.0",
  "description": "抖音视频转文字 — 发链接自动转录为带标点的中文文本",
  "author": "junweiren98-rgb",
  "license": "MIT",

  "keywords": ["douyin", "抖音", "transcribe", "whisper", "语音转文字", "视频转文本"],
  "repository": "",
  "requires": {
    "tools": ["browser", "exec", "read"],
    "external": ["ffmpeg"],
    "apis": ["groq"]
  },
  "triggers": ["抖音", "转文字", "转录", "视频转文本", "douyin", "transcribe"]
}


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