Skill: lycici/keyapi-youtube-video-analysis (version 1.0.0)

--- name: keyapi-youtube-video-analysis description: Analyze YouTube videos at depth — retrieve full metadata, comments, sub-comment threads, stream formats, related video recommendations, Shorts, search results, and trending content across categories. metadata: {"openclaw":{"requires":{"env":["KEYAPI_TOKEN"],"bins":["node"]},"primaryEnv":"KEYAPI_TOKEN","emoji":"🎬"}} author: KeyAPI license: MIT repository: https://github.com/EchoSell/keyapi-skills --- # keyapi-youtube-video-analysis > Analyze YouTube videos at depth — from full metadata and stream formats to comment threads, related recommendations, Shorts discovery, and trending video intelligence. This skill provides comprehensive YouTube video intelligence using the KeyAPI MCP service. It enables detailed video inspection (metadata, views, engagement), layered comment analysis (top-level comments + nested replies), playback stream retrieval, related content discovery, Shorts-specific search, keyword-based video search, and trending video monitoring — all through a cache-first workflow. Use this skill when you need to: - Retrieve complete metadata, engagement metrics, and raw player data for any YouTube video - Read and analyze top-level comments and their nested reply threads - Obtain all available playback stream URLs and quality levels for a video - Discover related and recommended videos as surfaced by the YouTube algorithm - Search YouTube Shorts by keyword with category and time-based filtering - Find videos by keyword with advanced sort and recency filters - Monitor trending videos globally or by category (Music, Gaming, Movies) author: KeyAPI license: MIT repository: https://github.com/EchoSell/keyapi-skills --- ## Prerequisites | Requirement | Details | |-------------|---------| | **KEYAPI_TOKEN** | A valid API token from [keyapi.ai](https://keyapi.ai/). If you don't have one, register at the site to obtain your free token. Set it as an environment variable: `export KEYAPI_TOKEN=your_token_here` | | **Node.js** | v18 or higher | | **Dependencies** | Run `npm install` in the skill directory to install `@modelcontextprotocol/sdk` | author: KeyAPI license: MIT repository: https://github.com/EchoSell/keyapi-skills --- ## MCP Server Configuration All tool calls in this skill target the KeyAPI YouTube MCP server: ``` Server URL : https://mcp.keyapi.ai/youtube/mcp Auth Header: Authorization: Bearer $KEYAPI_TOKEN ``` **Setup (one-time):** ```bash # 1. Install dependencies npm install # 2. Set your API token (get one free at https://keyapi.ai/) export KEYAPI_TOKEN=your_token_here # 3. List all available tools to verify the connection node scripts/run.js --platform youtube --list-tools ``` author: KeyAPI license: MIT repository: https://github.com/EchoSell/keyapi-skills --- ## Analysis Scenarios ### Video Data Nodes | User Need | Node(s) | Best For | |-----------|---------|----------| | Full video metadata, stats, and raw player data | `get_video_information` | Complete video audit — title, description, views, likes, tags, chapters, `playerResponse` + `initialData` | | Video comments (top-level) | `get_video_comments` | Audience sentiment, comment volume analysis — sortable by `top` or `newest` | | Replies to a specific comment | `get_video_sub_comments` | Nested conversation thread analysis — requires `reply_continuation_token` from a comment | | All playback stream URLs and quality levels | `get_video_streams_info` | Download URL extraction, quality enumeration (standard merged + adaptive formats) | | Related / recommended videos | `get_related_videos` | Algorithm-surfaced content map — returns 20–30 recommendations in one call | ### Search & Discovery Nodes | User Need | Node(s) | Best For | |-----------|---------|----------| | Search YouTube Shorts by keyword | `youtube_shorts_search` | Shorts-specific content research — use `continuation_token` for pure Shorts after first call | | Search regular videos by keyword | `search_video` | General video discovery with recency filter (this_week / this_month / this_year / last_hour / today) | | Get trending videos by category | `get_trending_videos` | Market pulse — categories: Now (default), Music, Gaming, Movies | author: KeyAPI license: MIT repository: https://github.com/EchoSell/keyapi-skills --- ## Workflow ### Step 1 — Identify the Video Analysis Objective and Select Nodes Clarify the research goal and map it to one or more nodes. Common patterns: - **Video deep-dive**: Start with `get_video_information` (set `need_format: true` for clean structured output), then layer `get_video_comments` and `get_video_streams_info`. - **Comment thread analysis**: `get_video_comments` → collect `reply_continuation_token` from comments with replies → `get_video_sub_comments` per thread. - **Related content mapping**: `get_related_videos` returns the full recommendation set in one call — no pagination needed. - **Shorts research**: `youtube_shorts_search` with keyword → use `continuation_token` for pure Shorts on subsequent pages. - **Trend monitoring**: `get_trending_videos` with section filter → cross-reference with `search_video` by keyword. > **Video ID extraction** > > Extract the video ID from the URL: > - `https://www.youtube.com/watch?v=oaSNBz4qMQY` → `oaSNBz4qMQY` > > Most endpoints accept either `video_id` (recommended) or `video_url` (full URL string) — use `video_id` where possible for reliability. ### Step 2 — Retrieve API Schema Before calling any node, inspect its input schema to confirm required parameters and available options: ```bash node scripts/run.js --platform youtube --schema <tool_name> # Examples node scripts/run.js --platform youtube --schema get_video_information node scripts/run.js --platform youtube --schema youtube_shorts_search ``` ### Step 3 — Call APIs and Cache Results Locally Execute tool calls and persist responses to the local cache. **Calling a tool:** ```bash node scripts/run.js --platform youtube --tool <tool_name> \ --params '<json_args>' --pretty # Skip cache for fresh results node scripts/run.js --platform youtube --tool <tool_name> \ --params '<json_args>' --no-cache --pretty ``` **Example — get full video information (formatted):** ```bash node scripts/run.js --platform youtube --tool get_video_information \ --params '{"video_id":"oaSNBz4qMQY","need_format":true,"language_code":"en-US"}' --pretty ``` **Example — get top comments:** ```bash node scripts/run.js --platform youtube --tool get_video_comments \ --params '{"video_id":"oaSNBz4qMQY","sort_by":"top","language_code":"en-US","country_code":"US"}' --pretty ``` **Example — paginate to next comment page:** ```bash node scripts/run.js --platform youtube --tool get_video_comments \ --params '{"video_id":"oaSNBz4qMQY","continuation_token":"<token_from_previous_response>"}' --pretty ``` **Example — get replies to a comment:** ```bash # Extract reply_continuation_token from a comment in get_video_comments response node scripts/run.js --platform youtube --tool get_video_sub_comments \ --params '{"continuation_token":"<reply_continuation_token>","language_code":"en-US"}' --pretty ``` **Example — get related videos:** ```bash node scripts/run.js --platform youtube --tool get_related_videos \ --params '{"video_id":"dQw4w9WgXcQ","need_format":true}' --pretty ``` **Example — search Shorts:** ```bash # First call (may return mixed content) node scripts/run.js --platform youtube --tool youtube_shorts_search \ --params '{"search_query":"cooking tips","filter_mixed_content":true,"language_code":"en-US","country_code":"US"}' --pretty # Subsequent pages (pure Shorts via continuation_token) node scripts/run.js --platform youtube --tool youtube_shorts_search \ --params '{"search_query":"cooking tips","continuation_token":"<token>"}' --pretty ``` **Example — search videos with recency filter:** ```bash node scripts/run.js --platform youtube --tool search_video \ --params '{"search_query":"AI tutorial","order_by":"this_month","country_code":"US","language_code":"en"}' --pretty ``` **Example — get trending videos:** ```bash node scripts/run.js --platform youtube --tool get_trending_videos \ --params '{"section":"Music","country_code":"US","language_code":"en"}' --pretty ``` **Pagination:** | Endpoint | Pagination | Notes | |---|---|---| | `get_video_comments` | `continuation_token` | From previous response | | `get_video_sub_comments` | `continuation_token` | Must use `reply_continuation_token` field from a comment — NOT the video comment pagination token | | `youtube_shorts_search`, `search_video` | `continuation_token` | From previous response | | `get_video_information`, `get_video_streams_info`, `get_related_videos`, `get_trending_videos` | — | Single-call; no pagination | > **`get_video_sub_comments` critical note**: This endpoint requires the `reply_continuation_token` field extracted from a specific comment in the `get_video_comments` response — it is different from the comment-level `continuation_token` used to paginate comments. Do NOT pass the video_id to this endpoint. > **`get_video_streams_info` time limit**: Returned playback URLs are valid for approximately 6 hours. Use them promptly. HD video (720p+) streams require separate audio and video downloads that must be merged locally. > **`youtube_shorts_search` mixed content**: The first request may return a mix of Shorts and regular videos. Use `continuation_token` on subsequent calls for pure Shorts results. Set `filter_mixed_content: true` (default) to filter long videos from the first response. **Cache directory structure:** ``` .keyapi-cache/ └── YYYY-MM-DD/ ├── get_video_information/ │ └── {params_hash}.json ├── get_video_comments/ │ └── {params_hash}.json ├── get_video_sub_comments/ │ └── {params_hash}.json ├── get_video_streams_info/ │ └── {params_hash}.json ├── get_related_videos/ │ └── {params_hash}.json ├── youtube_shorts_search/ │ └── {params_hash}.json ├── search_video/ │ └── {params_hash}.json └── get_trending_videos/ └── {params_hash}.json ``` **Cache-first policy:** Before every API call, check whether a cached result already exists. If valid, load from disk and skip the API call. ### Step 4 — Synthesize and Report Findings **For video analysis:** 1. **Video Overview** — Title, channel, publish date, view count, like count, comment count, duration, tags, description summary. 2. **Engagement Quality** — Like-to-view ratio, comment depth (replies per top-level comment), engagement rate estimate. 3. **Comment Intelligence** — Top themes from most-liked comments, sentiment signals, key community questions or reactions. 4. **Related Content Map** — Algorithm-recommended videos, common topics and channels in the recommendation cluster. 5. **Stream Quality Summary** — Available resolutions, format types (merged vs. adaptive), playback URL validity window. **For search / discovery:** 1. **Search Results Overview** — Video count returned, top titles, channel distribution, view spread. 2. **Shorts Landscape** — Trending Shorts topics, creator diversity, engagement patterns. 3. **Trending Signals** — Top trending videos by category, creator names, view velocity indicators. author: KeyAPI license: MIT repository: https://github.com/EchoSell/keyapi-skills --- ## Common Rules | Rule | Detail | |------|--------| | **Video ID extraction** | Extract from URL: `youtube.com/watch?v=VIDEO_ID`. Use `video_id` parameter where available; `video_url` is also accepted by `get_video_streams_info` and `get_related_videos`. | | **`get_video_sub_comments`** | Requires `continuation_token` from the `reply_continuation_token` field of a comment returned by `get_video_comments` — not the video-level pagination token and not the video ID. | | **Shorts first-call behavior** | The first `youtube_shorts_search` call may return mixed content. Use `continuation_token` for subsequent pages to receive pure Shorts. Use `filter_mixed_content: true` to auto-filter on the first call. | | **Stream URL validity** | `get_video_streams_info` returns time-limited URLs (~6 hours). Process immediately after retrieval. | | **`need_format` parameter** | Available on `get_video_information`, `get_related_videos`, and `get_video_comments`. Set to `true` for structured, cleaned output; `false` for raw YouTube API data useful for debugging. | | **Language / region params** | `language_code` and `country_code` influence result language and regional content bias. Use `en-US` / `US` for English-language global results. | | **Success check** | `code = 0` → success. Any other value → failure. Always check the response code before processing data. | | **Retry on 500** | If `code = 500`, retry the identical request up to 3 times with a 2–3 second pause between attempts before reporting the error. | | **Cache first** | Always check the local `.keyapi-cache/` directory before issuing a live API call. | author: KeyAPI license: MIT repository: https://github.com/EchoSell/keyapi-skills --- ## Error Handling | Code | Meaning | Action | |------|---------|--------| | `0` | Success | Continue workflow normally | | `400` | Bad request — invalid or missing parameters | Validate input against the tool schema; check `video_id` format, `continuation_token` source, and required fields | | `401` | Unauthorized — token missing or expired | Confirm `KEYAPI_TOKEN` is set correctly; visit [keyapi.ai](https://keyapi.ai/) to renew | | `403` | Forbidden — plan quota exceeded or feature restricted | Review plan limits at [keyapi.ai](https://keyapi.ai/) | | `404` | Resource not found — video may be deleted, private, or region-restricted | Verify the video ID; the content may no longer be available | | `429` | Rate limit exceeded | Wait 60 seconds, then retry | | `500` | Internal server error | Retry up to 3 times with a 2–3 second pause; if it persists, log the full request and response and skip this node | | Other non-0 | Unexpected error | Log the full response body and surface the error message to the user |

