Просмотр исходного кода

feat(voc-skill): catalog-driven forwarding API (voc_api_search/doc/call) v0.3.17

Add a forwarding-interface catalog plus generic call tools so models discover an interface, read its params, and assemble the call themselves instead of using hand-written per-endpoint tools. Reuses hardened transport (r: token precheck, 5xx retry, secret redaction) and error classification (auth/upstream_unstable/billing/needs_input) so input/upstream errors are never misreported as no-data/category-unsupported. Any industry/keyword works; unlisted interfaces callable via rawPath.
gangvy 3 месяцев назад
Родитель
Сommit
142066f815

+ 1 - 1
claude-code/claude-code-voc-intelligence/.claude-plugin/plugin.json

@@ -1,7 +1,7 @@
 {
   "name": "voc-intelligence",
   "description": "VOC intelligence skills for Claude Code: end-to-end VOC business workflows, Xiaohongshu and Douyin trend reports, live social VOC collection, Fmode Doubao image analysis, token/recharge checks, evidence-card outputs, prioritized issue pools, single-problem deep dives, competitor maps, 7-day content plans, VOC-backed speaking script co-creation, boss/operator action plans, and multi-turn memory.",
-  "version": "0.3.16",
+  "version": "0.3.17",
   "author": {
     "name": "nkkj-BrainHack"
   }

+ 7 - 0
claude-code/claude-code-voc-intelligence/bin/claude-voc.js

@@ -184,7 +184,10 @@ function assertInstalled(targetDir) {
     'skills/voc-competitor-map/SKILL.md',
     'skills/voc-business-workflow/SKILL.md',
     'skills/fmode-image-analysis/SKILL.md',
+    'skills/voc-api-catalog/SKILL.md',
     'mcp/src/tools/fmode-image-analysis.js',
+    'mcp/src/tools/voc-api-catalog-run.js',
+    'mcp/catalog/voc-social-endpoints.json',
     'mcp/src/server.js'
   ];
   const missing = required.filter(rel => !fs.existsSync(path.join(targetDir, rel)));
@@ -646,6 +649,7 @@ function writeWorkspaceSkillEntries(targetDir) {
   const competitorMapSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'voc-competitor-map');
   const businessWorkflowSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'voc-business-workflow');
   const fmodeImageSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'fmode-image-analysis');
+  const apiCatalogSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'voc-api-catalog');
   ensureDir(xhsSkillDir);
   ensureDir(douyinSkillDir);
   ensureDir(issuePoolSkillDir);
