Skill: chall2015/douyin-downloader-latest (version 1.0.0)

--- name: douyin-downloader description: 抖音无水印视频下载器，支持分享链接解析、批量下载和元数据保存 triggers: - 下载抖音 - 解析抖音 - 保存抖音 - douyin download - 无水印 - tiktok video --- # 🎵 抖音下载器 (douyin-downloader) 下载抖音视频（无水印），支持分享链接解析和批量下载。 ## 功能 1. **单链接解析** - 解析抖音分享链接，获取无水印视频地址 2. **批量下载** - 支持多个链接同时下载 3. **自动命名** - 根据视频信息自动生成文件名 4. **元数据保存** - 可选保存视频描述、作者信息等 ## 使用示例 ``` 下载这个抖音视频：https://v.douyin.com/xxxxx 解析这些链接： - https://v.douyin.com/xxx1 - https://v.douyin.com/xxx2 ``` ## 配置 在 `TOOLS.md` 中添加： ```markdown ### 抖音下载器 - 默认保存目录：~/Videos/douyin - 是否保存元数据：true ``` ## 依赖 - Node.js 18+ - ffmpeg（可选，用于视频处理） ## 注意事项 - 仅支持公开视频 - 请遵守抖音用户协议，仅用于个人学习 - 不要用于商业用途或批量爬取

path: src/browser-extract.js
content: /**
 * 使用 Playwright 获取抖音视频真实地址
 */

import { chromium } from 'playwright';

/**
 * 从抖音视频页获取真实视频 URL
 * @param {string} videoId - 视频 ID
 * @returns {Promise<Object>} 视频信息
 */
export async function getVideoWithPlaywright(videoId) {
  const url = `https://www.douyin.com/video/${videoId}`;
  let browser = null;
  
  try {
    console.log('启动浏览器...');
    browser = await chromium.launch({
      headless: true,
      args: ['--no-sandbox', '--disable-setuid-sandbox']
    });
    
    const page = await browser.newPage({
      userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
    });
    
    console.log('访问页面:', url);
    await page.goto(url, { 
      waitUntil: 'domcontentloaded',
      timeout: 30000 
    });
    
    // 等待页面基本加载
    console.log('等待页面内容...');
    await page.waitForTimeout(5000);
    
    // 等待视频元素加载
    console.log('等待视频加载...');
    try {
      await page.waitForSelector('video, [data-e2e="video-player"]', { timeout: 10000 });
    } catch (e) {
      console.log('未找到视频元素，尝试查找其他数据源...');
    }
    
    // 截取屏幕用于调试
    try {
      await page.screenshot({ path: 'debug-screenshot.png', fullPage: false });
      console.log('已保存调试截图：debug-screenshot.png');
    } catch (e) {
      console.log('截图失败:', e.message);
    }
    
    // 尝试从页面中提取视频信息
    const videoInfo = await page.evaluate(() => {
      const result = {
        videoUrl: null,
        title: null,
        author: null,
        cover: null
      };
      
      // 1. 尝试从 video 标签获取
      const videoEl = document.querySelector('video');
      if (videoEl && videoEl.src) {
        result.videoUrl = videoEl.src;
      }
      
      // 2. 尝试从 script 标签中获取数据
      const scripts = document.querySelectorAll('script');
      for (const script of scripts) {
        const content = script.innerHTML;
        if (content.includes('play_addr') || content.includes('video')) {
          // 尝试提取 JSON 数据
          const jsonMatch = content.match(/\{[^{}]*"play_addr"[^{}]*\}/);
          if (jsonMatch) {
            try {
              const data = JSON.parse(jsonMatch[0]);
              if (data.play_addr?.url_list?.[0]) {
                result.videoUrl = data.play_addr.url_list[0];
              }
            } catch (e) {}
          }
        }
      }
      
      // 3. 获取标题
      const titleEl = document.querySelector('h1, title, meta[property="og:title"]');
      if (titleEl) {
        result.title = titleEl.content || titleEl.textContent?.trim();
      }
      
      // 4. 获取作者
      const authorEl = document.querySelector('[data-e2e="user-name"], meta[property="og:author"]');
      if (authorEl) {
        result.author = authorEl.content || authorEl.textContent?.trim();
      }
      
      // 5. 获取封面
      const coverEl = document.querySelector('meta[property="og:image"]');
      if (coverEl) {
        result.cover = coverEl.content;
      }
      
      return result;
    });
    
    console.log('初步提取结果:', videoInfo);
    
    // 如果还是没找到，尝试从网络请求中拦截
    if (!videoInfo.videoUrl) {
      console.log('尝试从网络请求中获取...');
      const capturedUrls = [];
      
      // 拦截所有视频相关请求
      await page.route('**/*', route => {
        const url = route.request().url();
        if (url.includes('.mp4') || url.includes('video') || url.includes('aweme')) {
          console.log('捕获请求:', url);
          if (!capturedUrls.includes(url) && !url.includes('effect')) {
            capturedUrls.push(url);
            // 优先保存非 effect 的视频 URL
            if (!videoInfo.videoUrl) {
              videoInfo.videoUrl = url;
            }
          }
        }
        route.continue();
      });
      
      // 等待一段时间让视频开始加载
      await page.waitForTimeout(10000);
      
      if (capturedUrls.length > 0) {
        console.log('成功捕获', capturedUrls.length, '个视频相关 URL');
        console.log('第一个 URL:', capturedUrls[0]);
      }
    }
    
    // 更新成功状态
    if (videoInfo.videoUrl) {
      videoInfo.success = true;
    }
    
    return {
      success: !!videoInfo.videoUrl,
      videoId,
      url,
      ...videoInfo
    };
    
  } catch (error) {
    console.error('获取失败:', error.message);
    return {
      success: false,
      error: error.message,
      videoId,
      url
    };
  } finally {
    if (browser) {
      await browser.close();
    }
  }
}