path: package.json
content: {
  "name": "keyapi-youtube-video-analysis",
  "type": "module",
  "engines": { "node": ">=18.0.0" },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.10.2"
  }
}


path: scripts/run.js
content: #!/usr/bin/env node
/**
 * KeyAPI MCP Tool Runner
 *
 * Calls any KeyAPI MCP tool with built-in caching, auto-pagination,
 * and automatic cover-image URL conversion.
 *
 * Usage:
 *   node scripts/run.js --tool <tool_name> [options]
 *   node scripts/run.js --list-tools
 *   node scripts/run.js --schema <tool_name>
 *
 * Options:
 *   --tool <name>       MCP tool name to call  (required for tool calls)
 *   --platform <name>   Platform to target  (default: tiktok)
 *   --params <json>     Tool parameters as JSON string  (default: {})
 *   --page-num <n>      Page number for paginated endpoints  (default: 1)
 *   --page-size <n>     Items per page — max 10  (default: 10)
 *   --all-pages         Auto-fetch ALL pages and merge list results
 *   --no-cache          Skip cache lookup, force a fresh API call
 *   --no-images         Skip automatic cover-image URL conversion
 *   --cache-dir <path>  Cache directory  (default: .keyapi-cache)
 *   --output <path>     Also save result to this file path
 *   --pretty            Pretty-print JSON output to stdout
 *   --list-tools        List all available tools on the MCP server
 *   --schema <name>     Show the input schema for a specific tool
 *   --help              Show this help
 *
 * Environment variables:
 *   KEYAPI_TOKEN        Required. Get yours at https://keyapi.ai/
 *   KEYAPI_SERVER_URL   Optional MCP base URL override  (default: https://mcp.keyapi.ai)
 */

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { createHash } from "crypto";
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
import { join, resolve, dirname } from "path";
import { fileURLToPath } from "url";
import { parseArgs } from "util";