@@ -655,6 +659,7 @@ function writeWorkspaceSkillEntries(targetDir) {
   ensureDir(competitorMapSkillDir);
   ensureDir(businessWorkflowSkillDir);
   ensureDir(fmodeImageSkillDir);
+  ensureDir(apiCatalogSkillDir);
   fs.writeFileSync(path.join(xhsSkillDir, 'SKILL.md'), workspaceSkillText(targetDir), 'utf8');
   fs.writeFileSync(path.join(douyinSkillDir, 'SKILL.md'), workspaceDouyinSkillText(targetDir), 'utf8');
   fs.writeFileSync(path.join(issuePoolSkillDir, 'SKILL.md'), workspaceIssuePoolSkillText(targetDir), 'utf8');
@@ -664,6 +669,7 @@ function writeWorkspaceSkillEntries(targetDir) {
   fs.writeFileSync(path.join(competitorMapSkillDir, 'SKILL.md'), workspaceCompetitorMapSkillText(targetDir), 'utf8');
   fs.writeFileSync(path.join(businessWorkflowSkillDir, 'SKILL.md'), workspaceBusinessWorkflowSkillText(targetDir), 'utf8');
   fs.copyFileSync(path.join(targetDir, 'skills', 'fmode-image-analysis', 'SKILL.md'), path.join(fmodeImageSkillDir, 'SKILL.md'));
+  fs.copyFileSync(path.join(targetDir, 'skills', 'voc-api-catalog', 'SKILL.md'), path.join(apiCatalogSkillDir, 'SKILL.md'));
 }
 
 function writeWorkspaceActivation(targetDir) {
@@ -680,6 +686,7 @@ function writeWorkspaceActivation(targetDir) {
   console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-competitor-map\\SKILL.md');
   console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-business-workflow\\SKILL.md');
   console.log('Wrote workspace skill entry: .\\.claude\\skills\\fmode-image-analysis\\SKILL.md');
+  console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-api-catalog\\SKILL.md');
 }
 
 function printNextSteps(targetDir) {

+ 3 - 0
claude-code/claude-code-voc-intelligence/install.js

@@ -112,7 +112,10 @@ function checkFiles() {
     'skills/voc-competitor-map/SKILL.md',
     'skills/voc-business-workflow/SKILL.md',
     'skills/fmode-image-analysis/SKILL.md',
+    'skills/voc-api-catalog/SKILL.md',
     'mcp/src/tools/fmode-image-analysis.js',
+    'mcp/src/tools/voc-api-catalog-run.js',
+    'mcp/catalog/voc-social-endpoints.json',
     'mcp/src/server.js'
   ].forEach(assertFile);
 

+ 143 - 0
claude-code/claude-code-voc-intelligence/mcp/catalog/voc-social-endpoints.json

@@ -0,0 +1,143 @@
+{
+  "version": "1.0.0",
+  "updatedAt": "2026-06-21",
+  "gateway": {
+    "social": {
+      "baseUrl": "https://server.fmode.cn/api/voc-social",
+      "auth": "Authorization: Bearer <r: 开头的会话 token>",
+      "upstream": "TikHub (server.fmode.cn/thapi/v1)",
+      "billing": "每次转发调用扣除 1 次社交平台 API 额度(缓存命中也会计费)。",
+      "note": "这是一个通用转发网关:任意 proxyPath 都会被透传到上游 TikHub。本清单只列出已验证的常用接口;未列出的接口可以直接用 voc_api_call 传 rawPath + method + params 调用,无需改代码。",
+      "rawPathHint": "调用未在清单中的接口时,把上游的相对路径填到 voc_api_call 的 rawPath,例如 douyin/search/fetch_general_search_v2,并按上游文档拼 query/body。"
+    }
+  },
+  "platforms": [
+    { "key": "douyin", "label": "抖音", "status": "verified" },
+    { "key": "xiaohongshu", "label": "小红书", "status": "verified" },
+    { "key": "tiktok", "label": "TikTok", "status": "gateway_available" },
+    { "key": "youtube", "label": "YouTube", "status": "gateway_available" },
+    { "key": "twitter", "label": "Twitter/X", "status": "gateway_available" },
+    { "key": "instagram", "label": "Instagram", "status": "gateway_available" },
+    { "key": "linkedin", "label": "LinkedIn", "status": "gateway_available" }
+  ],
+  "endpoints": [
+    {
+      "id": "douyin.search_general",
+      "platform": "douyin",
+      "channel": "social",
+      "title": "抖音综合搜索",
+      "summary": "按关键词搜索抖音视频(综合搜索 v2)。任意行业/方向关键词均可用于趋势与 VOC 选题采集。",
+      "proxyPath": "douyin/search/fetch_general_search_v2",
+      "method": "POST",
+      "paramsIn": "body",
+      "params": [
+        { "name": "keyword", "in": "body", "type": "string", "required": true, "desc": "搜索关键词,任意行业/方向均可,例如 股权律师、医美 玻尿酸、少儿编程、新能源汽车。" },
+        { "name": "cursor", "in": "body", "type": "integer", "required": false, "default": 0, "desc": "分页游标,首页为 0,翻页用上一次返回的 cursor。" },
+        { "name": "sort_type", "in": "body", "type": "string", "required": false, "default": "0", "desc": "排序:0 综合,1 最多点赞,2 最新发布。" },
+        { "name": "publish_time", "in": "body", "type": "string", "required": false, "default": "0", "desc": "发布时间:0 不限,1 一天内,7 一周内,180 半年内。" },
+        { "name": "filter_duration", "in": "body", "type": "string", "required": false, "default": "0", "desc": "时长筛选:0 不限,0-1 一分钟内,1-5 一到五分钟,5-10000 五分钟以上。" },
+        { "name": "content_type", "in": "body", "type": "string", "required": false, "default": "0", "desc": "内容类型:0 不限,1 视频,2 图集,3 文章。" },
+        { "name": "search_id", "in": "body", "type": "string", "required": false, "default": "", "desc": "连续翻页时透传上一次返回的 search_id。" },
+        { "name": "backtrace", "in": "body", "type": "string", "required": false, "default": "", "desc": "连续翻页时透传上一次返回的 backtrace。" }
+      ],
+      "tags": ["抖音", "douyin", "搜索", "search", "视频", "关键词", "趋势", "voc"],
+      "billing": 1,
+      "responseHint": "返回视频列表,每条含 aweme_id、desc、统计字段等;aweme_id 可用于拉取评论。",
+      "example": { "keyword": "新能源汽车", "cursor": 0, "sort_type": "0" }
+    },
+    {
+      "id": "douyin.video_comments",
+      "platform": "douyin",
+      "channel": "social",
+      "title": "抖音视频评论列表",
+      "summary": "按 aweme_id 拉取某条抖音视频的评论,用于采集真实用户声音(VOC)。",
+      "proxyPath": "douyin/app/v3/fetch_video_comments",
+      "method": "GET",
+      "paramsIn": "query",
+      "params": [
+        { "name": "aweme_id", "in": "query", "type": "string", "required": true, "desc": "视频 ID,来自抖音综合搜索结果。" },
+        { "name": "cursor", "in": "query", "type": "integer", "required": false, "default": 0, "desc": "评论分页游标,首页 0。" },
+        { "name": "count", "in": "query", "type": "integer", "required": false, "default": 20, "desc": "每页评论数,建议 20。" }
+      ],
+      "tags": ["抖音", "douyin", "评论", "comments", "voc", "用户声音"],
+      "billing": 1,
+      "responseHint": "返回评论列表,含评论文本、点赞数、cid(用于拉取楼中楼回复)。",
+      "example": { "aweme_id": "7300000000000000000", "cursor": 0, "count": 20 }
+    },
+    {
+      "id": "douyin.comment_replies",
+      "platform": "douyin",
+      "channel": "social",
+      "title": "抖音评论回复(楼中楼)",
+      "summary": "按视频 item_id 和评论 comment_id 拉取某条评论下的回复。",
+      "proxyPath": "douyin/app/v3/fetch_video_comment_replies",
+      "method": "GET",
+      "paramsIn": "query",
+      "params": [
+        { "name": "item_id", "in": "query", "type": "string", "required": true, "desc": "视频 ID(同 aweme_id)。" },
+        { "name": "comment_id", "in": "query", "type": "string", "required": true, "desc": "父评论 ID(来自评论列表的 cid)。" },
+        { "name": "cursor", "in": "query", "type": "integer", "required": false, "default": 0, "desc": "回复分页游标。" },
+        { "name": "count", "in": "query", "type": "integer", "required": false, "default": 20, "desc": "每页回复数。" }
+      ],
+      "tags": ["抖音", "douyin", "评论", "回复", "replies", "voc"],
+      "billing": 1,
+      "responseHint": "返回某条父评论下的回复列表。",
+      "example": { "item_id": "7300000000000000000", "comment_id": "7300000000000000001", "cursor": 0, "count": 20 }
+    },
+    {
+      "id": "xiaohongshu.search_notes",
+      "platform": "xiaohongshu",
+      "channel": "social",
+      "title": "小红书笔记搜索",
+      "summary": "按关键词搜索小红书笔记,任意行业/方向均可,用于趋势与 VOC 选题采集。",
+      "proxyPath": "xiaohongshu/app/search_notes",
+      "method": "GET",
+      "paramsIn": "query",
+      "params": [
+        { "name": "keyword", "in": "query", "type": "string", "required": true, "desc": "搜索关键词,任意行业/方向均可。" },
+        { "name": "page", "in": "query", "type": "integer", "required": false, "default": 1, "desc": "页码,从 1 开始。" },
+        { "name": "sort", "in": "query", "type": "string", "required": false, "default": "popularity_descending", "desc": "排序:general 综合,time_descending 最新,popularity_descending 最热。" },
+        { "name": "note_type", "in": "query", "type": "string", "required": false, "default": "_0", "desc": "笔记类型:_0 不限,_1 视频,_2 图文。" }
+      ],
+      "tags": ["小红书", "xiaohongshu", "xhs", "搜索", "search", "笔记", "关键词", "趋势", "voc"],
+      "billing": 1,
+      "responseHint": "返回笔记列表,每条含 note_id,可用于拉取详情和评论。",
+      "example": { "keyword": "敏感肌护肤", "page": 1, "sort": "popularity_descending", "note_type": "_0" }
+    },
+    {
+      "id": "xiaohongshu.note_detail",
+      "platform": "xiaohongshu",
+      "channel": "social",
+      "title": "小红书笔记详情",
+      "summary": "按 note_id 拉取小红书笔记详情(正文、图片、统计等)。",
+      "proxyPath": "xiaohongshu/app/get_note_info",
+      "method": "GET",
+      "paramsIn": "query",
+      "params": [
+        { "name": "note_id", "in": "query", "type": "string", "required": true, "desc": "笔记 ID,来自搜索结果。" }
+      ],
+      "tags": ["小红书", "xiaohongshu", "xhs", "笔记", "详情", "note"],
+      "billing": 1,
+      "responseHint": "返回单条笔记的完整内容。",
+      "example": { "note_id": "650000000000000000000000" }
+    },
+    {
+      "id": "xiaohongshu.note_comments",
+      "platform": "xiaohongshu",
+      "channel": "social",
+      "title": "小红书笔记评论",
+      "summary": "按 note_id 拉取小红书笔记评论,用于采集真实用户声音(VOC)。",
+      "proxyPath": "xiaohongshu/app/get_note_comments",
+      "method": "GET",
+      "paramsIn": "query",
+      "params": [
+        { "name": "note_id", "in": "query", "type": "string", "required": true, "desc": "笔记 ID,来自搜索结果。" },
+        { "name": "cursor", "in": "query", "type": "string", "required": false, "default": "", "desc": "评论分页游标,翻页用上一次返回的 cursor。" }
+      ],
+      "tags": ["小红书", "xiaohongshu", "xhs", "评论", "comments", "voc", "用户声音"],
+      "billing": 1,
+      "responseHint": "返回笔记评论列表。",
+      "example": { "note_id": "650000000000000000000000", "cursor": "" }
+    }
+  ]
+}

+ 91 - 0
claude-code/claude-code-voc-intelligence/mcp/src/core/api-catalog.js

@@ -0,0 +1,91 @@
+const fs = require('fs');
+const path = require('path');
+
+const CATALOG_PATH = path.resolve(__dirname, '..', '..', 'catalog', 'voc-social-endpoints.json');
+
+let cached;
+
+function loadCatalog() {
+  if (cached) return cached;
+  const raw = fs.readFileSync(CATALOG_PATH, 'utf8').replace(/^\uFEFF/, '');
+  cached = JSON.parse(raw);
+  return cached;
+}
+
+function listEndpoints() {
+  return loadCatalog().endpoints || [];
+}
+
+function normalize(value) {
+  return String(value || '').trim().toLowerCase();
+}
+
+function findEndpoint(idOrPath) {
+  const needle = normalize(idOrPath);
+  if (!needle) return undefined;
+  const endpoints = listEndpoints();
+  return (
+    endpoints.find(item => normalize(item.id) === needle) ||
+    endpoints.find(item => normalize(item.proxyPath) === needle) ||
+    endpoints.find(item => normalize(item.proxyPath).replace(/^\//, '') === needle.replace(/^\//, ''))
+  );
+}
+
+function scoreEndpoint(endpoint, terms) {
+  if (!terms.length) return 1;
+  const haystack = normalize([
+    endpoint.id,
+    endpoint.platform,
+    endpoint.title,
+    endpoint.summary,
+    endpoint.proxyPath,
+    (endpoint.tags || []).join(' ')
+  ].join(' '));
+  let score = 0;
+  for (const term of terms) {
+    if (!term) continue;
+    if (haystack.includes(term)) score += 1;
+  }
+  return score;
+}
+
+function searchEndpoints({ query, platform, tag, limit = 20 } = {}) {
+  const terms = normalize(query).split(/\s+/).filter(Boolean);
+  const platformKey = normalize(platform);
+  const tagKey = normalize(tag);
+  let results = listEndpoints().map(endpoint => ({
+    endpoint,
+    score: scoreEndpoint(endpoint, terms)
+  }));
+  if (platformKey) {
+    results = results.filter(item => normalize(item.endpoint.platform) === platformKey);
+  }
+  if (tagKey) {
+    results = results.filter(item => (item.endpoint.tags || []).some(t => normalize(t) === tagKey));
+  }
+  results = results.filter(item => item.score > 0);
+  results.sort((a, b) => b.score - a.score);
+  return results.slice(0, Math.max(1, limit)).map(item => item.endpoint);
+}
+
+function summarizeEndpoint(endpoint) {
+  return {
+    id: endpoint.id,
+    platform: endpoint.platform,
+    title: endpoint.title,
+    summary: endpoint.summary,
+    method: endpoint.method,
+    proxyPath: endpoint.proxyPath,
+    requiredParams: (endpoint.params || []).filter(p => p.required).map(p => p.name),
+    tags: endpoint.tags || []
+  };
+}
+
+module.exports = {
+  CATALOG_PATH,
+  loadCatalog,
+  listEndpoints,
+  findEndpoint,
+  searchEndpoints,
+  summarizeEndpoint
+};

+ 162 - 0
claude-code/claude-code-voc-intelligence/mcp/src/providers/voc-gateway.js

@@ -0,0 +1,162 @@
+const SOCIAL_GATEWAY_ROOT = 'https://server.fmode.cn/api/voc-social';
+
+function resolveSocialRoot(baseUrl) {
+  return String(
+    baseUrl ||
+      process.env.VOC_SOCIAL_API_BASE_URL ||
+      process.env.VOC_SOCIAL_GATEWAY_URL ||
+      SOCIAL_GATEWAY_ROOT
+  ).replace(/\/$/, '');
+}
+
+function buildGatewayUrl(root, proxyPath, query = {}) {
+  const url = new URL(`${root.replace(/\/$/, '')}/${String(proxyPath || '').replace(/^\//, '')}`);
+  Object.entries(query || {}).forEach(([key, value]) => {
+    if (value !== undefined && value !== null && value !== '') {
+      url.searchParams.set(key, String(value));
+    }
+  });
+  return url.toString();
+}
+
+function unwrapData(json) {
+  if (!json || typeof json !== 'object') return {};
+  return json.data?.data || json.data || json;
+}
+
+function pickBusinessStatus(json) {
+  const topCode = Number(json?.code);
+  if (Number.isFinite(topCode) && topCode !== 0 && topCode !== 200) {
+    return topCode;
+  }
+  const nestedCode = Number(json?.data?.status_code);
+  if (Number.isFinite(nestedCode) && nestedCode !== 0 && nestedCode !== 200) {
+    return nestedCode;
+  }
+  return 0;
+}
+
+function redactSecret(value) {
+  return String(value || '')
+    .replace(/Bearer\s+[^"'\s,}]+/gi, 'Bearer [REDACTED]')
+    .replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, 'sk-[REDACTED]')
+    .replace(/\br:[A-Za-z0-9]{4,}\b/g, 'r:[REDACTED]')
+    .replace(/("Authorization"\s*:\s*")([^"]+)(")/gi, '$1[REDACTED]$3')
+    .replace(/("api[_-]?token"\s*:\s*")([^"]+)(")/gi, '$1[REDACTED]$3');
+}
+
+function isSuccessCode(json) {
+  const topCode = Number(json?.code);
+  if (Number.isFinite(topCode) && ![0, 200].includes(topCode)) {
+    return false;
+  }
+  const statusCode = Number(json?.data?.status_code);
+  if (Number.isFinite(statusCode) && ![0, 200].includes(statusCode)) {
+    return false;
+  }
+  return true;
+}
+
+function classifyApiError({ response, json, message }) {
+  const httpStatus = response?.status || 0;
+  const businessStatus = pickBusinessStatus(json);
+  const status = businessStatus || httpStatus;
+  const text = String(message || '');
+  if (/company或用户信息不存在|用户信息不存在|用户不存在|账[号户]信息不存在|未登录|登录失效|请输入.*token|sessiontoken/i.test(text)) {
+    return 'auth';
+  }
+  if (status === 401 || /invalid token|无效.*token|token.*invalid|未授权|unauthorized/i.test(text)) {
+    return 'auth';
+  }
+  if (status === 402 || /余额不足|额度不足|没开通|未开通|开通.*权限|权限.*额度|insufficient|balance|quota|payment|充值/i.test(text)) {
+    return 'billing';
+  }
+  if (status === 403 && /余额不足|额度不足|没开通|未开通|insufficient|balance|quota|payment/i.test(text)) {
+    return 'billing';
+  }
+  if (/参数|入参|keyword|关键词|invalid request|bad request/i.test(text) || [400, 422].includes(status)) {
+    return 'request';
+  }
+  if (status >= 500) {
+    return 'upstream';
+  }
+  if (status === 403 || /permission|权限|无权限/i.test(text)) {
+    return 'permission';
+  }
+  return 'upstream';
+}
+
+const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
+
+async function requestJson(url, token, { method = 'GET', body, retries = 3 } = {}) {
+  let lastError;
+  for (let attempt = 0; attempt <= retries; attempt++) {
+    let response;
+    let text;
+    try {
+      response = await fetch(url, {
+        method,
+        headers: {
+          Authorization: `Bearer ${token}`,
+          Accept: 'application/json',
+          ...(body ? { 'Content-Type': 'application/json' } : {})
+        },
+        body: body ? JSON.stringify(body) : undefined
+      });
+      text = await response.text();
+    } catch (networkError) {
+      lastError = networkError;
+      if (attempt < retries) {
+        await delay(400 * (attempt + 1));
+        continue;
+      }
+      const error = new Error(redactSecret(networkError && networkError.message ? networkError.message : 'fetch failed'));
+      error.kind = 'upstream';
+      error.httpStatus = 0;
+      throw error;
+    }
+    let json;
+    try {
+      json = JSON.parse(text);
+    } catch {
+      json = undefined;
+    }
+    if (!response.ok || (json && !isSuccessCode(json))) {
+      const message = json?.message || json?.msg || json?.data?.msg || json?.data?.message || json?.mess || text || `HTTP ${response.status}`;
+      const error = new Error(redactSecret(message));
+      error.httpStatus = pickBusinessStatus(json) || response.status || 500;
+      error.kind = classifyApiError({ response, json, message });
+      error.responseBody = redactSecret(text);
+      if (error.httpStatus >= 500 && attempt < retries) {
+        lastError = error;
+        await delay(400 * (attempt + 1));
+        continue;
+      }
+      throw error;
+    }
+    return { json, data: unwrapData(json), rawText: text, httpStatus: response.status };
+  }
+  throw lastError;
+}
+
+async function callSocialGateway({ proxyPath, method = 'GET', query = {}, body, token, baseUrl, retries = 3 } = {}) {
+  const root = resolveSocialRoot(baseUrl);
+  const url = buildGatewayUrl(root, proxyPath, query);
+  const upper = String(method || 'GET').toUpperCase();
+  return requestJson(url, token, {
+    method: upper,
+    body: upper === 'GET' ? undefined : body,
+    retries
+  });
+}
+
+module.exports = {
+  SOCIAL_GATEWAY_ROOT,
+  resolveSocialRoot,
+  buildGatewayUrl,
+  classifyApiError,
+  redactSecret,
+  unwrapData,
+  requestJson,
+  callSocialGateway
+};

+ 94 - 1
claude-code/claude-code-voc-intelligence/mcp/src/server.js

@@ -14,6 +14,7 @@ const { runVocCompetitorMap } = require('./tools/voc-competitor-map-run');
 const { runVocSpeakingScript } = require('./tools/voc-speaking-script-run');
 const { runBusinessWorkflow } = require('./tools/voc-business-workflow-run');
 const { analyzeFmodeImage } = require('./tools/fmode-image-analysis');
+const { searchVocApis, getVocApiDoc, callVocApi } = require('./tools/voc-api-catalog-run');
 const { buildVocRechargeInfo, buildMissingTokenMessage } = require('./core/payment-links');
 
 function asToolResult(result) {
@@ -60,7 +61,7 @@ function normalizeToolResult(result = {}) {
 function createServer() {
   const server = new McpServer({
     name: 'voc-intelligence',
-    version: '0.3.16'
+    version: '0.3.17'
   });
 
   server.registerTool(
@@ -673,6 +674,98 @@ function createServer() {
     }
   );
 
+  server.registerTool(
+    'voc_api_search',
+    {
+      title: 'Search VOC Forwarding API Catalog',
+      description: [
+        'Search the VOC social forwarding interface catalog (douyin, xiaohongshu, and other TikHub-backed platforms).',
+        'Use when the user wants to collect social data for any keyword/industry and you need to find which forwarding interface to call before reading its parameter doc and invoking it.'
+      ].join(' '),
+      inputSchema: {
+        query: z.string().optional().describe('Free-text search over interface id/title/summary/tags, e.g. 抖音 评论 / search notes.'),
+        platform: z.string().optional().describe('Optional platform filter, e.g. douyin, xiaohongshu.'),
+        tag: z.string().optional().describe('Optional tag filter.'),
+        limit: z.number().int().min(1).max(100).optional()
+      },
+      outputSchema: {
+        status: z.string(),
+        assistantMessage: z.string(),
+        summary: z.object({}).passthrough().optional(),
+        data: z.object({}).passthrough().optional(),
+        files: z.array(z.string()).optional(),
+        nextActions: z.array(z.string()).optional(),
+        warnings: z.array(z.any()).optional(),
+        errors: z.array(z.any()).optional()
+      }
+    },
+    async input => asToolResult(await searchVocApis(input))
+  );
+
+  server.registerTool(
+    'voc_api_doc',
+    {
+      title: 'Read VOC Forwarding API Parameter Doc',
+      description: [
+        'Read the detailed parameter documentation for one VOC forwarding interface, plus a ready-to-use call template.',
+        'Use after voc_api_search to learn an interface\u2019s required/optional parameters before calling voc_api_call.'
+      ].join(' '),
+      inputSchema: {
+        id: z.string().optional().describe('Interface id from the catalog, e.g. douyin.search_general.'),
+        proxyPath: z.string().optional().describe('Alternatively, the upstream proxy path, e.g. douyin/search/fetch_general_search_v2.')
+      },
+      outputSchema: {
+        status: z.string(),
+        assistantMessage: z.string(),
+        summary: z.object({}).passthrough().optional(),
+        data: z.object({}).passthrough().optional(),
+        files: z.array(z.string()).optional(),
+        nextActions: z.array(z.string()).optional(),
+        warnings: z.array(z.any()).optional(),
+        errors: z.array(z.any()).optional()
+      }
+    },
+    async input => asToolResult(await getVocApiDoc(input))
+  );
+
+  server.registerTool(
+    'voc_api_call',
+    {
+      title: 'Call A VOC Forwarding API',
+      description: [
+        'Invoke any VOC social forwarding interface and return the upstream data.',
+        'Provide a catalog id (preferred) or a rawPath + method for unlisted interfaces, plus a params object assembled from the interface doc.',
+        'Requires an r: session token (not an sk- AIGate key). Input/parameter errors, upstream instability, auth, and billing are reported distinctly and are never disguised as \u201cno data / category unsupported\u201d.'
+      ].join(' '),
+      inputSchema: {
+        id: z.string().optional().describe('Catalog interface id, e.g. douyin.search_general.'),
+        rawPath: z.string().optional().describe('Upstream proxy path for interfaces not in the catalog, e.g. douyin/search/fetch_general_search_v2.'),
+        proxyPath: z.string().optional().describe('Alias of rawPath.'),
+        method: z.enum(['GET', 'POST', 'PUT', 'DELETE']).optional().describe('HTTP method, only needed for rawPath calls. Defaults to GET.'),
+        params: z.object({}).passthrough().optional().describe('Parameter object assembled from the interface doc.'),
+        query: z.object({}).passthrough().optional().describe('Optional explicit query parameters.'),
+        body: z.object({}).passthrough().optional().describe('Optional explicit request body.'),
+        retries: z.number().int().min(0).max(8).optional(),
+        baseUrl: z.string().optional(),
+        vocToken: z.string().optional().describe('Optional VOC social r: session token. Used only for this run and never echoed back.'),
+        token: z.string().optional().describe('Alias of vocToken. Never echoed back.'),
+        douyinToken: z.string().optional(),
+        xiaohongshuToken: z.string().optional()
+      },
+      outputSchema: {
+        status: z.string(),
+        assistantMessage: z.string(),
+        summary: z.object({}).passthrough().optional(),
+        data: z.object({}).passthrough().optional(),
+        files: z.array(z.string()).optional(),
+        nextActions: z.array(z.string()).optional(),
+        warnings: z.array(z.any()).optional(),
+        errors: z.array(z.any()).optional()
+      }
+    },
+    async input => asToolResult(await callVocApi(input))
+  );
+
   return server;
 }
 

+ 313 - 0
claude-code/claude-code-voc-intelligence/mcp/src/tools/voc-api-catalog-run.js

@@ -0,0 +1,313 @@
+const { readVocToken } = require('../core/credentials');
+const {
+  listEndpoints,
+  findEndpoint,
+  searchEndpoints,
+  summarizeEndpoint,
+  loadCatalog
+} = require('../core/api-catalog');
+const { callSocialGateway, redactSecret } = require('../providers/voc-gateway');
+const {
+  buildVocRechargeInfo,
+  buildMissingTokenMessage,
+  buildRechargeRequiredMessage,
+  buildWrongTokenTypeMessage
+} = require('../core/payment-links');
+const { okResult, errorResult } = require('../core/result-envelope');
+
+function isSkToken(token) {
+  return Boolean(token) && /^\s*sk-/i.test(token);
+}
+
+function buildCallTemplate(endpoint) {
+  const args = { id: endpoint.id, params: {} };
+  for (const param of endpoint.params || []) {
+    if (param.required || param.default !== undefined) {
+      args.params[param.name] = param.default !== undefined ? param.default : `<${param.name}>`;
+    }
+  }
+  return args;
+}
+
+async function searchVocApis(input = {}) {
+  const catalog = loadCatalog();
+  const query = input.query || input.q || input.keyword || '';
+  const matches = (query || input.platform || input.tag)
+    ? searchEndpoints({ query, platform: input.platform, tag: input.tag, limit: input.limit || 20 })
+    : listEndpoints();
+  const results = matches.map(summarizeEndpoint);
+  const lines = [
+    '## VOC 转发接口清单',
+    '',
+    `网关:${catalog.gateway?.social?.baseUrl || ''}(通用转发,任意 proxyPath 透传到上游 TikHub;每次调用计费 1 次)。`,
+    query ? `匹配关键词「${query}」的接口(${results.length} 条):` : `全部已登记接口(${results.length} 条):`,
+    '',
+    ...results.map(
+      item =>
+        `- ${item.id} | ${item.title}(${item.method} ${item.proxyPath})必填参数: ${item.requiredParams.join(', ') || '无'}`
+    ),
+    '',
+    '下一步:用 voc_api_doc 读取某个接口的详细参数文档,再用 voc_api_call 传参调用。',
+    '清单里没有的接口,可直接用 voc_api_call 传 rawPath + method + params 调用。'
+  ];
+  return okResult({
+    assistantMessage: lines.join('\n'),
+    summary: { total: results.length, query: query || null },
+    data: { endpoints: results, gateway: catalog.gateway, platforms: catalog.platforms }
+  });
+}
+
+async function getVocApiDoc(input = {}) {
+  const idOrPath = input.id || input.endpointId || input.proxyPath || input.rawPath || input.path;
+  if (!idOrPath) {
+    return errorResult('请提供接口 id 或 proxyPath(可先用 voc_api_search 查清单)。', {
+      data: { endpoints: listEndpoints().map(summarizeEndpoint) }
+    });
+  }
+  const endpoint = findEndpoint(idOrPath);
+  if (!endpoint) {
+    return errorResult(`清单里没有找到接口「${idOrPath}」。可用 voc_api_search 查询,或直接用 voc_api_call 传 rawPath 调用未登记接口。`, {
+      data: { endpoints: listEndpoints().map(summarizeEndpoint) }
+    });
+  }
+  const paramLines = (endpoint.params || []).map(p => {
+    const flag = p.required ? '必填' : '可选';
+    const def = p.default !== undefined ? `,默认 ${JSON.stringify(p.default)}` : '';
+    return `- ${p.name}(${p.in}, ${p.type}, ${flag}${def}):${p.desc || ''}`;
+  });
+  const template = buildCallTemplate(endpoint);
+  const lines = [
+    `## ${endpoint.title}(${endpoint.id})`,
+    '',
+    endpoint.summary || '',
+    '',
+    `请求:${endpoint.method} ${endpoint.proxyPath}(计费 ${endpoint.billing || 1} 次)`,
+    '',
+    '参数:',
+    ...paramLines,
+    '',
+    endpoint.responseHint ? `返回:${endpoint.responseHint}` : '',
+    '',
+    '调用示例(传给 voc_api_call):',
+    '```json',
+    JSON.stringify(template, null, 2),
+    '```',
+    '',
+    'token 用从充值/开通页复制的、以 r: 开头的会话 token(不要用 sk- 开头的 AIGate Key)。'
+  ].filter(line => line !== '');
+  return okResult({
+    assistantMessage: lines.join('\n'),
+    summary: { id: endpoint.id, method: endpoint.method, proxyPath: endpoint.proxyPath },
+    data: { endpoint, callTemplate: template }
+  });
+}
+
+function resolveParamValue(name, params, input) {
+  if (params && Object.prototype.hasOwnProperty.call(params, name)) return params[name];
+  if (Object.prototype.hasOwnProperty.call(input, name)) return input[name];
+  return undefined;
+}
+
+function buildRequestParts(endpoint, input) {
+  const params = input.params && typeof input.params === 'object' ? input.params : {};
+  const query = {};
+  const body = {};
+  const missing = [];
+  const target = (param) => {
+    const where = param.in || endpoint.paramsIn || (endpoint.method === 'GET' ? 'query' : 'body');
+    return where === 'query' ? query : body;
+  };
+  for (const param of endpoint.params || []) {
+    let value = resolveParamValue(param.name, params, input);
+    if ((value === undefined || value === null || value === '') && param.default !== undefined) {
+      value = param.default;
+    }
+    if (value === undefined || value === null || value === '') {
+      if (param.required) missing.push(param.name);
+      continue;
+    }
+    target(param)[param.name] = value;
+  }
+  return { query, body: endpoint.method === 'GET' ? undefined : body, missing };
+}
+
+function buildRawRequestParts(input) {
+  const method = String(input.method || 'GET').toUpperCase();
+  const explicitQuery = input.query && typeof input.query === 'object' ? input.query : null;
+  const explicitBody = input.body && typeof input.body === 'object' ? input.body : null;
+  const params = input.params && typeof input.params === 'object' ? input.params : {};
+  if (method === 'GET') {
+    return { method, query: explicitQuery || params, body: undefined };
+  }
+  return { method, query: explicitQuery || {}, body: explicitBody || params };
+}
+
+async function callVocApi(input = {}) {
+  const idOrPath = input.id || input.endpointId;
+  const rawPath = input.rawPath || input.proxyPath || (idOrPath ? null : input.path);
+  const endpoint = idOrPath || rawPath ? findEndpoint(idOrPath || rawPath) : undefined;
+
+  if (!endpoint && !rawPath) {
+    return errorResult('请提供接口 id(先用 voc_api_search 查清单),或提供 rawPath + method 调用未登记接口。', {
+      data: { endpoints: listEndpoints().map(summarizeEndpoint) }
+    });
+  }
+
+  const token = readVocToken(input);
+  if (!token) {
+    const recharge = await buildVocRechargeInfo();
+    return {
+      status: 'needs_token',
+      assistantMessage: buildMissingTokenMessage(recharge.paymentUrl, recharge, { platformLabel: '社交平台' }),
+      summary: { endpoint: endpoint?.id || rawPath },
+      data: { recharge },
+      files: [],
+      nextActions: [
+        `打开充值/开通链接:${recharge.paymentUrl}`,
+        '配置以 r: 开头的会话 token 后重新调用'
+      ],
+      warnings: [],
+      errors: []
+    };
+  }
+  if (isSkToken(token)) {
+    const recharge = await buildVocRechargeInfo();
+    return {
+      status: 'needs_valid_token',
+      assistantMessage: buildWrongTokenTypeMessage(recharge.paymentUrl, recharge, { platformLabel: '社交平台' }),
+      summary: { endpoint: endpoint?.id || rawPath, tokenType: 'sk-' },
+      data: { recharge },
+      files: [],
+      nextActions: [
+        '从充值/开通页复制以 r: 开头的会话 token(不是 sk- 开头的 AIGate API Key)',
+        `打开充值/开通链接:${recharge.paymentUrl}`
+      ],
+      warnings: [],
+      errors: []
+    };
+  }
+
+  let proxyPath;
+  let method;
+  let query;
+  let body;
+  if (endpoint) {
+    proxyPath = endpoint.proxyPath;
+    method = endpoint.method;
+    const parts = buildRequestParts(endpoint, input);
+    if (parts.missing.length) {
+      return errorResult(
+        `缺少必填参数:${parts.missing.join(', ')}。这不是类目/关键词/余额问题,请补齐参数后再调用(可用 voc_api_doc 查看参数说明)。`,
+        {
+          status: 'needs_input',
+          summary: { endpoint: endpoint.id, missing: parts.missing },
+          data: { endpoint: summarizeEndpoint(endpoint), missing: parts.missing }
+        }
+      );
+    }
+    query = parts.query;
+    body = parts.body;
+  } else {
+    proxyPath = rawPath;
+    const parts = buildRawRequestParts(input);
+    method = parts.method;
+    query = parts.query;
+    body = parts.body;
+  }
+
+  try {
+    const result = await callSocialGateway({
+      proxyPath,
+      method,
+      query,
+      body,
+      token,
+      baseUrl: input.baseUrl,
+      retries: Number.isFinite(input.retries) ? input.retries : 3
+    });
+    return okResult({
+      assistantMessage: `调用成功:${proxyPath}(${method})。`,
+      summary: {
+        endpoint: endpoint?.id || proxyPath,
+        method,
+        proxyPath,
+        billing: endpoint?.billing || 1,
+        httpStatus: result.httpStatus
+      },
+      data: { result: result.data, raw: result.json }
+    });
+  } catch (error) {
+    const kind = String(error && (error.kind || error.errorKind) || 'upstream');
+    const httpStatus = Number(error && error.httpStatus) || 0;
+    const safeMessage = redactSecret(error && error.message ? error.message : 'request failed');
+
+    if (kind === 'auth') {
+      const recharge = await buildVocRechargeInfo({ token });
+      return {
+        status: 'needs_valid_token',
+        assistantMessage: [
+          '当前 VOC-AI 数据服务 token 没有通过校验,暂时不能调用该接口。',
+          '',
+          '这是 token 无效或类型不对,不是关键词/类目/参数问题:需要从充值/开通页复制的、以 r: 开头的会话 token(不要用 sk- 开头的 AIGate API Key)。',
+          '',
+          `请打开充值/开通链接:${recharge.paymentUrl}`
+        ].join('\n'),
+        summary: { endpoint: endpoint?.id || proxyPath, errorKind: kind, httpStatus },
+        data: { recharge },
+        files: [],
+        nextActions: [`打开充值/开通链接:${recharge.paymentUrl}`, '配置以 r: 开头的 session token 后重试'],
+        warnings: [],
+        errors: []
+      };
+    }
+    if (kind === 'billing') {
+      const recharge = await buildVocRechargeInfo({ token });
+      return {
+        status: 'needs_recharge',
+        assistantMessage: buildRechargeRequiredMessage(recharge.paymentUrl, recharge, { platformLabel: '社交平台' }),
+        summary: { endpoint: endpoint?.id || proxyPath, errorKind: kind, httpStatus },
+        data: { recharge },
+        files: [],
+        nextActions: [`打开充值链接补充额度:${recharge.paymentUrl}`],
+        warnings: [],
+        errors: []
+      };
+    }
+    if (kind === 'request') {
+      return {
+        status: 'needs_input',
+        assistantMessage: [
+          `接口 ${proxyPath} 返回了入参/请求错误(${safeMessage})。`,
+          '',
+          '这不是余额不足,也不是类目不支持:请检查参数是否符合接口文档(可用 voc_api_doc 查看),修正后重试。'
+        ].join('\n'),
+        summary: { endpoint: endpoint?.id || proxyPath, errorKind: kind, httpStatus },
+        data: { message: safeMessage },
+        files: [],
+        nextActions: ['用 voc_api_doc 核对参数', '修正参数后重新调用'],
+        warnings: [],
+        errors: [{ message: safeMessage, kind, httpStatus }]
+      };
+    }
+    return {
+      status: 'upstream_unstable',
+      assistantMessage: [
+        `接口 ${proxyPath} 这次调用失败,是上游数据接口返回错误或暂时不稳定(如 5xx、连接超时、fetch failed)。`,
+        '',
+        '这不是关键词问题,也不是类目不支持、也不是余额不足:通常稍后重试即可(本工具已自动重试若干次)。'
+      ].join('\n'),
+      summary: { endpoint: endpoint?.id || proxyPath, errorKind: kind, httpStatus },
+      data: { message: safeMessage },
+      files: [],
+      nextActions: ['稍后重试该接口调用'],
+      warnings: [],
+      errors: [{ message: safeMessage, kind, httpStatus }]
+    };
+  }
+}
+
+module.exports = {
+  searchVocApis,
+  getVocApiDoc,
+  callVocApi
+};

+ 2 - 2
claude-code/claude-code-voc-intelligence/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "@vocmarket/voc-skill",
-  "version": "0.3.16",
+  "version": "0.3.17",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "@vocmarket/voc-skill",
-      "version": "0.3.16",
+      "version": "0.3.17",
       "dependencies": {
         "@modelcontextprotocol/sdk": "^1.29.0",
         "zod": "^4.4.3"

+ 1 - 1
claude-code/claude-code-voc-intelligence/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vocmarket/voc-skill",
-  "version": "0.3.16",
+  "version": "0.3.17",
   "description": "Claude Code VOC intelligence skills for Xiaohongshu and Douyin trend reports, live social VOC collection, Fmode image analysis, single-issue deep dives, boss/operator action plans, and multi-turn memory.",
   "type": "commonjs",
   "bin": {

+ 38 - 0
claude-code/claude-code-voc-intelligence/scripts/smoke-mcp.js

@@ -303,6 +303,44 @@ async function main() {
       throw new Error(`Expected VOC business workflow result, got: ${JSON.stringify(businessWorkflowStructured)}`);
     }
 
+    for (const tool of ['voc_api_search', 'voc_api_doc', 'voc_api_call']) {
+      if (!toolNames.includes(tool)) {
+        throw new Error(`Expected catalog tool ${tool}, got: ${toolNames.join(', ')}`);
+      }
+    }
+
+    const apiSearchResult = await client.callTool({
+      name: 'voc_api_search',
+      arguments: { platform: 'douyin' }
+    });
+    const apiSearchStructured = apiSearchResult.structuredContent || {};
+    if (apiSearchStructured.status !== 'ok' || !apiSearchStructured.data?.endpoints?.length) {
+      throw new Error(`Expected voc_api_search to list douyin endpoints, got: ${JSON.stringify(apiSearchStructured)}`);
+    }
+
+    const apiDocResult = await client.callTool({
+      name: 'voc_api_doc',
+      arguments: { id: 'douyin.search_general' }
+    });
+    const apiDocStructured = apiDocResult.structuredContent || {};
+    if (apiDocStructured.status !== 'ok' ||
+      !String(apiDocStructured.data?.endpoint?.proxyPath || '').includes('fetch_general_search_v2')) {
+      throw new Error(`Expected voc_api_doc to return douyin search doc, got: ${JSON.stringify(apiDocStructured)}`);
+    }
+
+    const apiCallSentinel = 'sk-SMOKE_WRONG_TOKEN_TYPE';
+    const apiCallResult = await client.callTool({
+      name: 'voc_api_call',
+      arguments: { id: 'douyin.search_general', params: { keyword: '律师' }, vocToken: apiCallSentinel }
+    });
+    const apiCallStructured = apiCallResult.structuredContent || {};
+    if (apiCallStructured.status !== 'needs_valid_token') {
+      throw new Error(`Expected voc_api_call to reject sk- token as needs_valid_token, got: ${JSON.stringify(apiCallStructured)}`);
+    }
+    if (JSON.stringify(apiCallStructured).includes(apiCallSentinel)) {
+      throw new Error('voc_api_call leaked the request-scoped token');
+    }
+
     console.log(JSON.stringify({
       status: 'ok',
       tools: toolNames,

+ 4 - 1
claude-code/claude-code-voc-intelligence/scripts/smoke-package.js

@@ -958,7 +958,10 @@ async function main() {
     'voc_problem_deep_dive_run',
     'voc_content_plan_run',
     'voc_speaking_script_run',
-    'voc_competitor_map_run'
+    'voc_competitor_map_run',
+    'voc_api_search',
+    'voc_api_doc',
+    'voc_api_call'
   ];
   for (const tool of requiredTools) {
     if (!toolNames.includes(tool)) {

+ 8 - 3
claude-code/claude-code-voc-intelligence/skill-package-manifest.json

@@ -1,6 +1,6 @@
 {
   "name": "claude-code-voc-intelligence",
-  "version": "0.3.16",
+  "version": "0.3.17",
   "description": "Claude Code native VOC intelligence package for cross-industry social VOC workflows. Includes Xiaohongshu and Douyin trend intelligence, live token checks, evidence-card reports, single-issue VOC deep dives, boss/operator action plans, and scoped multi-turn memory.",
   "plugin": "voc-intelligence",
   "skills": [
@@ -12,7 +12,8 @@
     "voc-speaking-script",
     "voc-competitor-map",
     "voc-business-workflow",
-    "fmode-image-analysis"
+    "fmode-image-analysis",
+    "voc-api-catalog"
   ],
   "mcpTools": [
     "fmode_image_analysis",
@@ -27,8 +28,12 @@
     "voc_xiaohongshu_preference_update",
     "voc_douyin_token_check",
     "voc_douyin_trend_run",
-    "voc_douyin_preference_update"
+    "voc_douyin_preference_update",
+    "voc_api_search",
+    "voc_api_doc",
+    "voc_api_call"
   ],
+  "apiCatalog": "mcp/catalog/voc-social-endpoints.json",
   "entrySkill": "xiaohongshu-trend-intelligence",
   "capabilityMap": "docs/capability-map.md",
   "bossPromptTemplates": "docs/boss-prompt-templates.md",

+ 55 - 0
claude-code/claude-code-voc-intelligence/skills/voc-api-catalog/SKILL.md

@@ -0,0 +1,55 @@
+---
+name: voc-api-catalog
+description: 清单驱动的 VOC 社交数据采集。把所有转发接口做成清单,按需查清单、读参数文档,自己拼参数完成任意行业/任意方向的社交数据采集(抖音、小红书及 TikHub 上游其他平台)。当用户要采集某关键词的社交内容/评论、要调用某个社交数据接口、或现有专用工具不覆盖某接口时使用。
+---
+
+# VOC 接口清单驱动采集
+
+## 核心理念
+
+VOC 后端是一个**通用转发网关**(`https://server.fmode.cn/api/voc-social/<proxyPath>`),任意 `proxyPath` 都会被透传到上游 TikHub。所以不需要为每个接口写死一个工具:你只要
+
+1. **查清单**:用 `voc_api_search` 找到要用的接口;
+2. **读参数文档**:用 `voc_api_doc` 读该接口的详细参数;
+3. **拼参数调用**:用 `voc_api_call` 自己拼好参数发起真实调用。
+
+清单里没有的接口,也可以直接用 `voc_api_call` 传 `rawPath` + `method` + `params` 调用,无需改代码。
+
+## 工具
+
+- `voc_api_search`:检索接口清单。入参 `query`(关键词,可选)、`platform`(如 douyin / xiaohongshu,可选)、`tag`(可选)。返回接口 id、标题、method、proxyPath、必填参数。
+- `voc_api_doc`:读取单个接口的完整参数文档。入参 `id`(或 `proxyPath`)。返回参数表 + 可直接复制的调用模板。
+- `voc_api_call`:发起真实调用。入参:
+  - `id`:清单里的接口 id(推荐),或
+  - `rawPath` + `method`:调用未登记接口;
+  - `params`:参数对象(按 doc 拼)。也可显式用 `query` / `body` 分开传;
+  - token:从 `vocToken` / `token` 或环境读取,**绝不回显**。
+
+## 标准流程
+
+1. 理解任务 → 确定平台和要采集的内容(搜索 / 评论 / 详情)。
+2. `voc_api_search`(带行业无关的关键词或 platform)找到接口。
+3. `voc_api_doc` 读参数,确认必填项和取值含义。
+4. `voc_api_call` 用真实关键词调用;翻页/拉评论时把上一次返回里的 cursor / id 透传进下一次调用。
+5. 用返回数据继续后续分析(可交给 voc-issue-pool、voc-content-plan 等技能)。
+
+## Token
+
+采集需要从充值/开通页复制的、以 `r:` 开头的会话 token(session token)。
+**不要**用 `~/.claude/voc-credentials.json` 里 `sk-` 开头的 AIGate API Key —— 后端不认它,会被工具直接判为 `needs_valid_token`。
+
+## 禁止事项(护栏)
+
+- 禁止把**入参/参数错误**(`needs_input`)误报成「采集不到数据 / 该关键词没结果 / 类目不支持」。参数错就如实说参数错,并用 `voc_api_doc` 核对后重试。
+- 禁止把**上游不稳定**(`upstream_unstable`,如 5xx / 连接超时 / fetch failed)误报成「关键词问题 / 类目不支持 / 余额不足」。如实说明是上游接口波动,稍后重试。
+- 禁止把 **token 无效/类型不对**(`needs_valid_token`)误报成「余额不足」或「类目不支持」。
+- 禁止输出「只支持餐饮 / 某行业不可用 / 系统只支持某类目」这类结论——**任何行业、任何关键词都能采集**,前提只是 token 类型正确且有额度。
+- 失败时禁止退回 web search / yt-dlp / ffmpeg / 让用户手动传媒体文件来「绕过」采集。
+
+## 状态码含义
+
+- `ok`:调用成功,`data.result` 是上游返回数据。
+- `needs_token` / `needs_valid_token`:没配置 token,或 token 类型不对(sk-)。提示配置 `r:` token。
+- `needs_recharge`:额度不足,提示充值。
+- `needs_input`:必填参数缺失或参数错误(不是类目/余额问题)。
+- `upstream_unstable`:上游接口报错或波动,稍后重试。