// 测试运行
if (process.argv[1]?.includes('browser-extract.js')) {
  const videoId = process.argv[2] || '7477287476999015732';
  console.log('测试视频 ID:', videoId);
  getVideoWithPlaywright(videoId).then(result => {
    console.log('\n=== 最终结果 ===');
    console.log(JSON.stringify(result, null, 2));
  });
}


path: src/downloader.js
content: /**
 * 抖音视频下载器
 * 支持单文件下载和批量下载
 */

import axios from 'axios';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

/**
 * 确保目录存在
 */
function ensureDir(dirPath) {
  if (!fs.existsSync(dirPath)) {
    fs.mkdirSync(dirPath, { recursive: true });
  }
  return dirPath;
}

/**
 * 清理文件名，移除非法字符
 */
function sanitizeFilename(filename) {
  return filename
    .replace(/[<>:"/\\|？*]/g, '')
    .replace(/\s+/g, '_')
    .substring(0, 100); // 限制长度
}

/**
 * 下载文件
 * @param {string} url - 文件 URL
 * @param {string} destPath - 保存路径
 * @param {Function} onProgress - 进度回调
 */
export async function downloadFile(url, destPath, onProgress) {
  const response = await axios({
    url,
    method: 'GET',
    responseType: 'stream',
    headers: {
      'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
      'Referer': 'https://www.douyin.com/',
    }
  });

  const totalSize = parseInt(response.headers['content-length'] || '0', 10);
  let downloadedSize = 0;

  return new Promise((resolve, reject) => {
    const writer = fs.createWriteStream(destPath);

    response.data.on('data', (chunk) => {
      downloadedSize += chunk.length;
      if (onProgress && totalSize > 0) {
        const progress = ((downloadedSize / totalSize) * 100).toFixed(1);
        onProgress(progress);
      }
    });

    writer.on('finish', () => {
      resolve({
        path: destPath,
        size: downloadedSize,
        success: true
      });
    });

    writer.on('error', (error) => {
      reject(error);
    });

    response.data.pipe(writer);
  });
}

/**
 * 下载抖音视频
 * @param {Object} videoInfo - 视频信息对象（来自 parser）
 * @param {string} saveDir - 保存目录
 * @param {Object} options - 选项
 */
export async function downloadDouyinVideo(videoInfo, saveDir, options = {}) {
  const {
    saveMetadata = true,
    filenameTemplate = '{author}_{title}_{id}'
  } = options;

  try {
    // 确保保存目录存在
    ensureDir(saveDir);

    // 生成文件名
    const filename = filenameTemplate
      .replace('{author}', sanitizeFilename(videoInfo.author || 'unknown'))
      .replace('{title}', sanitizeFilename(videoInfo.title || 'video'))
      .replace('{id}', videoInfo.id || 'unknown')
      .replace('{date}', new Date().toISOString().split('T')[0]);

    const videoPath = path.join(saveDir, `${filename}.mp4`);

    // 检查文件是否已存在
    if (fs.existsSync(videoPath)) {
      console.log(`文件已存在：${videoPath}`);
      return {
        success: true,
        path: videoPath,
        skipped: true,
        message: '文件已存在'
      };
    }

    // 下载视频
    console.log(`开始下载：${videoInfo.title}`);
    const result = await downloadFile(videoInfo.videoUrl, videoPath, (progress) => {
      console.log(`下载进度：${progress}%`);
    });

    // 保存元数据（可选）
    if (saveMetadata) {
      const metadataPath = path.join(saveDir, `${filename}.json`);
      fs.writeFileSync(metadataPath, JSON.stringify(videoInfo, null, 2));
      console.log(`元数据已保存：${metadataPath}`);
    }

    return {
      success: true,
      path: videoPath,
      size: result.size,
      metadata: videoInfo
    };
  } catch (error) {
    console.error('下载失败:', error.message);
    return {
      success: false,
      error: error.message,
      videoInfo
    };
  }
}

/**
 * 批量下载
 * @param {Array} videoInfos - 视频信息数组
 * @param {string} saveDir - 保存目录
 * @param {Object} options - 选项
 */
export async function batchDownload(videoInfos, saveDir, options = {}) {
  const {
    concurrency = 3, // 并发数
    ...downloadOptions
  } = options;

  const results = [];
  const queue = [...videoInfos];
  const active = [];

  while (queue.length > 0 || active.length > 0) {
    // 填充并发队列
    while (active.length < concurrency && queue.length > 0) {
      const videoInfo = queue.shift();
      const promise = downloadDouyinVideo(videoInfo, saveDir, downloadOptions)
        .then(result => {
          results.push(result);
          active.splice(active.indexOf(promise), 1);
          return result;
        });
      active.push(promise);
    }

    // 等待至少一个完成
    if (active.length > 0) {
      await Promise.race(active);
    }
  }

  return results;
}

/**
 * 获取默认保存目录
 */
export function getDefaultSaveDir() {
  const homeDir = process.env.HOME || process.env.USERPROFILE;
  const defaultDir = path.join(homeDir, 'Videos', 'douyin');
  ensureDir(defaultDir);
  return defaultDir;
}


path: src/index.js
content: /**
 * 抖音下载器 - OpenClaw Skill 主入口
 * 
 * 功能：
 * - 解析抖音分享链接
 * - 下载无水印视频
 * - 批量处理
 */

import { parseDouyinVideo, parseMultipleUrls, isDouyinUrl } from './parser.js';
import { downloadDouyinVideo, batchDownload, getDefaultSaveDir } from './downloader.js';
import { getVideoWithPlaywright } from './browser-extract.js';

/**
 * 处理用户请求
 * @param {Object} context - OpenClaw 上下文
 */
export async function handle(context) {
  const { message, config = {} } = context;
  
  // 提取消息中的抖音链接
  const urls = extractDouyinUrls(message);
  
  if (urls.length === 0) {
    return {
      success: false,
      message: '未找到抖音链接。请分享抖音视频链接，例如：https://v.douyin.com/xxxxx'
    };
  }

  // 获取保存目录
  const saveDir = config.saveDir || getDefaultSaveDir();

  try {
    if (urls.length === 1) {
      // 单个视频处理
      let videoInfo = await parseDouyinVideo(urls[0]);
      
      // 如果没有获取到视频 URL，使用 Playwright 浏览器提取
      if (!videoInfo.videoUrl || videoInfo.videoUrl.includes('douyin.com')) {
        console.log('使用 Playwright 获取真实视频地址...');
        const browserResult = await getVideoWithPlaywright(videoInfo.id);
        if (browserResult.success) {
          videoInfo = { ...videoInfo, ...browserResult };
        }
      }
      
      const result = await downloadDouyinVideo(videoInfo, saveDir, {
        saveMetadata: config.saveMetadata !== false
      });

      if (result.success) {
        return {
          success: true,
          message: `✅ 下载成功！\n\n📹 视频：${videoInfo.title}\n👤 作者：${videoInfo.author}\n📁 保存：${result.path}\n💾 大小：${formatSize(result.size)}`,
          data: result
        };
      } else {
        return {
          success: false,
          message: `❌ 下载失败：${result.error}`
        };
      }
    } else {
      // 批量处理
      const parseResults = await parseMultipleUrls(urls);
      
      if (parseResults.results.length === 0) {
        return {
          success: false,
          message: `所有链接解析失败：\n${parseResults.errors.map(e => `- ${e.url}: ${e.error}`).join('\n')}`
        };
      }

      const downloadResults = await batchDownload(parseResults.results, saveDir, {
        saveMetadata: config.saveMetadata !== false
      });

      const successCount = downloadResults.filter(r => r.success).length;
      const failCount = downloadResults.filter(r => !r.success).length;

      return {
        success: true,
        message: `📥 批量下载完成！\n\n✅ 成功：${successCount}\n❌ 失败：${failCount}\n📁 目录：${saveDir}`,
        data: downloadResults
      };
    }
  } catch (error) {
    return {
      success: false,
      message: `❌ 处理失败：${error.message}`
    };
  }
}

/**
 * 从消息中提取抖音链接
 */
function extractDouyinUrls(message) {
  const patterns = [
    /https?:\/\/(?:www\.)?douyin\.com\/[^\s]+/gi,
    /https?:\/\/v\.douyin\.com\/[^\s]+/gi,
    /https?:\/\/m\.douyin\.com\/[^\s]+/gi,
    /https?:\/\/(?:www\.)?iesdouyin\.com\/[^\s]+/gi,
  ];

  const urls = [];
  for (const pattern of patterns) {
    const matches = message.match(pattern);
    if (matches) {
      urls.push(...matches);
    }
  }

  return [...new Set(urls)]; // 去重
}

/**
 * 格式化文件大小
 */
function formatSize(bytes) {
  if (bytes === 0) return '0 B';
  const k = 1024;
  const sizes = ['B', 'KB', 'MB', 'GB'];
  const i = Math.floor(Math.log(bytes) / Math.log(k));
  return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}

/**
 * 技能信息
 */
export const skillInfo = {
  name: 'douyin-downloader',
  version: '1.0.0',
  description: '抖音无水印视频下载器',
  triggers: [
    '下载抖音',
    '解析抖音',
    '保存抖音',
    'douyin download',
    '无水印'
  ]
};

// 导出供测试使用
export { parseDouyinVideo, downloadDouyinVideo, getDefaultSaveDir, isDouyinUrl };


path: src/parser.js
content: /**
 * 抖音视频链接解析器
 * 支持多种抖音分享链接格式
 */

import axios from 'axios';
import * as cheerio from 'cheerio';

// 抖音链接正则匹配
const DOUYIN_PATTERNS = [
  /https?:\/\/(?:www\.)?douyin\.com\/video\/(\d+)/,
  /https?:\/\/(?:www\.)?douyin\.com\/note\/(\d+)/,
  /modal_id=(\d+)/,  // 搜索页视频链接（灵活匹配）
  /https?:\/\/v\.douyin\.com\/([a-zA-Z0-9]+)/,
  /https?:\/\/m\.douyin\.com\/([a-zA-Z0-9]+)/,
  /https?:\/\/(?:www\.)?iesdouyin\.com\/([a-zA-Z0-9]+)/,
];

/**
 * 检查是否为抖音链接
 */
export function isDouyinUrl(url) {
  return DOUYIN_PATTERNS.some(pattern => pattern.test(url));
}

/**
 * 提取抖音链接中的关键部分
 */
export function extractDouyinId(url) {
  for (const pattern of DOUYIN_PATTERNS) {
    const match = url.match(pattern);
    if (match) {
      return match[1];
    }
  }
  return null;
}

/**
 * 解析抖音分享链接，获取视频信息
 * @param {string} url - 抖音分享链接
 * @returns {Promise<Object>} 视频信息对象
 */
export async function parseDouyinVideo(url) {
  try {
    // 处理短链接，获取重定向后的真实 URL
    const finalUrl = await getRedirectUrl(url);
    
    // 提取视频 ID
    const videoId = extractDouyinId(finalUrl);
    if (!videoId) {
      throw new Error('无法提取视频 ID');
    }

    // 获取视频页面 HTML
    const html = await fetchPage(finalUrl);
    const $ = cheerio.load(html);

    // 尝试从 script 标签中获取视频数据
    let videoData = null;
    $('script').each((_, script) => {
      const content = $(script).html();
      if (content && content.includes('video')) {
        try {
          // 尝试提取 JSON 数据
          const match = content.match(/window\._ROUTER_DATA\s*=\s*({.+?});/);
          if (match) {
            videoData = JSON.parse(match[1]);
          }
        } catch (e) {
          // 忽略解析错误
        }
      }
    });

    // 提取视频标题
    const title = $('title').text().replace(' - 抖音', '').trim();
    
    // 提取视频描述
    const description = $('meta[name="description"]').attr('content') || title;

    // 提取作者信息
    const author = $('meta[property="og:author"]').attr('content') || 
                   $('meta[name="author"]').attr('content') || 
                   '未知作者';

    // 提取封面图片
    const cover = $('meta[property="og:image"]').attr('content');

    // 提取无水印视频地址（需要进一步解析）
    const videoUrl = await extractVideoUrl(finalUrl, videoId);

    return {
      id: videoId,
      url: finalUrl,
      title,
      description,
      author,
      cover,
      videoUrl,
      parsedAt: new Date().toISOString()
    };
  } catch (error) {
    console.error('解析失败:', error.message);
    throw new Error(`解析抖音视频失败：${error.message}`);
  }
}

/**
 * 获取重定向后的真实 URL
 */
async function getRedirectUrl(url) {
  try {
    const response = await axios.get(url, {
      maxRedirects: 5,
      headers: {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
        'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
      }
    });
    return response.request.res.responseUrl || url;
  } catch (error) {
    // 如果重定向失败，返回原 URL
    return url;
  }
}

/**
 * 获取页面 HTML
 */
async function fetchPage(url) {
  const response = await axios.get(url, {
    headers: {
      'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
      'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
      'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
    }
  });
  return response.data;
}

/**
 * 提取视频下载地址
 * 注意：抖音会动态生成视频地址，这里使用备用方案
 */
async function extractVideoUrl(url, videoId) {
  // 方案 1: 尝试从 API 获取
  try {
    const apiUrl = `https://www.iesdouyin.com/web/api/v2/aweme/iteminfo/?item_ids=${videoId}`;
    const response = await axios.get(apiUrl, {
      headers: {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
        'Referer': url,
      }
    });
    
    if (response.data && response.data.item_list && response.data.item_list[0]) {
      const video = response.data.item_list[0].video;
      // 优先获取无水印版本
      return video.play_addr.url_list[0] || video.download_addr.url_list[0];
    }
  } catch (error) {
    console.log('API 解析失败，使用备用方案');
  }

  // 方案 2: 返回一个标记，表示需要进一步处理
  return `https://www.douyin.com/video/${videoId}`;
}

/**
 * 批量解析链接
 */
export async function parseMultipleUrls(urls) {
  const results = [];
  const errors = [];

  for (const url of urls) {
    try {
      const result = await parseDouyinVideo(url);
      results.push(result);
    } catch (error) {
      errors.push({ url, error: error.message });
    }
  }

  return { results, errors };
}


path: package.json
content: {
  "name": "douyin-downloader",
  "version": "1.0.0",
  "description": "抖音无水印视频下载器 - 支持链接解析、浏览器提取和批量下载",
  "main": "src/index.js",
  "type": "module",
  "scripts": {
    "test": "node src/test.js"
  },
  "keywords": [
    "douyin",
    "tiktok",
    "downloader",
    "openclaw",
    "skill",
    "video",
    "browser",
    "playwright"
  ],
  "author": "OpenClaw User",
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "https://github.com/openclaw/skills"
  },
  "dependencies": {
    "axios": "^1.6.0",
    "cheerio": "^1.0.0-rc.12",
    "playwright": "^1.58.2"
  },
  "optionalDependencies": {
    "fluent-ffmpeg": "^2.1.2"
  },
  "clawhub": {
    "displayName": "🎵 抖音下载器",
    "category": "media",
    "tags": ["douyin", "tiktok", "video", "downloader", "browser"],
    "readme": "README.md",
    "skillDefinition": "SKILL.md"
  }
}


path: README.md
content: # 🎵 抖音下载器 (douyin-downloader)

> 抖音视频下载工具 - OpenClaw Skill

## ⚠️ 当前状态

**基础功能可用，高级功能需要配置**

- ✅ 链接解析 - 支持多种抖音分享链接格式
- ✅ 视频 ID 提取 - 正确识别视频
- ⚠️ 视频下载 - 需要无头浏览器（Playwright）支持
- ⚠️ 元数据抓取 - 需要客户端渲染支持

## ✨ 功能

- 🔗 **链接解析** - 支持多种抖音分享链接格式
- 📹 **视频下载** - 需要 Playwright 获取真实地址
- 📦 **批量处理** - 一次处理多个链接
- 📝 **元数据保存** - 保存视频信息（可选）
- 🎯 **智能命名** - 自动生成有意义的文件名

## 🚀 快速开始

### 1. 安装依赖

```bash
cd skills/douyin-downloader
npm install
```

### 2. 安装无头浏览器（必需）

抖音使用动态加载，需要 Playwright 来获取真实视频地址：

```bash
npm install playwright
npx playwright install chromium
```

### 3. 配置（可选）

在 `TOOLS.md` 中添加：

```markdown
### 抖音下载器

- 默认保存目录：~/Videos/douyin
- 保存元数据：true
- 使用无头浏览器：true
```

### 2. 使用方法

在 OpenClaw 中直接发送：

```
下载这个抖音视频：https://v.douyin.com/xxxxx
```

或批量下载：

```
下载这些视频：
- https://v.douyin.com/xxx1
- https://v.douyin.com/xxx2
- https://v.douyin.com/xxx3
```

### 3. 配置（可选）

在 `TOOLS.md` 中添加配置：

```markdown
### 抖音下载器

- 默认保存目录：~/Videos/douyin
- 保存元数据：true
- 并发下载数：3
```

## 📋 支持的链接格式

- `https://www.douyin.com/video/123456789`
- `https://www.douyin.com/note/123456789`
- `https://v.douyin.com/abc123`
- `https://m.douyin.com/abc123`
- `https://www.iesdouyin.com/abc123`

## 📁 输出示例

下载成功后：

```
✅ 下载成功！

📹 视频：精彩的舞蹈表演
👤 作者：舞蹈达人
📁 保存：C:\Users\xxx\Videos\douyin\舞蹈达人_精彩的舞蹈表演_123456789.mp4
💾 大小：15.3 MB
```

## ⚙️ API 参考

### 解析单个视频

```javascript
import { parseDouyinVideo } from './src/parser.js';

const videoInfo = await parseDouyinVideo('https://v.douyin.com/xxx');
console.log(videoInfo);
// { id, url, title, author, cover, videoUrl, ... }
```

### 下载视频

```javascript
import { downloadDouyinVideo } from './src/downloader.js';

const result = await downloadDouyinVideo(videoInfo, './videos', {
  saveMetadata: true
});
```

### 批量下载

```javascript
import { batchDownload } from './src/downloader.js';

const results = await batchDownload(videoInfos, './videos', {
  concurrency: 3
});
```

## 🔧 开发

### 测试

```bash
npm test
```

### 目录结构

```
douyin-downloader/
├── SKILL.md           # OpenClaw 技能定义
├── package.json       # 依赖配置
├── README.md          # 使用说明
├── src/
│   ├── index.js       # 主入口
│   ├── parser.js      # 链接解析
│   └── downloader.js  # 文件下载
└── scripts/
    └── setup.js       # 初始化脚本（可选）
```

## ⚠️ 注意事项

1. **仅用于个人学习** - 请勿用于商业用途
2. **遵守用户协议** - 尊重创作者版权
3. **频率限制** - 避免短时间内大量请求
4. **公开视频** - 仅支持公开可见的视频

## 🐛 常见问题

### Q: 下载失败怎么办？
A: 检查链接是否正确，确保视频是公开的。部分视频可能有访问限制。

### Q: 如何修改保存目录？
A: 在 TOOLS.md 中配置 `saveDir`，或在调用时传入配置。

### Q: 支持 TikTok 吗？
A: 当前版本仅支持抖音（中国版）。TikTok 需要单独的解析逻辑。

## 📄 许可证

MIT License

## 🙏 致谢

感谢 OpenClaw 社区和所有贡献者！


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