const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, "..");

// ── .env loader ───────────────────────────────────────────────────────────────

(function loadDotenv() {
  const envPath = join(ROOT, ".env");
  if (!existsSync(envPath)) return;
  for (const line of readFileSync(envPath, "utf8").split("\n")) {
    const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/);
    if (m && !process.env[m[1]])
      process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim();
  }
})();

// ── Config ────────────────────────────────────────────────────────────────────

const SERVER_BASE = process.env.KEYAPI_SERVER_URL ?? "https://mcp.keyapi.ai";
const PAGE_SIZE_MAX = 10;

/**
 * Platforms that require cover-image URL conversion.
 * Only TikTok serves images via the echosell CDN host that needs proxying.
 * Other platforms serve images directly — no conversion needed.
 */
const COVER_IMAGE_HOSTS = {
  tiktok: ["echosell-images.tos-ap-southeast-1.volces.com"],
};

function getCoverImageHosts(platform) {
  return COVER_IMAGE_HOSTS[platform] ?? [];
}

// ── Help text ─────────────────────────────────────────────────────────────────

const HELP = `KeyAPI MCP Tool Runner
======================

Calls KeyAPI MCP tools with caching, auto-pagination, and cover-image conversion.

Usage:
  node scripts/run.js --tool <tool_name> [options]
  node scripts/run.js --list-tools
  node scripts/run.js --schema <tool_name>

Options:
  --tool <name>       MCP tool name to call  (required for tool calls)
  --platform <name>   Platform to target  (default: tiktok)
  --params <json>     Tool parameters as JSON string  (default: {})
  --page-num <n>      Page number for paginated endpoints  (default: 1)
  --page-size <n>     Items per page — max 10  (default: 10)
  --all-pages         Auto-fetch ALL pages and merge list results
  --no-cache          Skip cache lookup, force a fresh API call
  --no-images         Skip automatic cover-image URL conversion
  --cache-dir <path>  Cache directory  (default: .keyapi-cache)
  --output <path>     Also save result to this file path
  --pretty            Pretty-print JSON output to stdout
  --list-tools        List all available tools on the MCP server
  --schema <name>     Show the input schema for a specific tool
  --help              Show this help

Environment:
  KEYAPI_TOKEN        Required. Get yours at https://keyapi.ai/
                      Set via: export KEYAPI_TOKEN=your_token
                      Or save to a .env file in the skill directory.
  KEYAPI_SERVER_URL   Optional MCP base URL override
                      (default: https://mcp.keyapi.ai)

Examples:
  # List all tools on a platform
  node scripts/run.js --list-tools
  node scripts/run.js --platform youtube --list-tools

  # Inspect a tool's input schema
  node scripts/run.js --schema search_influencers

  # Call a tool (platform defaults to tiktok)
  node scripts/run.js --tool search_influencers \\
    --params '{"keyword":"fitness","region":"US"}' --pretty

  # Explicit platform
  node scripts/run.js --platform tiktok --tool get_influencer_detail \\
    --params '{"unique_id":"charlidamelio"}' --pretty

  # Another platform
  node scripts/run.js --platform youtube --tool search_channels \\
    --params '{"keyword":"fitness"}' --pretty
`;

