Skill: link-1069/douyin-analyse-batch (version 1.0.2)

--- name: douyin-daily-report description: 抖音每日自动热榜日报生成与邮件推送。当用户说"抖音日报"、"发送邮件报告"、"自动分析抖音"、"定时推送抖音"或需要"生成抖音视频分析报告"时使用此技能。自动获取热榜 TOP15 → OpenClaw LLM 分析 → Word 文档输出 → 邮件定时发送至指定收件人。本技能包含完整依赖（douyin-video-analysis、douyin-hot-trend、douyin-mcp-server），安装即用。 --- # 抖音每日热榜日报 全自动抖音视频分析邮件推送系统。每天定时（08:00、16:00）获取抖音热榜 TOP15，对每个视频调用 OpenClaw 内置模型（MiniMax-M2.7）生成结构化分析笔记，输出 Word 文档并邮件推送。 ## 安装即用 本 skill 已包含所有依赖，无需单独安装其他 skills。安装后运行一键部署： ```bash bash /root/.openclaw/workspace/skills/douyin-daily-report/scripts/setup_douyin_daily_report.sh ``` 该脚本自动完成： 1. 创建 Python 虚拟环境（faster-whisper / yt-dlp / python-docx / requests） 2. 生成 `.env` 环境变量文件 3. 检查 TikHub API Token 4. 设置 Cron 定时任务（每天 08:00 和 16:00） ## 环境变量配置 编辑 `.env` 文件： ```env SMTP_USER=[REDACTED_SECRET] SMTP_PASS=QQ邮箱SMTP授权码 SMTP_HOST=smtp.qq.com SMTP_PORT=587 DOUYIN_EMAIL_RECIPIENTS= DOUYIN_DIGEST_LIMIT=15 ``` TikHub Token 写入 `~/.openclaw/config.json`： ```json {"tikhub_api_token": "tk_live_xxxxxxxx"} ``` ## 手动运行 ```bash # 发送邮件 bash /root/.openclaw/workspace/skills/douyin-daily-report/scripts/cron_daily_digest_wrapper.sh # 指定条数，不发邮件（测试用） SMTP_USER=xxx SMTP_PASS=xxx DOUYIN_EMAIL_RECIPIENTS=xxx \ /usr/bin/python3 /root/.openclaw/workspace/skills/douyin-daily-report/scripts/run_daily_digest.py \ --limit 15 --skip-transcribe --no-email ``` ## 完整链路 ``` TikHub 热榜 API ↓ fetch_hot_total_video_list OpenClaw LLM（MiniMax-M2.7） ↓ title + tags → 分析笔记 MD 文件写入 ↓ md_to_docx.py（python-docx） ↓ EmailMessage（RFC 5987 编码） ↓ SMTP → QQ 邮箱 只发 .docx 附件 ``` ## 输出内容 每条视频：播放量、点赞量、作者、链接、**LLM 分析笔记**（钩子、核心主张、论据支撑、批判性分析） ## 故障排查 - **邮件显示 .bin**：已修复，使用 EmailMessage RFC 5987 编码 - **LLM 分析失败**：确认 OpenClaw Gateway 运行中 - **TikHub 失败**：确认 `~/.openclaw/config.json` 中 `tikhub_api_token` 正确 - **日报少于15条**：TikHub 免费热榜每日返回数据量不固定 ## 文件结构 ``` douyin-daily-report/ ├── SKILL.md ├── .env ← 环境变量（setup 脚本生成） ├── scripts/ │ ├── setup_douyin_daily_report.sh ← 一键安装（含 venv + Cron） │ ├── cron_daily_digest_wrapper.sh ← Cron 入口 │ ├── run_daily_digest.py ← 核心流水线 │ └── helpers/ │ ├── send_email.py ← 邮件发送 │ └── md_to_docx.py ← MD→Word └── dependencies/ ← 完整依赖 skills ├── douyin-video-analysis/ ├── douyin-hot-trend/ └── douyin-mcp-server/ ``` ## 卸载 ```bash # 删除 Cron crontab -l | grep -v "douyin_daily_digest" | crontab - # 删除虚拟环境和输出 rm -rf /tmp/douyin_transcribe rm -rf ~/Documents/douyin_analysis # 删除 skill 目录 rm -rf /root/.openclaw/workspace/skills/douyin-daily-report ```

path: dependencies/douyin-hot-trend/cron-job.js
content: #!/usr/bin/env node

/**
 * 抖音热榜定时任务 - OpenClaw 集成版本
 * 直接发送到 Telegram
 */

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

// 获取热榜数据
function getHotTrend(limit = 10) {
  try {
    const scriptPath = path.join(__dirname, 'scripts', 'douyin.js');
    const output = execSync(`node "${scriptPath}" hot ${limit}`, {
      encoding: 'utf-8',
      cwd: __dirname
    });
    return output;
  } catch (error) {
    console.error('获取抖音热榜失败:', error.message);
    return null;
  }
}

// 解析热榜数据
function parseHotTrend(output) {
  const lines = output.split('\n');
  const items = [];
  
  let currentItem = null;
  
  for (const line of lines) {
    // 匹配排名行
    const rankMatch = line.match(/^\s*(\d+)\.\s+(.+)/);
    if (rankMatch) {
      if (currentItem && currentItem.title) {
        items.push(currentItem);
      }
      currentItem = {
        rank: parseInt(rankMatch[1]),
        title: rankMatch[2].trim(),
        popularity: 0,
        link: '',
        label: null
      };
      continue;
    }
    
    // 匹配热度值
    if (currentItem && line.includes('热度:')) {
      const hotMatch = line.match(/热度:\s*([\d,]+)/);
      if (hotMatch) {
        currentItem.popularity = parseInt(hotMatch[1].replace(/,/g, ''));
      }
    }
    
    // 匹配链接
    if (currentItem && line.includes('链接:')) {
      const linkMatch = line.match(/链接:\s*(.+)/);
      if (linkMatch) {
        currentItem.link = linkMatch[1].trim();
      }
    }
    
    // 匹配标签
    if (currentItem && line.includes('标签:')) {
      const labelMatch = line.match(/标签:\s*(.+)/);
      if (labelMatch) {
        currentItem.label = labelMatch[1].trim();
      }
    }
  }
  
  if (currentItem && currentItem.title) {
    items.push(currentItem);
  }
  
  return items;
}

// 格式化 Telegram 消息
function formatTelegramMessage(items) {
  let message = '🔥 **抖音热榜 TOP ' + items.length + '**\n';
  message += '_每天下午6点自动推送_\n\n';
  
  for (const item of items) {
    const emoji = ['🥇', '🥈', '🥉'][item.rank - 1] || '🎯';
    message += `${emoji} *${item.rank}.* ${item.title}\n`;
    message += `   🔥 ${item.popularity.toLocaleString()}\n`;
    
    if (item.rank < 5) {
      // 前5名显示链接
      message += `   [查看详情](${item.link})\n`;
    }
    
    message += '\n';
  }
  
  message += '📱 _数据来源：抖音网页端_';
  message += '\n⏰ ' + new Date().toLocaleString('zh-CN', { 
    timeZone: 'Asia/Shanghai',
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit'
  });
  
  return message;
}

// 主函数
async function main() {
  const limit = 10;
  
  console.log('🎬 开始执行抖音热榜定时任务...');
  
  // 获取热榜数据
  const rawData = getHotTrend(limit);
  if (!rawData) {
    console.error('❌ 获取热榜数据失败');
    process.exit(1);
  }
  
  // 解析数据
  const items = parseHotTrend(rawData);
  console.log(`✅ 成功解析 ${items.length} 条热榜数据`);
  
  // 格式化消息
  const message = formatTelegramMessage(items);
  
  // 保存到文件（调试用）
  const outputFile = path.join(__dirname, 'daily-hot-trend-message.txt');
  fs.writeFileSync(outputFile, message, 'utf-8');
  
  // 输出 JSON（供 OpenClaw 消息工具使用）
  const jsonOutput = {
    success: true,
    timestamp: new Date().toISOString(),
    timezone: 'Asia/Shanghai',
    chat_id: '8428610733',
    channel: 'telegram',
    message: message,
    items: items.slice(0, limit),
    format: 'markdown'
  };
  
  const jsonFile = path.join(__dirname, 'daily-hot-trend-output.json');
  fs.writeFileSync(jsonFile, JSON.stringify(jsonOutput, null, 2), 'utf-8');
  
  console.log('📤 消息已准备发送到 Telegram');
  console.log(`💾 保存位置: ${jsonFile}`);
  
  // 输出消息内容（OpenClaw 会捕获）
  console.log('\n=== 消息预览 ===\n');
  console.log(message);
  
  return jsonOutput;
}

main().catch(error => {
  console.error('执行出错:', error);
  process.exit(1);
});


path: dependencies/douyin-hot-trend/daily-hot-trend-message.txt
content: 🔥 **抖音热榜 TOP 10**
_每天下午6点自动推送_

