Skill: yjli-new/douyin-upload-skill (version 0.1.0)

--- name: douyin-upload-skill description: Login and publish Douyin (China mainland) videos from local files with OAuth, local speech-to-text, and generated caption drafts. Use when users ask to authorize Douyin accounts, upload local videos, auto-generate title/description from video audio, confirm content, and publish via official Douyin OpenAPI with fallback export when publish permissions are missing. --- # Douyin Upload Skill ## Overview Use this skill to publish local videos to Douyin with a deterministic CLI flow: 1. Validate local dependencies and env. 2. OAuth authorize and store encrypted token locally. 3. Prepare video metadata and transcript from local audio. 4. Generate 3 caption candidates in chat from transcript. 5. Confirm or edit caption, then publish. 6. If official publish permission is unavailable, export an outbox package for manual publish. Use the script at `<skill_root>/scripts/douyin.js`. ## Required Environment Set these environment variables before `auth` or `publish`: - `DOUYIN_CLIENT_KEY` - `DOUYIN_CLIENT_SECRET` - `DOUYIN_REDIRECT_URI` Optional overrides: - `DOUYIN_SCOPE` - `DOUYIN_TOKEN_ENC_KEY` - `DOUYIN_ASR_MODE` (`api` / `whisper-gpu` / `whisper-cpu`) - `DOUYIN_ASR_API_URL` - `DOUYIN_ASR_API_MODEL` - `DOUYIN_ASR_API_KEY` - `DOUYIN_WHISPER_BIN` - `DOUYIN_WHISPER_MODEL_PATH` - `DOUYIN_FFMPEG_BIN` - `DOUYIN_FFPROBE_BIN` ## Workflow 1. Run dependency checks: ```bash node <skill_root>/scripts/douyin.js doctor ``` 2. Authorize account (manual code paste flow): ```bash node <skill_root>/scripts/douyin.js auth ``` 3. Prepare transcript and metadata from a local video path. Accept both Linux and Windows path formats. ```bash node <skill_root>/scripts/douyin.js prepare --video "E:\\videos\\demo.mp4" ``` 4. Create 3 caption candidates from `transcript.text` with this structure: - Line 1: title hook - Line 2-3: concise description - Final line: 2-5 hashtags 5. Ask user to select or edit one final caption. 6. Publish with explicit visibility and confirmation policy: ```bash node <skill_root>/scripts/douyin.js publish \ --video "E:\\videos\\demo.mp4" \ --text "<final caption>" \ --private-status 0 \ --auto-confirm false ``` ## Command Behavior - `doctor`: reports dependency and env readiness plus install hints. - `auth`: opens OAuth URL, accepts pasted callback URL or `code`, stores encrypted token. - `prepare`: returns metadata, transcript, and ASR failure detail (without stopping publish flow). - `publish`: uploads and creates video via official API. If permission-like API errors occur, writes fallback files under outbox and returns `mode: fallback`. - `config`: stores persistent settings (`defaultPrivateStatus`, `autoConfirm`, `whisperBin`, `whisperModelPath`, `outboxDir`, etc.). ## Caption Rules Before publish: - Keep final text length <= 1000. - Always show the final draft to the user. - If `auto-confirm` is false, require explicit user confirmation in terminal. ## Output Contracts Treat script stdout as JSON. Always parse and branch by: - `ok` - `command` - `mode` (`official` or `fallback` for `publish`) - `asrError` (optional in `prepare`)

path: README.md
content: <p align="center">
  <h1 align="center">🎥 Douyin Upload Skill <br> (抖音全自动发布助手)</h1>
  <p align="center">
    一条本地优先、确定性的抖音视频发布流水线：<b>授权 → 提取字幕 → 生成文案 → 自动发布</b>。<br>
    自带<b>“降级发件箱”</b>机制，即使官方 API 权限受限，也能完美兜底！📦
  </p>
</p>

<p align="center">
  <img alt="Node.js" src="https://img.shields.io/badge/node-%E2%89%A520-339933?logo=node.js&logoColor=white" />
  <img alt="Output JSON" src="https://img.shields.io/badge/output-JSON-informational" />
  <img alt="ASR" src="https://img.shields.io/badge/ASR-whisper.cpp-8A2BE2" />
  <img alt="FFmpeg" src="https://img.shields.io/badge/media-ffmpeg%20%2F%20ffprobe-blue" />
  <img alt="Target" src="https://img.shields.io/badge/target-Douyin%20OpenAPI-red" />
</p>

> ⚠️ **注意：** 本项目针对的是 **抖音（中国大陆）** 官方 OpenAPI，不适用于海外版 TikTok。

---

## 💡 为什么开发这个工具？

发布视频**本该**是完全可以自动化的，但在真实的业务流中往往会遇到以下阻碍：

- ❌ 你的抖音开放平台应用没有获批“视频发布”权限。
- ❌ 手动发布步骤繁琐，难以在自动化流水线（Pipeline）中复用。
- ❌ 想要用 AI 生成优质的视频文案（Caption），前提是你得先知道视频里说了什么。

本项目提供了一套**可重复执行的 CLI 工作流**，并且所有标准输出（stdout）都是**机器可读的 JSON 格式**。你可以轻松将其接入自动化脚本，而且全程**不需要**将视频原声上传给任何云端 ASR 服务，绝对保护隐私！

---

## ✨ 核心特性

- 🔐 **OAuth 快捷登录**：支持手动粘贴授权码，并将 Token **加密安全存储**在本地。
- 🎙️ **本地语音转文字 (ASR)**：内置 `whisper.cpp` (`whisper-cli`)，使用本地模型提取字幕。
- 🧩 **ASR 三模式**：支持 `第三方服务 API`（默认）、`whisper+gpu`、`whisper+cpu`。
- ⚡ **智能缓存**：基于视频的 SHA-256 计算哈希，避免重复转录。
- 🚀 **官方 API 直发**：在权限允许的情况下，一键调用官方接口完成发布。
- 📦 **降级发件箱 (Fallback Outbox)**：遇到无权限等阻碍时，自动打包**“视频 + 文案 + 字幕 + 元数据”**到发件箱，方便手动无缝接管。
- 🤖 **面向开发者友好**：确定性的 JSON 输出，非常适合 CI/CD 和自动化脚本解析。

---

## 📚 目录