// ── File / cache utilities ────────────────────────────────────────────────────

function ensureDir(dir) {
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}

function readJSON(path) {
  try {
    if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8"));
  } catch { /* ignore */ }
  return null;
}

function writeJSON(path, data) {
  ensureDir(dirname(path));
  writeFileSync(path, JSON.stringify(data, null, 2), "utf8");
}

/** Deterministic cache path: .keyapi-cache/YYYY-MM-DD/<tool>/<hash>.json */
function cacheKey(tool, params, cacheDir) {
  const hash = createHash("md5")
    .update(JSON.stringify(params, Object.keys(params).sort()))
    .digest("hex")
    .slice(0, 16);
  const date = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
  return join(cacheDir, date, tool, `${hash}.json`);
}

// ── Cover-image helpers ───────────────────────────────────────────────────────

function collectImageUrls(obj, hosts, acc = []) {
  if (!obj || typeof obj !== "object") return acc;
  if (Array.isArray(obj)) {
    for (const item of obj) collectImageUrls(item, hosts, acc);
  } else {
    for (const v of Object.values(obj)) {
      if (typeof v === "string" && hosts.some(h => v.includes(h))) acc.push(v);
      else collectImageUrls(v, hosts, acc);
    }
  }
  return acc;
}

function replaceImageUrls(obj, map) {
  if (!obj || typeof obj !== "object") return obj;
  if (Array.isArray(obj)) return obj.map(i => replaceImageUrls(i, map));
  return Object.fromEntries(
    Object.entries(obj).map(([k, v]) =>
      typeof v === "string" && map.has(v) ? [k, map.get(v)] : [k, replaceImageUrls(v, map)]
    )
  );
}