🥇 *1.* 国防部正告日方
   🔥 12,094,236
   [查看详情](https://www.douyin.com/search/%E5%9B%BD%E9%98%B2%E9%83%A8%E6%AD%A3%E5%91%8A%E6%97%A5%E6%96%B9)

🥈 *2.* 中国造出“世界最强”碳纤维
   🔥 11,993,029
   [查看详情](https://www.douyin.com/search/%E4%B8%AD%E5%9B%BD%E9%80%A0%E5%87%BA%E2%80%9C%E4%B8%96%E7%95%8C%E6%9C%80%E5%BC%BA%E2%80%9D%E7%A2%B3%E7%BA%A4%E7%BB%B4)

🥉 *3.* 体验经济持续火爆
   🔥 11,950,960
   [查看详情](https://www.douyin.com/search/%E4%BD%93%E9%AA%8C%E7%BB%8F%E6%B5%8E%E6%8C%81%E7%BB%AD%E7%81%AB%E7%88%86)

🎯 *4.* 去机场不该比坐飞机时间还长
   🔥 11,871,691
   [查看详情](https://www.douyin.com/search/%E5%8E%BB%E6%9C%BA%E5%9C%BA%E4%B8%8D%E8%AF%A5%E6%AF%94%E5%9D%90%E9%A3%9E%E6%9C%BA%E6%97%B6%E9%97%B4%E8%BF%98%E9%95%BF)

🎯 *5.* 建议城市建立农民工子弟寄宿学校
   🔥 10,712,408

🎯 *6.* 全国两会第三场委员通道
   🔥 10,418,207

🎯 *7.* 建议AI应用设计多考虑残疾人
   🔥 9,136,507

🎯 *8.* 建议推行婴幼儿父母弹性上下班
   🔥 9,136,342

🎯 *9.* 制造业“马拉松”怎么跑
   🔥 9,110,599

🎯 *10.* 建议修改年休假天数规定
   🔥 9,102,688

📱 _数据来源：抖音网页端_
⏰ 2026/03/11 18:00

path: dependencies/douyin-hot-trend/daily-hot-trend-output.json
content: {
  "success": true,
  "timestamp": "2026-03-11T10:00:03.316Z",
  "timezone": "Asia/Shanghai",
  "chat_id": "8428610733",
  "channel": "telegram",
  "message": "🔥 **抖音热榜 TOP 10**\n_每天下午6点自动推送_\n\n🥇 *1.* 国防部正告日方\n   🔥 12,094,236\n   [查看详情](https://www.douyin.com/search/%E5%9B%BD%E9%98%B2%E9%83%A8%E6%AD%A3%E5%91%8A%E6%97%A5%E6%96%B9)\n\n🥈 *2.* 中国造出“世界最强”碳纤维\n   🔥 11,993,029\n   [查看详情](https://www.douyin.com/search/%E4%B8%AD%E5%9B%BD%E9%80%A0%E5%87%BA%E2%80%9C%E4%B8%96%E7%95%8C%E6%9C%80%E5%BC%BA%E2%80%9D%E7%A2%B3%E7%BA%A4%E7%BB%B4)\n\n🥉 *3.* 体验经济持续火爆\n   🔥 11,950,960\n   [查看详情](https://www.douyin.com/search/%E4%BD%93%E9%AA%8C%E7%BB%8F%E6%B5%8E%E6%8C%81%E7%BB%AD%E7%81%AB%E7%88%86)\n\n🎯 *4.* 去机场不该比坐飞机时间还长\n   🔥 11,871,691\n   [查看详情](https://www.douyin.com/search/%E5%8E%BB%E6%9C%BA%E5%9C%BA%E4%B8%8D%E8%AF%A5%E6%AF%94%E5%9D%90%E9%A3%9E%E6%9C%BA%E6%97%B6%E9%97%B4%E8%BF%98%E9%95%BF)\n\n🎯 *5.* 建议城市建立农民工子弟寄宿学校\n   🔥 10,712,408\n\n🎯 *6.* 全国两会第三场委员通道\n   🔥 10,418,207\n\n🎯 *7.* 建议AI应用设计多考虑残疾人\n   🔥 9,136,507\n\n🎯 *8.* 建议推行婴幼儿父母弹性上下班\n   🔥 9,136,342\n\n🎯 *9.* 制造业“马拉松”怎么跑\n   🔥 9,110,599\n\n🎯 *10.* 建议修改年休假天数规定\n   🔥 9,102,688\n\n📱 _数据来源：抖音网页端_\n⏰ 2026/03/11 18:00",
  "items": [
    {
      "rank": 1,
      "title": "国防部正告日方",
      "popularity": 12094236,
      "link": "https://www.douyin.com/search/%E5%9B%BD%E9%98%B2%E9%83%A8%E6%AD%A3%E5%91%8A%E6%97%A5%E6%96%B9",
      "label": "1"
    },
    {
      "rank": 2,
      "title": "中国造出“世界最强”碳纤维",
      "popularity": 11993029,
      "link": "https://www.douyin.com/search/%E4%B8%AD%E5%9B%BD%E9%80%A0%E5%87%BA%E2%80%9C%E4%B8%96%E7%95%8C%E6%9C%80%E5%BC%BA%E2%80%9D%E7%A2%B3%E7%BA%A4%E7%BB%B4",
      "label": "3"
    },
    {
      "rank": 3,
      "title": "体验经济持续火爆",
      "popularity": 11950960,
      "link": "https://www.douyin.com/search/%E4%BD%93%E9%AA%8C%E7%BB%8F%E6%B5%8E%E6%8C%81%E7%BB%AD%E7%81%AB%E7%88%86",
      "label": "3"
    },
    {
      "rank": 4,
      "title": "去机场不该比坐飞机时间还长",
      "popularity": 11871691,
      "link": "https://www.douyin.com/search/%E5%8E%BB%E6%9C%BA%E5%9C%BA%E4%B8%8D%E8%AF%A5%E6%AF%94%E5%9D%90%E9%A3%9E%E6%9C%BA%E6%97%B6%E9%97%B4%E8%BF%98%E9%95%BF",
      "label": "3"
    },
    {
      "rank": 5,
      "title": "建议城市建立农民工子弟寄宿学校",
      "popularity": 10712408,
      "link": "https://www.douyin.com/search/%E5%BB%BA%E8%AE%AE%E5%9F%8E%E5%B8%82%E5%BB%BA%E7%AB%8B%E5%86%9C%E6%B0%91%E5%B7%A5%E5%AD%90%E5%BC%9F%E5%AF%84%E5%AE%BF%E5%AD%A6%E6%A0%A1",
      "label": "3"
    },
    {
      "rank": 6,
      "title": "全国两会第三场委员通道",
      "popularity": 10418207,
      "link": "https://www.douyin.com/search/%E5%85%A8%E5%9B%BD%E4%B8%A4%E4%BC%9A%E7%AC%AC%E4%B8%89%E5%9C%BA%E5%A7%94%E5%91%98%E9%80%9A%E9%81%93",
      "label": "3"
    },
    {
      "rank": 7,
      "title": "建议AI应用设计多考虑残疾人",
      "popularity": 9136507,
      "link": "https://www.douyin.com/search/%E5%BB%BA%E8%AE%AEAI%E5%BA%94%E7%94%A8%E8%AE%BE%E8%AE%A1%E5%A4%9A%E8%80%83%E8%99%91%E6%AE%8B%E7%96%BE%E4%BA%BA",
      "label": "3"
    },
    {
      "rank": 8,
      "title": "建议推行婴幼儿父母弹性上下班",
      "popularity": 9136342,
      "link": "https://www.douyin.com/search/%E5%BB%BA%E8%AE%AE%E6%8E%A8%E8%A1%8C%E5%A9%B4%E5%B9%BC%E5%84%BF%E7%88%B6%E6%AF%8D%E5%BC%B9%E6%80%A7%E4%B8%8A%E4%B8%8B%E7%8F%AD",
      "label": "3"
    },
    {
      "rank": 9,
      "title": "制造业“马拉松”怎么跑",
      "popularity": 9110599,
      "link": "https://www.douyin.com/search/%E5%88%B6%E9%80%A0%E4%B8%9A%E2%80%9C%E9%A9%AC%E6%8B%89%E6%9D%BE%E2%80%9D%E6%80%8E%E4%B9%88%E8%B7%91",
      "label": "3"
    },
    {
      "rank": 10,
      "title": "建议修改年休假天数规定",
      "popularity": 9102688,
      "link": "https://www.douyin.com/search/%E5%BB%BA%E8%AE%AE%E4%BF%AE%E6%94%B9%E5%B9%B4%E4%BC%91%E5%81%87%E5%A4%A9%E6%95%B0%E8%A7%84%E5%AE%9A",
      "label": "1"
    }
  ],
  "format": "markdown"
}

path: dependencies/douyin-hot-trend/package-lock.json
content: {
  "name": "douyin-hot-trend",
  "version": "1.0.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "douyin-hot-trend",
      "version": "1.0.0",
      "license": "MIT"
    }
  }
}


path: dependencies/douyin-hot-trend/package.json
content: {
  "name": "douyin-hot-trend",
  "version": "1.1.0",
  "description": "获取抖音热榜/热搜榜数据，包含热门视频、挑战赛、音乐等多领域热门内容",
  "keywords": [
    "douyin",
    "tiktok",
    "hot",
    "trend",
    "social",
    "chinese"
  ],
  "author": "franklu0819-lang",
  "license": "MIT",
  "openclaw": {
    "requires": {
      "bins": [
        "node"
      ]
    }
  }
}

path: dependencies/douyin-hot-trend/README.md
content: # 抖音热榜获取技能

获取抖音热榜/热搜榜数据，包含热门视频、挑战赛、音乐等多领域热门内容，并输出标题、热度值与跳转链接。

## 功能特性

- 🔥 **实时热榜**: 获取抖音最新热门内容
- 📊 **热度值**: 显示每个话题的热度评分
- 🔗 **跳转链接**: 提供详情页直达链接
- 🎯 **自定义数量**: 可指定获取前 N 条数据
- 📱 **多领域内容**: 热门视频、挑战赛、音乐等

## 快速开始

```bash
# 获取抖音热榜前 50 条（默认）
node scripts/douyin.js hot

# 获取前 20 条
node scripts/douyin.js hot 20

# 获取前 10 条
node scripts/douyin.js hot 10
```

## 输出格式

每条热榜包含：
- 📌 **排名**: 热榜排名
- 🔥 **标题**: 热门话题/视频标题
- 📊 **热度值**: 热度评分
- 🔗 **链接**: 详情页跳转链接

## 使用示例

```bash
# 获取热门前 20
node scripts/douyin.js hot 20

# 输出示例：
# 1. 🔥 xxx话题
#    热度：1234567
#    链接：https://www.douyin.com/...
```

## 数据来源

抖音网页端公开接口

## 注意事项

- ⚠️ 该接口为网页端公开接口，返回结构可能变动
- ⚠️ 访问频繁可能触发风控
- ⚠️ 建议合理使用，避免频繁请求

## 使用场景

- 📰 热点追踪
- 📊 内容趋势分析
- 🎯 营销策划参考
- 📱 社交媒体运营

## Author

franklu0819-lang

## License

MIT


path: dependencies/douyin-hot-trend/scripts/douyin-with-cover.js
content: #!/usr/bin/env node

/**
 * 抖音热榜抓取脚本（增强版 - 带封面图）
 * 获取抖音热搜榜数据，并尝试获取封面图
 */

const https = require('https');

const USER_AGENTS = [
  'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
  'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
  'Mozilla/5.0 (Windows NT 10.0; Win64; x64) rv:121.0) Gecko/20100101 Firefox/121.0'
];

function getRandomUserAgent() {
  return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
}

function fetchDouyinHotBoard() {
  return new Promise((resolve, reject) => {
    const options = {
      hostname: 'www.douyin.com',
      path: '/aweme/v1/hot/search/list/',
      method: 'GET',
      headers: {
        'User-Agent': getRandomUserAgent(),
        'Accept': 'application/json',
        'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
        'Referer': 'https://www.douyin.com/'
      }
    };

    const req = https.request(options, (res) => {
      let data = '';

      res.on('data', (chunk) => {
        data += chunk;
      });

      res.on('end', () => {
        try {
          const jsonData = JSON.parse(data);
          resolve(jsonData);
        } catch (error) {
          reject(new Error(`JSON 解析失败: ${error.message}`));
        }
      });
    });

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

    req.setTimeout(10000, () => {
      req.destroy();
      reject(new Error('请求超时'));
    });

    req.end();
  });
}

function formatHotBoard(data, limit = 50) {
  if (!data || !data.data || !data.data.word_list) {
    return [];
  }

  return data.data.word_list.slice(0, limit).map((item, index) => ({
    rank: index + 1,
    title: item.word || '无标题',
    popularity: item.hot_value || 0,
    link: item.url || `https://www.douyin.com/search/${encodeURIComponent(item.word || '')}`,
    cover: item.cover || null,
    label: item.label || null,
    type: item.type || '未知'
  }));
}

function printHotBoard(hotList) {
  console.log('🔥 抖音热榜 TOP ' + hotList.length);
  console.log('=' .repeat(70));
  console.log();

  hotList.forEach((item) => {
    console.log(`${item.rank.toString().padStart(2, ' ')}. ${item.title}`);
    console.log(`    🔥 热度: ${item.popularity.toLocaleString()}`);
    if (item.label) {
      console.log(`    🏷️ 标签: ${item.label}`);
    }
    if (item.cover) {
      console.log(`    🖼️  封面: ${item.cover}`);
    }
    console.log(`    🔗 链接: ${item.link}`);
    console.log();
  });
}

// 主函数
async function main() {
  const args = process.argv.slice(2);
  const command = args[0] || 'hot';
  const limit = parseInt(args[1]) || 50;

  if (command === 'hot') {
    try {
      console.log('正在获取抖音热榜...\n');
      const data = await fetchDouyinHotBoard();
      const hotList = formatHotBoard(data, limit);

      if (hotList.length === 0) {
        console.log('❌ 未获取到热榜数据');
        process.exit(1);
      }

      printHotBoard(hotList);
    } catch (error) {
      console.error(`❌ 获取热榜失败: ${error.message}`);
      process.exit(1);
    }
  } else {
    console.log('用法:');
    console.log('  node scripts/douyin.js hot [数量]');
    console.log('');
    console.log('示例:');
    console.log('  node scripts/douyin.js hot      # 获取热榜（默认50条）');
    console.log('  node scripts/douyin.js hot 20   # 获取热榜前20条');
    process.exit(1);
  }
}

main();


path: dependencies/douyin-hot-trend/scripts/douyin.js
content: #!/usr/bin/env node

/**
 * 抖音热榜抓取脚本（增强版 - 带封面图）
 * 获取抖音热搜榜数据，并尝试获取封面图
 */

const https = require('https');

const USER_AGENTS = [
  'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
  'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
  'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Gecko/20100101 Firefox/121.0'
];

function getRandomUserAgent() {
  return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
}

function fetchDouyinHotBoard() {
  return new Promise((resolve, reject) => {
    const options = {
      hostname: 'www.douyin.com',
      path: '/aweme/v1/hot/search/list/',
      method: 'GET',
      headers: {
        'User-Agent': getRandomUserAgent(),
        'Accept': 'application/json',
        'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
        'Referer': 'https://www.douyin.com/'
      }
    };

    const req = https.request(options, (res) => {
      let data = '';

      res.on('data', (chunk) => {
        data += chunk;
      });

      res.on('end', () => {
        try {
          const jsonData = JSON.parse(data);
          resolve(jsonData);
        } catch (error) {
          reject(new Error(`JSON 解析失败: ${error.message}`));
        }
      });
    });

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

    req.setTimeout(10000, () => {
      req.destroy();
      reject(new Error('请求超时'));
    });

    req.end();
  });
}

function formatHotBoard(data, limit = 50) {
  if (!data || !data.data || !data.data.word_list) {
    return [];
  }

  return data.data.word_list.slice(0, limit).map((item, index) => ({
    rank: index + 1,
    title: item.word || '无标题',
    popularity: item.hot_value || 0,
    link: item.url || `https://www.douyin.com/search/${encodeURIComponent(item.word || '')}`,
    cover: item.cover || null,
    label: item.label || null,
    type: item.type || '未知'
  }));
}

function printHotBoard(hotList) {
  console.log('🔥 抖音热榜 TOP ' + hotList.length);
  console.log('=' .repeat(70));
  console.log();

  hotList.forEach((item) => {
    console.log(`${item.rank.toString().padStart(2, ' ')}. ${item.title}`);
    console.log(`    🔥 热度: ${item.popularity.toLocaleString()}`);
    if (item.label) {
      console.log(`    🏷️ 标签: ${item.label}`);
    }
    if (item.cover) {
      console.log(`    🖼️  封面: ${item.cover}`);
    }
    console.log(`    🔗 链接: ${item.link}`);
    console.log();
  });
}

// 主函数
async function main() {
  const args = process.argv.slice(2);
  const command = args[0] || 'hot';
  const limit = parseInt(args[1]) || 50;

  if (command === 'hot') {
    try {
      console.log('正在获取抖音热榜...\n');
      const data = await fetchDouyinHotBoard();
      const hotList = formatHotBoard(data, limit);

      if (hotList.length === 0) {
        console.log('❌ 未获取到热榜数据');
        process.exit(1);
      }

      printHotBoard(hotList);
    } catch (error) {
      console.error(`❌ 获取热榜失败: ${error.message}`);
      process.exit(1);
    }
  } else {
    console.log('用法:');
    console.log('  node scripts/douyin.js hot [数量]');
    console.log('');
    console.log('示例:');
    console.log('  node scripts/douyin.js hot      # 获取热榜（默认50条）');
    console.log('  node scripts/douyin.js hot 20   # 获取热榜前20条');
    process.exit(1);
  }
}

main();


path: dependencies/douyin-hot-trend/scripts/get-hot-trend.js
content: #!/usr/bin/env node

/**
 * 抖音热榜定时任务脚本
 * 用于 OpenClaw cron 自动化
 */

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

// 获取热榜数据
function getHotTrend(limit = 10) {
  try {
    const scriptPath = path.join(__dirname, 'douyin.js');
    const output = execSync(`node "${scriptPath}" hot ${limit}`, {
      encoding: 'utf-8',
      cwd: path.dirname(scriptPath)
    });
    return output;
  } catch (error) {
    console.error('获取抖音热榜失败:', error.message);
    return null;
  }
}

// 格式化热榜消息
function formatMessage(hotTrendText) {
  const lines = hotTrendText.split('\n').filter(line => line.trim());
  
  let message = '🔥 **抖音热榜 TOP 10**\n';
  message += '=' .repeat(50) + '\n\n';
  
  let inList = false;
  let itemCount = 0;
  
  for (const line of lines) {
    // 跳过标题和分隔线
    if (line.includes('抖音热榜') || line.includes('===') || line.includes('正在获取')) {
      continue;
    }
    
    // 解析榜单条目
    if (line.match(/^\s*\d+\./)) {
      inList = true;
      itemCount++;
      if (itemCount > 10) break; // 只取前10
      
      // 提取排名和标题
      const match = line.match(/^\s*(\d+)\.\s*(.+)/);
      if (match) {
        const rank = match[1];
        const title = match[2].trim();
        message += `${rank}. ${title}\n`;
      }
    } else if (inList && line.includes('热度:')) {
      // 提取热度值
      const hotMatch = line.match(/热度:\s*([\d,]+)/);
      if (hotMatch) {
        message += `   🔥 ${hotMatch[1]}\n\n`;
      }
    }
  }
  
  message += '\n📱 数据来源：抖音网页端公开接口';
  message += '\n⏰ 更新时间：' + new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
  
  return message;
}

// 主函数
async function main() {
  const limit = process.argv[2] || 10;
  
  console.log('开始获取抖音热榜...');
  const hotTrendData = getHotTrend(limit);
  
  if (!hotTrendData) {
    console.error('❌ 获取热榜数据失败');
    process.exit(1);
  }
  
  const message = formatMessage(hotTrendData);
  
  // 输出到文件（用于调试）
  const outputFile = path.join(__dirname, 'latest-hot-trend.txt');
  fs.writeFileSync(outputFile, message, 'utf-8');
  
  // 输出到 stdout（OpenClaw 会捕获）
  console.log('\n=== 抖音热榜报告 ===\n');
  console.log(message);
  
  // 保存为 JSON 格式（供程序调用）
  const jsonOutput = {
    success: true,
    timestamp: new Date().toISOString(),
    timezone: 'Asia/Shanghai',
    message: message,
    rawData: hotTrendData
  };
  
  const jsonFile = path.join(__dirname, 'latest-hot-trend.json');
  fs.writeFileSync(jsonFile, JSON.stringify(jsonOutput, null, 2), 'utf-8');
  
  console.log('\n✅ 热榜数据已保存到:');
  console.log(`  - ${outputFile}`);
  console.log(`  - ${jsonFile}`);
}

main().catch(error => {
  console.error('执行出错:', error);
  process.exit(1);
});


path: dependencies/douyin-hot-trend/scripts/latest-hot-trend.json
content: {
  "success": true,
  "timestamp": "2026-03-11T18:01:14.694Z",
  "timezone": "Asia/Shanghai",
  "message": "🔥 **抖音热榜 TOP 10**\n==================================================\n\n1. 国防部正告日方\n   🔥 11,860,886\n\n2. 麦基CBA生涯首秀\n   🔥 11,791,769\n\n3. “十五五”开局 国家把“人”放在C位\n   🔥 11,727,050\n\n4. 中国女篮81:68马里女篮\n   🔥 11,469,270\n\n5. 水庆霞代表为退役运动员发声\n   🔥 10,846,967\n\n6. CBA深圳新世纪88:96辽宁本钢\n   🔥 10,819,474\n\n7. 建议修改年休假天数规定\n   🔥 9,434,709\n\n8. 建议推行婴幼儿父母弹性上下班\n   🔥 9,336,524\n\n9. 从“投资于人”到“民生为大”\n   🔥 9,220,301\n\n10. 建议AI应用设计多考虑残疾人\n   🔥 9,145,796\n\n\n📱 数据来源：抖音网页端公开接口\n⏰ 更新时间：2026/3/12 02:01:14",
  "rawData": "正在获取抖音热榜...\n\n🔥 抖音热榜 TOP 10\n======================================================================\n\n 1. 国防部正告日方\n    🔥 热度: 11,860,886\n    🏷️ 标签: 3\n    🔗 链接: https://www.douyin.com/search/%E5%9B%BD%E9%98%B2%E9%83%A8%E6%AD%A3%E5%91%8A%E6%97%A5%E6%96%B9\n\n 2. 麦基CBA生涯首秀\n    🔥 热度: 11,791,769\n    🔗 链接: https://www.douyin.com/search/%E9%BA%A6%E5%9F%BACBA%E7%94%9F%E6%B6%AF%E9%A6%96%E7%A7%80\n\n 3. “十五五”开局 国家把“人”放在C位\n    🔥 热度: 11,727,050\n    🏷️ 标签: 3\n    🔗 链接: https://www.douyin.com/search/%E2%80%9C%E5%8D%81%E4%BA%94%E4%BA%94%E2%80%9D%E5%BC%80%E5%B1%80%20%E5%9B%BD%E5%AE%B6%E6%8A%8A%E2%80%9C%E4%BA%BA%E2%80%9D%E6%94%BE%E5%9C%A8C%E4%BD%8D\n\n 4. 中国女篮81:68马里女篮\n    🔥 热度: 11,469,270\n    🏷️ 标签: 3\n    🔗 链接: https://www.douyin.com/search/%E4%B8%AD%E5%9B%BD%E5%A5%B3%E7%AF%AE81%3A68%E9%A9%AC%E9%87%8C%E5%A5%B3%E7%AF%AE\n\n 5. 水庆霞代表为退役运动员发声\n    🔥 热度: 10,846,967\n    🔗 链接: https://www.douyin.com/search/%E6%B0%B4%E5%BA%86%E9%9C%9E%E4%BB%A3%E8%A1%A8%E4%B8%BA%E9%80%80%E5%BD%B9%E8%BF%90%E5%8A%A8%E5%91%98%E5%8F%91%E5%A3%B0\n\n 6. CBA深圳新世纪88:96辽宁本钢\n    🔥 热度: 10,819,474\n    🏷️ 标签: 3\n    🔗 链接: https://www.douyin.com/search/CBA%E6%B7%B1%E5%9C%B3%E6%96%B0%E4%B8%96%E7%BA%AA88%3A96%E8%BE%BD%E5%AE%81%E6%9C%AC%E9%92%A2\n\n 7. 建议修改年休假天数规定\n    🔥 热度: 9,434,709\n    🔗 链接: https://www.douyin.com/search/%E5%BB%BA%E8%AE%AE%E4%BF%AE%E6%94%B9%E5%B9%B4%E4%BC%91%E5%81%87%E5%A4%A9%E6%95%B0%E8%A7%84%E5%AE%9A\n\n 8. 建议推行婴幼儿父母弹性上下班\n    🔥 热度: 9,336,524\n    🔗 链接: https://www.douyin.com/search/%E5%BB%BA%E8%AE%AE%E6%8E%A8%E8%A1%8C%E5%A9%B4%E5%B9%BC%E5%84%BF%E7%88%B6%E6%AF%8D%E5%BC%B9%E6%80%A7%E4%B8%8A%E4%B8%8B%E7%8F%AD\n\n 9. 从“投资于人”到“民生为大”\n    🔥 热度: 9,220,301\n    🏷️ 标签: 3\n    🔗 链接: https://www.douyin.com/search/%E4%BB%8E%E2%80%9C%E6%8A%95%E8%B5%84%E4%BA%8E%E4%BA%BA%E2%80%9D%E5%88%B0%E2%80%9C%E6%B0%91%E7%94%9F%E4%B8%BA%E5%A4%A7%E2%80%9D\n\n10. 建议AI应用设计多考虑残疾人\n    🔥 热度: 9,145,796\n    🔗 链接: https://www.douyin.com/search/%E5%BB%BA%E8%AE%AEAI%E5%BA%94%E7%94%A8%E8%AE%BE%E8%AE%A1%E5%A4%9A%E8%80%83%E8%99%91%E6%AE%8B%E7%96%BE%E4%BA%BA\n\n"
}

path: dependencies/douyin-hot-trend/scripts/latest-hot-trend.txt
content: 🔥 **抖音热榜 TOP 10**
==================================================

1. 国防部正告日方
   🔥 11,860,886

2. 麦基CBA生涯首秀
   🔥 11,791,769

3. “十五五”开局 国家把“人”放在C位
   🔥 11,727,050

4. 中国女篮81:68马里女篮
   🔥 11,469,270

5. 水庆霞代表为退役运动员发声
   🔥 10,846,967

6. CBA深圳新世纪88:96辽宁本钢
   🔥 10,819,474

7. 建议修改年休假天数规定
   🔥 9,434,709

8. 建议推行婴幼儿父母弹性上下班
   🔥 9,336,524

9. 从“投资于人”到“民生为大”
   🔥 9,220,301

10. 建议AI应用设计多考虑残疾人
   🔥 9,145,796


📱 数据来源：抖音网页端公开接口
⏰ 更新时间：2026/3/12 02:01:14

path: dependencies/douyin-hot-trend/_meta.json
content: ownerId: kn784af36jyc12gczmcr7fq0a981dbhr
slug: douyin-hot-trend
version: 1.1.0
publishedAt: 1773252093299

path: dependencies/douyin-mcp-server/douyin-video/scripts/douyin_downloader.py
content: #!/usr/bin/env python3
"""
抖音无水印视频下载和文案提取工具

功能:
1. 从抖音分享链接获取无水印视频下载链接
2. 下载视频并提取音频
3. 使用硅基流动 API 从音频中提取文本
4. 自动保存文案到文件 (一个视频一个文件夹)

环境变量:
- API_KEY: 硅基流动 API 密钥 (用于文案提取功能)

使用示例:
  # 获取下载链接 (无需 API 密钥)
  python douyin_downloader.py --link "抖音分享链接" --action info

  # 下载视频
  python douyin_downloader.py --link "抖音分享链接" --action download --output ./videos

  # 提取文案并保存到文件 (需要 API_KEY 环境变量)
  python douyin_downloader.py --link "抖音分享链接" --action extract --output ./output
"""

import os
import re
import sys
import json
import argparse
import tempfile
import shutil
from pathlib import Path
from typing import Optional
from datetime import datetime


def check_dependencies():
    """检查必要的依赖是否已安装"""
    missing = []
    try:
        import requests
    except ImportError:
        missing.append("requests")
    try:
        import ffmpeg
    except ImportError:
        missing.append("ffmpeg-python")

    if missing:
        print(f"缺少依赖: {', '.join(missing)}")
        print(f"请运行: pip install {' '.join(missing)}")
        sys.exit(1)


check_dependencies()

import requests
import ffmpeg

# 请求头，模拟移动端访问
HEADERS = {
    'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/121.0.2277.107 Version/17.0 Mobile/15E148 Safari/604.1'
}

# 硅基流动 API 配置
DEFAULT_API_BASE_URL = "https://api.siliconflow.cn/v1/audio/transcriptions"
DEFAULT_MODEL = "FunAudioLLM/SenseVoiceSmall"


class DouyinProcessor:
    """抖音视频处理器"""

    def __init__(self, api_key: str = "", api_base_url: Optional[str] = None, model: Optional[str] = None):
        self.[REDACTED_SECRET]
        self.api_base_url = api_base_url or DEFAULT_API_BASE_URL
        self.model = model or DEFAULT_MODEL
        self.temp_dir = Path(tempfile.mkdtemp())

    def __del__(self):
        """清理临时目录"""
        if hasattr(self, 'temp_dir') and self.temp_dir.exists():
            shutil.rmtree(self.temp_dir, ignore_errors=True)

    def parse_share_url(self, share_text: str) -> dict:
        """从分享文本中提取无水印视频链接"""
        # 提取分享链接
        urls = re.findall(r'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+', share_text)
        if not urls:
            raise ValueError("未找到有效的分享链接")

        share_url = urls[0]
        share_response = requests.get(share_url, headers=HEADERS)
        video_id = share_response.url.split("?")[0].strip("/").split("/")[-1]
        share_url = f'https://www.iesdouyin.com/share/video/{video_id}'

        # 获取视频页面内容
        response = requests.get(share_url, headers=HEADERS)
        response.raise_for_status()

        pattern = re.compile(
            pattern=r"window\._ROUTER_DATA\s*=\s*(.*?)</script>",
            flags=re.DOTALL,
        )
        find_res = pattern.search(response.text)

        if not find_res or not find_res.group(1):
            raise ValueError("从HTML中解析视频信息失败")

        # 解析JSON数据
        json_data = json.loads(find_res.group(1).strip())
        VIDEO_ID_PAGE_KEY = "video_(id)/page"
        NOTE_ID_PAGE_KEY = "note_(id)/page"

        if VIDEO_ID_PAGE_KEY in json_data["loaderData"]:
            original_video_info = json_data["loaderData"][VIDEO_ID_PAGE_KEY]["videoInfoRes"]
        elif NOTE_ID_PAGE_KEY in json_data["loaderData"]:
            original_video_info = json_data["loaderData"][NOTE_ID_PAGE_KEY]["videoInfoRes"]
        else:
            raise Exception("无法从JSON中解析视频或图集信息")

        data = original_video_info["item_list"][0]

        # 获取视频信息
        video_url = data["video"]["play_addr"]["url_list"][0].replace("playwm", "play")
        desc = data.get("desc", "").strip() or f"douyin_{video_id}"

        # 替换文件名中的非法字符
        desc = re.sub(r'[\\/:*?"<>|]', '_', desc)

        return {
            "url": video_url,
            "title": desc,
            "video_id": video_id
        }

    def download_video(self, video_info: dict, output_dir: Optional[Path] = None, show_progress: bool = True) -> Path:
        """下载视频"""
        if output_dir is None:
            output_dir = self.temp_dir
        else:
            output_dir = Path(output_dir)
            output_dir.mkdir(parents=True, exist_ok=True)

        filename = f"{video_info['video_id']}.mp4"
        filepath = output_dir / filename

        if show_progress:
            print(f"正在下载视频: {video_info['title']}")

        response = requests.get(video_info['url'], headers=HEADERS, stream=True)
        response.raise_for_status()

        # 获取文件大小
        total_size = int(response.headers.get('content-length', 0))

        # 下载文件
        downloaded = 0
        with open(filepath, 'wb') as f:
            for chunk in response.iter_content(chunk_size=8192):
                if chunk:
                    f.write(chunk)
                    downloaded += len(chunk)
                    if show_progress and total_size > 0:
                        progress = downloaded / total_size * 100
                        print(f"\r下载进度: {progress:.1f}%", end="", flush=True)

        if show_progress:
            print(f"\n视频下载完成: {filepath}")
        return filepath

    def extract_audio(self, video_path: Path, show_progress: bool = True) -> Path:
        """从视频文件中提取音频"""
        audio_path = video_path.with_suffix('.mp3')

        if show_progress:
            print("正在提取音频...")
        try:
            (
                ffmpeg
                .input(str(video_path))
                .output(str(audio_path), acodec='libmp3lame', q=0)
                .run(capture_stdout=True, capture_stderr=True, overwrite_output=True)
            )
            if show_progress:
                print(f"音频提取完成: {audio_path}")
            return audio_path
        except Exception as e:
            raise Exception(f"提取音频时出错: {str(e)}")

    def get_audio_info(self, audio_path: Path) -> dict:
        """获取音频文件信息（时长和大小）"""
        try:
            probe = ffmpeg.probe(str(audio_path))
            duration = float(probe['format'].get('duration', 0))
            size = audio_path.stat().st_size
            return {'duration': duration, 'size': size}
        except Exception:
            return {'duration': 0, 'size': audio_path.stat().st_size}

    def split_audio(self, audio_path: Path, segment_duration: int = 600, show_progress: bool = True) -> list:
        """
        将音频分割成多个片段

        参数:
            audio_path: 音频文件路径
            segment_duration: 每段时长（秒），默认 10 分钟
            show_progress: 是否显示进度

        返回:
            分割后的音频文件路径列表
        """
        audio_info = self.get_audio_info(audio_path)
        duration = audio_info['duration']

        if duration <= segment_duration:
            return [audio_path]

        segments = []
        segment_index = 0
        current_time = 0

        if show_progress:
            total_segments = int(duration / segment_duration) + 1
            print(f"音频时长 {duration:.0f} 秒，将分割为 {total_segments} 段...")

        while current_time < duration:
            segment_path = self.temp_dir / f"segment_{segment_index}.mp3"

            try:
                (
                    ffmpeg
                    .input(str(audio_path), ss=current_time, t=segment_duration)
                    .output(str(segment_path), acodec='libmp3lame', q=0)
                    .run(capture_stdout=True, capture_stderr=True, overwrite_output=True)
                )
                segments.append(segment_path)

                if show_progress:
                    print(f"  分割片段 {segment_index + 1}: {current_time:.0f}s - {min(current_time + segment_duration, duration):.0f}s")

            except Exception as e:
                raise Exception(f"分割音频片段 {segment_index} 时出错: {str(e)}")

            current_time += segment_duration
            segment_index += 1

        return segments

    def transcribe_single_audio(self, audio_path: Path) -> str:
        """转录单个音频文件"""
        files = {
            'file': (audio_path.name, open(audio_path, 'rb'), 'audio/mpeg'),
            'model': (None, self.model)
        }

        headers = {
            "Authorization": f"Bearer {self.api_key}"
        }

        try:
            response = requests.post(self.api_base_url, files=files, headers=headers)
            response.raise_for_status()

            result = response.json()
            if 'text' in result:
                return result['text']
            else:
                return response.text

        except Exception as e:
            raise Exception(f"提取文字时出错: {str(e)}")
        finally:
            files['file'][1].close()

    def extract_text_from_audio(self, audio_path: Path, show_progress: bool = True) -> str:
        """从音频文件中提取文字（支持大文件自动分段）"""
        if not self.api_key:
            raise ValueError("未设置 API 密钥，请设置环境变量 DOUYIN_API_KEY")

        # 检查文件大小和时长
        audio_info = self.get_audio_info(audio_path)
        max_duration = 3600  # 1 小时
        max_size = 50 * 1024 * 1024  # 50MB

        # 判断是否需要分段
        need_split = audio_info['duration'] > max_duration or audio_info['size'] > max_size

        if not need_split:
            # 文件在限制范围内，直接处理
            if show_progress:
                print("正在识别语音...")
            return self.transcribe_single_audio(audio_path)

        # 需要分段处理
        if show_progress:
            print(f"音频文件较大（时长: {audio_info['duration']:.0f}秒, 大小: {audio_info['size'] / 1024 / 1024:.1f}MB）")
            print("将自动分段处理...")

        # 分割音频
        segments = self.split_audio(audio_path, segment_duration=540, show_progress=show_progress)  # 9分钟一段，留余量

        # 逐段转录
        all_texts = []
        for i, segment_path in enumerate(segments):
            if show_progress:
                print(f"正在识别第 {i + 1}/{len(segments)} 段...")

            text = self.transcribe_single_audio(segment_path)
            all_texts.append(text)

            # 清理分段文件
            if segment_path != audio_path:
                self.cleanup_files(segment_path)

        # 合并文本
        merged_text = ''.join(all_texts)

        if show_progress:
            print(f"语音识别完成，共处理 {len(segments)} 个片段")

        return merged_text

    def cleanup_files(self, *file_paths: Path):
        """清理指定的文件"""
        for file_path in file_paths:
            if file_path.exists():
                file_path.unlink()


def get_video_info(share_link: str) -> dict:
    """获取视频信息和下载链接"""
    processor = DouyinProcessor()
    return processor.parse_share_url(share_link)


def download_video(share_link: str, output_dir: str = ".") -> Path:
    """下载视频到指定目录"""
    processor = DouyinProcessor()
    video_info = processor.parse_share_url(share_link)
    return processor.download_video(video_info, Path(output_dir))


def extract_text(share_link: str, [REDACTED_SECRET] = None, output_dir: Optional[str] = None,
                 save_video: bool = False, show_progress: bool = True) -> dict:
    """
    从视频中提取文案并保存到文件

    返回:
        dict: 包含 video_info, text, output_path 的字典
    """
    [REDACTED_SECRET] or os.getenv('API_KEY')
    if not api_key:
        raise ValueError("未设置环境变量 API_KEY，请先获取硅基流动 API 密钥")

    processor = DouyinProcessor(api_key)

    if show_progress:
        print("正在解析抖音分享链接...")
    video_info = processor.parse_share_url(share_link)

    if show_progress:
        print("正在下载视频...")
    video_path = processor.download_video(video_info, show_progress=show_progress)

    if show_progress:
        print("正在提取音频...")
    audio_path = processor.extract_audio(video_path, show_progress=show_progress)

    if show_progress:
        print("正在从音频中提取文本...")
    text_content = processor.extract_text_from_audio(audio_path, show_progress=show_progress)

    result = {
        "video_info": video_info,
        "text": text_content,
        "output_path": None
    }

    # 保存到文件
    if output_dir:
        output_base = Path(output_dir)
        video_folder = output_base / video_info['video_id']
        video_folder.mkdir(parents=True, exist_ok=True)

        # 保存文案为 Markdown 格式
        transcript_path = video_folder / "transcript.md"
        with open(transcript_path, 'w', encoding='utf-8') as f:
            f.write(f"# {video_info['title']}\n\n")
            f.write(f"| 属性 | 值 |\n")
            f.write(f"|------|----|\n")
            f.write(f"| 视频ID | `{video_info['video_id']}` |\n")
            f.write(f"| 提取时间 | {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} |\n")
            f.write(f"| 下载链接 | [点击下载]({video_info['url']}) |\n\n")
            f.write(f"---\n\n")
            f.write(f"## 文案内容\n\n")
            f.write(text_content)

        result["output_path"] = str(video_folder)

        if show_progress:
            print(f"文案已保存到: {transcript_path}")

        # 保存视频 (可选)
        if save_video:
            saved_video_path = video_folder / f"{video_info['video_id']}.mp4"
            shutil.copy2(video_path, saved_video_path)
            if show_progress:
                print(f"视频已保存到: {saved_video_path}")

    # 清理临时文件
    if show_progress:
        print("正在清理临时文件...")
    processor.cleanup_files(video_path, audio_path)

    return result


def main():
    parser = argparse.ArgumentParser(
        description="抖音无水印视频下载和文案提取工具",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
示例:
  # 获取视频信息和下载链接
  python douyin_downloader.py --link "抖音分享链接" --action info

  # 下载视频
  python douyin_downloader.py --link "抖音分享链接" --action download --output ./videos

  # 提取文案并保存到文件 (需要设置 DOUYIN_API_KEY 环境变量)
  python douyin_downloader.py --link "抖音分享链接" --action extract --output ./output

  # 提取文案并同时保存视频
  python douyin_downloader.py --link "抖音分享链接" --action extract --output ./output --save-video
        """
    )

    parser.add_argument("--link", "-l", required=True, help="抖音分享链接或包含链接的文本")
    parser.add_argument("--action", "-a", choices=["info", "download", "extract"],
                        default="info", help="操作类型: info(获取信息), download(下载视频), extract(提取文案)")
    parser.add_argument("--output", "-o", default="./output", help="输出目录 (默认 ./output)")
    parser.add_argument("--api-key", "-k", help="硅基流动 API 密钥 (也可通过 DOUYIN_API_KEY 环境变量设置)")
    parser.add_argument("--save-video", "-v", action="store_true", help="提取文案时同时保存视频")
    parser.add_argument("--quiet", "-q", action="store_true", help="安静模式，减少输出")

    args = parser.parse_args()

    try:
        if args.action == "info":
            info = get_video_info(args.link)
            print("\n" + "=" * 50)
            print("视频信息:")
            print("=" * 50)
            print(f"视频ID: {info['video_id']}")
            print(f"标题: {info['title']}")
            print(f"下载链接: {info['url']}")
            print("=" * 50)

        elif args.action == "download":
            video_path = download_video(args.link, args.output)
            print(f"\n视频已保存到: {video_path}")

        elif args.action == "extract":
            result = extract_text(
                args.link,
                args.api_key,
                output_dir=args.output,
                save_video=args.save_video,
                show_progress=not args.quiet
            )

            if not args.quiet:
                print("\n" + "=" * 50)
                print("提取完成!")
                print("=" * 50)
                print(f"视频ID: {result['video_info']['video_id']}")
                print(f"标题: {result['video_info']['title']}")
                if result['output_path']:
                    print(f"保存位置: {result['output_path']}")
                print("=" * 50)
                print("\n文案内容:\n")
                print(result['text'][:500] + "..." if len(result['text']) > 500 else result['text'])
                print("\n" + "=" * 50)

    except Exception as e:
        print(f"\n错误: {e}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()


path: dependencies/douyin-mcp-server/douyin_mcp_server/server.py
content: #!/usr/bin/env python3
"""
抖音无水印视频下载并提取文本的 MCP 服务器

该服务器提供以下功能：
1. 解析抖音分享链接获取无水印视频链接
2. 下载视频并提取音频
3. 从音频中提取文本内容
4. 自动清理中间文件
"""

import os
import re
import json
import requests
import tempfile
import asyncio
from pathlib import Path
from typing import Optional, Tuple
import ffmpeg
from tqdm.asyncio import tqdm
from urllib import request
from http import HTTPStatus
import dashscope

from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp import Context


# 创建 MCP 服务器实例
mcp = FastMCP("Douyin MCP Server", 
              dependencies=["requests", "ffmpeg-python", "tqdm", "dashscope"])

# 请求头，模拟移动端访问
HEADERS = {
    'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/121.0.2277.107 Version/17.0 Mobile/15E148 Safari/604.1'
}

# 默认 API 配置
DEFAULT_MODEL = "paraformer-v2"


class DouyinProcessor:
    """抖音视频处理器"""
    
    def __init__(self, api_key: str, model: Optional[str] = None):
        self.[REDACTED_SECRET]
        self.model = model or DEFAULT_MODEL
        self.temp_dir = Path(tempfile.mkdtemp())
        # 设置阿里云百炼API密钥
        dashscope.[REDACTED_SECRET]
    
    def __del__(self):
        """清理临时目录"""
        import shutil
        if hasattr(self, 'temp_dir') and self.temp_dir.exists():
            shutil.rmtree(self.temp_dir, ignore_errors=True)
    
    def parse_share_url(self, share_text: str) -> dict:
        """从分享文本中提取无水印视频链接"""
        # 提取分享链接
        urls = re.findall(r'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+', share_text)
        if not urls:
            raise ValueError("未找到有效的分享链接")
        
        share_url = urls[0]
        share_response = requests.get(share_url, headers=HEADERS)
        video_id = share_response.url.split("?")[0].strip("/").split("/")[-1]
        share_url = f'https://www.iesdouyin.com/share/video/{video_id}'
        
        # 获取视频页面内容
        response = requests.get(share_url, headers=HEADERS)
        response.raise_for_status()
        
        pattern = re.compile(
            pattern=r"window\._ROUTER_DATA\s*=\s*(.*?)</script>",
            flags=re.DOTALL,
        )
        find_res = pattern.search(response.text)

        if not find_res or not find_res.group(1):
            raise ValueError("从HTML中解析视频信息失败")

        # 解析JSON数据
        json_data = json.loads(find_res.group(1).strip())
        VIDEO_ID_PAGE_KEY = "video_(id)/page"
        NOTE_ID_PAGE_KEY = "note_(id)/page"
        
        if VIDEO_ID_PAGE_KEY in json_data["loaderData"]:
            original_video_info = json_data["loaderData"][VIDEO_ID_PAGE_KEY]["videoInfoRes"]
        elif NOTE_ID_PAGE_KEY in json_data["loaderData"]:
            original_video_info = json_data["loaderData"][NOTE_ID_PAGE_KEY]["videoInfoRes"]
        else:
            raise Exception("无法从JSON中解析视频或图集信息")

        data = original_video_info["item_list"][0]

        # 获取视频信息
        video_url = data["video"]["play_addr"]["url_list"][0].replace("playwm", "play")
        desc = data.get("desc", "").strip() or f"douyin_{video_id}"
        
        # 替换文件名中的非法字符
        desc = re.sub(r'[\\/:*?"<>|]', '_', desc)
        
        return {
            "url": video_url,
            "title": desc,
            "video_id": video_id
        }
    
    async def download_video(self, video_info: dict, ctx: Context) -> Path:
        """异步下载视频到临时目录"""
        filename = f"{video_info['video_id']}.mp4"
        filepath = self.temp_dir / filename
        
        ctx.info(f"正在下载视频: {video_info['title']}")
        
        response = requests.get(video_info['url'], headers=HEADERS, stream=True)
        response.raise_for_status()
        
        # 获取文件大小
        total_size = int(response.headers.get('content-length', 0))
        
        # 异步下载文件，显示进度
        with open(filepath, 'wb') as f:
            downloaded = 0
            for chunk in response.iter_content(chunk_size=8192):
                if chunk:
                    f.write(chunk)
                    downloaded += len(chunk)
                    if total_size > 0:
                        progress = downloaded / total_size
                        await ctx.report_progress(downloaded, total_size)
        
        ctx.info(f"视频下载完成: {filepath}")
        return filepath
    
    def extract_audio(self, video_path: Path) -> Path:
        """从视频文件中提取音频"""
        audio_path = video_path.with_suffix('.mp3')
        
        try:
            (
                ffmpeg
                .input(str(video_path))
                .output(str(audio_path), acodec='libmp3lame', q=0)
                .run(capture_stdout=True, capture_stderr=True, overwrite_output=True)
            )
            return audio_path
        except Exception as e:
            raise Exception(f"提取音频时出错: {str(e)}")
    
    def extract_text_from_video_url(self, video_url: str) -> str:
        """从视频URL中提取文字（使用阿里云百炼API）"""
        try:
            # 发起异步转录任务
            task_response = dashscope.audio.asr.Transcription.async_call(
                model=self.model,
                file_urls=[video_url],
                language_hints=['zh', 'en']
            )
            
            # 等待转录完成
            transcription_response = dashscope.audio.asr.Transcription.wait(
                task=task_response.output.task_id
            )
            
            if transcription_response.status_code == HTTPStatus.OK:
                # 获取转录结果
                for transcription in transcription_response.output['results']:
                    url = transcription['transcription_url']
                    result = json.loads(request.urlopen(url).read().decode('utf8'))
                    
                    # 保存结果到临时文件
                    temp_json_path = self.temp_dir / 'transcription.json'
                    with open(temp_json_path, 'w') as f:
                        json.dump(result, f, indent=4, ensure_ascii=False)
                    
                    # 提取文本内容
                    if 'transcripts' in result and len(result['transcripts']) > 0:
                        return result['transcripts'][0]['text']
                    else:
                        return "未识别到文本内容"
                        
            else:
                raise Exception(f"转录失败: {transcription_response.output.message}")
                
        except Exception as e:
            raise Exception(f"提取文字时出错: {str(e)}")
    
    def cleanup_files(self, *file_paths: Path):
        """清理指定的文件"""
        for file_path in file_paths:
            if file_path.exists():
                file_path.unlink()


@mcp.tool()
def get_douyin_download_link(share_link: str) -> str:
    """
    获取抖音视频的无水印下载链接
    
    参数:
    - share_link: 抖音分享链接或包含链接的文本
    
    返回:
    - 包含下载链接和视频信息的JSON字符串
    """
    try:
        processor = DouyinProcessor("")  # 获取下载链接不需要API密钥
        video_info = processor.parse_share_url(share_link)
        
        return json.dumps({
            "status": "success",
            "video_id": video_info["video_id"],
            "title": video_info["title"],
            "download_url": video_info["url"],
            "description": f"视频标题: {video_info['title']}",
            "usage_tip": "可以直接使用此链接下载无水印视频"
        }, ensure_ascii=False, indent=2)
        
    except Exception as e:
        return json.dumps({
            "status": "error",
            "error": f"获取下载链接失败: {str(e)}"
        }, ensure_ascii=False, indent=2)


@mcp.tool()
async def extract_douyin_text(
    share_link: str,
    model: Optional[str] = None,
    ctx: Context = None
) -> str:
    """
    从抖音分享链接提取视频中的文本内容
    
    参数:
    - share_link: 抖音分享链接或包含链接的文本
    - model: 语音识别模型（可选，默认使用paraformer-v2）
    
    返回:
    - 提取的文本内容
    
    注意: 需要设置环境变量 API_KEY
    """
    try:
        # 从环境变量获取API密钥
        [REDACTED_SECRET]'API_KEY')
        if not api_key:
            raise ValueError("未设置环境变量 API_KEY，请在配置中添加阿里云百炼API密钥")
        
        processor = DouyinProcessor(api_key, model)
        
        # 解析视频链接
        ctx.info("正在解析抖音分享链接...")
        video_info = processor.parse_share_url(share_link)
        
        # 直接使用视频URL进行文本提取
        ctx.info("正在从视频中提取文本...")
        text_content = processor.extract_text_from_video_url(video_info['url'])
        
        ctx.info("文本提取完成!")
        return text_content
        
    except Exception as e:
        ctx.error(f"处理过程中出现错误: {str(e)}")
        raise Exception(f"提取抖音视频文本失败: {str(e)}")


@mcp.tool()
def parse_douyin_video_info(share_link: str) -> str:
    """
    解析抖音分享链接，获取视频基本信息
    
    参数:
    - share_link: 抖音分享链接或包含链接的文本
    
    返回:
    - 视频信息（JSON格式字符串）
    """
    try:
        processor = DouyinProcessor("")  # 不需要API密钥来解析链接
        video_info = processor.parse_share_url(share_link)
        
        return json.dumps({
            "video_id": video_info["video_id"],
            "title": video_info["title"],
            "download_url": video_info["url"],
            "status": "success"
        }, ensure_ascii=False, indent=2)
        
    except Exception as e:
        return json.dumps({
            "status": "error",
            "error": str(e)
        }, ensure_ascii=False, indent=2)


@mcp.resource("douyin://video/{video_id}")
def get_video_info(video_id: str) -> str:
    """
    获取指定视频ID的详细信息
    
    参数:
    - video_id: 抖音视频ID
    
    返回:
    - 视频详细信息
    """
    share_url = f"https://www.iesdouyin.com/share/video/{video_id}"
    try:
        processor = DouyinProcessor("")
        video_info = processor.parse_share_url(share_url)
        return json.dumps(video_info, ensure_ascii=False, indent=2)
    except Exception as e:
        return f"获取视频信息失败: {str(e)}"


@mcp.prompt()
def douyin_text_extraction_guide() -> str:
    """抖音视频文本提取使用指南"""
    return """
# 抖音视频文本提取使用指南

## 功能说明
这个MCP服务器可以从抖音分享链接中提取视频的文本内容，以及获取无水印下载链接。

## 环境变量配置
请确保设置了以下环境变量：
- `API_KEY`: 阿里云百炼API密钥

## 使用步骤
1. 复制抖音视频的分享链接
2. 在Claude Desktop配置中设置环境变量 API_KEY
3. 使用相应的工具进行操作

## 工具说明
- `extract_douyin_text`: 完整的文本提取流程（需要API密钥）
- `get_douyin_download_link`: 获取无水印视频下载链接（无需API密钥）
- `parse_douyin_video_info`: 仅解析视频基本信息
- `douyin://video/{video_id}`: 获取指定视频的详细信息

## Claude Desktop 配置示例
```json
{
  "mcpServers": {
    "douyin-mcp": {
      "command": "uvx",
      "args": ["douyin-mcp-server"],
      "env": {
        "API_KEY": "your-api-key-here"
      }
    }
  }
}
```

## 注意事项
- 需要提供有效的阿里云百炼API密钥（通过环境变量）
- 使用阿里云百炼的paraformer-v2模型进行语音识别
- 支持大部分抖音视频格式
- 获取下载链接无需API密钥
"""


def main():
    """启动MCP服务器"""
    mcp.run()


if __name__ == "__main__":
    main()

path: dependencies/douyin-mcp-server/douyin_mcp_server/__init__.py
content: """抖音无水印链接提取 MCP 服务器"""

__version__ = "1.3.0"
__author__ = "yzfly"
__email__ = "[REDACTED_SECRET]"

from .server import main

__all__ = ["main"]

path: dependencies/douyin-mcp-server/douyin_mcp_server/__main__.py
content: #!/usr/bin/env python3
"""
抖音MCP服务器入口点
"""

from .server import main

if __name__ == "__main__":
    main()

path: dependencies/douyin-mcp-server/pyproject.toml
content: [build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "douyin-mcp-server"
version = "1.4.0"
description = "MCP server for downloading Douyin videos and extracting text"
readme = "README.md"
license = {text = "MIT"}
authors = [
    {name = "yzfly", email = "[REDACTED_SECRET]"}
]
classifiers = [
    "Development Status :: 3 - Alpha",
    "Intended Audience :: Developers",
    "License :: OSI Approved :: MIT License",
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
]
requires-python = ">=3.10"
dependencies = [
    "mcp>=1.0.0",
    "requests",
    "ffmpeg-python",
    "tqdm",
    "dashscope",
    "fastapi",
    "uvicorn",
    "jinja2",
]

[project.urls]
Homepage = "https://github.com/yzfly/douyin-mcp-server"
Repository = "https://github.com/yzfly/douyin-mcp-server"
Issues = "https://github.com/yzfly/douyin-mcp-server/issues"

[project.scripts]
douyin-mcp-server = "douyin_mcp_server.server:main"

path: dependencies/douyin-mcp-server/README.md
content: # 短视频文案提取器

[![PyPI version](https://badge.fury.io/py/douyin-mcp-server.svg)](https://badge.fury.io/py/douyin-mcp-server)
[![Python version](https://img.shields.io/pypi/pyversions/douyin-mcp-server.svg)](https://pypi.org/project/douyin-mcp-server/)
[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)

从短视频分享链接下载无水印视频，AI 自动提取语音文案。

![WebUI 界面预览](douyin-video.png)

<a href="https://glama.ai/mcp/servers/@yzfly/douyin-mcp-server">
  <img width="380" height="200" src="https://glama.ai/mcp/servers/@yzfly/douyin-mcp-server/badge" alt="douyin-mcp-server MCP server" />
</a>

## ✨ 功能特性

- 🎬 **无水印视频** - 获取高质量无水印视频下载链接
- 🎙️ **AI 语音识别** - 使用硅基流动 SenseVoice 自动提取文案
- 📑 **大文件支持** - 自动分段处理超过 1 小时或 50MB 的音频
- 🌐 **WebUI** - 现代化浏览器界面，无需命令行
- 🔌 **MCP 集成** - 支持 Claude Desktop 等 AI 应用

---

## 📦 使用方式

| 方式 | 适用场景 | 特点 |
|------|----------|------|
| [**WebUI**](#-webui-推荐) | 普通用户 | 浏览器操作，最简单 |
| [**MCP Server**](#-mcp-server) | Claude Desktop 用户 | AI 对话中直接调用 |
| [**命令行**](#️-命令行工具) | 开发者 | 批量处理，脚本集成 |

---

## 🌐 WebUI (推荐)

最简单的使用方式，打开浏览器即可使用。

### 快速开始

```bash
# 1. 克隆项目
git clone https://github.com/yzfly/douyin-mcp-server.git
cd douyin-mcp-server

# 2. 安装依赖
uv sync

# 3. 启动服务
uv run python web/app.py
```

打开浏览器访问 **http://localhost:8080**

### 配置 API Key

有两种方式配置 API Key：

**方式一：浏览器内配置（推荐）**

1. 打开 WebUI 页面
2. 点击顶部的「API 未配置」按钮
3. 在弹窗中输入 API Key 并保存
4. API Key 保存在浏览器本地，刷新页面后仍有效

**方式二：环境变量**

```bash
export [REDACTED_SECRET]"
uv run python web/app.py
```

> 💡 获取免费 API Key：[硅基流动](https://cloud.siliconflow.cn/i/TxUlXG3u)（新用户有免费额度）

### 功能说明

| 操作 | 说明 | 需要 API |
|------|------|:--------:|
| **获取信息** | 解析视频标题、ID，获取无水印下载链接 | ❌ |
| **提取文案** | 下载视频 → 提取音频 → AI 语音识别 | ✅ |
| **下载视频** | 点击下载链接保存无水印视频 | ❌ |
| **复制/下载文案** | 一键复制或下载 Markdown 格式文案 | - |

### 使用步骤

1. **粘贴链接** - 将分享链接粘贴到输入框
2. **点击按钮** - 选择「获取信息」或「提取文案」
3. **查看结果** - 右侧显示视频信息和提取的文案
4. **导出** - 复制文案或下载 Markdown 文件

---

## 🚀 MCP Server

在 Claude Desktop、Cherry Studio 等支持 MCP 的应用中使用。

### 配置方法

编辑 MCP 配置文件，添加：

```json
{
  "mcpServers": {
    "douyin-mcp": {
      "command": "uvx",
      "args": ["douyin-mcp-server"],
      "env": {
        "API_KEY": "sk-xxxxxxxxxxxxxxxx"
      }
    }
  }
}
```

### 可用工具

| 工具名 | 功能 | 需要 API |
|--------|------|:--------:|
| `parse_douyin_video_info` | 解析视频信息 | ❌ |
| `get_douyin_download_link` | 获取下载链接 | ❌ |
| `extract_douyin_text` | 提取视频文案 | ✅ |

### 对话示例

```
用户：帮我提取这个视频的文案 https://v.douyin.com/xxxxx/

Claude：我来帮你提取视频文案...
[调用 extract_douyin_text 工具]
提取完成，文案内容如下：
...
```

---

## 🛠️ 命令行工具

适合开发者和批量处理场景。

### 安装

```bash
git clone https://github.com/yzfly/douyin-mcp-server.git
cd douyin-mcp-server
uv sync
```

### 命令说明

```bash
# 查看帮助
uv run python douyin-video/scripts/douyin_downloader.py --help

# 获取视频信息（无需 API）
uv run python douyin-video/scripts/douyin_downloader.py -l "分享链接" -a info

# 下载无水印视频
uv run python douyin-video/scripts/douyin_downloader.py -l "分享链接" -a download -o ./videos

# 提取文案（需要 API_KEY）
export [REDACTED_SECRET]"
uv run python douyin-video/scripts/douyin_downloader.py -l "分享链接" -a extract -o ./output

# 提取文案并保存视频
uv run python douyin-video/scripts/douyin_downloader.py -l "分享链接" -a extract -o ./output --save-video
```

### 输出格式

```
output/
└── 7600361826030865707/
    ├── transcript.md    # 文案文件
    └── *.mp4            # 视频文件（可选）
```

**transcript.md 内容：**

```markdown
# 视频标题

| 属性 | 值 |
|------|-----|
| 视频ID | `7600361826030865707` |
| 提取时间 | 2026-01-30 14:19:00 |
| 下载链接 | [点击下载](url) |

---

## 文案内容

这里是 AI 识别的语音文案...
```

---

## 📋 系统要求

| 依赖 | 说明 | 安装方式 |
|------|------|----------|
| uv | Python 包管理 | `curl -LsSf https://astral.sh/uv/install.sh \| sh` |
| Python | 3.10+ | `uv python install 3.12` |
| FFmpeg | 音视频处理 | `brew install ffmpeg` (macOS) <br> `apt install ffmpeg` (Ubuntu) |

---

## 🔧 技术说明

### 大文件处理

当音频文件超过 API 限制时（1 小时或 50MB），自动执行：

1. 检测音频时长和文件大小
2. 使用 FFmpeg 分割成 9 分钟的片段
3. 逐段调用 API 转录
4. 合并所有文本结果

### API 说明

语音识别使用 [硅基流动 SenseVoice API](https://cloud.siliconflow.cn/)：

- 模型：`FunAudioLLM/SenseVoiceSmall`
- 限制：单次最大 1 小时 / 50MB（已自动处理）
- 费用：新用户有免费额度

---

## 📝 更新日志

### v1.4.0 (最新)

- 🌐 **WebUI** - 新增浏览器可视化界面
- 🔑 **浏览器配置 API Key** - 无需环境变量
- 📑 **大文件支持** - 自动分段处理长音频

### v1.3.0

- ✨ Claude Code Skill 支持
- 📄 Markdown 格式输出

### v1.2.0

- 🔄 API 升级

### v1.0.0

- 🎉 首次发布

---

## ⚠️ 免责声明

- 本项目仅供学习和研究使用
- 使用者需遵守相关法律法规
- 禁止用于侵犯知识产权的行为
- 作者不对使用本项目产生的损失承担责任

---

## 📄 许可证

Apache License 2.0

## 👨‍💻 作者

**yzfly** - [GitHub](https://github.com/yzfly) | [Email](mailto:[REDACTED_SECRET])


path: dependencies/douyin-mcp-server/web/app.py
content: #!/usr/bin/env python3
"""
抖音视频文案提取器 WebUI

启动方式:
    cd douyin-mcp-server
    export [REDACTED_SECRET]"
    python web/app.py
    # 访问 http://localhost:8080
"""

import os
import sys
from pathlib import Path

# 添加项目路径
sys.path.insert(0, str(Path(__file__).parent.parent / "douyin-video" / "scripts"))

from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
import uvicorn
import requests

# 导入抖音处理模块
from douyin_downloader import get_video_info, extract_text, HEADERS

app = FastAPI(title="抖音文案提取器", version="1.0.0")
templates = Jinja2Templates(directory=Path(__file__).parent / "templates")


class VideoRequest(BaseModel):
    """视频请求模型"""
    url: str
    api_key: str = ""  # 可选，从前端传入


class VideoInfoResponse(BaseModel):
    """视频信息响应"""
    success: bool
    video_id: str = ""
    title: str = ""
    download_url: str = ""
    error: str = ""


class ExtractResponse(BaseModel):
    """文案提取响应"""
    success: bool
    video_id: str = ""
    title: str = ""
    text: str = ""
    download_url: str = ""
    error: str = ""


@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
    """主页面"""
    return templates.TemplateResponse("index.html", {"request": request})


@app.get("/api/health")
async def health_check():
    """健康检查"""
    [REDACTED_SECRET]"API_KEY", "")
    return {
        "status": "ok",
        "api_key_configured": bool(api_key)
    }


@app.post("/api/video/info", response_model=VideoInfoResponse)
async def get_info(req: VideoRequest):
    """获取视频信息（无需 API_KEY）"""
    try:
        info = get_video_info(req.url)
        return VideoInfoResponse(
            success=True,
            video_id=info["video_id"],
            title=info["title"],
            download_url=info["url"]
        )
    except Exception as e:
        return VideoInfoResponse(success=False, error=str(e))


@app.post("/api/video/extract", response_model=ExtractResponse)
async def extract_transcript(req: VideoRequest):
    """提取视频文案（需要 API_KEY）"""
    # 优先使用请求中的 API Key，其次使用环境变量
    [REDACTED_SECRET] or os.getenv("API_KEY", "")
    if not [REDACTED_SECRET] ExtractResponse(
            success=False,
            error="请先配置 API Key"
        )

    try:
        result = extract_text(req.url, [REDACTED_SECRET], show_progress=False)
        return ExtractResponse(
            success=True,
            video_id=result["video_info"]["video_id"],
            title=result["video_info"]["title"],
            text=result["text"],
            download_url=result["video_info"]["url"]
        )
    except Exception as e:
        return ExtractResponse(success=False, error=str(e))


@app.get("/api/video/download")
async def download_video(url: str, filename: str = "video.mp4"):
    """代理下载视频（解决跨域和请求头问题）"""
    print(f"[Download] URL: {url}")
    print(f"[Download] Filename: {filename}")
    try:
        # 完整的请求头，模拟浏览器访问
        download_headers = {
            'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/121.0.2277.107 Version/17.0 Mobile/15E148 Safari/604.1',
            'Referer': 'https://www.douyin.com/',
            'Accept': '*/*',
            'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
            'Accept-Encoding': 'identity',
            'Connection': 'keep-alive',
        }

        response = requests.get(url, headers=download_headers, stream=True, allow_redirects=True)
        print(f"[Download] Response status: {response.status_code}")
        print(f"[Download] Final URL: {response.url}")
        response.raise_for_status()

        content_length = response.headers.get("content-length", "")

        def iter_content():
            for chunk in response.iter_content(chunk_size=8192):
                if chunk:
                    yield chunk

        headers = {
            "Content-Disposition": f'attachment; filename="{filename}"',
        }
        if content_length:
            headers["Content-Length"] = content_length

        return StreamingResponse(
            iter_content(),
            media_type="video/mp4",
            headers=headers
        )
    except requests.exceptions.HTTPError as e:
        raise HTTPException(status_code=e.response.status_code, detail=f"下载失败: {e.response.status_code}")
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


def main():
    """启动服务"""
    port = int(os.getenv("PORT", "8080"))
    print(f"🚀 启动文案提取器 WebUI: http://localhost:{port}")
    print(f"📝 API_KEY 配置状态: {'已配置' if os.getenv('API_KEY') else '未配置'}")
    uvicorn.run(app, host="0.0.0.0", port=port)


if __name__ == "__main__":
    main()


path: dependencies/douyin-mcp-server/web/templates/index.html
content: <!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>文案提取器 - Transcript Extractor</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
    <script>
        tailwind.config = {
            theme: {
                extend: {
                    fontFamily: {
                        'sans': ['Inter', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
                    },
                    colors: {
                        'douyin': {
                            'red': '#FE2C55',
                            'pink': '#FF6B9D',
                            'coral': '#FF4F6D',
                            'dark': '#161823',
                            'gray': {
                                50: '#F8F8F8',
                                100: '#F1F1F2',
                                200: '#E5E5E6',
                                300: '#C9C9CA',
                                400: '#9E9E9F',
                                500: '#6C6C6D',
                                600: '#4A4A4B',
                                700: '#2F2F30',
                                800: '#1F1F20',
                                900: '#121213',
                            }
                        }
                    },
                    boxShadow: {
                        'card': '0 2px 8px rgba(0, 0, 0, 0.04), 0 4px 24px rgba(0, 0, 0, 0.06)',
                        'card-hover': '0 4px 12px rgba(0, 0, 0, 0.06), 0 8px 32px rgba(0, 0, 0, 0.08)',
                        'input': '0 0 0 4px rgba(254, 44, 85, 0.1)',
                    }
                }
            }
        }
    </script>
    <style>
        * { box-sizing: border-box; }

        body {
            font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
            -webkit-font-smoothing: antialiased;
            -moz-osx-font-smoothing: grayscale;
        }

        /* Custom scrollbar */
        ::-webkit-scrollbar { width: 6px; height: 6px; }
        ::-webkit-scrollbar-track { background: transparent; }
        ::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 3px; }
        ::-webkit-scrollbar-thumb:hover { background: #9ca3af; }

        /* Animations */
        @keyframes fadeIn {
            from { opacity: 0; transform: translateY(8px); }
            to { opacity: 1; transform: translateY(0); }
        }
        .animate-fadeIn { animation: fadeIn 0.3s ease-out; }

        @keyframes pulse-soft {
            0%, 100% { opacity: 1; }
            50% { opacity: 0.6; }
        }
        .animate-pulse-soft { animation: pulse-soft 2s ease-in-out infinite; }

        @keyframes spin-slow {
            from { transform: rotate(0deg); }
            to { transform: rotate(360deg); }
        }
        .animate-spin-slow { animation: spin-slow 1.5s linear infinite; }

        /* Gradient backgrounds */
        .bg-gradient-douyin {
            background: linear-gradient(135deg, #FE2C55 0%, #FF6B9D 100%);
        }

        .bg-gradient-mesh {
            background:
                radial-gradient(at 40% 20%, rgba(254, 44, 85, 0.08) 0px, transparent 50%),
                radial-gradient(at 80% 0%, rgba(255, 107, 157, 0.06) 0px, transparent 50%),
                radial-gradient(at 0% 50%, rgba(254, 44, 85, 0.04) 0px, transparent 50%);
        }

        /* Focus states */
        .focus-ring:focus {
            outline: none;
            box-shadow: 0 0 0 4px rgba(254, 44, 85, 0.15);
        }

        /* Transitions */
        .transition-smooth {
            transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
        }

        /* Text ellipsis */
        .line-clamp-2 {
            display: -webkit-box;
            -webkit-line-clamp: 2;
            -webkit-box-orient: vertical;
            overflow: hidden;
        }
    </style>
</head>
<body class="bg-douyin-gray-50 min-h-screen bg-gradient-mesh" x-data="douyinExtractor()" x-init="checkHealth()">

    <!-- Top Navigation Bar -->
    <nav class="sticky top-0 z-50 bg-white/80 backdrop-blur-xl border-b border-douyin-gray-100">
        <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
            <div class="flex items-center justify-between h-16">
                <!-- Logo -->
                <div class="flex items-center gap-3">
                    <div class="w-9 h-9 bg-gradient-douyin rounded-xl flex items-center justify-center shadow-lg shadow-douyin-red/20">
                        <svg class="w-5 h-5 text-white" viewBox="0 0 24 24" fill="currentColor">
                            <path d="M19.59 6.69a4.83 4.83 0 01-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 01-5.2 1.74 2.89 2.89 0 012.31-4.64 2.93 2.93 0 01.88.13V9.4a6.84 6.84 0 00-1-.05A6.33 6.33 0 005 20.1a6.34 6.34 0 0010.86-4.43v-7a8.16 8.16 0 004.77 1.52v-3.4a4.85 4.85 0 01-1-.1z"/>
                        </svg>
                    </div>
                    <div>
                        <h1 class="text-lg font-semibold text-douyin-gray-900">文案提取器</h1>
                        <p class="text-xs text-douyin-gray-400 -mt-0.5">Transcript Extractor</p>
                    </div>
                </div>

                <!-- API Status Badge -->
                <div class="flex items-center gap-4">
                    <button @click="showSettings = true"
                            class="flex items-center gap-2 px-3 py-1.5 rounded-full text-sm cursor-pointer hover:opacity-80 transition-smooth"
                            :class="apiKeyConfigured ? 'bg-green-50 text-green-700' : 'bg-amber-50 text-amber-700'">
                        <span class="relative flex h-2 w-2">
                            <span class="absolute inline-flex h-full w-full rounded-full opacity-75"
                                  :class="apiKeyConfigured ? 'bg-green-400 animate-ping' : 'bg-amber-400'"></span>
                            <span class="relative inline-flex rounded-full h-2 w-2"
                                  :class="apiKeyConfigured ? 'bg-green-500' : 'bg-amber-500'"></span>
                        </span>
                        <span x-text="apiKeyConfigured ? 'API 已连接' : '点击配置 API'"></span>
                        <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
                            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
                        </svg>
                    </button>

                    <!-- Help Button -->
                    <button @click="showHelp = !showHelp"
                            class="w-9 h-9 rounded-full bg-douyin-gray-100 hover:bg-douyin-gray-200 flex items-center justify-center transition-smooth">
                        <svg class="w-5 h-5 text-douyin-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
                        </svg>
                    </button>
                </div>
            </div>
        </div>
    </nav>

    <!-- Settings Modal (API Key Configuration) -->
    <div x-show="showSettings" x-transition.opacity class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4"
         @click.self="showSettings = false">
        <div x-show="showSettings" x-transition:enter="transition ease-out duration-200"
             x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
             class="bg-white rounded-2xl shadow-2xl max-w-md w-full p-6 animate-fadeIn">
            <div class="flex items-center justify-between mb-6">
                <div class="flex items-center gap-3">
                    <div class="w-10 h-10 bg-gradient-douyin rounded-xl flex items-center justify-center">
                        <svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
                        </svg>
                    </div>
                    <div>
                        <h3 class="text-lg font-semibold text-douyin-gray-900">API 配置</h3>
                        <p class="text-xs text-douyin-gray-400">配置硅基流动 API Key</p>
                    </div>
                </div>
                <button @click="showSettings = false" class="w-8 h-8 rounded-full hover:bg-douyin-gray-100 flex items-center justify-center">
                    <svg class="w-5 h-5 text-douyin-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
                    </svg>
                </button>
            </div>

            <!-- API Key Input -->
            <div class="space-y-4">
                <div>
                    <label class="block text-sm font-medium text-douyin-gray-700 mb-2">API Key</label>
                    <div class="relative">
                        <input
                            :type="showApiKey ? 'text' : 'password'"
                            x-model="apiKeyInput"
                            placeholder="sk-xxxxxxxxxxxxxxxx"
                            class="w-full px-4 py-3 pr-20 text-sm border border-douyin-gray-200 rounded-xl focus-ring transition-smooth font-mono"
                        >
                        <div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
                            <button @click="showApiKey = !showApiKey"
                                    class="w-8 h-8 rounded-lg hover:bg-douyin-gray-100 flex items-center justify-center transition-smooth">
                                <svg x-show="!showApiKey" class="w-4 h-4 text-douyin-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
                                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
                                </svg>
                                <svg x-show="showApiKey" class="w-4 h-4 text-douyin-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
                                </svg>
                            </button>
                            <button x-show="apiKeyInput"
                                    @click="apiKeyInput = ''"
                                    class="w-8 h-8 rounded-lg hover:bg-douyin-gray-100 flex items-center justify-center transition-smooth">
                                <svg class="w-4 h-4 text-douyin-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
                                </svg>
                            </button>
                        </div>
                    </div>
                    <p class="mt-2 text-xs text-douyin-gray-400">API Key 仅保存在浏览器本地，不会上传到服务器</p>
                </div>

                <!-- Status Indicator -->
                <div class="p-4 rounded-xl" :class="apiKeyInput ? 'bg-green-50 border border-green-100' : 'bg-douyin-gray-50'">
                    <div class="flex items-center gap-3">
                        <div class="w-10 h-10 rounded-lg flex items-center justify-center"
                             :class="apiKeyInput ? 'bg-green-100' : 'bg-douyin-gray-200'">
                            <svg x-show="apiKeyInput" class="w-5 h-5 text-green-600" fill="currentColor" viewBox="0 0 20 20">
                                <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
                            </svg>
                            <svg x-show="!apiKeyInput" class="w-5 h-5 text-douyin-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
                            </svg>
                        </div>
                        <div>
                            <p class="text-sm font-medium" :class="apiKeyInput ? 'text-green-700' : 'text-douyin-gray-600'"
                               x-text="apiKeyInput ? 'API Key 已配置' : '未配置 API Key'"></p>
                            <p class="text-xs" :class="apiKeyInput ? 'text-green-600' : 'text-douyin-gray-400'"
                               x-text="apiKeyInput ? '可以使用文案提取功能' : '仅可获取视频信息'"></p>
                        </div>
                    </div>
                </div>

                <!-- Actions -->
                <div class="flex gap-3">
                    <a href="https://cloud.siliconflow.cn/i/TxUlXG3u" target="_blank"
                       class="flex-1 h-11 border border-douyin-gray-200 hover:bg-douyin-gray-50 text-douyin-gray-700 font-medium text-sm rounded-xl transition-smooth flex items-center justify-center gap-2">
                        <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
                        </svg>
                        获取 API Key
                    </a>
                    <button @click="saveApiKey"
                            class="flex-1 h-11 bg-gradient-douyin hover:opacity-90 text-white font-medium text-sm rounded-xl transition-smooth flex items-center justify-center gap-2 shadow-lg shadow-douyin-red/20">
                        <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
                        </svg>
                        保存配置
                    </button>
                </div>

                <!-- Clear Button -->
                <button x-show="apiKey"
                        @click="clearApiKey"
                        class="w-full h-10 text-sm text-red-500 hover:text-red-600 hover:bg-red-50 rounded-xl transition-smooth flex items-center justify-center gap-2">
                    <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
                    </svg>
                    清除已保存的 API Key
                </button>
            </div>
        </div>
    </div>

    <!-- Help Modal -->
    <div x-show="showHelp" x-transition.opacity class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4"
         @click.self="showHelp = false">
        <div x-show="showHelp" x-transition:enter="transition ease-out duration-200"
             x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
             class="bg-white rounded-2xl shadow-2xl max-w-md w-full p-6 animate-fadeIn">
            <div class="flex items-center justify-between mb-4">
                <h3 class="text-lg font-semibold text-douyin-gray-900">使用说明</h3>
                <button @click="showHelp = false" class="w-8 h-8 rounded-full hover:bg-douyin-gray-100 flex items-center justify-center">
                    <svg class="w-5 h-5 text-douyin-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
                    </svg>
                </button>
            </div>
            <div class="space-y-4 text-sm text-douyin-gray-600">
                <div class="p-4 bg-douyin-gray-50 rounded-xl">
                    <p class="font-medium text-douyin-gray-900 mb-2">1. 配置 API Key</p>
                    <p>点击顶部状态栏，在弹窗中输入 API Key</p>
                </div>
                <div class="p-4 bg-douyin-gray-50 rounded-xl">
                    <p class="font-medium text-douyin-gray-900 mb-2">2. 粘贴分享链接</p>
                    <p>支持各种短视频平台的分享链接</p>
                </div>
                <div class="p-4 bg-douyin-gray-50 rounded-xl">
                    <p class="font-medium text-douyin-gray-900 mb-2">3. 点击提取</p>
                    <p>「获取信息」无需 API，「提取文案」需要 API</p>
                </div>
            </div>
            <a href="https://cloud.siliconflow.cn/i/TxUlXG3u" target="_blank"
               class="mt-4 block w-full text-center py-2.5 bg-gradient-douyin text-white rounded-xl font-medium hover:opacity-90 transition-smooth">
                获取免费 API Key
            </a>
        </div>
    </div>

    <!-- Main Content -->
    <main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
        <div class="grid grid-cols-1 lg:grid-cols-12 gap-6">

            <!-- Left Panel - Input -->
            <div class="lg:col-span-5 space-y-6">

                <!-- Input Card -->
                <div class="bg-white rounded-2xl shadow-card p-6">
                    <div class="mb-5">
                        <label class="block text-sm font-medium text-douyin-gray-700 mb-2">分享链接</label>
                        <div class="relative">
                            <textarea
                                x-model="url"
                                @keydown.meta.enter="extractText"
                                @keydown.ctrl.enter="extractText"
                                placeholder="粘贴分享链接或文本，例如：&#10;7.89 02/February/2025 xxx https://v.douyin.com/xxxxx/"
                                rows="4"
                                class="w-full px-4 py-3 text-sm border border-douyin-gray-200 rounded-xl resize-none focus-ring transition-smooth placeholder:text-douyin-gray-400"
                                :disabled="loading"
                            ></textarea>
                            <button
                                x-show="url"
                                @click="url = ''; resetState()"
                                class="absolute top-3 right-3 w-6 h-6 rounded-full bg-douyin-gray-100 hover:bg-douyin-gray-200 flex items-center justify-center transition-smooth">
                                <svg class="w-3.5 h-3.5 text-douyin-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
                                </svg>
                            </button>
                        </div>
                        <p class="mt-2 text-xs text-douyin-gray-400">提示：按 <kbd class="px-1.5 py-0.5 bg-douyin-gray-100 rounded text-douyin-gray-600">⌘</kbd> + <kbd class="px-1.5 py-0.5 bg-douyin-gray-100 rounded text-douyin-gray-600">Enter</kbd> 快速提取</p>
                    </div>

                    <!-- Action Buttons -->
                    <div class="flex gap-3">
                        <button
                            @click="extractInfo"
                            :disabled="!url || loading"
                            class="flex-1 h-11 bg-douyin-gray-100 hover:bg-douyin-gray-200 text-douyin-gray-700 font-medium text-sm rounded-xl transition-smooth disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2">
                            <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
                            </svg>
                            获取信息
                        </button>
                        <button
                            @click="extractText"
                            :disabled="!url || loading || !apiKeyConfigured"
                            class="flex-1 h-11 bg-gradient-douyin hover:opacity-90 text-white font-medium text-sm rounded-xl transition-smooth disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 shadow-lg shadow-douyin-red/20">
                            <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
                            </svg>
                            提取文案
                        </button>
                    </div>

                    <!-- API Warning -->
                    <div x-show="!apiKeyConfigured" @click="showSettings = true"
                         class="mt-4 p-3 bg-amber-50 border border-amber-100 rounded-xl cursor-pointer hover:bg-amber-100 transition-smooth">
                        <div class="flex items-center justify-between">
                            <div class="flex items-start gap-2">
                                <svg class="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
                                    <path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
                                </svg>
                                <div class="text-xs text-amber-800">
                                    <p class="font-medium">需要配置 API Key 才能提取文案</p>
                                    <p class="mt-1 text-amber-600">点击此处配置 API Key</p>
                                </div>
                            </div>
                            <svg class="w-4 h-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
                            </svg>
                        </div>
                    </div>
                </div>

                <!-- Video Info Card -->
                <div x-show="videoInfo" x-transition:enter="transition ease-out duration-300"
                     x-transition:enter-start="opacity-0 translate-y-2" x-transition:enter-end="opacity-100 translate-y-0"
                     class="bg-white rounded-2xl shadow-card overflow-hidden">

                    <!-- Card Header -->
                    <div class="px-6 py-4 border-b border-douyin-gray-100 flex items-center justify-between">
                        <div class="flex items-center gap-2">
                            <div class="w-8 h-8 bg-gradient-douyin rounded-lg flex items-center justify-center">
                                <svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
                                </svg>
                            </div>
                            <span class="font-semibold text-douyin-gray-900">视频信息</span>
                        </div>
                        <span class="text-xs text-douyin-gray-400 font-mono" x-text="videoInfo?.video_id"></span>
                    </div>

                    <!-- Card Body -->
                    <div class="p-6">
                        <div class="flex items-start gap-2 mb-4 group">
                            <h3 class="font-medium text-douyin-gray-900 flex-1" x-text="videoInfo?.title"></h3>
                            <button @click="copyToClipboard(videoInfo?.title)"
                                    class="opacity-0 group-hover:opacity-100 w-8 h-8 rounded-lg bg-douyin-gray-100 hover:bg-douyin-gray-200 flex items-center justify-center flex-shrink-0 transition-smooth">
                                <svg class="w-4 h-4 text-douyin-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
                                </svg>
                            </button>
                        </div>

                        <!-- Info Items -->
                        <div class="space-y-3">
                            <div class="flex items-center gap-3 p-3 bg-douyin-gray-50 rounded-xl group hover:bg-douyin-gray-100 transition-smooth">
                                <div class="w-10 h-10 bg-white rounded-lg flex items-center justify-center shadow-sm">
                                    <svg class="w-5 h-5 text-douyin-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z"/>
                                    </svg>
                                </div>
                                <div class="flex-1 min-w-0">
                                    <p class="text-xs text-douyin-gray-400 mb-0.5">视频 ID</p>
                                    <p class="text-sm font-mono text-douyin-gray-700 truncate" x-text="videoInfo?.video_id"></p>
                                </div>
                                <button @click="copyToClipboard(videoInfo?.video_id)"
                                        class="opacity-0 group-hover:opacity-100 w-8 h-8 rounded-lg bg-white hover:bg-douyin-gray-200 flex items-center justify-center transition-smooth">
                                    <svg class="w-4 h-4 text-douyin-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
                                    </svg>
                                </button>
                            </div>

                            <a x-show="videoInfo?.download_url"
                               :href="`/api/video/download?url=${encodeURIComponent(videoInfo?.download_url)}&filename=${encodeURIComponent(videoInfo?.video_id || 'video')}.mp4`"
                               class="flex items-center gap-3 p-3 bg-green-50 rounded-xl group hover:bg-green-100 transition-smooth cursor-pointer">
                                <div class="w-10 h-10 bg-white rounded-lg flex items-center justify-center shadow-sm">
                                    <svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
                                    </svg>
                                </div>
                                <div class="flex-1 min-w-0">
                                    <p class="text-xs text-green-600 mb-0.5">无水印视频</p>
                                    <p class="text-sm font-medium text-green-700">点击下载原视频</p>
                                </div>
                                <svg class="w-5 h-5 text-green-500 group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
                                </svg>
                            </a>
                        </div>
                    </div>
                </div>

                <!-- Loading State -->
                <div x-show="loading" x-transition class="bg-white rounded-2xl shadow-card p-6">
                    <div class="flex items-center gap-4 mb-6">
                        <div class="relative w-12 h-12">
                            <div class="absolute inset-0 rounded-full border-4 border-douyin-gray-100"></div>
                            <div class="absolute inset-0 rounded-full border-4 border-douyin-red border-t-transparent animate-spin-slow"></div>
                        </div>
                        <div>
                            <p class="font-medium text-douyin-gray-900" x-text="loadingText"></p>
                            <p class="text-sm text-douyin-gray-400">请稍候...</p>
                        </div>
                    </div>

                    <!-- Progress Steps -->
                    <div x-show="extractingText" class="space-y-2">
                        <template x-for="(stepItem, index) in steps" :key="index">
                            <div class="flex items-center gap-3 p-2 rounded-lg transition-smooth"
                                 :class="step > index + 1 ? 'bg-green-50' : step === index + 1 ? 'bg-douyin-red/5' : ''">
                                <div class="w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium transition-smooth"
                                     :class="step > index + 1 ? 'bg-green-500 text-white' : step === index + 1 ? 'bg-douyin-red text-white' : 'bg-douyin-gray-200 text-douyin-gray-500'">
                                    <svg x-show="step > index + 1" class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
                                        <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
                                    </svg>
                                    <span x-show="step <= index + 1" x-text="index + 1"></span>
                                </div>
                                <span class="text-sm" :class="step >= index + 1 ? 'text-douyin-gray-900' : 'text-douyin-gray-400'" x-text="stepItem"></span>
                            </div>
                        </template>
                    </div>
                </div>

                <!-- Error State -->
                <div x-show="error" x-transition class="bg-red-50 border border-red-100 rounded-2xl p-5">
                    <div class="flex items-start gap-3">
                        <div class="w-10 h-10 bg-red-100 rounded-xl flex items-center justify-center flex-shrink-0">
                            <svg class="w-5 h-5 text-red-500" fill="currentColor" viewBox="0 0 20 20">
                                <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
                            </svg>
                        </div>
                        <div class="flex-1">
                            <h4 class="font-medium text-red-800">提取失败</h4>
                            <p class="text-sm text-red-600 mt-1" x-text="error"></p>
                        </div>
                        <button @click="error = ''" class="w-8 h-8 rounded-lg hover:bg-red-100 flex items-center justify-center">
                            <svg class="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
                            </svg>
                        </button>
                    </div>
                </div>
            </div>

            <!-- Right Panel - Results -->
            <div class="lg:col-span-7">

                <!-- Empty State -->
                <div x-show="!transcript && !loading" class="bg-white rounded-2xl shadow-card h-full min-h-[500px] flex flex-col items-center justify-center p-8 text-center">
                    <div class="w-24 h-24 bg-douyin-gray-100 rounded-3xl flex items-center justify-center mb-6">
                        <svg class="w-12 h-12 text-douyin-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
                        </svg>
                    </div>
                    <h3 class="text-lg font-semibold text-douyin-gray-900 mb-2">等待提取</h3>
                    <p class="text-sm text-douyin-gray-500 max-w-xs">粘贴分享链接，点击「提取文案」按钮，AI 将自动识别视频中的语音内容</p>

                    <!-- Feature Pills -->
                    <div class="flex flex-wrap justify-center gap-2 mt-6">
                        <span class="px-3 py-1.5 bg-douyin-gray-100 rounded-full text-xs text-douyin-gray-600">无水印下载</span>
                        <span class="px-3 py-1.5 bg-douyin-gray-100 rounded-full text-xs text-douyin-gray-600">AI 语音识别</span>
                        <span class="px-3 py-1.5 bg-douyin-gray-100 rounded-full text-xs text-douyin-gray-600">一键复制</span>
                    </div>
                </div>

                <!-- Transcript Card -->
                <div x-show="transcript" x-transition:enter="transition ease-out duration-300"
                     x-transition:enter-start="opacity-0 translate-y-2" x-transition:enter-end="opacity-100 translate-y-0"
                     class="bg-white rounded-2xl shadow-card overflow-hidden h-full flex flex-col">

                    <!-- Card Header -->
                    <div class="px-6 py-4 border-b border-douyin-gray-100 flex items-center justify-between flex-shrink-0">
                        <div class="flex items-center gap-2">
                            <div class="w-8 h-8 bg-gradient-douyin rounded-lg flex items-center justify-center">
                                <svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
                                </svg>
                            </div>
                            <span class="font-semibold text-douyin-gray-900">提取结果</span>
                        </div>

                        <div class="flex items-center gap-2">
                            <span class="text-xs text-douyin-gray-400 mr-2" x-text="`${transcript?.length || 0} 字`"></span>

                            <button @click="copyTranscript"
                                    class="h-9 px-4 bg-douyin-gray-100 hover:bg-douyin-gray-200 rounded-lg text-sm font-medium text-douyin-gray-700 transition-smooth flex items-center gap-2">
                                <svg x-show="!copied" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
                                </svg>
                                <svg x-show="copied" class="w-4 h-4 text-green-600" fill="currentColor" viewBox="0 0 20 20">
                                    <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
                                </svg>
                                <span x-text="copied ? '已复制' : '复制'"></span>
                            </button>

                            <button @click="downloadTranscript"
                                    class="h-9 px-4 bg-gradient-douyin hover:opacity-90 rounded-lg text-sm font-medium text-white transition-smooth flex items-center gap-2 shadow-lg shadow-douyin-red/20">
                                <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
                                </svg>
                                下载
                            </button>
                        </div>
                    </div>

                    <!-- Transcript Content -->
                    <div class="flex-1 overflow-y-auto p-6">
                        <div class="prose prose-sm max-w-none">
                            <p class="text-douyin-gray-700 leading-relaxed whitespace-pre-wrap" x-text="transcript"></p>
                        </div>
                    </div>

                    <!-- Card Footer -->
                    <div class="px-6 py-3 bg-douyin-gray-50 border-t border-douyin-gray-100 flex items-center justify-between flex-shrink-0">
                        <div class="flex items-center gap-2 text-xs text-douyin-gray-400">
                            <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
                            </svg>
                            <span>Powered by SiliconFlow SenseVoice</span>
                        </div>
                        <div class="flex items-center gap-1 text-xs text-douyin-gray-400">
                            <span x-text="videoInfo?.title" class="max-w-xs truncate"></span>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </main>

    <!-- Toast Notification -->
    <div x-show="toast" x-transition:enter="transition ease-out duration-200"
         x-transition:enter-start="opacity-0 translate-y-4" x-transition:enter-end="opacity-100 translate-y-0"
         x-transition:leave="transition ease-in duration-150"
         x-transition:leave-start="opacity-100 translate-y-0" x-transition:leave-end="opacity-0 translate-y-4"
         class="fixed bottom-8 left-1/2 -translate-x-1/2 z-50">
        <div class="px-4 py-3 bg-douyin-gray-900 text-white text-sm rounded-xl shadow-2xl flex items-center gap-2">
            <svg class="w-4 h-4 text-green-400" fill="currentColor" viewBox="0 0 20 20">
                <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
            </svg>
            <span x-text="toast"></span>
        </div>
    </div>

    <!-- Footer -->
    <footer class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
        <div class="flex flex-col sm:flex-row items-center justify-between gap-4 text-sm text-douyin-gray-400">
            <p>Made with <span class="text-douyin-red">♥</span> by <a href="https://github.com/yzfly" target="_blank" class="hover:text-douyin-red transition-colors">yzfly</a></p>
            <div class="flex items-center gap-4">
                <a href="https://github.com/yzfly/douyin-mcp-server" target="_blank" class="hover:text-douyin-red transition-colors flex items-center gap-1">
                    <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
                    GitHub
                </a>
                <span>·</span>
                <span>v1.4.0</span>
            </div>
        </div>
    </footer>

    <script>
        function douyinExtractor() {
            return {
                url: '',
                loading: false,
                loadingText: '',
                extractingText: false,
                step: 0,
                steps: ['解析分享链接', '下载视频', '提取音频', '识别语音'],
                error: '',
                videoInfo: null,
                transcript: '',
                copied: false,
                toast: '',
                showHelp: false,
                showSettings: false,
                showApiKey: false,
                apiKey: '',
                apiKeyInput: '',
                serverApiKeyConfigured: false,

                get apiKeyConfigured() {
                    return this.serverApiKeyConfigured || !!this.apiKey;
                },

                async checkHealth() {
                    // Load API key from localStorage
                    const savedKey = localStorage.getItem('douyin_api_key');
                    if (savedKey) {
                        this.[REDACTED_SECRET];
                        this.apiKeyInput = savedKey;
                    }

                    try {
                        const res = await fetch('/api/health');
                        const data = await res.json();
                        this.serverApiKeyConfigured = data.api_key_configured;
                    } catch (e) {
                        console.error('Health check failed:', e);
                    }
                },

                saveApiKey() {
                    if (this.apiKeyInput) {
                        this.[REDACTED_SECRET];
                        localStorage.setItem('douyin_api_key', this.apiKeyInput);
                        this.showSettings = false;
                        this.showToast('API Key 已保存');
                    } else {
                        this.showToast('请输入 API Key');
                    }
                },

                clearApiKey() {
                    this.apiKey = '';
                    this.apiKeyInput = '';
                    localStorage.removeItem('douyin_api_key');
                    this.showToast('API Key 已清除');
                },

                resetState() {
                    this.error = '';
                    this.videoInfo = null;
                    this.transcript = '';
                    this.step = 0;
                    this.extractingText = false;
                },

                showToast(message) {
                    this.toast = message;
                    setTimeout(() => this.toast = '', 2000);
                },

                async copyToClipboard(text) {
                    try {
                        await navigator.clipboard.writeText(text);
                        this.showToast('已复制到剪贴板');
                    } catch (e) {
                        this.error = '复制失败';
                    }
                },

                async extractInfo() {
                    if (!this.url || this.loading) return;

                    this.resetState();
                    this.loading = true;
                    this.loadingText = '正在获取视频信息...';

                    try {
                        const res = await fetch('/api/video/info', {
                            method: 'POST',
                            headers: { 'Content-Type': 'application/json' },
                            body: JSON.stringify({ url: this.url })
                        });
                        const data = await res.json();

                        if (data.success) {
                            this.videoInfo = data;
                            this.showToast('视频信息获取成功');
                        } else {
                            this.error = data.error || '获取视频信息失败';
                        }
                    } catch (e) {
                        this.error = '网络错误，请检查网络连接后重试';
                    } finally {
                        this.loading = false;
                    }
                },

                async extractText() {
                    if (!this.url || this.loading) return;

                    // Check if API key is configured
                    if (!this.apiKeyConfigured) {
                        this.showSettings = true;
                        return;
                    }

                    this.resetState();
                    this.loading = true;
                    this.extractingText = true;
                    this.loadingText = '正在提取文案...';

                    // Simulate progress steps
                    this.step = 1;
                    const stepInterval = setInterval(() => {
                        if (this.step < 4) this.step++;
                    }, 2500);

                    try {
                        const res = await fetch('/api/video/extract', {
                            method: 'POST',
                            headers: { 'Content-Type': 'application/json' },
                            body: JSON.stringify({
                                url: this.url,
                                [REDACTED_SECRET]  // Send local API key if available
                            })
                        });
                        const data = await res.json();

                        clearInterval(stepInterval);
                        this.step = 5;

                        if (data.success) {
                            this.videoInfo = {
                                video_id: data.video_id,
                                title: data.title,
                                download_url: data.download_url
                            };
                            this.transcript = data.text;
                            this.showToast('文案提取成功');
                        } else {
                            this.error = data.error || '提取文案失败';
                        }
                    } catch (e) {
                        clearInterval(stepInterval);
                        this.error = '网络错误，请检查网络连接后重试';
                    } finally {
                        this.loading = false;
                    }
                },

                async copyTranscript() {
                    try {
                        await navigator.clipboard.writeText(this.transcript);
                        this.copied = true;
                        this.showToast('文案已复制到剪贴板');
                        setTimeout(() => this.copied = false, 2000);
                    } catch (e) {
                        this.error = '复制失败，请手动选择文本复制';
                    }
                },

                downloadTranscript() {
                    const content = `# ${this.videoInfo?.title || '视频文案'}\n\n视频ID: ${this.videoInfo?.video_id || ''}\n\n---\n\n${this.transcript}`;
                    const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
                    const url = URL.createObjectURL(blob);
                    const a = document.createElement('a');
                    a.href = url;
                    a.download = `${this.videoInfo?.video_id || 'transcript'}.md`;
                    document.body.appendChild(a);
                    a.click();
                    document.body.removeChild(a);
                    URL.revokeObjectURL(url);
                    this.showToast('文案已下载');
                }
            }
        }
    </script>
</body>
</html>


path: dependencies/douyin-video-analysis/analysis_template.md
content: # 抖音视频分析输出模板

## 0. 基础信息
- 来源链接：
- 实际视频页：
- 账号：
- 标题：
- 时长：
- 分析依据：
  - [ ] 页面元信息
  - [ ] 页面可见文字
  - [ ] 音频机器转写
  - [ ] 清洗后可读稿

## 1. 这条视频到底在讲什么
- 核心主题：
- 叙事结构：
- 最终想让观众相信什么：

## 2. 内容总结（先总结，再判断）
### 2.1 三句话总结
1. 
2. 
3. 

### 2.2 结构化摘要
- 开头钩子：
- 主体论点：
- 支撑案例 / 材料：
- 最后结论 / 行动暗示：

## 3. 有用信息提取 + 对应证据
> 只提炼“有信息价值”的内容，不把情绪话术混进去。

| 有用信息 | 视频中给出的证据/说法 | 可信度初判 | 备注 |
|---|---|---|---|
|  |  | 高 / 中 / 低 |  |
|  |  | 高 / 中 / 低 |  |
|  |  | 高 / 中 / 低 |  |

## 4. 标题 / 钩子分析
- 标题类型：
- 情绪按钮：
- 是否夸张：
- 是否偷换概念：
- 为什么容易传播：

## 5. 文案是否像 AI 生成 / AI 辅助
### 判断结论
- [ ] 更像人工原生
- [ ] 更像 AI 辅助
- [ ] 高概率 AI 主体生成

### 判断依据
- 结构痕迹：
- 语言痕迹：
- 金句密度：
- 信息颗粒度：
- 人味 / 模板味：

## 6. 批判性思维分析
### 6.1 成立的部分
- 

### 6.2 夸大的部分
- 

### 6.3 偷换概念的部分
- 

### 6.4 幸存者偏差 / 叙事陷阱
- 

### 6.5 它在调动什么情绪
- 

### 6.6 它真正卖的是什么
- 观点？
- 情绪安慰？
- 焦虑？
- 课程/系统/身份叙事？

## 7. 对 Bob 真正有用的部分
### 可以参考
- 

### 可以观察
- 

### 先别急着信
- 

## 8. 最终一句话结论
> 


path: dependencies/douyin-video-analysis/CHANGELOG.md
content: # Changelog

## 0.1.4
- Reordered README into a Chinese-first visual layout
- Moved Chinese section titles and descriptions ahead of English throughout the product page
- Improved ClawHub-facing readability for Chinese users

## 0.1.3
- Added a stronger Chinese-first short description for ClawHub-facing docs
- Moved Chinese listing copy above English in the marketing copy
- Updated the skill description to better fit Chinese users

## 0.1.2
- Added stronger bilingual listing copy
- Rewrote README to feel more like a product page
- Expanded the value proposition: why this skill exists and why the analysis order matters
- Improved clarity around outputs, best use cases, and practical benefits

## 0.1.1
- Added bilingual documentation (Chinese + English)
- Expanded README with full capability overview
- Added usage guide and expected outputs
- Added ClawHub listing notes
- Clarified workflow, requirements, and limits
- Emphasized summary -> evidence -> critique analysis order

## 0.1.0
- Initial public release
- Resolve Douyin short/full video URLs
- Capture page metadata and visible text from Chrome
- Extract and download audio stream when available
- Transcribe audio with local `mlx-whisper`
- Create transcript and analysis notes in Obsidian
- Provide a stable analysis template with:
  - content summary
  - useful information + evidence
  - title/hook analysis
  - AI-assisted likelihood
  - critical-thinking critique
- Include helper scripts for grab, transcribe, finalize, and full pipeline


path: dependencies/douyin-video-analysis/CLAWHUB.md
content: # ClawHub Listing Notes / ClawHub 上架说明

## Short description / 短描述
Douyin video analysis pipeline for OpenClaw: capture metadata and audio, transcribe speech, summarize content, extract useful information plus evidence, critically analyze the script, and save everything into Obsidian.

用于 OpenClaw 的抖音视频分析流水线：抓取页面信息与音频、转写口播、总结内容、提取有用信息与对应证据、进行批判性思维分析，并将结果写入 Obsidian。

## Key capabilities / 核心能力
- Resolve Douyin short/full URLs / 解析抖音短链与完整链接
- Extract title, metadata, visible text / 抓标题、元信息、可见文字
- Capture and download audio / 提取并下载音频
- Transcribe audio locally / 本地音频转写
- Create transcript notes / 生成转写笔记
- Create structured analysis notes / 生成结构化分析笔记
- Judge AI-assisted likelihood / 判断是否像 AI 辅助文案
- Perform critical-thinking analysis / 做批判性思维分析
- Save results into Obsidian / 保存到 Obsidian

## Recommended audience / 适合谁
- researchers / 研究短视频内容的人
- creators / 做内容创作的人
- operators / 做内容运营的人
- anyone who wants more than surface-level hot takes / 不满足于表层热评的人

## Important message / 重要说明
This skill is designed to avoid shallow commentary.
It always tries to:
1. summarize first
2. extract useful information and evidence second
3. critique third

这个 skill 专门用来避免“上来就嘴炮”。
它会优先：
1. 先总结
2. 再提取有用信息与证据
3. 最后批判性分析


path: dependencies/douyin-video-analysis/douyin_analysis_workflow.md
content: # Douyin Video Analysis Workflow

## Trigger
User sends a Douyin URL and wants content analysis.

## Fixed pipeline

### Step 1: Resolve page
- Open short link or full link in Chrome.
- Capture:
  - canonical video URL
  - title
  - page description
  - creator/account
  - visible chapter points

### Step 2: Get media candidates
- Read browser performance resources.
- Look for:
  - `douyinvod`
  - `media-audio`
  - `media-video`
- Save candidate URLs.

### Step 3: Download audio
- Prefer audio stream over full video.
- If 403 occurs:
  - use browser-derived cookie
  - include referer/origin/user-agent
- Save under `/tmp/douyin_transcribe/`.

### Step 4: Transcribe
- Use local `mlx-whisper` in venv.
- Fast-first strategy:
  - first pass with tiny/small model
  - refine if necessary
- Produce:
  - raw transcript
  - cleaned readable draft

### Step 5: Summarize first
Before critique, always add:
- a concise content summary
- a structured summary of hook / main claim / support / ending
- a "useful information + evidence" section

The "useful information + evidence" section should distinguish:
- actual useful facts/claims
- the evidence offered by the video itself
- your initial confidence level

### Step 6: Analyze
Minimum sections:
- What the video says
- Title/hook strategy
- AI-generated likelihood
- Critical-thinking critique
- What is useful vs misleading

### Step 7: Save
Write two notes:
- transcript note
- analysis note

### Step 7: Reply
Tell the user:
- transcript quality level
- main conclusions
- note paths

## Honesty guardrails
- Metadata != full transcript
- Rough machine transcript != polished verbatim script
- Be explicit about confidence and limitations


path: dependencies/douyin-video-analysis/helpers/douyin_grab.py
content: #!/usr/bin/env python3
"""
douyin_grab.py — Linux 版
使用 agent-browser 替代 chrome_bridge，yt-dlp 下载音视频，faster-whisper 转写
"""
import argparse
import json
import os
import re
import subprocess
import sys
import time
from pathlib import Path

TMP_DIR = Path("/tmp/douyin_transcribe")
TMP_DIR.mkdir(parents=True, exist_ok=True)

VENV_PY = os.environ.get('DOUYIN_VENV_PY', '/tmp/douyin_transcribe/venv/bin/python3')
WHISPER_MODEL = os.environ.get('DOUYIN_WHISPER_MODEL', 'Systran/faster-whisper-medium')

AGENT_BROWSER = os.environ.get('AGENT_BROWSER_BIN', 'agent-browser')


def run(cmd, capture=True, check=True):
    if isinstance(cmd, str):
        cmd = cmd.split()
    p = subprocess.run(cmd, capture_output=capture, text=True)
    if check and p.returncode != 0:
        raise RuntimeError(p.stderr.strip() or f"command failed: {cmd}")
    return p.stdout.strip() if capture else ""


def get_page_text(url: str) -> dict:
    """用 agent-browser 打开页面，获取标题、正文、资源列表"""
    session = f"douyin_grab_{int(time.time())}"
    try:
        run([AGENT_BROWSER, '--session', session, 'open', url])
        time.sleep(4)
        run([AGENT_BROWSER, '--session', session, 'wait', '--load', 'networkidle'])
        snap = run([AGENT_BROWSER, '--session', session, 'snapshot', '-i', '-c', '-d', '3', '--json'])
        data = json.loads(snap)
        snapshot_text = data.get('data', {}).get('snapshot', '')
        refs = data.get('data', {}).get('refs', {})

        title = ""
        for ref in refs.values():
            if ref.get('role') == 'heading':
                title = ref.get('name', '')
                break

        js = r"""JSON.stringify({
          url: location.href,
          title: document.title,
          bodyText: document.body ? document.body.innerText.slice(0,12000) : "",
          resources: performance.getEntriesByType("resource").map(r=>r.name).filter(n => /douyinvod|media-audio|media-video|aweme\/detail/.test(n)).slice(0,200)
        })"""
        eval_result = run([AGENT_BROWSER, '--session', session, 'eval', js])
        page_data = json.loads(eval_result)

        return {
            'title': title or page_data.get('title', ''),
            'body_text': page_data.get('bodyText', '')[:4000],
            'resources': page_data.get('resources', []),
            'url': page_data.get('url', url),
            'snapshot_text': snapshot_text
        }
    finally:
        run([AGENT_BROWSER, '--session', session, 'close'], check=False)


def pick_audio_url(resources):
    for u in resources:
        if 'media-audio' in u or 'aweme' in u:
            return u
    return None


def download_with_ytdlp(url: str, out_path: Path) -> bool:
    """用 yt-dlp 下载抖音视频/音频"""
    cmd = [
        str(VENV_PY), '-m', 'yt_dlp',
        '-f', 'bestaudio/best',
        '--no-playlist',
        '-o', str(out_path.with_suffix('.%(ext)s')),
        url
    ]
    p = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
    if p.returncode == 0:
        downloaded = list(out_path.parent.glob(f"{out_path.stem}.*"))
        if downloaded:
            actual = downloaded[0]
            if actual != out_path:
                actual.rename(out_path)
            return True
    return False


def transcribe_audio(audio_path: Path) -> str:
    code = f"""
from faster_whisper import transcribe
r, info = None, None
def _run():
    global r, info
    r, info = transcribe({str(audio_path)!r}, model="medium", language="zh", task="transcribe")
    text = " ".join([s.text for s in r[0]])
    print(text)
_run()
"""
    p = subprocess.run([VENV_PY, '-c', code], capture_output=True, text=True, timeout=300)
    if p.returncode != 0:
        raise RuntimeError(p.stderr.strip() or 'transcription failed')
    return p.stdout.strip()


def main():
    if len(sys.argv) < 2:
        print("usage: douyin_grab.py <douyin-url>", file=sys.stderr)
        sys.exit(2)

    url = sys.argv[1]
    print(f"[INFO] Grabbing: {url}", file=sys.stderr)

    data = get_page_text(url)
    audio_url = pick_audio_url(data.get('resources', []))

    out = {
        "input_url": url,
        "page_url": data.get('url', url),
        "title": data.get('title', ''),
        "body_preview": data.get('body_text', ''),
        "audio_url": audio_url,
        "audio_file": None,
        "transcript": None,
    }

    if audio_url:
        out_path = TMP_DIR / "audio_latest.mp4"
        try:
            ok = download_with_ytdlp(audio_url, out_path)
            if ok and out_path.exists() and out_path.stat().st_size > 5000:
                out["audio_file"] = str(out_path)
                out["audio_size"] = out_path.stat().st_size
                # 转写
                try:
                    out["transcript"] = transcribe_audio(out_path)
                except Exception as e:
                    out["transcribe_error"] = str(e)
            else:
                out["download_error"] = "file too small or not found"
        except Exception as e:
            out["download_error"] = str(e)

    print(json.dumps(out, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()


path: dependencies/douyin-video-analysis/helpers/finalize_analysis_note.py
content: #!/usr/bin/env python3
import argparse
import datetime as dt
import json
from pathlib import Path

TEMPLATE = Path('/Users/bobzhong/.openclaw/workspace/skills/douyin-video-analysis/analysis_template.md')
VAULT = Path('/Users/bobzhong/Library/Mobile Documents/iCloud~md~obsidian/Documents/BobVault')
INBOX = VAULT / 'OpenClaw' / 'Inbox'


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--topic', required=True)
    ap.add_argument('--source-url', default='')
    ap.add_argument('--page-url', default='')
    ap.add_argument('--account', default='')
    ap.add_argument('--title', default='')
    ap.add_argument('--duration', default='')
    ap.add_argument('--basis', nargs='*', default=[])
    ap.add_argument('--transcript-note', default='')
    args = ap.parse_args()

    today = dt.date.today().isoformat()
    note = INBOX / f'{today} 抖音视频分析 - {args.topic}.md'
    template = TEMPLATE.read_text(encoding='utf-8')

    basis_lines = []
    basis_map = {
        'meta': '- [x] 页面元信息',
        'visible': '- [x] 页面可见文字',
        'raw_transcript': '- [x] 音频机器转写',
        'cleaned': '- [x] 清洗后可读稿',
    }
    for key in ['meta', 'visible', 'raw_transcript', 'cleaned']:
        basis_lines.append(basis_map[key] if key in args.basis else basis_map[key].replace('[x]', '[ ]'))

    front = f"# 抖音视频分析 - {args.topic}\n\n- 时间：{today}\n- 来源链接：{args.source_url}\n- 实际视频页：{args.page_url}\n- 账号：{args.account}\n- 标题：{args.title}\n- 时长：{args.duration}\n"
    if args.transcript_note:
        front += f"- 关联转写：[[{args.transcript_note}]]\n"
    front += "\n"

    body = template
    body = body.replace('- 来源链接：', f'- 来源链接：{args.source_url}')
    body = body.replace('- 实际视频页：', f'- 实际视频页：{args.page_url}')
    body = body.replace('- 账号：', f'- 账号：{args.account}')
    body = body.replace('- 标题：', f'- 标题：{args.title}')
    body = body.replace('- 时长：', f'- 时长：{args.duration}')

    # replace basis block
    old_basis = "- [ ] 页面元信息\n  - [ ] 页面可见文字\n  - [ ] 音频机器转写\n  - [ ] 清洗后可读稿"
    new_basis = '\n'.join(basis_lines)
    body = body.replace(old_basis, new_basis)

    note.write_text(front + body, encoding='utf-8')
    print(json.dumps({'analysis_note': str(note)}, ensure_ascii=False, indent=2))


if __name__ == '__main__':
    main()


path: dependencies/douyin-video-analysis/helpers/md_to_docx.py
content: #!/usr/bin/env python3
"""
将抖音日报 MD 内容转换为 Word 文档
"""
import sys
import re
from pathlib import Path
from docx import Document
from docx.shared import Pt, RGBColor, Inches
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml.ns import qn
from docx.oxml import OxmlElement


def md_to_docx(md_path: Path, docx_path: Path = None):
    """将 MD 文件内容转换为 Word 文档"""
    content = md_path.read_text(encoding='utf-8')
    if docx_path is None:
        docx_path = md_path.with_suffix('.docx')

    doc = Document()

    # 设置默认字体（支持中文）
    style = doc.styles['Normal']
    style.font.name = 'Microsoft YaHei'
    style._element.rPr.rFonts.set(qn('w:eastAsia'), 'Microsoft YaHei')
    style.font.size = Pt(11)

    lines = content.split('\n')
    i = 0
    while i < len(lines):
        line = lines[i]

        # 标题处理
        if line.startswith('# '):
            h = doc.add_heading(line[2:], level=0)
            h.alignment = WD_ALIGN_PARAGRAPH.CENTER
        elif line.startswith('## '):
            h = doc.add_heading(line[3:], level=1)
        elif line.startswith('### '):
            h = doc.add_heading(line[4:], level=2)
        elif line.startswith('#### '):
            h = doc.add_heading(line[5:], level=3)

        # 表格处理
        elif line.startswith('|'):
            # 收集连续的多行表格
            table_lines = [line]
            while i + 1 < len(lines) and lines[i + 1].startswith('|'):
                i += 1
                table_lines.append(lines[i])

            # 解析表格（跳过分隔行）
            rows = []
            for tl in table_lines:
                cells = [c.strip() for c in tl.strip('|').split('|')]
                if not any('---' in c for c in cells):  # 跳过分隔行
                    rows.append(cells)

            if len(rows) >= 2:
                tbl = doc.add_table(rows=len(rows), cols=len(rows[0]))
                tbl.style = 'Table Grid'
                for ri, row_data in enumerate(rows):
                    for ci, cell_text in enumerate(row_data):
                        cell = tbl.rows[ri].cells[ci]
                        cell.text = cell_text
                        # 表头加粗
                        if ri == 0:
                            for p in cell.paragraphs:
                                for run in p.runs:
                                    run.bold = True
                doc.add_paragraph()  # 空行

        # 引用块
        elif line.startswith('>'):
            p = doc.add_paragraph()
            p.style = 'Quote'
            p.text = line[1:].strip()

        # 列表
        elif line.startswith('- '):
            p = doc.add_paragraph(style='List Bullet')
            p.text = line[2:]

        # 标题后不带特殊标记的普通段落
        elif line.strip():
            doc.add_paragraph(line)

        # 空行
        else:
            doc.add_paragraph()

        i += 1

    doc.save(str(docx_path))
    return docx_path


if __name__ == '__main__':
    if len(sys.argv) < 2:
        print("Usage: md_to_docx.py <md_file> [docx_output]")
        sys.exit(1)
    md_path = Path(sys.argv[1])
    docx_path = Path(sys.argv[2]) if len(sys.argv) > 2 else None
    out = md_to_docx(md_path, docx_path)
    print(f"Word 文档已生成: {out}")


path: dependencies/douyin-video-analysis/helpers/run_douyin_pipeline.py
content: #!/usr/bin/env python3
import argparse
import json
import re
import subprocess
from pathlib import Path

BASE = Path('/Users/bobzhong/.openclaw/workspace/skills/douyin-video-analysis/helpers')
GRAB = BASE / 'douyin_grab.py'
TRANSCRIBE = BASE / 'transcribe_and_note.py'
SEND_EMAIL = BASE / 'send_email.py'


def run_json(cmd):
    p = subprocess.run(cmd, capture_output=True, text=True)
    if p.returncode != 0:
        raise RuntimeError(p.stderr.strip() or p.stdout.strip() or f'command failed: {cmd}')
    txt = p.stdout.strip()
    # tolerate accidental leading log lines
    start = txt.find('{')
    if start == -1:
        raise RuntimeError(f'no JSON object in output: {txt[:400]}')
    return json.loads(txt[start:])


def pick_account(body_preview: str) -> str:
    skip = {
        '开启读屏标签', '读屏标签已关闭', '精选', '推荐', '搜索', '关注', '我的', '直播', '放映厅', '短剧',
        '客户端', '壁纸', '通知', '私信', '投稿', '倍速', '智能', '清屏', '连播', '章节要点', '内容由AI生成',
        '展开', '播放中', '举报', '全部评论', '留下你的精彩评论吧', '加载中', '点击加载更多', '推荐视频'
    }
    lines = [line.strip() for line in (body_preview or '').splitlines() if line.strip()]
    for i, s in enumerate(lines):
        if s in skip:
            continue
        if re.search(r'[#0-9:/：]|https?://', s):
            continue
        if '粉丝' in s or '获赞' in s:
            continue
        # strongest clue: creator name often appears right before a 粉丝/获赞 line
        if i + 1 < len(lines) and ('粉丝' in lines[i + 1] or '获赞' in lines[i + 1]):
            return s
    for s in lines:
        if s in skip:
            continue
        if re.search(r'[#0-9:/：]|https?://', s):
            continue
        if 2 <= len(s) <= 12:
            return s
    return ''


def short_topic(title: str) -> str:
    t = title or '抖音视频'
    t = t.split(' - 抖音')[0].strip()
    t = re.sub(r'\s+', ' ', t)
    return t[:30]


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('url')
    ap.add_argument('--topic', default='')
    ap.add_argument('--account', default='')
    ap.add_argument('--model', default='mlx-community/whisper-tiny-mlx')
    args = ap.parse_args()

    grab = run_json(['python3', str(GRAB), args.url])

    audio_file = grab.get('audio_file')
    if not audio_file:
        raise RuntimeError(f"audio_file missing; grab result: {json.dumps(grab, ensure_ascii=False)}")

    title = grab.get('title', '')
    page_url = grab.get('page_url', '')
    body_preview = grab.get('body_preview', '')
    account = args.account or pick_account(body_preview)
    topic = args.topic or short_topic(title)

    transcribe = run_json([
        'python3', str(TRANSCRIBE),
        '--audio-file', audio_file,
        '--title', title,
        '--source-url', args.url,
        '--page-url', page_url,
        '--account', account,
        '--topic', topic,
        '--model', args.model,
    ])

    out = {
        'input_url': args.url,
        'page_url': page_url,
        'title': title,
        'account': account,
        'topic': topic,
        'audio_file': audio_file,
        'audio_url': grab.get('audio_url'),
        'note_path': transcribe.get('note_path'),
        'transcript_chars': transcribe.get('chars'),
    }

    note_path = transcribe.get('note_path')
    if note_path:
        email_result = run_json([
            'python3', str(SEND_EMAIL),
            '--note-path', note_path,
            '--topic', topic,
        ])
        out['email'] = email_result

    print(json.dumps(out, ensure_ascii=False, indent=2))


if __name__ == '__main__':
    main()


path: dependencies/douyin-video-analysis/helpers/send_email.py
content: #!/usr/bin/env python3
"""
Send a Douyin analysis markdown file + optional Word doc by email.
Uses EmailMessage for proper non-ASCII filename handling.
"""
import argparse
import json
import os
import smtplib
from email.message import EmailMessage
from email import encoders
from pathlib import Path


DEFAULT_RECIPIENTS = ['[REDACTED_SECRET]']
DEFAULT_SMTP_HOST = 'smtp.qq.com'
DEFAULT_SMTP_PORT = 587


def build_recipients() -> list[str]:
    env_val = os.environ.get('DOUYIN_EMAIL_RECIPIENTS', '')
    if env_val.strip():
        return [r.strip() for r in env_val.split(',') if r.strip()]
    return DEFAULT_RECIPIENTS


def send(note_path: str, topic: str, docx_path: str = None) -> dict:
    smtp_host = os.environ.get('SMTP_HOST', DEFAULT_SMTP_HOST)
    smtp_port = int(os.environ.get('SMTP_PORT', DEFAULT_SMTP_PORT))
    sender = os.environ.get('SMTP_USER', '')
    [REDACTED_SECRET]'SMTP_PASS', '')

    if not sender or not password:
        raise EnvironmentError(
            'SMTP_USER and SMTP_PASS environment variables must be set.'
        )

    recipients = build_recipients()
    path = Path(note_path)

    subject = f'【抖音视频分析】{topic}'
    body_text = path.read_text(encoding='utf-8')

    # Use EmailMessage for proper non-ASCII filename encoding (RFC 5987)
    msg = EmailMessage()
    msg['From'] = sender
    msg['To'] = ', '.join(recipients)
    msg['Subject'] = subject
    msg.set_content(body_text, charset='utf-8')

    # 附件1：MD 文件（不再发送，仅保留内容用于生成 DOCX）
    # 附件2：DOCX 文件（如有）
    if docx_path and Path(docx_path).exists():
        docx_bytes = Path(docx_path).read_bytes()
        docx_name = Path(docx_path).name
        msg.add_attachment(
            docx_bytes,
            maintype='application',
            subtype='vnd.openxmlformats-officedocument.wordprocessingml.document',
            filename=docx_name,
        )

    with smtplib.SMTP(smtp_host, smtp_port) as smtp:
        smtp.ehlo()
        smtp.starttls()
        smtp.ehlo()
        smtp.login(sender, password)
        smtp.sendmail(sender, recipients, msg.as_bytes())

    return {
        'status': 'sent',
        'recipients': recipients,
        'subject': subject,
        'attachments': [path.name] + ([Path(docx_path).name] if docx_path else []),
    }


def main():
    ap = argparse.ArgumentParser(description='Email a Douyin analysis note.')
    ap.add_argument('--note-path', required=True, help='Path to the .md file')
    ap.add_argument('--docx-path', default='', help='Path to the optional .docx file')
    ap.add_argument('--topic', default='抖音视频', help='Short topic label for subject line')
    args = ap.parse_args()

    result = send(args.note_path, args.topic, args.docx_path if args.docx_path else None)
    print(json.dumps(result, ensure_ascii=False, indent=2))


if __name__ == '__main__':
    main()


path: dependencies/douyin-video-analysis/helpers/tikhub_client.py
content: #!/usr/bin/env python3
"""
TikHub API 客户端 - 获取视频信息和下载链接
"""
import json
import os
import re
import requests

TIKHUB_API_BASE = "https://api.tikhub.dev"

def load_config():
    config_path = os.path.expanduser("~/.openclaw/config.json")
    if os.path.exists(config_path):
        with open(config_path, "r") as f:
            return json.load(f)
    return {}

def get_token():
    config = load_config()
    [REDACTED_SECRET]"tikhub_api_token")
    if not token:
        raise ValueError("缺少 TikHub API Token，请在 ~/.openclaw/config.json 中配置")
    return token

def get_video_info(aweme_id: str) -> dict:
    """
    通过 aweme_id 获取视频详情（标题、下载链接等）
    """
    [REDACTED_SECRET])
    url = f"{TIKHUB_API_BASE}/api/v1/douyin/web/fetch_one_video"
    params = {"aweme_id": aweme_id, "need_anchor_info": "false"}
    headers = {"Authorization": f"Bearer {token}"}
    resp = requests.get(url, headers=headers, params=params, timeout=30)
    resp.raise_for_status()
    return resp.json()

def get_video_url(aweme_id: str) -> str | None:
    """
    通过 aweme_id 获取视频直接播放链接
    """
    try:
        result = get_video_info(aweme_id)
        ad = result.get("data", {}).get("aweme_detail", {})
        if not ad:
            return None
        # 尝试从 stream URL 获取
        video = ad.get("video", {})
        # play_addr 里有 url_list
        play = video.get("play_addr", {}) or video.get("download_addr", {})
        urls = play.get("url_list", []) if play else []
        for u in urls:
            if u and ("v11-cold" in u or "v19" in u or "douyinvod" in u):
                return u
        return urls[0] if urls else None
    except Exception as e:
        print(f"[WARN] get_video_url failed for {aweme_id}: {e}")
        return None

def get_aweme_id_from_topic(keyword: str, token: str) -> list[dict]:
    """
    用话题关键词搜索视频，返回 [{aweme_id, title, item_url}]
    注意：此接口需要付费额度
    """
    url = f"{TIKHUB_API_BASE}/api/v1/douyin/search/fetch_video_search_v2"
    headers = {
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json"
    }
    payload = {"keyword": keyword, "count": 3}
    resp = requests.post(url, headers=headers, json=payload, timeout=30)
    resp.raise_for_status()
    data = resp.json()
    if data.get("code") != 200:
        return []
    items = data.get("data", {}).get("video_list", [])
    return [
        {"aweme_id": v.get("aweme_id"), "title": v.get("title", ""), "item_url": v.get("play_url", "")}
        for v in items if v.get("aweme_id")
    ]

def get_hot_video_list(page: int = 1, page_size: int = 15, sub_type: int = 1001) -> dict:
    """
    获取视频热榜（含 item_id + item_url 直接下载链接）
    返回 {"item_id": ..., "item_title": ..., "item_url": ..., ...}
    """
    [REDACTED_SECRET])
    url = f"{TIKHUB_API_BASE}/api/v1/douyin/billboard/fetch_hot_total_video_list"
    headers = {"Authorization": f"Bearer {token}"}
    payload = {"page": page, "page_size": page_size, "sub_type": sub_type}
    resp = requests.post(url, headers=headers, json=payload, timeout=30)
    resp.raise_for_status()
    data = resp.json()
    if data.get("code") != 200:
        raise RuntimeError(f"fetch_hot_total_video_list failed: {data.get('message')}")
    # TikHub 返回结构是双层嵌套: data.data.objs
    inner_data = data.get("data", {})
    objs = inner_data.get("data", {}).get("objs", []) if isinstance(inner_data, dict) else []
    return objs

if __name__ == "__main__":
    # 测试
    [REDACTED_SECRET])
    print(f"[REDACTED_SECRET]")

    # 测试热榜视频列表
    print("\n=== 测试 fetch_hot_total_video_list ===")
    videos = get_hot_video_list(page=1, page_size=3)
    for v in videos:
        print(f"item_id: {v.get('item_id')}")
        print(f"title: {v.get('item_title', '')[:60]}")
        print(f"item_url: {v.get('item_url', '')[:100]}")
        print()


path: dependencies/douyin-video-analysis/helpers/transcribe_and_note.py
content: #!/usr/bin/env python3
"""
transcribe_and_note.py — Linux 版
使用 faster-whisper 替代 mlx-whisper
"""
import argparse
import datetime as dt
import json
import os
import re
import subprocess
from pathlib import Path

OUTPUT_DIR = Path(os.environ.get('DOUYIN_OUTPUT_DIR',
                                  str(Path.home() / 'Documents' / 'douyin_analysis')))
VENV_PY = os.environ.get('DOUYIN_VENV_PY', '/tmp/douyin_transcribe/venv/bin/python3')


def slugify_topic(title: str) -> str:
    title = re.sub(r'\s+', ' ', title).strip()
    for ch in '/', ':', '?', '*':
        title = title.replace(ch, '？' if ch == '?' else '／')
    return title[:60] if title else '未命名主题'


def run_transcribe(audio_file: str, model: str = 'Systran/faster-whisper-medium') -> str:
    code = f"""
from faster_whisper import transcribe
r, info = None, None
def _run():
    global r, info
    r, info = transcribe({str(audio_file)!r}, model="medium", language="zh", task="transcribe")
    text = " ".join([s.text for s in r[0]])
    print(text)
_run()
"""
    p = subprocess.run([VENV_PY, '-c', code], capture_output=True, text=True, timeout=600)
    if p.returncode != 0:
        raise RuntimeError(p.stderr.strip() or p.stdout.strip() or 'transcription failed')
    return p.stdout.strip()


def write_note(path: Path, content: str):
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(content, encoding='utf-8')


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--audio-file', required=True)
    ap.add_argument('--title', default='')
    ap.add_argument('--page-url', default='')
    ap.add_argument('--source-url', default='')
    ap.add_argument('--account', default='')
    ap.add_argument('--topic', default='')
    ap.add_argument('--model', default='Systran/faster-whisper-medium')
    args = ap.parse_args()

    today = dt.date.today().isoformat()
    topic = slugify_topic(args.topic or args.title or '抖音视频')
    note_name = f"{today} 抖音视频分析 - {topic}.md"
    note_path = OUTPUT_DIR / note_name

    text = run_transcribe(args.audio_file, args.model)

    combined_md = f"""# 抖音视频分析 - {topic}

- 时间：{today}
- 来源链接：{args.source_url}
- 实际视频页：{args.page_url}
- 账号：{args.account}
- 音频文件：{args.audio_file}
- 转写模型：{args.model}

---

## 口播转写

> 说明：以下为机器转写初稿，可能存在专有名词、数字、断句错误。

{text}

---

## 分析笔记

### 1. 视频核心在说什么
-

### 2. 内容摘要
- 钩子：
- 核心主张：
- 论据支撑：
- 结论：

### 3. 有效信息 + 证据清单

| 信息点 | 视频提供的证据 | 可信度 |
|--------|--------------|--------|
|        |              | 高/中/低 |

### 4. 标题/封面钩子怎么设计
-

### 5. 文案是否像 AI 生成 / AI 辅助
-

### 6. 批判性思维分析
- 情绪钩子：
- 偷换概念：
- 夸大之处：
- 幸存者偏差：
- 真正有价值的部分：
- 不值得轻信的部分：

### 7. 最终结论
-
"""

    write_note(note_path, combined_md)

    print(json.dumps({
        'note_path': str(note_path),
        'topic': topic,
        'chars': len(text)
    }, ensure_ascii=False, indent=2))


if __name__ == '__main__':
    main()


path: dependencies/douyin-video-analysis/LISTING_COPY.md
content: # Douyin Video Analysis / 抖音视频分析

## 中文短描述（建议优先展示）
这是一个面向中文用户的抖音视频分析 skill：给一个抖音链接，它会尽可能抓取页面信息与音频、转写口播、总结内容、提取有用信息与对应证据、做批判性思维分析，并把结果写入 Obsidian。

## Hero copy / 封面简介

### 中文
把一个抖音链接，变成真正有用的分析材料。
这个 skill 会尽可能抓取页面信息、下载音频、转写口播、总结内容、提取有用信息与对应证据，然后再做批判性思维分析，而不是停留在表层热评。
它适合那些想理解短视频，而不是只想跟着情绪跑的人。

### English
Turn a Douyin link into something actually useful.
This skill captures the page, downloads audio when possible, transcribes speech, summarizes the content, extracts useful information plus evidence, and then performs critical-thinking analysis instead of shallow hot takes.
It is built for people who want to understand short-form videos, not just react to them.

## One-line pitch / 一句话卖点

### 中文
一个先总结、再提证据、最后再批判的抖音分析流水线。

### English
A Douyin analysis pipeline that summarizes first, extracts evidence second, and critiques last.

## Why it is worth using / 为什么值得用

### 中文
大多数短视频分析最后都掉进两个坑：
1. 只看标题和页面摘要，根本没碰真实口播内容
2. 直接开喷或站队，跳过证据，先下判断

这个 skill 就是专门拿来绕开这两个坑的。
它强制采用更靠谱的顺序：
- 抓内容
- 做转写
- 先总结
- 再提炼有用信息与证据
- 最后再批判性分析

### English
Most video analysis stops at one of two bad outcomes:
1. metadata-only summaries that never touch the real spoken content
2. emotional commentary that skips evidence and jumps straight to judgment

This skill is designed to avoid both.
It forces a better order:
- capture
- transcribe
- summarize
- extract useful information + evidence
- analyze critically

## Best-fit users / 最适合的人

### 中文
- 研究媒体叙事的人
- 想拆短视频说服套路的创作者
- 想搭建视频知识库的运营/研究者
- 不满足于“感觉像懂了”的观众

### English
- researchers tracking media narratives
- creators studying short-form persuasion tactics
- operators building a video knowledge base
- critical readers who want more than vibes


path: dependencies/douyin-video-analysis/publish-notes.md
content: # Publish Notes

## Suggested ClawHub metadata
- slug: `douyin-video-analysis`
- name: `Douyin Video Analysis`
- version: `0.1.0`
- changelog: `Initial release: capture Douyin metadata/audio, transcribe, summarize, critically analyze, and save into Obsidian.`

## Requires
- macOS with Google Chrome
- browser automation bridge skill/scripts available
- temporary write access to `/tmp/douyin_transcribe/`
- Python venv support
- local `mlx-whisper` install in the temp venv during runtime
- Obsidian vault at the configured BobVault path

## Important limits
- Some Douyin media URLs may still fail if anti-bot protections change
- Transcript quality depends on the selected Whisper model
- Final critical analysis still expects human/agent judgment rather than pure automation


path: dependencies/douyin-video-analysis/README.md
content: # 抖音视频分析 / Douyin Video Analysis

把一个抖音链接，变成真正有用的分析材料。
Turn a Douyin link into something actually useful.

这个 skill 会尽可能抓取页面信息、下载音频、转写口播、总结内容、提取有用信息与对应证据，然后再做批判性思维分析，而不是停留在表层热评。
This skill captures the page, downloads audio when possible, transcribes speech, summarizes the content, extracts useful information plus evidence, and then performs critical-thinking analysis instead of shallow hot takes.

它适合那些想理解短视频，而不是只想跟着情绪跑的人。
It is built for people who want to understand short-form videos, not just react to them.

---

# 为什么做这个 skill / Why this skill exists

## 中文
大多数短视频分析最后都会掉进两个坑：
1. 只看标题、元信息或页面摘要，根本没碰真实口播内容
2. 直接进入情绪化评论，没有把事实、主张、证据和判断拆开

这个 skill 就是专门拿来绕开这两个坑的。
它真正的价值，不只是下载或转写。
而是它强制采用一种更靠谱的分析顺序：
- 先抓内容
- 再转写
- 再总结
- 再提炼有用信息与证据
- 最后再批判

## English
Most video analysis falls into one of two bad patterns:
1. it only reads the title, metadata, or page summary and never touches the spoken content
2. it jumps straight into emotional commentary without separating facts, claims, evidence, and judgment

This skill is designed to avoid both.
Its real value is not just downloading or transcription.
Its real value is the analysis order it enforces:
- capture first
- transcribe second
- summarize third
- extract useful information + evidence fourth
- criticize last

---

# 这个 skill 能做什么 / What this skill does

## 中文
给一个抖音短链或完整视频链接，这个 skill 可以：
1. 解析真实视频页
2. 抓取标题、页面元信息、可见文字、账号信息
3. 检查浏览器已加载资源，并在可行时提取音频流
4. 将音频下载到本地临时目录
5. 使用本地 Whisper 流水线进行音频转写
6. 在 Obsidian 中生成转写笔记
7. 在 Obsidian 中生成结构化分析笔记
8. 在做判断前，先对视频内容进行总结
9. 提取视频中真正有用的信息，并对应视频自己给出的证据
10. 进行批判性思维分析，包括：标题策略、是否像 AI 写稿、说服技巧、夸大成分、逻辑质量等

## English
Given a Douyin short URL or full video URL, this skill can:
1. resolve the real video page
2. extract title, metadata, visible page text, and creator info
3. inspect browser-loaded resources and capture audio when possible
4. download the audio to a local temp directory
5. transcribe the audio with a local Whisper pipeline
6. generate a transcript note in Obsidian
7. generate a structured analysis note in Obsidian
8. summarize the video before judging it
9. extract useful information and pair it with the evidence offered by the video itself
10. perform critical-thinking analysis, including title strategy, AI-writing likelihood, persuasion tactics, exaggeration, and reasoning quality

---

# 最适合的使用场景 / Best use cases

## 中文
以下场景最适合使用：
- 想分析一个抖音视频，而不是只看标题和封面
- 想判断文案是否像 AI 生成 / AI 辅助
- 想把视频口播内容保存为笔记
- 想拆解视频中的说服 / 操控 / 叙事套路
- 想判断一个视频到底是有信息价值，还是浅薄、夸张、误导
- 想建立自己的短视频研究资料库

## English
Use this skill when you want to:
- analyze a Douyin video beyond its title or thumbnail
- check whether a script feels AI-generated or AI-assisted
- preserve the video's spoken content in notes
- break down persuasion / manipulation / rhetoric patterns
- evaluate whether a video is informative, shallow, exaggerated, or misleading
- build a research archive of short-form videos

---

# 工作流程 / Workflow

## 中文
这个 skill 的固定流程是：
1. 在 Chrome 中打开抖音链接
2. 解析真实视频页
3. 抓取标题、元信息和页面可见文字
4. 检查浏览器资源加载记录
5. 如果可行，抓取并下载音频
6. 将音频转写成原始文本
7. 将转写写入 Obsidian 笔记
8. 用固定模板创建分析笔记
9. 先对视频内容做总结
10. 列出有用信息 + 对应证据
11. 再做批判性思维分析
12. 返回笔记路径和结论

## English
This skill follows a fixed pipeline:
1. open the Douyin URL in Chrome
2. resolve the canonical video page
3. extract title, metadata, and visible text
4. inspect browser resource entries
5. capture and download audio if available
6. transcribe audio to raw text
7. write transcript note into Obsidian
8. create analysis note with a stable template
9. summarize the video content
10. list useful information + corresponding evidence
11. perform critical-thinking analysis
12. return note paths and final conclusions

---

# 输出结果 / Output

## 中文
典型输出包括：
- 一份 Obsidian 转写笔记
- 一份 Obsidian 分析笔记
- 一份聊天中的总结说明，告诉你：
  - 视频到底在说什么
  - 有哪些真正有用的信息
  - 视频自己给了哪些证据
  - 标题有没有夸张或偷换概念
  - 文案是否像 AI 辅助生成
  - 哪些地方值得信，哪些地方要打折看

典型笔记文件：
- `OpenClaw/Inbox/YYYY-MM-DD 抖音视频口播转写 - <topic>.md`
- `OpenClaw/Inbox/YYYY-MM-DD 抖音视频分析 - <topic>.md`

## English
Typical outputs include:
- a transcript note in Obsidian
- an analysis note in Obsidian
- a chat summary that explains:
  - what the video is saying
  - what useful information it contains
  - what evidence it gives
  - whether the title is exaggerated
  - whether the script looks AI-assisted
  - what parts are worth trusting or doubting

Typical note files:
- `OpenClaw/Inbox/YYYY-MM-DD 抖音视频口播转写 - <topic>.md`
- `OpenClaw/Inbox/YYYY-MM-DD 抖音视频分析 - <topic>.md`

---

# 分析结构 / Analysis structure

## 中文
这个 skill 的分析结构是故意设计成“先还原内容，再下判断”，避免上来就嘴炮。
它会明确区分：
1. 视频到底在讲什么
2. 内容总结
3. 有用信息 + 对应证据
4. 标题 / 钩子分析
5. AI 生成概率判断
6. 批判性思维分析
7. 可执行结论 / 有用启发

## English
The analysis is intentionally structured to avoid shallow hot takes.
It separates:
1. what the video actually says
2. a concise summary
3. useful information + evidence
4. title/hook analysis
5. AI-generated likelihood
6. critical-thinking critique
7. practical takeaways

---

# 如何使用 / How to use it

## 中文
在聊天里你可以直接这样说：
- “分析这个抖音视频：<链接>”
- “按抖音分析固定流程跑这个链接：<链接>”
- “把这个抖音视频转写并批判性分析：<链接>”

## English
In chat, you can simply say:
- "Analyze this Douyin video: <url>"
- "Run the Douyin analysis pipeline on this link: <url>"
- "Transcribe and critically analyze this Douyin video: <url>"

---

# 辅助脚本 / Helper scripts

## 1) 抓页面 + 抓音频 / Grab page + audio
```bash
python3 skills/douyin-video-analysis/helpers/douyin_grab.py '<douyin-url>'
```

### 中文
这个脚本会：
- 在 Chrome 中打开抖音页面
- 抓真实 URL、标题、元信息、正文预览
- 读取浏览器已加载资源
- 找到候选音频链接
- 尝试带认证信息下载音频
- 返回 JSON 结果

### English
This script:
- opens the Douyin page in Chrome
- captures canonical URL, title, metadata, body preview
- inspects browser-loaded resources
- finds a candidate audio URL
- attempts authenticated audio download
- returns JSON

## 2) 转写 + 生成笔记 / Transcribe + create notes
```bash
python3 skills/douyin-video-analysis/helpers/transcribe_and_note.py \
  --audio-file /tmp/douyin_transcribe/audio_latest.mp4 \
  --title '<video-title>' \
  --source-url '<shared-url>' \
  --page-url '<canonical-url>' \
  --account '<creator>' \
  --topic '<short-topic>'
```

### 中文
这个脚本会：
- 用 `mlx-whisper` 转写音频
- 生成 Obsidian 转写笔记
- 生成 Obsidian 分析笔记骨架
- 返回两个笔记路径的 JSON

### English
This script:
- transcribes audio with `mlx-whisper`
- creates a transcript note in Obsidian
- creates an analysis note skeleton in Obsidian
- returns both note paths as JSON

## 3) 跑完整流程 / Run the full pipeline
```bash
python3 skills/douyin-video-analysis/helpers/run_douyin_pipeline.py '<douyin-url>'
```

### 中文
这个总控脚本会把主要步骤串起来：
- 解析页面
- 抓音频
- 做转写
- 生成笔记

### English
This controller script chains the major steps:
- resolve page
- grab audio
- transcribe
- create notes

## 4) 生成标准分析模板 / Finalize analysis note
```bash
python3 skills/douyin-video-analysis/helpers/finalize_analysis_note.py \
  --topic '<topic>' \
  --source-url '<shared-url>' \
  --page-url '<canonical-url>' \
  --account '<creator>' \
  --title '<title>' \
  --duration '<duration>' \
  --basis meta visible raw_transcript cleaned \
  --transcript-note '<transcript-note-stem>'
```

### 中文
这个脚本会应用固定分析模板，并补齐分析笔记头部信息。

### English
This script applies the stable analysis template and fills the note header.

---

# 运行要求 / Requirements

## 中文
当前默认依赖 Bob 机器上的这些条件：
- macOS
- 已安装 Google Chrome
- 可用的 browser bridge 脚本
- 对 `/tmp/douyin_transcribe/` 有写权限
- 支持 Python venv
- 可在临时 venv 中安装 `mlx-whisper`
- 已配置 BobVault Obsidian 路径

## English
Current assumptions on Bob's machine:
- macOS
- Google Chrome available
- browser bridge scripts available
- temp write access to `/tmp/douyin_transcribe/`
- Python venv support
- `mlx-whisper` can be installed in the temp venv
- Obsidian vault available at the configured BobVault path

---

# 限制说明 / Limits

## 中文
- 如果抖音反爬策略变化，部分媒体链接可能失效。
- 转写质量取决于 Whisper 模型大小。
- 专有名词、数字、人物名称可能需要人工清洗。
- 批判性分析仍然适合由人/智能体来做判断，不适合完全自动化成盲目结论。

## English
- Some Douyin media URLs may fail if anti-bot behavior changes.
- Transcript quality depends on the chosen Whisper model.
- Proper nouns, numbers, and names may need cleanup.
- Critical analysis still benefits from human/agent judgment; it should not be fully automated into blind conclusions.

---

# 为什么它不只是“转写工具” / Why this skill is different

## 中文
这个 skill 不只是下载器或转写工具。
它真正的价值在于，它强制采用一种更克制的分析顺序：
- 先总结
- 再提炼信息和证据
- 最后再批判

这个顺序能减少“先入为主的嘴炮”，让分析质量更稳。

## English
This skill is not just a downloader or transcript helper.
Its real value is that it forces a disciplined analysis order:
- summarize first
- extract useful information second
- critique third

That order helps reduce shallow takes and improves reasoning quality.


path: dependencies/douyin-video-analysis/_meta.json
content: ownerId: kn7924nm5m9kpkq3jqwchxvx0d82yeda
slug: douyin-video-analysis
version: 0.1.4
publishedAt: 1773564426730

path: references/DEPLOY.md
content: # 部署详细文档

## 一、依赖项

### Python 虚拟环境

推荐使用 `uv` 创建：

```bash
uv venv /tmp/douyin_transcribe --python 3.12
source /tmp/douyin_transcribe/bin/activate
pip install faster-whisper yt-dlp python-docx requests
```

或直接运行 `setup_douyin_daily_report.sh` 自动完成。

### 环境变量

| 变量名 | 必填 | 说明 |
|--------|------|------|
| `SMTP_USER` | ✅ | QQ 邮箱地址 |
| `SMTP_PASS` | ✅ | QQ 邮箱 SMTP 授权码（不是密码）|
| `SMTP_HOST` | | 默认 `smtp.qq.com` |
| `SMTP_PORT` | | 默认 `587` |
| `DOUYIN_EMAIL_RECIPIENTS` | ✅ | 收件人，逗号分隔 |
| `DOUYIN_DIGEST_LIMIT` | | 默认 `15` |
| `DOUYIN_VENV_PY` | | 默认 `/tmp/douyin_transcribe/venv/bin/python3` |

### TikHub API Token

在 `~/.openclaw/config.json` 中配置：

```json
{
  "tikhub_api_token": "tk_live_xxxxxxxxxxxx"
}
```

注册地址：https://user.tikhub.io

## 二、获取 QQ 邮箱授权码

1. 登录 QQ 邮箱 → 设置 → 账户
2. 找到"POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务"
3. 开启"SMTP服务"→ 获取授权码（16位）
4. 将授权码填入 `SMTP_PASS`

## 三、Cron 定时任务

手动设置：

```bash
# 每天 08:00 和 16:00 执行
0 8,16 * * *  bash /root/.openclaw/workspace/skills/douyin-daily-report/scripts/cron_daily_digest_wrapper.sh 15 >> /tmp/douyin_cron.log 2>&1
```

## 四、故障排查

### 邮件发送失败
- 检查 `SMTP_USER` 和 `SMTP_PASS` 是否正确
- 确认 QQ 邮箱授权码未过期

### TikHub API 失败
- 确认 `tikhub_api_token` 已写入 `~/.openclaw/config.json`
- 确认 Token 未过期（tk_live_ 开头为正式版）

### DOCX 附件显示 .bin
- 当前已修复，使用 `EmailMessage.add_attachment()` 自动处理文件名编码
- 如仍有问题，检查邮件客户端是否支持 RFC 5987

### LLM 分析失败
- 确认 OpenClaw Gateway 运行中（`openclaw gateway status`）
- 确认 session key 有效：`agent:main:lightclawbot:direct:100012167891`

### 日报只有 3 条内容
- 检查日报文件大小，超过 500 字符邮件正文不会被截断
- 可能是 TikHub 返回数据不足（免费版限制）

## 五、卸载

```bash
# 删除 Cron
crontab -l | grep -v "douyin_daily_digest" | crontab -

# 删除虚拟环境
rm -rf /tmp/douyin_transcribe

# 删除输出文件
rm -rf ~/Documents/douyin_analysis

# 删除技能目录
rm -rf /root/.openclaw/workspace/skills/douyin-daily-report
```


path: scripts/cron_daily_digest_wrapper.sh
content: #!/bin/bash
# cron_daily_digest_wrapper.sh — Cron 调用的入口脚本
# 设置环境变量后调用 run_daily_digest.py
set -e

# 读取 .env 文件（如果存在）
ENV_FILE="$(cd "$(dirname "$0")" && cd .. && pwd)/.env"
if [ -f "$ENV_FILE" ]; then
    set -a
    source "$ENV_FILE"
    set +a
fi

# SMTP 邮件配置（必填）
export SMTP_USER="${SMTP_USER:-[REDACTED_SECRET]}"
export SMTP_PASS="${SMTP_PASS:-}"
export SMTP_HOST="${SMTP_HOST:-smtp.qq.com}"
export SMTP_PORT="${SMTP_PORT:-587}"

# 收件人配置
export DOUYIN_EMAIL_RECIPIENTS="${DOUYIN_EMAIL_RECIPIENTS:-[REDACTED_SECRET],[REDACTED_SECRET]}"

# 输出目录
export DOUYIN_OUTPUT_DIR="${DOUYIN_OUTPUT_DIR:-$HOME/Documents/douyin_analysis}"
export DOUYIN_ANALYSIS_DIR="$(cd "$(dirname "$0")" && cd .. && pwd)"

# Python 虚拟环境
export DOUYIN_VENV_PY="${DOUYIN_VENV_PY:-/tmp/douyin_transcribe/venv/bin/python3}"

# 日报条数（默认15）
LIMIT="${1:-${DOUYIN_DIGEST_LIMIT:-15}}"

# 运行主脚本
exec "$HOME/.openclaw/workspace/skills/douyin-daily-report/scripts/run_daily_digest.py" \
    --limit "$LIMIT" \
    --skip-transcribe


path: scripts/helpers/md_to_docx.py
content: #!/usr/bin/env python3
"""
md_to_docx.py — 将 Markdown 文件转换为 Word 文档（python-docx）
"""
import re
from pathlib import Path
from docx import Document
from docx.shared import Pt, RGBColor, Inches
from docx.enum.text import WD_ALIGN_PARAGRAPH


def md_to_docx(md_path: Path) -> Path:
    """将 Markdown 文件转换为 .docx，返回 docx 路径"""
    doc = Document()
    doc.core_properties.title = md_path.stem

    lines = md_path.read_text(encoding='utf-8').splitlines()
    in_table = False
    table = None
    in_code_block = False

    for line in lines:
        stripped = line.strip()

        # 代码块
        if stripped.startswith('```'):
            in_code_block = not in_code_block
            continue
        if in_code_block:
            run = doc.add_paragraph()
            run.add_run(line)
            run.style = 'No Spacing'
            run.runs[0].font.name = 'Courier New'
            run.runs[0].font.size = Pt(9)
            continue

        # 表格
        if stripped.startswith('|'):
            if not in_table:
                # 解析表头
                headers = [h.strip() for h in stripped.split('|')[1:-1]]
                table = doc.add_table(rows=1, cols=len(headers))
                table.style = 'Table Grid'
                hdr_cells = table.rows[0].cells
                for idx, h in enumerate(headers):
                    hdr_cells[idx].text = h
                    for run in hdr_cells[idx].paragraphs[0].runs:
                        run.bold = True
                in_table = True
            else:
                # 分隔线跳过
                if all(c.strip().startswith('-') for c in stripped.split('|')[1:-1]):
                    continue
                cells = [c.strip() for c in stripped.split('|')[1:-1]]
                row = table.add_row()
                for idx, cell_text in enumerate(cells):
                    row.cells[idx].text = cell_text
            continue
        else:
            if in_table and table:
                in_table = False
                table = None

        # 标题
        if stripped.startswith('# '):
            p = doc.add_heading(stripped[2:], level=1)
        elif stripped.startswith('## '):
            p = doc.add_heading(stripped[3:], level=2)
        elif stripped.startswith('### '):
            p = doc.add_heading(stripped[4:], level=3)
        elif stripped.startswith('#### '):
            p = doc.add_heading(stripped[5:], level=4)
        # 引用
        elif stripped.startswith('>'):
            p = doc.add_paragraph()
            run = p.add_run(stripped[1:].strip())
            run.font.color.rgb = RGBColor(100, 100, 100)
            run.font.italic = True
        # 分隔线
        elif stripped.startswith('---') or stripped.startswith('***'):
            p = doc.add_paragraph()
            p.add_run('─' * 40)
        # 无序列表
        elif stripped.startswith('- ') or stripped.startswith('* '):
            p = doc.add_paragraph(style='List Bullet')
            # 处理 **bold** 和 `code`
            _add_formatted_text(p, stripped[2:])
        # 有序列表
        elif re.match(r'^\d+\. ', stripped):
            p = doc.add_paragraph(style='List Number')
            m = re.match(r'^(\d+)\. (.*)', stripped)
            if m:
                _add_formatted_text(p, m.group(2))
        # 空行
        elif not stripped:
            p = doc.add_paragraph()
        # 普通段落
        else:
            p = doc.add_paragraph()
            _add_formatted_text(p, stripped)

    out_path = md_path.with_suffix('.docx')
    doc.save(str(out_path))
    return out_path


def _add_formatted_text(para, text: str):
    """在段落中添加带格式的文本（支持 **bold** 和 `code`）"""
    parts = re.split(r'(`[^`]+`|\*\*[^*]+\*\*)', text)
    for part in parts:
        run = para.add_run(part)
        if part.startswith('**') and part.endswith('**'):
            run.bold = True
            run.text = part[2:-2]
        elif part.startswith('`') and part.endswith('`'):
            run.font.name = 'Courier New'
            run.font.size = Pt(9)
            run.text = part[1:-1]
        else:
            run.text = part


if __name__ == '__main__':
    import sys
    if len(sys.argv) < 2:
        print("Usage: python3 md_to_docx.py <file.md>")
        sys.exit(1)
    md_path = Path(sys.argv[1])
    docx_path = md_to_docx(md_path)
    print(f"Word 文档已生成: {docx_path}")


path: scripts/helpers/send_email.py
content: #!/usr/bin/env python3
"""
send_email.py — 发送抖音日报邮件（使用 EmailMessage 正确处理中文文件名）
"""
import argparse
import json
import os
import smtplib
from email.message import EmailMessage
from pathlib import Path


DEFAULT_RECIPIENTS = ['[REDACTED_SECRET]']
DEFAULT_SMTP_HOST = 'smtp.qq.com'
DEFAULT_SMTP_PORT = 587


def build_recipients() -> list[str]:
    env_val = os.environ.get('DOUYIN_EMAIL_RECIPIENTS', '')
    if env_val.strip():
        return [r.strip() for r in env_val.split(',') if r.strip()]
    return DEFAULT_RECIPIENTS


def send(note_path: str, topic: str, docx_path: str = None) -> dict:
    smtp_host = os.environ.get('SMTP_HOST', DEFAULT_SMTP_HOST)
    smtp_port = int(os.environ.get('SMTP_PORT', DEFAULT_SMTP_PORT))
    sender = os.environ.get('SMTP_USER', '')
    [REDACTED_SECRET]'SMTP_PASS', '')

    if not sender or not password:
        raise EnvironmentError('SMTP_USER and SMTP_PASS environment variables must be set.')

    recipients = build_recipients()
    path = Path(note_path)
    subject = f'【抖音视频分析】{topic}'

    msg = EmailMessage()
    msg['From'] = sender
    msg['To'] = ', '.join(recipients)
    msg['Subject'] = subject
    msg.set_content(path.read_text(encoding='utf-8'), charset='utf-8')

    # 只发 DOCX 附件（不发 MD）
    if docx_path and Path(docx_path).exists():
        docx_bytes = Path(docx_path).read_bytes()
        docx_name = Path(docx_path).name
        msg.add_attachment(
            docx_bytes,
            maintype='application',
            subtype='vnd.openxmlformats-officedocument.wordprocessingml.document',
            filename=docx_name,
        )

    with smtplib.SMTP(smtp_host, smtp_port) as smtp:
        smtp.ehlo()
        smtp.starttls()
        smtp.ehlo()
        smtp.login(sender, password)
        smtp.sendmail(sender, recipients, msg.as_bytes())

    return {
        'status': 'sent',
        'recipients': recipients,
        'subject': subject,
        'attachments': [path.name] + ([Path(docx_path).name] if docx_path else []),
    }


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--note-path', required=True)
    ap.add_argument('--docx-path', default='')
    ap.add_argument('--topic', default='抖音视频')
    args = ap.parse_args()
    result = send(args.note_path, args.topic, args.docx_path if args.docx_path else None)
    print(json.dumps(result, ensure_ascii=False, indent=2))


if __name__ == '__main__':
    main()


path: scripts/run_daily_digest.py
content: #!/usr/bin/env python3
"""
run_daily_digest.py — 抖音每日日报流水线
获取热榜TOP15 → OpenClaw LLM分析 → MD+DOCX → 邮件发送
"""
import argparse
import datetime as dt
import json
import os
import subprocess
import sys
import time
from pathlib import Path

_HOME = Path.home()

# 当前脚本所在目录
SCRIPT_DIR = Path(__file__).parent.resolve()
SKILL_BASE = SCRIPT_DIR.parent

ANALYSIS_DIR = SKILL_BASE          # douyin-daily-report 根目录
OUTPUT_DIR   = Path(os.environ.get('DOUYIN_OUTPUT_DIR', str(_HOME / 'Documents' / 'douyin_analysis')))
VENV_PY     = os.environ.get('DOUYIN_VENV_PY', '/tmp/douyin_transcribe/venv/bin/python3')
TIKHUB_API_BASE = 'https://api.tikhub.dev'

TMP_DIR = Path("/tmp/douyin_transcribe")
TMP_DIR.mkdir(parents=True, exist_ok=True)

DEFAULT_LIMIT = int(os.environ.get('DOUYIN_DIGEST_LIMIT', '15'))
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)


# ── OpenClaw LLM 分析 ────────────────────────────────────────────────────────

def call_openclaw_llm(title: str, transcript: str, video_url: str = "") -> str:
    """通过 openclaw agent 命令调用内置模型生成分析笔记"""
    session_key = "agent:main:lightclawbot:direct:100012167891"

    prompt = f"""你是一个专业的抖音视频内容分析师。请分析以下抖音视频并生成分析笔记。

【视频标题】{title}
【视频链接】{video_url or "无"}
【口播转写内容】
{transcript if transcript else "（无转写内容，仅根据标题和标签分析）"}

请生成以下格式的分析笔记，用中文回复，严格只输出分析笔记本身：

#### 1. 核心内容
- 

#### 2. 内容摘要
- 钩子：
- 核心主张：
- 论据支撑：

#### 3. 批判性分析
- 情绪钩子：
- 夸大之处：
- 幸存者偏差：
- 真正有价值的部分："""

    result = subprocess.run(
        ["openclaw", "agent",
         "--session-id", session_key,
         "--message", prompt,
         "--timeout", "90"],
        capture_output=True,
        text=True,
        timeout=120,
        env={**os.environ, "PATH": os.environ.get("PATH", "") + ":/root/.local/share/pnpm"}
    )

    answer_lines = []
    for line in result.stdout.splitlines():
        if line.strip() and not line.startswith("["):
            answer_lines.append(line)

    if answer_lines:
        return "\n".join(answer_lines).strip()
    return None


# ── TikHub API ────────────────────────────────────────────────────────────────

def get_tikhub_token():
    config_path = _HOME / ".openclaw" / "config.json"
    if config_path.exists():
        with open(config_path) as f:
            cfg = json.load(f)
            [REDACTED_SECRET]"tikhub_api_token", "")
            if [REDACTED_SECRET] token
    raise RuntimeError("未配置 TikHub API Token，请先在 ~/.openclaw/config.json 中添加 tikhub_api_token")


def get_hot_videos_tikhub(limit: int = 15) -> list[dict]:
    """通过 TikHub 热榜 API 获取 TOP 视频"""
    import requests
    [REDACTED_SECRET])
    url = f"{TIKHUB_API_BASE}/api/v1/douyin/billboard/fetch_hot_total_video_list"
    headers = {"Authorization": f"Bearer {token}"}
    payload = {"page": 1, "page_size": limit, "sub_type": 1001}
    resp = requests.post(url, headers=headers, json=payload, timeout=30)
    resp.raise_for_status()
    data = resp.json()
    if data.get("code") != 200:
        raise RuntimeError(f"fetch_hot_total_video_list 失败: {data.get('message')}")
    inner = data.get("data", {})
    objs = inner.get("data", {}).get("objs", []) if isinstance(inner, dict) else []
    return objs


# ── 单视频处理 ────────────────────────────────────────────────────────────────

def analyze_one_video(video: dict, rank: int, skip_transcribe: bool) -> str:
    """处理单个视频：LLM分析 → 构造分析块"""
    item_id  = video.get('item_id', '')
    title    = video.get('item_title', '') or video.get('item_id', f'视频{item_id}')
    item_url = video.get('item_url', '')
    play_cnt = video.get('play_cnt', 0)
    like_cnt = video.get('like_cnt', 0)
    author   = video.get('nick_name', '未知作者')

    # 原生可打开的分享链接（稳定，不带时效token）
    share_url = f"https://www.douyin.com/video/{item_id}" if item_id else item_url

    header = (
        f"## 第 {rank} 条｜{title}\n\n"
        f"- 播放量：{play_cnt:,}\n"
        f"- 点赞量：{like_cnt:,}\n"
        f"- 作者：{author}\n"
    )
    if share_url:
        header += f"- 视频链接：{share_url}\n"
    header += "\n"

    transcript_text = ''
    transcript_note = '（已跳过转写，使用标题和标签分析）'

    body = "- 视频标题：" + title + "\n\n"
    body += "### 口播转写\n\n"
    body += transcript_note + "\n\n"

    # 调用 OpenClaw LLM 生成分析笔记
    llm_analysis = ""
    if title:
        try:
            llm_result = call_openclaw_llm(title, transcript_text, share_url)
            if llm_result:
                llm_analysis = "\n### 分析笔记\n\n" + llm_result + "\n\n---\n"
            else:
                llm_analysis = (
                    "\n### 分析笔记\n\n"
                    "#### 1. 核心内容\n- （LLM 分析生成失败）\n\n"
                    "---\n"
                )
        except Exception as e:
            llm_analysis = (
                f"\n### 分析笔记\n\n"
                f"#### 1. 核心内容\n- （LLM 调用异常：{str(e)[:100]}）\n\n"
                "---\n"
            )

    body += llm_analysis
    return header + body


# ── 日报汇总 ─────────────────────────────────────────────────────────────────

def build_toc(videos: list[dict]) -> str:
    rows = []
    for i, v in enumerate(videos, 1):
        title = v.get('item_title', v.get('item_id', f'视频{i}'))
        play  = v.get('play_cnt', 0)
        like  = v.get('like_cnt', 0)
        item_id = v.get('item_id', '')
        share_url = f"https://www.douyin.com/video/{item_id}" if item_id else v.get('item_url', '')
        rows.append(f"| {i} | {title} | {play:,} | {like:,} | {share_url} |")
    header = "| 排名 | 标题 | 播放量 | 点赞量 | 链接 |\n|------|------|--------|--------|------|\n"
    return header + '\n'.join(rows)


def write_daily_report(today: str, videos: list[dict], sections: list[str],
                       success_count: int) -> tuple[Path, Path]:
    now_str = dt.datetime.now().strftime('%Y-%m-%d %H:%M')
    file_path = OUTPUT_DIR / f"{today} 抖音热门视频日报.md"
    toc    = build_toc(videos)
    detail = '\n'.join(sections)

    content = f"""# 抖音热门视频日报 {today}

> 自动生成时间：{now_str}
> 热榜来源：TikHub 视频热榜（每日更新）
> 视频条目数：{len(videos)}，成功分析：{success_count}

---

## 热榜总览

{toc}

---

## 视频分析详情

{detail}
"""
    file_path.write_text(content, encoding='utf-8')

    # MD → DOCX
    docx_path = None
    try:
        # 用 venv Python 运行 md_to_docx（依赖 python-docx）
        p = subprocess.run(
            [VENV_PY, str(SCRIPT_DIR / 'helpers' / 'md_to_docx.py'), str(file_path)],
            capture_output=True, text=True, timeout=60
        )
        if p.returncode == 0:
            docx_path = file_path.with_suffix('.docx')
            print(f"[OK] Word 文档已生成: {docx_path}")
        else:
            print(f"[WARN] Word 文档生成失败: {p.stderr.strip()[:100]}", file=sys.stderr)
    except Exception as e:
        print(f"[WARN] Word 文档生成失败: {e}", file=sys.stderr)

    return file_path, docx_path


# ── 发邮件 ────────────────────────────────────────────────────────────────────

def send_email(note_path: Path, docx_path: Path, today: str, success_count: int):
    topic = f"{today}（共 {success_count} 条分析）"
    try:
        p = subprocess.run(
            [sys.executable, str(SCRIPT_DIR / 'helpers' / 'send_email.py'),
             '--note-path', str(note_path),
             '--docx-path', str(docx_path) if docx_path else '',
             '--topic', topic],
            capture_output=True, text=True, timeout=60
        )
        if p.returncode == 0:
            print(f"[OK] 邮件已发送 → {os.environ.get('DOUYIN_EMAIL_RECIPIENTS', '')}")
        else:
            print(f"[WARN] 邮件发送失败: {p.stderr.strip()[:200]}", file=sys.stderr)
    except Exception as e:
        print(f"[WARN] 邮件发送异常: {e}", file=sys.stderr)


# ── 主入口 ────────────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(description='抖音每日日报生成')
    parser.add_argument('--limit', type=int, default=DEFAULT_LIMIT)
    parser.add_argument('--no-email', action='store_true')
    parser.add_argument('--skip-transcribe', action='store_true')
    args = parser.parse_args()

    today = dt.date.today().strftime('%Y-%m-%d')
    print(f"[START] 抖音热门视频日报 {today}，获取 TOP {args.limit}")

    # 步骤1：获取热榜
    try:
        videos = get_hot_videos_tikhub(args.limit)
        print(f"[OK] TikHub 热榜获取成功，共 {len(videos)} 条")
    except Exception as e:
        print(f"[ERROR] 热榜获取失败: {e}", file=sys.stderr)
        sys.exit(1)

    # 步骤2：逐一分析
    sections = []
    success_count = 0
    for i, video in enumerate(videos):
        rank  = i + 1
        title = video.get('item_title', video.get('item_id', f'视频{rank}'))
        print(f'[{rank}/{len(videos)}] 分析：{title[:40]}')
        try:
            section = analyze_one_video(video, rank, skip_transcribe=args.skip_transcribe)
            sections.append(section)
            if 'LLM 分析生成失败' not in section and 'LLM 调用异常' not in section:
                success_count += 1
        except Exception as e:
            print(f'[WARN] 分析失败: {e}', file=sys.stderr)
            err_section = f"## 第 {rank} 条｜{title}\n\n### 获取失败\n\n```\n{str(e)[:200]}\n```\n\n---\n"
            sections.append(err_section)

    # 步骤3：写入日报（MD + DOCX）
    report_path, docx_path = write_daily_report(today, videos, sections, success_count)
    print(f'[OK] 日报已写入: {report_path}')

    # 步骤4：发送邮件
    if not args.no_email:
        send_email(report_path, docx_path, today, success_count)

    result = {
        'date': today,
        'report_path': str(report_path),
        'total': len(videos),
        'success': success_count,
    }
    print(json.dumps(result, ensure_ascii=False, indent=2))


if __name__ == '__main__':
    main()


path: scripts/setup_douyin_daily_report.sh
content: #!/bin/bash
# setup_douyin_daily_report.sh — 一键安装/重装抖音日报环境
set -e

SKILL_DIR="$(cd "$(dirname "$0")" && pwd)"
WORKSPACE_DIR="$(dirname "$SKILL_DIR")"
VENV_DIR="/tmp/douyin_transcribe"
EMAIL_RECIPIENTS="${DOUYIN_EMAIL_RECIPIENTS:-[REDACTED_SECRET],[REDACTED_SECRET]}"
DIGEST_LIMIT="${DOUYIN_DIGEST_LIMIT:-15}"

echo "=== 抖音日报环境安装 ==="

# 1. 创建虚拟环境
echo "[1/6] 创建 Python 虚拟环境..."
if [ -d "$VENV_DIR" ]; then
    echo "  虚拟环境已存在，跳过"
else
    /root/.local/bin/uv venv "$VENV_DIR" --python 3.12
fi
source "$VENV_DIR/bin/activate"

# 2. 安装依赖
echo "[2/6] 安装 Python 依赖..."
pip install --upgrade pip -q
pip install faster-whisper yt-dlp python-docx requests -q

# 3. 写入 .env 示例
echo "[3/6] 写入环境变量配置..."
ENV_FILE="$WORKSPACE_DIR/douyin-daily-report/.env"
cat > "$ENV_FILE" << 'EOF'
# SMTP 邮件（必填）
SMTP_USER=[REDACTED_SECRET]
SMTP_PASS=your_smtp授权码
SMTP_HOST=smtp.qq.com
SMTP_PORT=587

# 收件人（逗号分隔）
DOUYIN_EMAIL_RECIPIENTS=[REDACTED_SECRET],[REDACTED_SECRET]

# 日报条数
DOUYIN_DIGEST_LIMIT=15

# Python 虚拟环境路径（自动检测，无需修改）
DOUYIN_VENV_PY=/tmp/douyin_transcribe/venv/bin/python3

# TikHub API Token（必填，写入 ~/.openclaw/config.json）
# 在 ~/.openclaw/config.json 中添加: "tikhub_api_token": "your_token"
EOF
echo "  环境变量示例已写入: $ENV_FILE"

# 4. 检查 TikHub Token
echo "[4/6] 检查 TikHub API Token..."
TIKHUB_TOKEN=""
if [ -f ~/.openclaw/config.json ]; then
    TIKHUB_TOKEN=$(python3 -c "import json; d=json.load(open('$HOME/.openclaw/config.json')); print(d.get('tikhub_api_token',''))" 2>/dev/null || echo "")
fi
if [ -z "$TIKHUB_TOKEN" ]; then
    echo "  警告: ~/.openclaw/config.json 中未找到 tikhub_api_token"
    echo "  请手动添加到 ~/.openclaw/config.json: {\"tikhub_api_token\": \"your_token\"}"
else
    echo "  Token 已配置 ✓"
fi

# 5. 设置 Cron 定时任务（08:00 和 16:00）
echo "[5/6] 设置 Cron 定时任务..."
CRON_WRAPPER="$SKILL_DIR/scripts/cron_daily_digest_wrapper.sh"
CRON_EXPR="0 8,16 * * *"
CRON_CMD="$CRON_EXPR  bash \"$CRON_WRAPPER\" $DIGEST_LIMIT >> /tmp/douyin_cron.log 2>&1"
(crontab -l 2>/dev/null | grep -v "douyin_daily_digest"; echo "$CRON_CMD") | crontab -
echo "  Cron 已设置: 每天 08:00 和 16:00 ✓"

# 6. 验证依赖
echo "[6/6] 验证 Python 依赖..."
source "$VENV_DIR/bin/activate"
python3 -c "import faster_whisper; import yt_dlp; import docx; import requests; print('  所有依赖 OK')" && echo "  依赖检查通过 ✓" || echo "  警告: 部分依赖导入失败"

echo ""
echo "=== 安装完成 ==="
echo ""
echo "下一步操作:"
echo "1. 编辑 ~/.openclaw/config.json，添加 TikHub Token:"
echo "   {\"tikhub_api_token\": \"your_token_from_tikhub.io\"}"
echo ""
echo "2. 编辑 $ENV_FILE，设置 SMTP 信息:"
echo "   SMTP_USER=你的QQ邮箱"
echo "   SMTP_PASS=QQ邮箱授权码"
echo ""
echo "3. 手动测试运行:"
echo "   bash $CRON_WRAPPER"
echo ""
echo "4. 查看日志:"
echo "   tail -f /tmp/douyin_cron.log"


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