- [快速开始](#-快速开始)
- [核心工作流](#-核心工作流)
- [环境依赖安装](#-环境依赖安装)
- [配置指南](#-配置指南)
- [命令行速查](#-命令行速查)
- [JSON 响应格式](#-json-响应格式)
- [降级发件箱机制](#-降级发件箱机制)
- [安全与隐私](#-安全与隐私)
- [常见问题 Q&A](#-常见问题-qa)

---

## 🚀 快速开始

### 1. 克隆项目
```bash
git clone https://github.com/YJLi-new/douyin-skill.git
cd douyin-skill
```

### 2. 配置环境变量 (必填)
在进行授权或发布前，请先导出你的抖音应用凭证：
```bash
export DOUYIN_CLIENT_KEY="你的_CLIENT_KEY"
export DOUYIN_CLIENT_SECRET="你的_CLIENT_SECRET"
export DOUYIN_REDIRECT_URI="https://your.domain/callback"
```

<details>
<summary><b>📖 展开：如何获取 CLIENT_KEY / CLIENT_SECRET / REDIRECT_URI（详细教程）</b></summary>

### A. 创建抖音开放平台应用

在抖音开放平台调用 OpenAPI 之前，需要先开通开发者能力并创建应用，同时提前准备可访问的回调地址。普通抖音账号本身无法直接调用开放平台 API。

### B. 如何申请开发者账号（开通流程）

1. 打开抖音开放平台（中国大陆）：`https://developer.open-douyin.com/`
2. 使用你的抖音账号登录后，进入“开发者入驻/开通开发者”流程。
3. 根据页面提示选择主体类型（个人或企业）并提交资料：
   - 个人：通常需要实名信息
   - 企业：通常需要企业主体信息、管理员信息等
4. 等待平台审核通过后，你的账号才具备创建应用和申请能力的入口。

> 提示：控制台菜单名称可能会变动，但关键词通常是“开发者中心 / 应用管理 / 能力管理 / 开发设置”。

### C. 创建应用并获取 `Client Key / Client Secret`

1. 在控制台创建应用（网站应用/移动应用按你的场景选择）。
2. 填写应用基础信息（应用名、图标、简介、用途说明等）并提交审核。
3. 审核通过后，进入应用详情页，在“应用信息/凭证”区域找到：
   - `Client Key`（对应 `DOUYIN_CLIENT_KEY`）
   - `Client Secret`（对应 `DOUYIN_CLIENT_SECRET`）
4. 保存好这两项，不要提交到仓库。

### D. 可访问回调地址如何准备和获取（`DOUYIN_REDIRECT_URI`）

你需要一个**浏览器能访问**的回调地址，例如：
- `https://your.domain/callback`

常见准备方案（按推荐顺序）：

1. **已有公网域名（推荐）**
   - 在你的服务器上部署一个简单回调路由（如 `/callback`）
   - 配置 HTTPS 证书（Nginx/Caddy/Traefik 都可以）
   - 得到稳定地址：`https://your.domain/callback`

2. **临时公网隧道（开发联调）**
   - 用 `cloudflared`、`ngrok` 或类似工具把本地端口映射到公网
   - 得到临时 HTTPS 地址，例如：`https://xxx.trycloudflare.com/callback`
   - 适合测试，注意地址可能变化，变化后要同步改控制台配置

3. **纯手动 code 粘贴模式（你当前项目已支持）**
   - 依然需要在平台里配置一个合法 `redirect_uri`
   - 授权后从浏览器地址栏复制 `code`（或完整回调 URL）粘贴给 CLI
   - 这种方式不要求你本地必须启动回调服务，但地址本身仍需先配置

`Redirect URI` 常见要求：
- 必须与你控制台里配置的地址严格一致（协议、域名、路径都要一致）
- 通常要求使用 HTTPS（开发场景可按平台实际规则处理）
- 不要随意在回调地址后追加未登记参数

### E. 在控制台配置并验证 `DOUYIN_REDIRECT_URI`

1. 打开你的应用配置页，找到“回调地址/Redirect URI”设置项。
2. 填入你准备好的地址（如 `https://your.domain/callback`）并保存。
3. 本地环境变量设置为同一个值：
   - `DOUYIN_REDIRECT_URI`
4. 执行授权命令验证：
   - `node scripts/douyin.js auth`
   - 浏览器授权完成后，把返回 URL 或 `code` 粘贴回终端

### F. 申请发布相关权限（发布接口必需）

如果你要走官方直发（`publish` 的 official 模式），还需要在控制台申请对应能力/Scope（如视频发布相关权限）。未获批时，工具会自动进入 fallback outbox 模式。

### G. 本地配置示例（bash）

```bash
export DOUYIN_CLIENT_KEY="填写你的Client Key"
export DOUYIN_CLIENT_SECRET="填写你的Client Secret"
export DOUYIN_REDIRECT_URI="https://your.domain/callback"
```

快速自检：

```bash
node scripts/douyin.js doctor
```

当返回结果中这三项为 `true`，表示变量已就绪：
- `DOUYIN_CLIENT_KEY`
- `DOUYIN_CLIENT_SECRET`
- `DOUYIN_REDIRECT_URI`

</details>

### 3. 环境自检 (Doctor)
```bash
node scripts/douyin.js doctor
```
> 🩺 这会输出一份 JSON，检查你的环境是否准备就绪，并提供缺失依赖的安装建议。

### 4. 账号授权 (Auth)
```bash
node scripts/douyin.js auth
```
浏览器会自动打开授权页。登录完成后，将回调 URL（或 `code=` 后面的内容）粘贴回终端。Token 将被加密保存在本地磁盘。

### 5. 解析视频与提取字幕 (Prepare)
```bash
node scripts/douyin.js prepare --video "./demo.mp4"
```
你将获得一个包含视频元数据（时长、分辨率、SHA-256）和**文字稿 (transcript.text)** 的 JSON 数据。

### 6. 创作文案 (推荐格式)
拿到字幕后，你可以交给大语言模型 (LLM) 生成极具网感的文案。
> 💡 **Prompt 示例：** “基于以下视频文字稿，生成 3 种抖音文案。每种文案需包含：1 句吸引人的开头，1-2 句简短描述，以及 2-5 个热门标签（Hashtags）。要求自然流畅。”

### 7. 一键发布 (Publish)
```bash
node scripts/douyin.js publish \
  --video "./demo.mp4" \
  --text "这是我生成的最终文案...\n#标签1 #标签2" \
  --private-status 0 \
  --auto-confirm false
```
*注：`--private-status` 可选值为 0(公开), 1(私密), 2(好友可见)。如果要在非交互式环境 (如 CI) 中运行，请设置 `--auto-confirm true`。*

---

## 🗺️ 核心工作流

```mermaid
graph TD;
    A[doctor自检] --> B[auth授权];
    B --> C[prepare视频分析/ASR提取];
    C -.生成文案后.-> D{publish请求发布};
    D -- 有官方权限 --> E[✅ 通过官方API成功发布];
    D -- 无权限/受限 --> F[📦 导出至降级发件箱];
```
> **核心设计理念：** 所有步骤的标准输出都是 JSON。你的流水线脚本只需监听 `ok`、`command` 和 `mode` 字段即可实现自动分发与错误处理。

---

## 🛠️ 环境依赖安装

运行本工具需要满足以下最低条件：
- **Node.js** >= 20
- **ffmpeg** + **ffprobe**
- **whisper-cli** (来自 whisper.cpp) + 模型文件 (默认 `ggml-small.bin`)
- **xdg-open** (Linux，用于自动打开浏览器)

**Ubuntu/Debian 快速安装指令：**
```bash
sudo apt-get update && sudo apt-get install -y ffmpeg cmake build-essential curl jq xdg-utils

# 安装 whisper.cpp
git clone https://github.com/ggerganov/whisper.cpp.git
cd whisper.cpp
cmake -B build && cmake --build build -j
ln -sf "$(pwd)/build/bin/whisper-cli" ~/.local/bin/whisper-cli

# 下载模型文件
mkdir -p ~/.cache/whisper.cpp
curl -L https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin -o ~/.cache/whisper.cpp/ggml-small.bin
```

---

## ⚙️ 配置指南

工具有两种配置方式，优先级为：**环境变量 > 持久化配置**。

### 1. 持久化配置 (推荐个人开发者)
```bash
# 查看当前配置
node scripts/douyin.js config

# 设置配置项
node scripts/douyin.js config set defaultPrivateStatus 0
node scripts/douyin.js config set autoConfirm true
node scripts/douyin.js config set whisperModelPath "/path/to/ggml-small.bin"
node scripts/douyin.js config set asrMode whisper-gpu
```

ASR 模式说明：
- `api`：第三方服务 API（默认）
- `whisper-gpu`：本地 `whisper-cli` GPU 转写
- `whisper-cpu`：本地 `whisper-cli` CPU 转写（`--no-gpu`）

### 2. 环境变量覆盖 (推荐 CI/CD)
常用环境变量：
`DOUYIN_CLIENT_KEY`, `DOUYIN_CLIENT_SECRET`, `DOUYIN_REDIRECT_URI`, `DOUYIN_TOKEN_ENC_KEY`, `DOUYIN_WHISPER_BIN`, `DOUYIN_OUTBOX_DIR`, `DOUYIN_ASR_MODE`, `DOUYIN_ASR_API_URL`, `DOUYIN_ASR_API_MODEL`, `DOUYIN_ASR_API_KEY`...
> 💡 **CI 小贴士：** 可以在 CI 环境中设置一个稳定的加密密钥：
> `export DOUYIN_TOKEN_ENC_KEY="$(openssl rand -hex 32)"`

---

## 📄 JSON 响应格式 (Output Contract)

为了方便与其他程序交互，不同命令会有固定的返回格式（截取部分）：

<details>
<summary><b>点击查看 Prepare 输出格式</b></summary>

```json
{
  "ok": true,
  "command": "prepare",
  "video": {
    "inputPath": "./demo.mp4",
    "normalizedPath": "/abs/path/demo.mp4",
    "sha256": "…",
    "sizeBytes": 123,
    "durationSec": 45
  },
  "transcript": { "source": "whisper", "text": "提取的文本内容…", "segments": [] },
  "limits": { "maxDurationSec": 900, "maxTextLen": 1000 }
}
```

</details>

---

## 🧾 命令行速查

## 📦 降级发件箱机制

## 🔐 安全与隐私

## ❓ 常见问题 Q&A


path: agents/openai.yaml
content: interface:
  display_name: "Douyin Upload Skill"
  short_description: "Publish local Douyin videos with ASR and captions"
  default_prompt: "Authorize Douyin, transcribe local video audio, draft captions, and publish with confirmation."


path: scripts/douyin.js
content: #!/usr/bin/env node

const crypto = require("crypto");
const fs = require("fs");
const fsp = fs.promises;
const path = require("path");
const { spawn, spawnSync } = require("child_process");

const { confirm, parseArgs, parseBoolean, parseInteger, printJson, prompt, toErrorPayload } = require("./lib/cli-utils");
const { LIMITS } = require("./lib/constants");
const { assertAuthEnv, loadConfig, readConfigSync, setConfigKey } = require("./lib/config");
const { ensureDir, fileExists, readJson, writeJsonAtomic } = require("./lib/fs-utils");
const { normalizeLocalPath } = require("./lib/path-utils");
const { isAccessTokenExpired, loadTokenData, normalizeTokenPayload, saveTokenData } = require("./lib/token-store");
const {
  DouyinApiError,
  buildAuthUrl,
  createVideo,
  exchangeCodeForToken,
  isPermissionLikeError,
  refreshAccessToken,
  uploadVideoSmall,
} = require("./lib/douyin-api");
const { computeSha256, getTranscriptCachePath, getVideoMetadata, transcribeVideo, validateDurationOrThrow } = require("./lib/media");

function printHelp() {
  const helpText = [
    "douyin-upload-skill CLI",
    "",
    "Usage:",
    "  node scripts/douyin.js doctor",
    "  node scripts/douyin.js auth [--code <code>] [--callback-url <url>] [--scope <scope>] [--open true|false]",
    "  node scripts/douyin.js prepare --video <path>",
    "  node scripts/douyin.js publish --video <path> --text <caption> [--private-status 0|1|2] [--auto-confirm true|false]",
    "  node scripts/douyin.js config list",
    "  node scripts/douyin.js config get <key>",
    "  node scripts/douyin.js config set <key> <value>",
    "",
    "Environment:",
    "  DOUYIN_CLIENT_KEY, DOUYIN_CLIENT_SECRET, DOUYIN_REDIRECT_URI",
    "  DOUYIN_TOKEN_ENC_KEY (optional)",
    "  DOUYIN_ASR_MODE, DOUYIN_ASR_API_URL, DOUYIN_ASR_API_MODEL, DOUYIN_ASR_API_KEY (optional)",
  ];
  process.stdout.write(`${helpText.join("\n")}\n`);
}

function ensureFileReadable(filePath, label = "file") {
  if (!fs.existsSync(filePath)) {
    const error = new Error(`${label} not found: ${filePath}`);
    error.code = "FILE_NOT_FOUND";
    throw error;
  }

  try {
    fs.accessSync(filePath, fs.constants.R_OK);
  } catch {
    const error = new Error(`${label} is not readable: ${filePath}`);
    error.code = "FILE_NOT_READABLE";
    throw error;
  }
}

function extractCode(rawInput) {
  const input = String(rawInput || "").trim();
  if (!input) {
    return "";
  }

  if (/^https?:\/\//i.test(input)) {
    const url = new URL(input);
    return url.searchParams.get("code") || "";
  }

  if (input.includes("code=")) {
    const url = new URL(input.startsWith("http") ? input : `https://dummy.local/?${input.replace(/^\?/, "")}`);
    return url.searchParams.get("code") || "";
  }

  return input;
}

function openUrl(url) {
  const proc = spawn("xdg-open", [url], {
    detached: true,
    stdio: "ignore",
  });
  proc.unref();
}

function commandExists(commandName) {
  if (commandName.includes("/")) {
    return fs.existsSync(commandName);
  }

  const result = spawnSync("which", [commandName], { stdio: "ignore" });
  return result.status === 0;
}

function normalizeCaption(inputText) {
  const text = String(inputText || "").trim();
  if (!text) {
    const error = new Error("Caption text is required. Use --text.");
    error.code = "CAPTION_REQUIRED";
    throw error;
  }

  if (text.length <= LIMITS.maxCaptionLength) {
    return { text, truncated: false };
  }

  return {
    text: text.slice(0, LIMITS.maxCaptionLength),
    truncated: true,
  };
}

async function maybeRefreshToken(tokenData) {
  if (!isAccessTokenExpired(tokenData)) {
    return tokenData;
  }

  if (!tokenData.refreshToken) {
    const error = new Error("Access token expired and refresh_token is missing. Re-run auth.");
    error.code = "TOKEN_EXPIRED";
    throw error;
  }

  const { clientKey, clientSecret } = assertAuthEnv();
  const refreshed = await refreshAccessToken({
    clientKey,
    clientSecret,
    refreshToken: tokenData.refreshToken,
  });

  const normalized = normalizeTokenPayload(refreshed, tokenData);
  await saveTokenData(normalized);
  return normalized;
}

async function loadCachedTranscript(config, videoSha256) {
  const cachePath = getTranscriptCachePath(videoSha256, config);
  const legacyPath = path.join(config.transcriptCacheDir, `${videoSha256}.json`);
  const cached = (await readJson(cachePath, null)) || (await readJson(legacyPath, null));
  if (!cached) {
    return {
      text: "",
      segments: [],
      source: "none",
    };
  }

  return {
    text: String(cached.text || ""),
    segments: Array.isArray(cached.segments) ? cached.segments : [],
    source: "cache",
  };
}

async function createFallbackOutbox({
  config,
  videoPath,
  caption,
  privateStatus,
  transcript,
  reason,
}) {
  await ensureDir(config.outboxDir);

  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
  const id = crypto.randomBytes(3).toString("hex");
  const outboxPath = path.join(config.outboxDir, `${stamp}-${id}`);
  await ensureDir(outboxPath);

  await fsp.writeFile(path.join(outboxPath, "caption.txt"), `${caption}\n`, { mode: 0o600 });
  await fsp.writeFile(path.join(outboxPath, "transcript.txt"), `${transcript.text || ""}\n`, { mode: 0o600 });
  await fsp.writeFile(path.join(outboxPath, "source-path.txt"), `${videoPath}\n`, { mode: 0o600 });

  await writeJsonAtomic(path.join(outboxPath, "publish-meta.json"), {
    privateStatus,
    createdAt: new Date().toISOString(),
    reason,
  }, 0o600);

  return outboxPath;
}

async function cmdDoctor() {
  const config = await loadConfig();
  const asrMode = String(config.asrMode || "api").trim().toLowerCase();
  const asrModeOk = ["api", "whisper-gpu", "whisper-cpu"].includes(asrMode);

  const checks = {
    ffmpeg: {
      command: config.ffmpegBin,
      ok: commandExists(config.ffmpegBin),
    },
    ffprobe: {
      command: config.ffprobeBin,
      ok: commandExists(config.ffprobeBin),
    },
    xdgOpen: {
      command: "xdg-open",
      ok: commandExists("xdg-open"),
    },
    asrMode: {
      value: asrMode,
      ok: asrModeOk,
    },
  };

  if (asrMode === "whisper-gpu" || asrMode === "whisper-cpu") {
    checks.whisperCli = {
      command: config.whisperBin,
      ok: commandExists(config.whisperBin),
    };
    checks.whisperModel = {
      path: config.whisperModelPath,
      ok: await fileExists(config.whisperModelPath),
    };
  } else if (asrMode === "api") {
    checks.asrApiUrl = {
      value: config.asrApiUrl,
      ok: Boolean(String(config.asrApiUrl || "").trim()),
    };
    checks.asrApiKey = {
      env: "DOUYIN_ASR_API_KEY",
      ok: Boolean(String(process.env.DOUYIN_ASR_API_KEY || "").trim()),
    };
  }

  const env = {
    DOUYIN_CLIENT_KEY: Boolean(process.env.DOUYIN_CLIENT_KEY),
    DOUYIN_CLIENT_SECRET: Boolean(process.env.DOUYIN_CLIENT_SECRET),
    DOUYIN_REDIRECT_URI: Boolean(process.env.DOUYIN_REDIRECT_URI),
    DOUYIN_ASR_API_KEY: Boolean(process.env.DOUYIN_ASR_API_KEY),
  };

  const installHints = [
    "sudo apt-get update && sudo apt-get install -y ffmpeg cmake build-essential curl jq",
    "git clone https://github.com/ggerganov/whisper.cpp.git && cd whisper.cpp && cmake -B build && cmake --build build -j",
    "ln -sf $(pwd)/build/bin/whisper-cli ~/.local/bin/whisper-cli",
    "mkdir -p ~/.cache/whisper.cpp && curl -L https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin -o ~/.cache/whisper.cpp/ggml-small.bin",
    "export DOUYIN_ASR_MODE=api && export DOUYIN_ASR_API_KEY=*** && export DOUYIN_ASR_API_URL=https://api.openai.com/v1/audio/transcriptions",
    "or set local whisper mode: node scripts/douyin.js config set asrMode whisper-gpu",
  ];

  const missing = Object.entries(checks)
    .filter(([, value]) => !value.ok)
    .map(([key]) => key);

  printJson({
    ok: missing.length === 0,
    command: "doctor",
    checks,
    env,
    config,
    installHints,
  });
}

async function cmdAuth(options) {
  const config = await loadConfig();
  const { clientKey, clientSecret, redirectUri } = assertAuthEnv();

  const scope = String(options.scope || config.scope).trim();
  const state = crypto.randomBytes(8).toString("hex");
  const authUrl = buildAuthUrl({
    clientKey,
    redirectUri,
    scope,
    state,
  });

  const shouldOpen = parseBoolean(options.open, true);
  if (shouldOpen) {
    try {
      openUrl(authUrl);
    } catch (_) {
      // Ignore browser open failures.
    }
  }

  let rawCode = options.code || "";
  if (!rawCode && options["callback-url"]) {
    rawCode = extractCode(options["callback-url"]);
  }

  if (!rawCode) {
    if (!process.stdin.isTTY) {
      const error = new Error("No --code provided and terminal is non-interactive.");
      error.code = "AUTH_CODE_REQUIRED";
      throw error;
    }

    process.stderr.write(`Open this URL and authorize:\n${authUrl}\n`);
    const input = await prompt("Paste `code` or callback URL: ");
    rawCode = extractCode(input);
  }

  const code = extractCode(rawCode);
  if (!code) {
    const error = new Error("Cannot extract OAuth code.");
    error.code = "AUTH_CODE_INVALID";
    throw error;
  }

  const tokenPayload = await exchangeCodeForToken({
    clientKey,
    clientSecret,
    redirectUri,
    code,
  });

  const normalized = normalizeTokenPayload(tokenPayload);
  await saveTokenData(normalized);

  printJson({
    ok: true,
    command: "auth",
    authUrl,
    scope,
    token: {
      openId: normalized.openId,
      scope: normalized.scope,
      expiresAt: new Date(normalized.expiresAt).toISOString(),
      refreshExpiresAt: new Date(normalized.refreshExpiresAt).toISOString(),
    },
  });
}

async function cmdPrepare(options) {
  const videoInput = options.video;
  if (!videoInput) {
    const error = new Error("Missing required argument: --video <path>");
    error.code = "VIDEO_REQUIRED";
    throw error;
  }

  const config = await loadConfig();
  const normalizedPath = normalizeLocalPath(videoInput);
  ensureFileReadable(normalizedPath, "Video file");

  const sha256 = await computeSha256(normalizedPath);
  const metadata = await getVideoMetadata(normalizedPath, config);
  validateDurationOrThrow(metadata.durationSec);

  let transcript = {
    source: "none",
    text: "",
    segments: [],
  };
  let asrError = null;

  try {
    transcript = await transcribeVideo(normalizedPath, sha256, config);
  } catch (error) {
    asrError = {
      message: error.message,
      code: error.code || null,
    };
  }

  printJson({
    ok: true,
    command: "prepare",
    video: {
      inputPath: videoInput,
      normalizedPath,
      sha256,
      sizeBytes: metadata.sizeBytes,
      durationSec: metadata.durationSec,
      width: metadata.width,
      height: metadata.height,
    },
    transcript,
    asrError,
    limits: {
      maxDurationSec: LIMITS.maxDurationSec,
      maxTextLen: LIMITS.maxCaptionLength,
    },
  });
}

async function cmdPublish(options) {
  const videoInput = options.video;
  if (!videoInput) {
    const error = new Error("Missing required argument: --video <path>");
    error.code = "VIDEO_REQUIRED";
    throw error;
  }

  const config = await loadConfig();
  const normalizedPath = normalizeLocalPath(videoInput);
  ensureFileReadable(normalizedPath, "Video file");

  const requestedPrivateStatus = parseInteger(options["private-status"], config.defaultPrivateStatus);
  if (![0, 1, 2].includes(requestedPrivateStatus)) {
    const error = new Error("private-status must be one of: 0, 1, 2");
    error.code = "PRIVATE_STATUS_INVALID";
    throw error;
  }

  let captionInput = options.text || "";
  if (!captionInput && process.stdin.isTTY) {
    captionInput = await prompt("Paste final caption text: ");
  }

  const caption = normalizeCaption(captionInput);
  const autoConfirm = parseBoolean(options["auto-confirm"], config.autoConfirm);

  if (!autoConfirm) {
    if (!process.stdin.isTTY) {
      const error = new Error("Interactive confirmation is required. Pass --auto-confirm true in non-interactive mode.");
      error.code = "CONFIRMATION_REQUIRED";
      throw error;
    }
    const accepted = await confirm("Publish to Douyin now?", false);
    if (!accepted) {
      printJson({
        ok: false,
        command: "publish",
        cancelled: true,
      });
      return;
    }
  }

  const metadata = await getVideoMetadata(normalizedPath, config);
  validateDurationOrThrow(metadata.durationSec);

  const videoSha256 = await computeSha256(normalizedPath);
  const transcript = await loadCachedTranscript(config, videoSha256);

  let tokenData = await loadTokenData();
  if (!tokenData) {
    const error = new Error("Token not found. Run auth first.");
    error.code = "TOKEN_NOT_FOUND";
    throw error;
  }

  tokenData = await maybeRefreshToken(tokenData);

  try {
    const uploaded = await uploadVideoSmall({
      accessToken: tokenData.accessToken,
      videoPath: normalizedPath,
    });

    const published = await createVideo({
      accessToken: tokenData.accessToken,
      videoId: uploaded.videoId,
      text: caption.text,
      privateStatus: requestedPrivateStatus,
    });

    printJson({
      ok: true,
      command: "publish",
      mode: "official",
      itemId: published.itemId,
      videoId: published.videoId || uploaded.videoId,
      openId: tokenData.openId,
      textTruncated: caption.truncated,
      privateStatus: requestedPrivateStatus,
    });
    return;
  } catch (error) {
    if (!(error instanceof DouyinApiError) || !isPermissionLikeError(error)) {
      throw error;
    }

    const outboxPath = await createFallbackOutbox({
      config,
      videoPath: normalizedPath,
      caption: caption.text,
      privateStatus: requestedPrivateStatus,
      transcript,
      reason: {
        message: error.message,
        code: error.code,
      },
    });

    printJson({
      ok: true,
      command: "publish",
      mode: "fallback",
      outboxPath,
      reason: {
        message: error.message,
        code: error.code,
      },
      textTruncated: caption.truncated,
      privateStatus: requestedPrivateStatus,
    });
  }
}

async function cmdConfig(positionals) {
  const action = positionals[0];
  if (!action || action === "list") {
    const config = await loadConfig();
    printJson({ ok: true, command: "config", config });
    return;
  }

  if (action === "get") {
    const key = positionals[1];
    if (!key) {
      throw new Error("Usage: config get <key>");
    }

    const config = readConfigSync();
    printJson({
      ok: true,
      command: "config",
      key,
      value: config[key],
    });
    return;
  }

  if (action === "set") {
    const key = positionals[1];
    const value = positionals[2];
    if (!key || value === undefined) {
      throw new Error("Usage: config set <key> <value>");
    }

    const updated = await setConfigKey(key, value);
    printJson({
      ok: true,
      command: "config",
      key,
      value: updated[key],
      config: updated,
    });
    return;
  }

  throw new Error(`Unknown config action: ${action}`);
}

async function main() {
  const { positionals, options } = parseArgs(process.argv.slice(2));
  const [command, ...rest] = positionals;

  if (!command || ["-h", "--help", "help"].includes(command)) {
    printHelp();
    return;
  }

  switch (command) {
    case "doctor":
      await cmdDoctor();
      break;
    case "auth":
      await cmdAuth(options);
      break;
    case "prepare":
      await cmdPrepare(options);
      break;
    case "publish":
      await cmdPublish(options);
      break;
    case "config":
      await cmdConfig(rest);
      break;
    default:
      throw new Error(`Unknown command: ${command}`);
  }
}

main().catch((error) => {
  printJson(toErrorPayload(error));
  process.exitCode = 1;
});


path: scripts/lib/cli-utils.js
content: const readline = require("readline");

function parseArgs(argv) {
  const args = Array.from(argv || []);
  const positionals = [];
  const options = {};

  for (let i = 0; i < args.length; i += 1) {
    const [REDACTED_SECRET];
    if (!token.startsWith("--")) {
      positionals.push(token);
      continue;
    }

    const eqIdx = token.indexOf("=");
    if (eqIdx > -1) {
      const key = token.slice(2, eqIdx);
      const value = token.slice(eqIdx + 1);
      options[key] = value;
      continue;
    }

    const key = token.slice(2);
    if (key.startsWith("no-")) {
      options[key.slice(3)] = false;
      continue;
    }

    const next = args[i + 1];
    if (next && !next.startsWith("--")) {
      options[key] = next;
      i += 1;
      continue;
    }

    options[key] = true;
  }

  return { positionals, options };
}

function parseBoolean(value, defaultValue = false) {
  if (typeof value === "boolean") {
    return value;
  }
  if (value === undefined || value === null || value === "") {
    return defaultValue;
  }

  const normalized = String(value).trim().toLowerCase();
  if (["1", "true", "yes", "y", "on"].includes(normalized)) {
    return true;
  }
  if (["0", "false", "no", "n", "off"].includes(normalized)) {
    return false;
  }

  throw new Error(`Invalid boolean value: ${value}`);
}

function parseInteger(value, defaultValue = null) {
  if (value === undefined || value === null || value === "") {
    return defaultValue;
  }
  const parsed = Number.parseInt(String(value), 10);
  if (Number.isNaN(parsed)) {
    throw new Error(`Invalid integer value: ${value}`);
  }
  return parsed;
}

function printJson(payload) {
  process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
}

function prompt(question) {
  return new Promise((resolve) => {
    const rl = readline.createInterface({
      input: process.stdin,
      output: process.stdout,
    });
    rl.question(question, (answer) => {
      rl.close();
      resolve(answer);
    });
  });
}

async function confirm(question, defaultYes = false) {
  const suffix = defaultYes ? "[Y/n]" : "[y/N]";
  const answer = (await prompt(`${question} ${suffix} `)).trim().toLowerCase();
  if (!answer) {
    return defaultYes;
  }
  return ["y", "yes"].includes(answer);
}

function toErrorPayload(error) {
  return {
    ok: false,
    error: {
      message: error.message,
      code: error.code || null,
      details: error.details || null,
    },
  };
}

module.exports = {
  confirm,
  parseArgs,
  parseBoolean,
  parseInteger,
  printJson,
  prompt,
  toErrorPayload,
};


path: scripts/lib/config.js
content: const fs = require("fs");
const path = require("path");

const {
  CONFIG_DIR,
  CONFIG_PATH,
  DEFAULT_CONFIG,
} = require("./constants");
const { ensureDir, readJson, writeJsonAtomic } = require("./fs-utils");
const { parseBoolean, parseInteger } = require("./cli-utils");

function applyEnvOverrides(config) {
  const merged = { ...config };

  if (process.env.DOUYIN_SCOPE) merged.scope = process.env.DOUYIN_SCOPE;
  if (process.env.DOUYIN_ASR_MODE) merged.asrMode = process.env.DOUYIN_ASR_MODE;
  if (process.env.DOUYIN_ASR_API_URL) merged.asrApiUrl = process.env.DOUYIN_ASR_API_URL;
  if (process.env.DOUYIN_ASR_API_MODEL) merged.asrApiModel = process.env.DOUYIN_ASR_API_MODEL;
  if (process.env.DOUYIN_FFMPEG_BIN) merged.ffmpegBin = process.env.DOUYIN_FFMPEG_BIN;
  if (process.env.DOUYIN_FFPROBE_BIN) merged.ffprobeBin = process.env.DOUYIN_FFPROBE_BIN;
  if (process.env.DOUYIN_WHISPER_BIN) merged.whisperBin = process.env.DOUYIN_WHISPER_BIN;
  if (process.env.DOUYIN_WHISPER_MODEL_PATH) merged.whisperModelPath = process.env.DOUYIN_WHISPER_MODEL_PATH;
  if (process.env.DOUYIN_WHISPER_LANG) merged.whisperLang = process.env.DOUYIN_WHISPER_LANG;
  if (process.env.DOUYIN_OUTBOX_DIR) merged.outboxDir = process.env.DOUYIN_OUTBOX_DIR;
  if (process.env.DOUYIN_TRANSCRIPT_CACHE_DIR) merged.transcriptCacheDir = process.env.DOUYIN_TRANSCRIPT_CACHE_DIR;
  if (process.env.DOUYIN_ASR_API_TIMEOUT_MS !== undefined) {
    merged.asrApiTimeoutMs = parseInteger(process.env.DOUYIN_ASR_API_TIMEOUT_MS, merged.asrApiTimeoutMs);
  }

  if (process.env.DOUYIN_DEFAULT_PRIVATE_STATUS !== undefined) {
    merged.defaultPrivateStatus = parseInteger(process.env.DOUYIN_DEFAULT_PRIVATE_STATUS, merged.defaultPrivateStatus);
  }

  if (process.env.DOUYIN_AUTO_CONFIRM !== undefined) {
    merged.autoConfirm = parseBoolean(process.env.DOUYIN_AUTO_CONFIRM, merged.autoConfirm);
  }

  return merged;
}

async function loadConfig() {
  await ensureDir(CONFIG_DIR);
  const fileConfig = (await readJson(CONFIG_PATH, {})) || {};
  return applyEnvOverrides({ ...DEFAULT_CONFIG, ...fileConfig });
}

async function saveConfig(config) {
  await ensureDir(CONFIG_DIR);
  await writeJsonAtomic(CONFIG_PATH, config, 0o600);
}

function validateConfigValue(key, rawValue) {
  switch (key) {
    case "defaultPrivateStatus": {
      const value = parseInteger(rawValue, null);
      if (![0, 1, 2].includes(value)) {
        throw new Error("defaultPrivateStatus must be one of: 0, 1, 2.");
      }
      return value;
    }
    case "asrApiTimeoutMs": {
      const value = parseInteger(rawValue, null);
      if (!Number.isFinite(value) || value <= 0) {
        throw new Error("asrApiTimeoutMs must be a positive integer.");
      }
      return value;
    }
    case "asrMode": {
      const value = String(rawValue || "").trim().toLowerCase();
      const allowed = ["api", "whisper-gpu", "whisper-cpu"];
      if (!allowed.includes(value)) {
        throw new Error(`asrMode must be one of: ${allowed.join(", ")}`);
      }
      return value;
    }
    case "autoConfirm":
      return parseBoolean(rawValue);
    case "scope":
    case "asrApiUrl":
    case "asrApiModel":
    case "ffmpegBin":
    case "ffprobeBin":
    case "whisperBin":
    case "whisperModelPath":
    case "whisperLang":
    case "outboxDir":
    case "transcriptCacheDir": {
      const value = String(rawValue || "").trim();
      if (!value) {
        throw new Error(`${key} cannot be empty.`);
      }
      return value;
    }
    default:
      throw new Error(`Unknown config key: ${key}`);
  }
}

async function setConfigKey(key, rawValue) {
  const config = await loadConfig();
  const value = validateConfigValue(key, rawValue);
  const next = { ...config, [key]: value };
  await saveConfig(next);
  return next;
}

function getAuthEnv() {
  return {
    clientKey: process.env.DOUYIN_CLIENT_KEY || "",
    clientSecret: process.env.DOUYIN_CLIENT_SECRET || "",
    redirectUri: process.env.DOUYIN_REDIRECT_URI || "",
  };
}

function assertAuthEnv() {
  const env = getAuthEnv();
  const missing = [];
  if (!env.clientKey) missing.push("DOUYIN_CLIENT_KEY");
  if (!env.clientSecret) missing.push("DOUYIN_CLIENT_SECRET");
  if (!env.redirectUri) missing.push("DOUYIN_REDIRECT_URI");
  if (missing.length > 0) {
    const error = new Error(`Missing required environment variables: ${missing.join(", ")}`);
    error.code = "MISSING_ENV";
    error.details = { missing };
    throw error;
  }
  return env;
}

function readConfigSync() {
  if (!fs.existsSync(CONFIG_PATH)) {
    return { ...DEFAULT_CONFIG };
  }
  const text = fs.readFileSync(CONFIG_PATH, "utf8");
  return { ...DEFAULT_CONFIG, ...JSON.parse(text) };
}

module.exports = {
  CONFIG_PATH,
  assertAuthEnv,
  getAuthEnv,
  loadConfig,
  readConfigSync,
  saveConfig,
  setConfigKey,
};


path: scripts/lib/constants.js
content: const os = require("os");
const path = require("path");

const APP_NAME = "douyin-upload-skill";

const CONFIG_DIR = path.join(os.homedir(), ".config", APP_NAME);
const CACHE_DIR = path.join(os.homedir(), ".cache", APP_NAME);
const DATA_DIR = path.join(os.homedir(), ".local", "share", APP_NAME);

const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
const TOKENS_PATH = path.join(CONFIG_DIR, "tokens.enc.json");
const TOKEN_KEY_PATH = path.join(CONFIG_DIR, "token.key");

const DEFAULT_TRANSCRIPT_CACHE_DIR = path.join(CACHE_DIR, "transcripts");
const DEFAULT_OUTBOX_DIR = path.join(DATA_DIR, "outbox");
const DEFAULT_WHISPER_MODEL_PATH = path.join(os.homedir(), ".cache", "whisper.cpp", "ggml-small.bin");
const DEFAULT_ASR_API_URL = "https://api.openai.com/v1/audio/transcriptions";
const DEFAULT_ASR_API_MODEL = "whisper-1";

const DEFAULT_CONFIG = {
  defaultPrivateStatus: 0,
  autoConfirm: false,
  scope: "user_info,video.create.bind",
  asrMode: "api",
  asrApiUrl: DEFAULT_ASR_API_URL,
  asrApiModel: DEFAULT_ASR_API_MODEL,
  asrApiTimeoutMs: 120000,
  ffmpegBin: "ffmpeg",
  ffprobeBin: "ffprobe",
  whisperBin: "whisper-cli",
  whisperModelPath: DEFAULT_WHISPER_MODEL_PATH,
  whisperLang: "zh",
  transcriptCacheDir: DEFAULT_TRANSCRIPT_CACHE_DIR,
  outboxDir: DEFAULT_OUTBOX_DIR,
};

const LIMITS = {
  maxDurationSec: 15 * 60,
  maxCaptionLength: 1000,
};

const DOUYIN_BASE_URL = "https://open.douyin.com";

module.exports = {
  APP_NAME,
  CACHE_DIR,
  CONFIG_DIR,
  CONFIG_PATH,
  DATA_DIR,
  DEFAULT_ASR_API_MODEL,
  DEFAULT_ASR_API_URL,
  DEFAULT_CONFIG,
  DEFAULT_OUTBOX_DIR,
  DEFAULT_TRANSCRIPT_CACHE_DIR,
  DEFAULT_WHISPER_MODEL_PATH,
  DOUYIN_BASE_URL,
  LIMITS,
  TOKEN_KEY_PATH,
  TOKENS_PATH,
};


path: scripts/lib/douyin-api.js
content: const path = require("path");
const fs = require("fs");

const { DOUYIN_BASE_URL } = require("./constants");

class DouyinApiError extends Error {
  constructor(message, options = {}) {
    super(message);
    this.name = "DouyinApiError";
    this.code = options.code || null;
    this.status = options.status || null;
    this.details = options.details || null;
    this.response = options.response || null;
  }
}

function buildAuthUrl({ clientKey, redirectUri, scope, state }) {
  const params = new URLSearchParams({
    client_key: clientKey,
    response_type: "code",
    redirect_uri: redirectUri,
    scope,
    state,
  });
  return `${DOUYIN_BASE_URL}/platform/oauth/connect/?${params.toString()}`;
}

async function parseJsonResponse(response) {
  const text = await response.text();
  let json = null;
  if (text) {
    try {
      json = JSON.parse(text);
    } catch (_) {
      json = { raw: text };
    }
  }

  return {
    text,
    json,
  };
}

function extractErrorFromPayload(payload, statusCode) {
  if (!payload || typeof payload !== "object") {
    return null;
  }

  const candidates = [
    {
      code: payload.error_code,
      message: payload.description || payload.error_description || payload.message,
    },
    {
      code: payload.err_no,
      message: payload.err_tips || payload.message,
    },
    {
      code: payload.code,
      message: payload.msg || payload.message,
    },
    {
      code: payload?.extra?.error_code,
      message: payload?.extra?.description || payload?.extra?.error_msg,
    },
  ];

  for (const item of candidates) {
    if (item.code === undefined || item.code === null) {
      continue;
    }

    const numeric = Number(item.code);
    if (!Number.isNaN(numeric) && numeric === 0) {
      continue;
    }

    if (String(item.code) === "0") {
      continue;
    }

    return new DouyinApiError(item.message || "Douyin API returned an error", {
      code: String(item.code),
      status: statusCode,
      details: payload,
      response: payload,
    });
  }

  return null;
}

function unwrapData(payload) {
  if (payload && typeof payload === "object" && payload.data && typeof payload.data === "object") {
    return payload.data;
  }
  return payload;
}

async function requestJson(url, options = {}) {
  const response = await fetch(url, options);
  const parsed = await parseJsonResponse(response);

  if (!response.ok) {
    const payloadError = extractErrorFromPayload(parsed.json, response.status);
    if (payloadError) {
      throw payloadError;
    }

    throw new DouyinApiError(
      (parsed.json && (parsed.json.message || parsed.json.description || parsed.json.error_description))
      || `Douyin API HTTP ${response.status}`,
      {
        code: `HTTP_${response.status}`,
        status: response.status,
        details: parsed.json || parsed.text,
        response: parsed.json,
      }
    );
  }

  const apiError = extractErrorFromPayload(parsed.json, response.status);
  if (apiError) {
    throw apiError;
  }

  return unwrapData(parsed.json);
}

function extractTokenData(data) {
  if (!data || typeof data !== "object") {
    throw new DouyinApiError("Invalid token payload format.", {
      code: "TOKEN_PAYLOAD_INVALID",
      details: data,
    });
  }

  return {
    access_token: data.access_token,
    refresh_token: data.refresh_token,
    expires_in: data.expires_in,
    refresh_expires_in: data.refresh_expires_in,
    open_id: data.open_id,
    scope: data.scope,
  };
}

async function exchangeCodeForToken({ clientKey, clientSecret, redirectUri, code }) {
  const body = new URLSearchParams({
    client_key: clientKey,
    client_secret: clientSecret,
    grant_type: "authorization_code",
    code,
    redirect_uri: redirectUri,
  });

  const data = await requestJson(`${DOUYIN_BASE_URL}/oauth/access_token/`, {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body,
  });

  return extractTokenData(data);
}

async function refreshAccessToken({ clientKey, clientSecret, refreshToken }) {
  const body = new URLSearchParams({
    client_key: clientKey,
    client_secret: clientSecret,
    grant_type: "refresh_token",
    refresh_token: refreshToken,
  });

  const data = await requestJson(`${DOUYIN_BASE_URL}/oauth/refresh_token/`, {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body,
  });

  return extractTokenData(data);
}

function detectVideoId(payload) {
  const candidates = [
    payload?.video_id,
    payload?.video?.video_id,
    payload?.video?.id,
    payload?.data?.video_id,
    payload?.data?.video?.video_id,
  ];

  for (const item of candidates) {
    if (item) {
      return String(item);
    }
  }

  return null;
}

async function uploadVideoSmall({ accessToken, videoPath }) {
  const buffer = await fs.promises.readFile(videoPath);
  const fileName = path.basename(videoPath);

  const form = new FormData();
  form.append("access_token", accessToken);
  form.append("video", new File([buffer], fileName));

  const payload = await requestJson(`${DOUYIN_BASE_URL}/api/douyin/v1/video/upload_video/`, {
    method: "POST",
    body: form,
  });

  const videoId = detectVideoId(payload);
  if (!videoId) {
    throw new DouyinApiError("Cannot find video_id from upload response.", {
      code: "UPLOAD_VIDEO_ID_MISSING",
      details: payload,
    });
  }

  return {
    videoId,
    raw: payload,
  };
}

function detectCreateResult(payload) {
  const itemId = payload?.item_id || payload?.item?.item_id || payload?.aweme_id || null;
  const videoId = payload?.video_id || payload?.video?.video_id || null;
  return {
    itemId: itemId ? String(itemId) : null,
    videoId: videoId ? String(videoId) : null,
    raw: payload,
  };
}

async function createVideo({ accessToken, videoId, text, privateStatus }) {
  const url = `${DOUYIN_BASE_URL}/api/douyin/v1/video/create_video/?access_token=${encodeURIComponent(accessToken)}`;
  const body = {
    text,
    video_id: videoId,
    private_status: Number(privateStatus),
  };

  const payload = await requestJson(url, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(body),
  });

  return detectCreateResult(payload);
}

function isPermissionLikeError(error) {
  const text = `${error?.message || ""} ${error?.code || ""}`.toLowerCase();
  const status = Number(error?.status || 0);
  if (status === 401 || status === 403) {
    return true;
  }

  return (
    text.includes("permission") ||
    text.includes("scope") ||
    text.includes("video.create.bind") ||
    text.includes("能力") ||
    text.includes("权限") ||
    text.includes("auth")
  );
}

module.exports = {
  DouyinApiError,
  buildAuthUrl,
  createVideo,
  exchangeCodeForToken,
  isPermissionLikeError,
  refreshAccessToken,
  uploadVideoSmall,
};


path: scripts/lib/fs-utils.js
content: const fs = require("fs");
const fsp = fs.promises;
const path = require("path");
const crypto = require("crypto");

async function ensureDir(dirPath, mode = 0o700) {
  await fsp.mkdir(dirPath, { recursive: true, mode });
  try {
    await fsp.chmod(dirPath, mode);
  } catch (_) {
    // Ignore chmod failures on filesystems that do not support Unix mode bits.
  }
}

async function fileExists(filePath) {
  try {
    await fsp.access(filePath, fs.constants.F_OK);
    return true;
  } catch {
    return false;
  }
}

async function readJson(filePath, fallback = null) {
  if (!(await fileExists(filePath))) {
    return fallback;
  }
  const text = await fsp.readFile(filePath, "utf8");
  return JSON.parse(text);
}

async function writeJsonAtomic(filePath, data, mode = 0o600) {
  const dir = path.dirname(filePath);
  await ensureDir(dir);

  const tempFile = `${filePath}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
  const payload = `${JSON.stringify(data, null, 2)}\n`;
  await fsp.writeFile(tempFile, payload, { mode });
  await fsp.rename(tempFile, filePath);

  try {
    await fsp.chmod(filePath, mode);
  } catch (_) {
    // Ignore chmod failures on filesystems that do not support Unix mode bits.
  }
}

function maskSecret(value, keep = 4) {
  if (!value || typeof value !== "string") {
    return "";
  }
  if (value.length <= keep * 2) {
    return `${"*".repeat(Math.max(0, value.length - keep))}${value.slice(-keep)}`;
  }
  return `${value.slice(0, keep)}***${value.slice(-keep)}`;
}

module.exports = {
  ensureDir,
  fileExists,
  maskSecret,
  readJson,
  writeJsonAtomic,
};


path: scripts/lib/media.js
content: const crypto = require("crypto");
const fs = require("fs");
const fsp = fs.promises;
const os = require("os");
const path = require("path");
const { execFile } = require("child_process");
const { promisify } = require("util");

const { LIMITS } = require("./constants");
const { ensureDir, fileExists, readJson, writeJsonAtomic } = require("./fs-utils");

const execFileAsync = promisify(execFile);

async function runCommand(binary, args, options = {}) {
  try {
    return await execFileAsync(binary, args, {
      maxBuffer: 1024 * 1024 * 50,
      ...options,
    });
  } catch (error) {
    const wrapped = new Error(`Command failed: ${binary} ${args.join(" ")}\n${error.stderr || error.message}`);
    wrapped.code = "COMMAND_FAILED";
    wrapped.details = {
      binary,
      args,
      stderr: error.stderr || "",
      stdout: error.stdout || "",
      exitCode: error.code,
    };
    throw wrapped;
  }
}

async function computeSha256(filePath) {
  const hash = crypto.createHash("sha256");
  const stream = fs.createReadStream(filePath);
  return new Promise((resolve, reject) => {
    stream.on("error", reject);
    stream.on("data", (chunk) => hash.update(chunk));
    stream.on("end", () => resolve(hash.digest("hex")));
  });
}

async function getVideoMetadata(videoPath, config) {
  const args = [
    "-v", "error",
    "-print_format", "json",
    "-show_streams",
    "-show_format",
    videoPath,
  ];

  const { stdout } = await runCommand(config.ffprobeBin, args);
  const parsed = JSON.parse(stdout || "{}");
  const streams = Array.isArray(parsed.streams) ? parsed.streams : [];
  const videoStream = streams.find((stream) => stream.codec_type === "video") || {};
  const stats = await fsp.stat(videoPath);

  const durationSec = Number(parsed?.format?.duration || 0);
  const width = Number(videoStream.width || 0);
  const height = Number(videoStream.height || 0);

  return {
    durationSec,
    width,
    height,
    sizeBytes: stats.size,
  };
}

async function extractAudio(videoPath, wavPath, config) {
  const args = [
    "-y",
    "-i", videoPath,
    "-vn",
    "-ac", "1",
    "-ar", "16000",
    "-f", "wav",
    wavPath,
  ];
  await runCommand(config.ffmpegBin, args);
}

function parseWhisperTimestamp(raw) {
  if (typeof raw !== "string") {
    return null;
  }
  const match = raw.match(/^(\d+):(\d+):(\d+)[,.](\d+)$/);
  if (!match) {
    return null;
  }
  const hours = Number(match[1]);
  const minutes = Number(match[2]);
  const seconds = Number(match[3]);
  const millis = Number(match[4].padEnd(3, "0").slice(0, 3));
  return hours * 3600 + minutes * 60 + seconds + millis / 1000;
}

function normalizeWhisperJson(payload) {
  const segments = [];

  if (Array.isArray(payload?.transcription)) {
    for (const item of payload.transcription) {
      const from = parseWhisperTimestamp(item?.timestamps?.from) ?? null;
      const to = parseWhisperTimestamp(item?.timestamps?.to) ?? null;
      const text = String(item?.text || "").trim();
      if (!text) continue;
      segments.push({
        startSec: from,
        endSec: to,
        text,
      });
    }
  } else if (Array.isArray(payload?.segments)) {
    for (const item of payload.segments) {
      const text = String(item?.text || "").trim();
      if (!text) continue;
      const startSec = Number(item.start ?? item.t0 ?? 0);
      const endSec = Number(item.end ?? item.t1 ?? startSec);
      segments.push({
        startSec: Number.isFinite(startSec) ? startSec : null,
        endSec: Number.isFinite(endSec) ? endSec : null,
        text,
      });
    }
  }

  let text = "";
  if (segments.length > 0) {
    text = segments.map((item) => item.text).join(" ").replace(/\s+/g, " ").trim();
  } else if (typeof payload?.text === "string") {
    text = payload.text.trim();
  } else if (typeof payload?.result === "string") {
    text = payload.result.trim();
  }

  return { text, segments };
}

function getAsrMode(config) {
  return String(config?.asrMode || "api").trim().toLowerCase();
}

function buildAsrCacheVariant(config) {
  const mode = getAsrMode(config);
  if (mode === "api") {
    const material = [
      mode,
      config?.asrApiUrl || "",
      config?.asrApiModel || "",
      config?.whisperLang || "",
    ].join("|");
    const digest = crypto.createHash("sha1").update(material).digest("hex").slice(0, 10);
    return `${mode}-${digest}`;
  }
  return mode;
}

function getTranscriptCachePath(videoSha256, config) {
  const variant = buildAsrCacheVariant(config);
  return path.join(config.transcriptCacheDir, `${videoSha256}.${variant}.json`);
}

function getLegacyTranscriptCachePath(videoSha256, config) {
  return path.join(config.transcriptCacheDir, `${videoSha256}.json`);
}

function normalizeAsrApiResponse(payload) {
  let body = payload;
  if (body && typeof body === "object" && body.data && typeof body.data === "object") {
    body = body.data;
  }

  if (typeof body === "string") {
    return { text: body.trim(), segments: [] };
  }

  if (!body || typeof body !== "object") {
    return { text: "", segments: [] };
  }

  const whisperLike = normalizeWhisperJson(body);
  if (whisperLike.text) {
    return whisperLike;
  }

  let text = "";
  if (typeof body.text === "string") text = body.text.trim();
  else if (typeof body.result === "string") text = body.result.trim();
  else if (typeof body.transcript === "string") text = body.transcript.trim();

  const segments = [];
  if (Array.isArray(body.segments)) {
    for (const item of body.segments) {
      const segmentText = String(item?.text || "").trim();
      if (!segmentText) continue;
      const startSec = Number(item.start ?? item.startSec ?? item.t0 ?? 0);
      const endSec = Number(item.end ?? item.endSec ?? item.t1 ?? startSec);
      segments.push({
        startSec: Number.isFinite(startSec) ? startSec : null,
        endSec: Number.isFinite(endSec) ? endSec : null,
        text: segmentText,
      });
    }
  }

  return { text, segments };
}

async function runWhisper(audioPath, config, useGpu) {
  const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "douyin-whisper-"));
  const outputPrefix = path.join(tempDir, "transcript");
  const args = [
    "-m", config.whisperModelPath,
    "-f", audioPath,
    "-oj",
    "-of", outputPrefix,
  ];

  if (config.whisperLang) {
    args.push("-l", config.whisperLang);
  }

  if (!useGpu) {
    args.push("-ng");
  }

  await runCommand(config.whisperBin, args);

  const expectedJson = `${outputPrefix}.json`;
  let jsonPath = expectedJson;
  if (!(await fileExists(jsonPath))) {
    const files = await fsp.readdir(tempDir);
    const found = files.find((file) => file.endsWith(".json"));
    if (!found) {
      throw new Error(`Whisper output JSON not found in ${tempDir}`);
    }
    jsonPath = path.join(tempDir, found);
  }

  const payload = JSON.parse(await fsp.readFile(jsonPath, "utf8"));
  return normalizeWhisperJson(payload);
}

async function runThirdPartyAsrApi(audioPath, config) {
  const apiUrl = String(config.asrApiUrl || "").trim();
  if (!apiUrl) {
    const error = new Error("ASR API mode selected but asrApiUrl is empty.");
    error.code = "MISSING_ASR_API_URL";
    throw error;
  }

  const [REDACTED_SECRET] || "").trim();
  if (!apiKey) {
    const error = new Error("ASR API mode selected but DOUYIN_ASR_API_KEY is missing.");
    error.code = "MISSING_ASR_API_KEY";
    throw error;
  }

  const fileBuffer = await fsp.readFile(audioPath);
  const fileName = path.basename(audioPath);
  const form = new FormData();
  form.append("file", new File([fileBuffer], fileName, { type: "audio/wav" }));

  const model = String(config.asrApiModel || "").trim();
  if (model) {
    form.append("model", model);
  }

  if (config.whisperLang) {
    form.append("language", config.whisperLang);
  }

  const timeoutMs = Number(config.asrApiTimeoutMs || 120000);
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), timeoutMs);

  let response;
  try {
    response = await fetch(apiUrl, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
      },
      body: form,
      signal: controller.signal,
    });
  } catch (error) {
    const wrapped = new Error(`ASR API request failed: ${error.message}`);
    wrapped.code = error.name === "AbortError" ? "ASR_API_TIMEOUT" : "ASR_API_REQUEST_FAILED";
    wrapped.details = {
      apiUrl,
      timeoutMs,
    };
    throw wrapped;
  } finally {
    clearTimeout(timeout);
  }

  const bodyText = await response.text();
  let payload;
  try {
    payload = bodyText ? JSON.parse(bodyText) : {};
  } catch (_) {
    payload = bodyText;
  }

  if (!response.ok) {
    const wrapped = new Error(`ASR API HTTP ${response.status}`);
    wrapped.code = "ASR_API_HTTP_ERROR";
    wrapped.details = {
      status: response.status,
      body: typeof payload === "string" ? payload.slice(0, 500) : payload,
    };
    throw wrapped;
  }

  const normalized = normalizeAsrApiResponse(payload);
  if (!normalized.text) {
    const wrapped = new Error("ASR API succeeded but returned empty transcript.");
    wrapped.code = "ASR_API_EMPTY_TEXT";
    wrapped.details = {
      body: payload,
    };
    throw wrapped;
  }

  return normalized;
}

async function transcribeVideo(videoPath, videoSha256, config) {
  await ensureDir(config.transcriptCacheDir);

  const mode = getAsrMode(config);
  const cachePath = getTranscriptCachePath(videoSha256, config);
  const legacyCachePath = getLegacyTranscriptCachePath(videoSha256, config);
  const cached = (await readJson(cachePath, null)) || (await readJson(legacyCachePath, null));
  if (cached && typeof cached.text === "string") {
    return {
      source: "cache",
      text: cached.text,
      segments: Array.isArray(cached.segments) ? cached.segments : [],
    };
  }

  const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "douyin-asr-"));
  const wavPath = path.join(tmpDir, "audio.wav");

  try {
    await extractAudio(videoPath, wavPath, config);

    let transcription;
    let source = mode;
    if (mode === "whisper-gpu") {
      transcription = await runWhisper(wavPath, config, true);
      source = "whisper-gpu";
    } else if (mode === "whisper-cpu") {
      transcription = await runWhisper(wavPath, config, false);
      source = "whisper-cpu";
    } else if (mode === "api") {
      transcription = await runThirdPartyAsrApi(wavPath, config);
      source = "asr-api";
    } else {
      const error = new Error(`Unsupported asrMode: ${mode}`);
      error.code = "ASR_MODE_INVALID";
      throw error;
    }

    const toCache = {
      text: transcription.text,
      segments: transcription.segments,
      source,
      asrMode: mode,
      updatedAt: new Date().toISOString(),
      videoSha256,
    };
    await writeJsonAtomic(cachePath, toCache, 0o600);

    return {
      source,
      text: transcription.text,
      segments: transcription.segments,
    };
  } finally {
    try {
      await fsp.rm(tmpDir, { recursive: true, force: true });
    } catch (_) {
      // Ignore cleanup failures.
    }
  }
}

function validateDurationOrThrow(durationSec) {
  if (!Number.isFinite(durationSec) || durationSec <= 0) {
    const error = new Error("Cannot read video duration.");
    error.code = "VIDEO_DURATION_INVALID";
    throw error;
  }

  if (durationSec > LIMITS.maxDurationSec) {
    const error = new Error(`Video duration ${durationSec.toFixed(2)}s exceeds ${LIMITS.maxDurationSec}s.`);
    error.code = "VIDEO_DURATION_LIMIT";
    error.details = {
      durationSec,
      maxDurationSec: LIMITS.maxDurationSec,
    };
    throw error;
  }
}

module.exports = {
  computeSha256,
  getTranscriptCachePath,
  getVideoMetadata,
  transcribeVideo,
  validateDurationOrThrow,
  runCommand,
};


path: scripts/lib/path-utils.js
content: const os = require("os");
const path = require("path");

function isWindowsDrivePath(input) {
  return /^[A-Za-z]:[\\/]/.test(input);
}

function toWslPath(input) {
  const driveLetter = input[0].toLowerCase();
  const rest = input.slice(2).replace(/\\/g, "/").replace(/^\/+/, "");
  return `/mnt/${driveLetter}/${rest}`;
}

function expandHome(inputPath) {
  if (!inputPath.startsWith("~")) {
    return inputPath;
  }
  if (inputPath === "~") {
    return os.homedir();
  }
  if (inputPath.startsWith("~/")) {
    return path.join(os.homedir(), inputPath.slice(2));
  }
  return inputPath;
}

function normalizeLocalPath(inputPath) {
  if (!inputPath || typeof inputPath !== "string") {
    throw new Error("Path must be a non-empty string.");
  }

  let normalized = inputPath.trim();
  if (!normalized) {
    throw new Error("Path cannot be empty.");
  }

  if (isWindowsDrivePath(normalized)) {
    normalized = toWslPath(normalized);
  }

  normalized = expandHome(normalized);

  if (!path.isAbsolute(normalized)) {
    normalized = path.resolve(process.cwd(), normalized);
  }

  return path.normalize(normalized);
}

module.exports = {
  isWindowsDrivePath,
  normalizeLocalPath,
  toWslPath,
};


path: scripts/lib/token-store.js
content: const crypto = require("crypto");
const fs = require("fs");
const fsp = fs.promises;

const {
  CONFIG_DIR,
  TOKEN_KEY_PATH,
  TOKENS_PATH,
} = require("./constants");
const { ensureDir, fileExists, readJson, writeJsonAtomic } = require("./fs-utils");

function decodeExternalKey(raw) {
  const value = String(raw || "").trim();
  if (!value) {
    return null;
  }

  if (/^[a-fA-F0-9]{64}$/.test(value)) {
    return Buffer.from(value, "hex");
  }

  try {
    const base64 = Buffer.from(value, "base64");
    if (base64.length === 32) {
      return base64;
    }
  } catch (_) {
    // Ignore and fallback to hash derivation.
  }

  return crypto.createHash("sha256").update(value).digest();
}

async function readOrCreateLocalKey() {
  await ensureDir(CONFIG_DIR);

  if (await fileExists(TOKEN_KEY_PATH)) {
    const raw = (await fsp.readFile(TOKEN_KEY_PATH, "utf8")).trim();
    const decoded = decodeExternalKey(raw);
    if (decoded && decoded.length === 32) {
      return decoded;
    }
    throw new Error(`Invalid encryption key file format at ${TOKEN_KEY_PATH}`);
  }

  const key = crypto.randomBytes(32);
  await fsp.writeFile(TOKEN_KEY_PATH, key.toString("hex") + "\n", { mode: 0o600 });
  try {
    await fsp.chmod(TOKEN_KEY_PATH, 0o600);
  } catch (_) {
    // Ignore chmod failures on filesystems that do not support Unix mode bits.
  }
  return key;
}

async function getEncryptionKey() {
  const fromEnv = decodeExternalKey(process.env.DOUYIN_TOKEN_ENC_KEY || "");
  if (fromEnv && fromEnv.length === 32) {
    return fromEnv;
  }
  return readOrCreateLocalKey();
}

async function saveTokenData(tokenData) {
  const key = await getEncryptionKey();
  const iv = crypto.randomBytes(12);

  const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
  const plaintext = Buffer.from(JSON.stringify(tokenData), "utf8");
  const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
  const tag = cipher.getAuthTag();

  const payload = {
    version: 1,
    algorithm: "aes-256-gcm",
    iv: iv.toString("base64"),
    tag: tag.toString("base64"),
    ciphertext: ciphertext.toString("base64"),
    updatedAt: new Date().toISOString(),
  };

  await writeJsonAtomic(TOKENS_PATH, payload, 0o600);
}

async function loadTokenData() {
  if (!(await fileExists(TOKENS_PATH))) {
    return null;
  }

  const payload = await readJson(TOKENS_PATH, null);
  if (!payload) {
    return null;
  }

  const key = await getEncryptionKey();
  const iv = Buffer.from(payload.iv, "base64");
  const tag = Buffer.from(payload.tag, "base64");
  const ciphertext = Buffer.from(payload.ciphertext, "base64");

  const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
  decipher.setAuthTag(tag);

  const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
  return JSON.parse(plaintext.toString("utf8"));
}

function normalizeTokenPayload(apiData, existing = null) {
  const now = Date.now();

  const accessToken = apiData.access_token || existing?.accessToken;
  const refreshToken = apiData.refresh_token || existing?.refreshToken;
  const openId = apiData.open_id || existing?.openId || "";

  if (!accessToken) {
    const error = new Error("Missing access_token from Douyin response.");
    error.code = "TOKEN_PARSE_ERROR";
    throw error;
  }

  const expiresIn = Number(apiData.expires_in || 0);
  const refreshExpiresIn = Number(apiData.refresh_expires_in || 0);

  const expiresAt = expiresIn > 0 ? now + expiresIn * 1000 : (existing?.expiresAt || now + 3600 * 1000);
  const refreshExpiresAt = refreshExpiresIn > 0
    ? now + refreshExpiresIn * 1000
    : (existing?.refreshExpiresAt || now + 7 * 24 * 3600 * 1000);

  return {
    accessToken,
    refreshToken,
    openId,
    scope: apiData.scope || existing?.scope || "",
    expiresAt,
    refreshExpiresAt,
    updatedAt: new Date().toISOString(),
  };
}

function isAccessTokenExpired(tokenData, skewSeconds = 60) {
  if (!tokenData || !tokenData.expiresAt) {
    return true;
  }
  return Date.now() + skewSeconds * 1000 >= Number(tokenData.expiresAt);
}

module.exports = {
  TOKENS_PATH,
  isAccessTokenExpired,
  loadTokenData,
  normalizeTokenPayload,
  saveTokenData,
};


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