/** Split array into chunks of n */
function chunk(arr, n) {
  const out = [];
  for (let i = 0; i < arr.length; i += n) out.push(arr.slice(i, i + n));
  return out;
}

// ── Heuristic list extractor for --all-pages ─────────────────────────────────

const LIST_KEYS = [
  "list", "items", "results", "videos", "products", "creators",
  "shops", "influencers", "hashtags", "music", "ads", "data",
  "channels", "posts", "comments", "reviews", "users",
];

function extractList(data) {
  const d = data?.data ?? data;
  for (const k of LIST_KEYS) {
    if (Array.isArray(d?.[k])) return d[k];
  }
  return Array.isArray(d) ? d : [];
}

// ── Schema cache & pagination detection ──────────────────────────────────────

/** Session-level tool schema cache — loaded once per process. */
let _toolSchemas = null;

async function getToolSchemas(client) {
  if (!_toolSchemas) {
    const { tools } = await client.listTools();
    _toolSchemas = new Map(tools.map(t => [t.name, t]));
  }
  return _toolSchemas;
}

/**
 * Inspect a tool's input schema to determine its pagination style.
 *
 * Returns:
 *   "analytics"  → tool accepts page_num / page_size
 *   "trending"   → tool accepts page / limit
 *   null         → tool has no recognised pagination fields
 */
async function detectPagination(client, toolName) {
  const schemas = await getToolSchemas(client);
  const tool = schemas.get(toolName);
  if (!tool?.inputSchema?.properties) return null;
  const props = tool.inputSchema.properties;
  if ("page_num" in props && "page_size" in props) return "analytics";
  if ("page" in props && "limit" in props) return "trending";
  return null;
}

// ── MCP client ────────────────────────────────────────────────────────────────

/** Prompt for KEYAPI_TOKEN interactively and persist it to .env */
async function promptToken() {
  if (!process.stdin.isTTY) {
    throw new Error(
      "KEYAPI_TOKEN is not set.\n" +
      "  → Register at https://keyapi.ai/ to obtain a free token, then either:\n" +
      "    export KEYAPI_TOKEN=your_token_here\n" +
      "  → Or create a .env file in the skill directory:\n" +
      "    echo \"KEYAPI_TOKEN=your_token_here\" > .env"
    );
  }
  const { createInterface } = await import("readline");
  const rl = createInterface({ input: process.stdin, output: process.stderr });
  return new Promise((resolve, reject) => {
    process.stderr.write("\nKEYAPI_TOKEN is required. Get yours at https://keyapi.ai/\n\n");
    rl.question("Enter your token: ", (answer) => {
      rl.close();
      const [REDACTED_SECRET]);
      if (!token) {
        reject(new Error("No token entered. Set KEYAPI_TOKEN and try again."));
        return;
      }
      const envPath = join(ROOT, ".env");
      writeFileSync(envPath, `KEYAPI_TOKEN=${token}\n`, "utf8");
      log(`[token] Saved to ${envPath} — future runs will load it automatically`);
      process.env.KEYAPI_TOKEN = token;
      resolve(token);
    });
  });
}

async function connect(serverUrl) {
  let [REDACTED_SECRET];
  if (!token) token = await promptToken();

  const client = new Client({ name: "keyapi-runner", version: "1.0.0" });
  const transport = new StreamableHTTPClientTransport(
    new URL(serverUrl),
    { requestInit: { headers: { Authorization: `Bearer ${token}` } } }
  );
  await client.connect(transport);
  return client;
}

/** Call a tool, parse the text/JSON response, retry once on code=500 */
async function callTool(client, tool, args) {
  for (let attempt = 0; attempt <= 1; attempt++) {
    const raw = await client.callTool({ name: tool, arguments: args });
    const textBlock = raw?.content?.find(c => c.type === "text");
    let data;
    try { data = textBlock ? JSON.parse(textBlock.text) : raw; }
    catch { data = { raw: textBlock?.text ?? raw }; }

    if (data?.code === 500 && attempt === 0) {
      log(`[retry] ${tool} returned code=500, retrying in 2s…`);
      await sleep(2000);
      continue;
    }
    return data;
  }
}

/**
 * Batch-convert platform cover image URLs.
 * Hosts are resolved from PLATFORM_CONFIG; skipped entirely if the platform
 * has no configured image hosts.
 * API accepts max 10 URLs per call as a comma-separated string (cover_urls).
 */
async function convertImages(client, data, platform) {
  const hosts = getCoverImageHosts(platform);
  if (hosts.length === 0) return data;

  const urls = [...new Set(collectImageUrls(data, hosts))];
  if (urls.length === 0) return data;

  const urlMap = new Map();

  for (const batch of chunk(urls, 10)) {
    try {
      const result = await callTool(client, "batch_download_cover_images", {
        cover_urls: batch.join(","),
      });
      if (result?.code === 0 && result?.data) {
        if (Array.isArray(result.data)) {
          for (const { original_url, converted_url } of result.data) {
            if (original_url && converted_url) urlMap.set(original_url, converted_url);
          }
        } else if (typeof result.data === "object") {
          for (const [k, v] of Object.entries(result.data)) urlMap.set(k, v);
        }
      }
    } catch (e) {
      log(`[images] Warning: batch conversion failed — ${e.message}`);
    }
  }

  if (urlMap.size > 0) {
    log(`[images] Converted ${urlMap.size} cover image URL(s)`);
    return replaceImageUrls(data, urlMap);
  }
  return data;
}

// ── Commands ──────────────────────────────────────────────────────────────────

async function cmdListTools(client) {
  const { tools } = await client.listTools();
  const out = tools.map(t => ({
    name: t.name,
    description: (t.description ?? "").slice(0, 100),
  }));
  emit(out, null, true);
}

async function cmdSchema(client, toolName) {
  const schemas = await getToolSchemas(client);
  const tool = schemas.get(toolName);
  if (!tool) {
    throw new Error(
      `Tool "${toolName}" not found on the server.\n` +
      "  Run --list-tools to see all available tools."
    );
  }
  emit(tool, null, true);
}

async function cmdRun(client, opts) {
  const { tool, platform, params, pageNum, pageSize, allPages, noCache, noImages, cacheDir, output, pretty } = opts;

  // Clamp page_size to max allowed by the API
  const safePageSize = Math.min(pageSize, PAGE_SIZE_MAX);

  // ── Single page ────────────────────────────────────────────────────────────
  if (!allPages) {
    const finalParams = { ...params };

    const paginationType = await detectPagination(client, tool);
    if (paginationType === "analytics") {
      if (!("page_num"  in finalParams)) finalParams.page_num  = pageNum;
      if (!("page_size" in finalParams)) finalParams.page_size = safePageSize;
    } else if (paginationType === "trending") {
      if (!("page"  in finalParams)) finalParams.page  = pageNum;
      if (!("limit" in finalParams)) finalParams.limit = safePageSize;
    }

    const cachePath = cacheKey(tool, finalParams, cacheDir);
    if (!noCache) {
      const cached = readJSON(cachePath);
      if (cached) {
        log(`[cache] Loaded from ${cachePath}`);
        emit(cached, output, pretty);
        return;
      }
    }

    let data = await callTool(client, tool, finalParams);
    assertSuccess(data, tool);
    if (!noImages) data = await convertImages(client, data, platform);
    writeJSON(cachePath, data);
    emit(data, output, pretty);
    return;
  }

  // ── All pages ──────────────────────────────────────────────────────────────
  const paginationType = await detectPagination(client, tool);
  const isTrending = paginationType === "trending";
  let page = 1;
  let allItems = [];
  let lastData = null;

  while (true) {
    const pageParams = isTrending
      ? { ...params, page: page, limit: safePageSize }
      : { ...params, page_num: page, page_size: safePageSize };

    const cachePath = cacheKey(tool, pageParams, cacheDir);

    let data = noCache ? null : readJSON(cachePath);
    if (data) {
      log(`[cache] Page ${page} loaded from ${cachePath}`);
    } else {
      data = await callTool(client, tool, pageParams);
      if (data?.code !== undefined && data.code !== 0) {
        log(`[warn] API error on page ${page}: code=${data.code} — ${data.message ?? ""}`);
        break;
      }
      if (!noImages) data = await convertImages(client, data, platform);
      writeJSON(cachePath, data);
    }

    const items = extractList(data);
    if (items.length === 0) break;

    allItems = allItems.concat(items);
    lastData = data;
    log(`[page ${page}] ${items.length} items  (total so far: ${allItems.length})`);

    const hasMore = data?.data?.has_more ?? data?.has_more;
    if (hasMore === false || items.length < safePageSize) break;
    page++;
  }

  const merged = {
    ...lastData,
    _merged: { total_pages: page, total_items: allItems.length },
    items: allItems,
  };
  writeJSON(cacheKey(tool, { ...params, _all_pages: true }, cacheDir), merged);
  emit(merged, output, pretty);
}

// ── Output / logging ──────────────────────────────────────────────────────────

function emit(data, filePath, pretty) {
  console.log(pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data));
  if (filePath) {
    const abs = resolve(filePath);
    ensureDir(dirname(abs));
    writeFileSync(abs, JSON.stringify(data, null, 2), "utf8");
    log(`[output] Saved to ${abs}`);
  }
}

function log(msg) { process.stderr.write(msg + "\n"); }
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }

function assertSuccess(data, tool) {
  if (data?.code !== undefined && data.code !== 0) {
    throw new Error(`API error from "${tool}": code=${data.code} — ${data.message ?? "(no message)"}`);
  }
}

// ── Entry point ───────────────────────────────────────────────────────────────

async function main() {
  const { values } = parseArgs({
    options: {
      tool:          { type: "string"  },
      platform:      { type: "string",  default: "tiktok" },
      schema:        { type: "string"  },
      params:        { type: "string",  default: "{}" },
      "page-num":    { type: "string",  default: "1"  },
      "page-size":   { type: "string",  default: "10" },
      "all-pages":   { type: "boolean", default: false },
      "no-cache":    { type: "boolean", default: false },
      "no-images":   { type: "boolean", default: false },
      "cache-dir":   { type: "string"  },
      output:        { type: "string"  },
      pretty:        { type: "boolean", default: false },
      "list-tools":  { type: "boolean", default: false },
      help:          { type: "boolean", default: false },
    },
    strict: false,
  });

  const needsHelp = values.help || (!values.tool && !values["list-tools"] && !values.schema);
  if (needsHelp) {
    process.stdout.write(HELP);
    process.exit(0);
  }

  let params;
  try { params = JSON.parse(values.params); }
  catch { throw new Error(`--params is not valid JSON: ${values.params}`); }

  const cacheDir = values["cache-dir"]
    ? resolve(values["cache-dir"])
    : join(ROOT, ".keyapi-cache");

  const serverUrl = `${SERVER_BASE}/${values.platform}/mcp`;
  const client = await connect(serverUrl);

  try {
    if (values["list-tools"]) {
      await cmdListTools(client);
    } else if (values.schema) {
      await cmdSchema(client, values.schema);
    } else {
      await cmdRun(client, {
        tool:     values.tool,
        platform: values.platform,
        params,
        pageNum:  parseInt(values["page-num"],  10),
        pageSize: parseInt(values["page-size"], 10),
        allPages: values["all-pages"],
        noCache:  values["no-cache"],
        noImages: values["no-images"],
        cacheDir,
        output:   values.output,
        pretty:   values.pretty,
      });
    }
  } finally {
    await client.close().catch(() => {});
  }
}

main().catch(err => {
  process.stderr.write(`Error: ${err.message ?? String(err)}\n`);
  process.exit(1);
});


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