@vocmarket/voc-skill 0.3.16 → 0.3.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/claude-voc.js +7 -0
- package/install.js +3 -0
- package/mcp/catalog/voc-social-endpoints.json +143 -0
- package/mcp/src/core/api-catalog.js +91 -0
- package/mcp/src/providers/voc-gateway.js +162 -0
- package/mcp/src/server.js +192 -99
- package/mcp/src/tools/voc-api-catalog-run.js +313 -0
- package/package.json +1 -1
- package/scripts/smoke-mcp.js +38 -0
- package/scripts/smoke-package.js +4 -1
- package/skill-package-manifest.json +8 -3
- package/skills/voc-api-catalog/SKILL.md +55 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "voc-intelligence",
|
|
3
3
|
"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.",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.17",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "nkkj-BrainHack"
|
|
7
7
|
}
|
package/bin/claude-voc.js
CHANGED
|
@@ -184,7 +184,10 @@ function assertInstalled(targetDir) {
|
|
|
184
184
|
'skills/voc-competitor-map/SKILL.md',
|
|
185
185
|
'skills/voc-business-workflow/SKILL.md',
|
|
186
186
|
'skills/fmode-image-analysis/SKILL.md',
|
|
187
|
+
'skills/voc-api-catalog/SKILL.md',
|
|
187
188
|
'mcp/src/tools/fmode-image-analysis.js',
|
|
189
|
+
'mcp/src/tools/voc-api-catalog-run.js',
|
|
190
|
+
'mcp/catalog/voc-social-endpoints.json',
|
|
188
191
|
'mcp/src/server.js'
|
|
189
192
|
];
|
|
190
193
|
const missing = required.filter(rel => !fs.existsSync(path.join(targetDir, rel)));
|
|
@@ -646,6 +649,7 @@ function writeWorkspaceSkillEntries(targetDir) {
|
|
|
646
649
|
const competitorMapSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'voc-competitor-map');
|
|
647
650
|
const businessWorkflowSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'voc-business-workflow');
|
|
648
651
|
const fmodeImageSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'fmode-image-analysis');
|
|
652
|
+
const apiCatalogSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'voc-api-catalog');
|
|
649
653
|
ensureDir(xhsSkillDir);
|
|
650
654
|
ensureDir(douyinSkillDir);
|
|
651
655
|
ensureDir(issuePoolSkillDir);
|
|
@@ -655,6 +659,7 @@ function writeWorkspaceSkillEntries(targetDir) {
|
|
|
655
659
|
ensureDir(competitorMapSkillDir);
|
|
656
660
|
ensureDir(businessWorkflowSkillDir);
|
|
657
661
|
ensureDir(fmodeImageSkillDir);
|
|
662
|
+
ensureDir(apiCatalogSkillDir);
|
|
658
663
|
fs.writeFileSync(path.join(xhsSkillDir, 'SKILL.md'), workspaceSkillText(targetDir), 'utf8');
|
|
659
664
|
fs.writeFileSync(path.join(douyinSkillDir, 'SKILL.md'), workspaceDouyinSkillText(targetDir), 'utf8');
|
|
660
665
|
fs.writeFileSync(path.join(issuePoolSkillDir, 'SKILL.md'), workspaceIssuePoolSkillText(targetDir), 'utf8');
|
|
@@ -664,6 +669,7 @@ function writeWorkspaceSkillEntries(targetDir) {
|
|
|
664
669
|
fs.writeFileSync(path.join(competitorMapSkillDir, 'SKILL.md'), workspaceCompetitorMapSkillText(targetDir), 'utf8');
|
|
665
670
|
fs.writeFileSync(path.join(businessWorkflowSkillDir, 'SKILL.md'), workspaceBusinessWorkflowSkillText(targetDir), 'utf8');
|
|
666
671
|
fs.copyFileSync(path.join(targetDir, 'skills', 'fmode-image-analysis', 'SKILL.md'), path.join(fmodeImageSkillDir, 'SKILL.md'));
|
|
672
|
+
fs.copyFileSync(path.join(targetDir, 'skills', 'voc-api-catalog', 'SKILL.md'), path.join(apiCatalogSkillDir, 'SKILL.md'));
|
|
667
673
|
}
|
|
668
674
|
|
|
669
675
|
function writeWorkspaceActivation(targetDir) {
|
|
@@ -680,6 +686,7 @@ function writeWorkspaceActivation(targetDir) {
|
|
|
680
686
|
console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-competitor-map\\SKILL.md');
|
|
681
687
|
console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-business-workflow\\SKILL.md');
|
|
682
688
|
console.log('Wrote workspace skill entry: .\\.claude\\skills\\fmode-image-analysis\\SKILL.md');
|
|
689
|
+
console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-api-catalog\\SKILL.md');
|
|
683
690
|
}
|
|
684
691
|
|
|
685
692
|
function printNextSteps(targetDir) {
|
package/install.js
CHANGED
|
@@ -112,7 +112,10 @@ function checkFiles() {
|
|
|
112
112
|
'skills/voc-competitor-map/SKILL.md',
|
|
113
113
|
'skills/voc-business-workflow/SKILL.md',
|
|
114
114
|
'skills/fmode-image-analysis/SKILL.md',
|
|
115
|
+
'skills/voc-api-catalog/SKILL.md',
|
|
115
116
|
'mcp/src/tools/fmode-image-analysis.js',
|
|
117
|
+
'mcp/src/tools/voc-api-catalog-run.js',
|
|
118
|
+
'mcp/catalog/voc-social-endpoints.json',
|
|
116
119
|
'mcp/src/server.js'
|
|
117
120
|
].forEach(assertFile);
|
|
118
121
|
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.0.0",
|
|
3
|
+
"updatedAt": "2026-06-21",
|
|
4
|
+
"gateway": {
|
|
5
|
+
"social": {
|
|
6
|
+
"baseUrl": "https://server.fmode.cn/api/voc-social",
|
|
7
|
+
"auth": "Authorization: Bearer <r: 开头的会话 token>",
|
|
8
|
+
"upstream": "TikHub (server.fmode.cn/thapi/v1)",
|
|
9
|
+
"billing": "每次转发调用扣除 1 次社交平台 API 额度(缓存命中也会计费)。",
|
|
10
|
+
"note": "这是一个通用转发网关:任意 proxyPath 都会被透传到上游 TikHub。本清单只列出已验证的常用接口;未列出的接口可以直接用 voc_api_call 传 rawPath + method + params 调用,无需改代码。",
|
|
11
|
+
"rawPathHint": "调用未在清单中的接口时,把上游的相对路径填到 voc_api_call 的 rawPath,例如 douyin/search/fetch_general_search_v2,并按上游文档拼 query/body。"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"platforms": [
|
|
15
|
+
{ "key": "douyin", "label": "抖音", "status": "verified" },
|
|
16
|
+
{ "key": "xiaohongshu", "label": "小红书", "status": "verified" },
|
|
17
|
+
{ "key": "tiktok", "label": "TikTok", "status": "gateway_available" },
|
|
18
|
+
{ "key": "youtube", "label": "YouTube", "status": "gateway_available" },
|
|
19
|
+
{ "key": "twitter", "label": "Twitter/X", "status": "gateway_available" },
|
|
20
|
+
{ "key": "instagram", "label": "Instagram", "status": "gateway_available" },
|
|
21
|
+
{ "key": "linkedin", "label": "LinkedIn", "status": "gateway_available" }
|
|
22
|
+
],
|
|
23
|
+
"endpoints": [
|
|
24
|
+
{
|
|
25
|
+
"id": "douyin.search_general",
|
|
26
|
+
"platform": "douyin",
|
|
27
|
+
"channel": "social",
|
|
28
|
+
"title": "抖音综合搜索",
|
|
29
|
+
"summary": "按关键词搜索抖音视频(综合搜索 v2)。任意行业/方向关键词均可用于趋势与 VOC 选题采集。",
|
|
30
|
+
"proxyPath": "douyin/search/fetch_general_search_v2",
|
|
31
|
+
"method": "POST",
|
|
32
|
+
"paramsIn": "body",
|
|
33
|
+
"params": [
|
|
34
|
+
{ "name": "keyword", "in": "body", "type": "string", "required": true, "desc": "搜索关键词,任意行业/方向均可,例如 股权律师、医美 玻尿酸、少儿编程、新能源汽车。" },
|
|
35
|
+
{ "name": "cursor", "in": "body", "type": "integer", "required": false, "default": 0, "desc": "分页游标,首页为 0,翻页用上一次返回的 cursor。" },
|
|
36
|
+
{ "name": "sort_type", "in": "body", "type": "string", "required": false, "default": "0", "desc": "排序:0 综合,1 最多点赞,2 最新发布。" },
|
|
37
|
+
{ "name": "publish_time", "in": "body", "type": "string", "required": false, "default": "0", "desc": "发布时间:0 不限,1 一天内,7 一周内,180 半年内。" },
|
|
38
|
+
{ "name": "filter_duration", "in": "body", "type": "string", "required": false, "default": "0", "desc": "时长筛选:0 不限,0-1 一分钟内,1-5 一到五分钟,5-10000 五分钟以上。" },
|
|
39
|
+
{ "name": "content_type", "in": "body", "type": "string", "required": false, "default": "0", "desc": "内容类型:0 不限,1 视频,2 图集,3 文章。" },
|
|
40
|
+
{ "name": "search_id", "in": "body", "type": "string", "required": false, "default": "", "desc": "连续翻页时透传上一次返回的 search_id。" },
|
|
41
|
+
{ "name": "backtrace", "in": "body", "type": "string", "required": false, "default": "", "desc": "连续翻页时透传上一次返回的 backtrace。" }
|
|
42
|
+
],
|
|
43
|
+
"tags": ["抖音", "douyin", "搜索", "search", "视频", "关键词", "趋势", "voc"],
|
|
44
|
+
"billing": 1,
|
|
45
|
+
"responseHint": "返回视频列表,每条含 aweme_id、desc、统计字段等;aweme_id 可用于拉取评论。",
|
|
46
|
+
"example": { "keyword": "新能源汽车", "cursor": 0, "sort_type": "0" }
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"id": "douyin.video_comments",
|
|
50
|
+
"platform": "douyin",
|
|
51
|
+
"channel": "social",
|
|
52
|
+
"title": "抖音视频评论列表",
|
|
53
|
+
"summary": "按 aweme_id 拉取某条抖音视频的评论,用于采集真实用户声音(VOC)。",
|
|
54
|
+
"proxyPath": "douyin/app/v3/fetch_video_comments",
|
|
55
|
+
"method": "GET",
|
|
56
|
+
"paramsIn": "query",
|
|
57
|
+
"params": [
|
|
58
|
+
{ "name": "aweme_id", "in": "query", "type": "string", "required": true, "desc": "视频 ID,来自抖音综合搜索结果。" },
|
|
59
|
+
{ "name": "cursor", "in": "query", "type": "integer", "required": false, "default": 0, "desc": "评论分页游标,首页 0。" },
|
|
60
|
+
{ "name": "count", "in": "query", "type": "integer", "required": false, "default": 20, "desc": "每页评论数,建议 20。" }
|
|
61
|
+
],
|
|
62
|
+
"tags": ["抖音", "douyin", "评论", "comments", "voc", "用户声音"],
|
|
63
|
+
"billing": 1,
|
|
64
|
+
"responseHint": "返回评论列表,含评论文本、点赞数、cid(用于拉取楼中楼回复)。",
|
|
65
|
+
"example": { "aweme_id": "7300000000000000000", "cursor": 0, "count": 20 }
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"id": "douyin.comment_replies",
|
|
69
|
+
"platform": "douyin",
|
|
70
|
+
"channel": "social",
|
|
71
|
+
"title": "抖音评论回复(楼中楼)",
|
|
72
|
+
"summary": "按视频 item_id 和评论 comment_id 拉取某条评论下的回复。",
|
|
73
|
+
"proxyPath": "douyin/app/v3/fetch_video_comment_replies",
|
|
74
|
+
"method": "GET",
|
|
75
|
+
"paramsIn": "query",
|
|
76
|
+
"params": [
|
|
77
|
+
{ "name": "item_id", "in": "query", "type": "string", "required": true, "desc": "视频 ID(同 aweme_id)。" },
|
|
78
|
+
{ "name": "comment_id", "in": "query", "type": "string", "required": true, "desc": "父评论 ID(来自评论列表的 cid)。" },
|
|
79
|
+
{ "name": "cursor", "in": "query", "type": "integer", "required": false, "default": 0, "desc": "回复分页游标。" },
|
|
80
|
+
{ "name": "count", "in": "query", "type": "integer", "required": false, "default": 20, "desc": "每页回复数。" }
|
|
81
|
+
],
|
|
82
|
+
"tags": ["抖音", "douyin", "评论", "回复", "replies", "voc"],
|
|
83
|
+
"billing": 1,
|
|
84
|
+
"responseHint": "返回某条父评论下的回复列表。",
|
|
85
|
+
"example": { "item_id": "7300000000000000000", "comment_id": "7300000000000000001", "cursor": 0, "count": 20 }
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"id": "xiaohongshu.search_notes",
|
|
89
|
+
"platform": "xiaohongshu",
|
|
90
|
+
"channel": "social",
|
|
91
|
+
"title": "小红书笔记搜索",
|
|
92
|
+
"summary": "按关键词搜索小红书笔记,任意行业/方向均可,用于趋势与 VOC 选题采集。",
|
|
93
|
+
"proxyPath": "xiaohongshu/app/search_notes",
|
|
94
|
+
"method": "GET",
|
|
95
|
+
"paramsIn": "query",
|
|
96
|
+
"params": [
|
|
97
|
+
{ "name": "keyword", "in": "query", "type": "string", "required": true, "desc": "搜索关键词,任意行业/方向均可。" },
|
|
98
|
+
{ "name": "page", "in": "query", "type": "integer", "required": false, "default": 1, "desc": "页码,从 1 开始。" },
|
|
99
|
+
{ "name": "sort", "in": "query", "type": "string", "required": false, "default": "popularity_descending", "desc": "排序:general 综合,time_descending 最新,popularity_descending 最热。" },
|
|
100
|
+
{ "name": "note_type", "in": "query", "type": "string", "required": false, "default": "_0", "desc": "笔记类型:_0 不限,_1 视频,_2 图文。" }
|
|
101
|
+
],
|
|
102
|
+
"tags": ["小红书", "xiaohongshu", "xhs", "搜索", "search", "笔记", "关键词", "趋势", "voc"],
|
|
103
|
+
"billing": 1,
|
|
104
|
+
"responseHint": "返回笔记列表,每条含 note_id,可用于拉取详情和评论。",
|
|
105
|
+
"example": { "keyword": "敏感肌护肤", "page": 1, "sort": "popularity_descending", "note_type": "_0" }
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"id": "xiaohongshu.note_detail",
|
|
109
|
+
"platform": "xiaohongshu",
|
|
110
|
+
"channel": "social",
|
|
111
|
+
"title": "小红书笔记详情",
|
|
112
|
+
"summary": "按 note_id 拉取小红书笔记详情(正文、图片、统计等)。",
|
|
113
|
+
"proxyPath": "xiaohongshu/app/get_note_info",
|
|
114
|
+
"method": "GET",
|
|
115
|
+
"paramsIn": "query",
|
|
116
|
+
"params": [
|
|
117
|
+
{ "name": "note_id", "in": "query", "type": "string", "required": true, "desc": "笔记 ID,来自搜索结果。" }
|
|
118
|
+
],
|
|
119
|
+
"tags": ["小红书", "xiaohongshu", "xhs", "笔记", "详情", "note"],
|
|
120
|
+
"billing": 1,
|
|
121
|
+
"responseHint": "返回单条笔记的完整内容。",
|
|
122
|
+
"example": { "note_id": "650000000000000000000000" }
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
"id": "xiaohongshu.note_comments",
|
|
126
|
+
"platform": "xiaohongshu",
|
|
127
|
+
"channel": "social",
|
|
128
|
+
"title": "小红书笔记评论",
|
|
129
|
+
"summary": "按 note_id 拉取小红书笔记评论,用于采集真实用户声音(VOC)。",
|
|
130
|
+
"proxyPath": "xiaohongshu/app/get_note_comments",
|
|
131
|
+
"method": "GET",
|
|
132
|
+
"paramsIn": "query",
|
|
133
|
+
"params": [
|
|
134
|
+
{ "name": "note_id", "in": "query", "type": "string", "required": true, "desc": "笔记 ID,来自搜索结果。" },
|
|
135
|
+
{ "name": "cursor", "in": "query", "type": "string", "required": false, "default": "", "desc": "评论分页游标,翻页用上一次返回的 cursor。" }
|
|
136
|
+
],
|
|
137
|
+
"tags": ["小红书", "xiaohongshu", "xhs", "评论", "comments", "voc", "用户声音"],
|
|
138
|
+
"billing": 1,
|
|
139
|
+
"responseHint": "返回笔记评论列表。",
|
|
140
|
+
"example": { "note_id": "650000000000000000000000", "cursor": "" }
|
|
141
|
+
}
|
|
142
|
+
]
|
|
143
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const CATALOG_PATH = path.resolve(__dirname, '..', '..', 'catalog', 'voc-social-endpoints.json');
|
|
5
|
+
|
|
6
|
+
let cached;
|
|
7
|
+
|
|
8
|
+
function loadCatalog() {
|
|
9
|
+
if (cached) return cached;
|
|
10
|
+
const raw = fs.readFileSync(CATALOG_PATH, 'utf8').replace(/^\uFEFF/, '');
|
|
11
|
+
cached = JSON.parse(raw);
|
|
12
|
+
return cached;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function listEndpoints() {
|
|
16
|
+
return loadCatalog().endpoints || [];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalize(value) {
|
|
20
|
+
return String(value || '').trim().toLowerCase();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function findEndpoint(idOrPath) {
|
|
24
|
+
const needle = normalize(idOrPath);
|
|
25
|
+
if (!needle) return undefined;
|
|
26
|
+
const endpoints = listEndpoints();
|
|
27
|
+
return (
|
|
28
|
+
endpoints.find(item => normalize(item.id) === needle) ||
|
|
29
|
+
endpoints.find(item => normalize(item.proxyPath) === needle) ||
|
|
30
|
+
endpoints.find(item => normalize(item.proxyPath).replace(/^\//, '') === needle.replace(/^\//, ''))
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function scoreEndpoint(endpoint, terms) {
|
|
35
|
+
if (!terms.length) return 1;
|
|
36
|
+
const haystack = normalize([
|
|
37
|
+
endpoint.id,
|
|
38
|
+
endpoint.platform,
|
|
39
|
+
endpoint.title,
|
|
40
|
+
endpoint.summary,
|
|
41
|
+
endpoint.proxyPath,
|
|
42
|
+
(endpoint.tags || []).join(' ')
|
|
43
|
+
].join(' '));
|
|
44
|
+
let score = 0;
|
|
45
|
+
for (const term of terms) {
|
|
46
|
+
if (!term) continue;
|
|
47
|
+
if (haystack.includes(term)) score += 1;
|
|
48
|
+
}
|
|
49
|
+
return score;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function searchEndpoints({ query, platform, tag, limit = 20 } = {}) {
|
|
53
|
+
const terms = normalize(query).split(/\s+/).filter(Boolean);
|
|
54
|
+
const platformKey = normalize(platform);
|
|
55
|
+
const tagKey = normalize(tag);
|
|
56
|
+
let results = listEndpoints().map(endpoint => ({
|
|
57
|
+
endpoint,
|
|
58
|
+
score: scoreEndpoint(endpoint, terms)
|
|
59
|
+
}));
|
|
60
|
+
if (platformKey) {
|
|
61
|
+
results = results.filter(item => normalize(item.endpoint.platform) === platformKey);
|
|
62
|
+
}
|
|
63
|
+
if (tagKey) {
|
|
64
|
+
results = results.filter(item => (item.endpoint.tags || []).some(t => normalize(t) === tagKey));
|
|
65
|
+
}
|
|
66
|
+
results = results.filter(item => item.score > 0);
|
|
67
|
+
results.sort((a, b) => b.score - a.score);
|
|
68
|
+
return results.slice(0, Math.max(1, limit)).map(item => item.endpoint);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function summarizeEndpoint(endpoint) {
|
|
72
|
+
return {
|
|
73
|
+
id: endpoint.id,
|
|
74
|
+
platform: endpoint.platform,
|
|
75
|
+
title: endpoint.title,
|
|
76
|
+
summary: endpoint.summary,
|
|
77
|
+
method: endpoint.method,
|
|
78
|
+
proxyPath: endpoint.proxyPath,
|
|
79
|
+
requiredParams: (endpoint.params || []).filter(p => p.required).map(p => p.name),
|
|
80
|
+
tags: endpoint.tags || []
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = {
|
|
85
|
+
CATALOG_PATH,
|
|
86
|
+
loadCatalog,
|
|
87
|
+
listEndpoints,
|
|
88
|
+
findEndpoint,
|
|
89
|
+
searchEndpoints,
|
|
90
|
+
summarizeEndpoint
|
|
91
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
const SOCIAL_GATEWAY_ROOT = 'https://server.fmode.cn/api/voc-social';
|
|
2
|
+
|
|
3
|
+
function resolveSocialRoot(baseUrl) {
|
|
4
|
+
return String(
|
|
5
|
+
baseUrl ||
|
|
6
|
+
process.env.VOC_SOCIAL_API_BASE_URL ||
|
|
7
|
+
process.env.VOC_SOCIAL_GATEWAY_URL ||
|
|
8
|
+
SOCIAL_GATEWAY_ROOT
|
|
9
|
+
).replace(/\/$/, '');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function buildGatewayUrl(root, proxyPath, query = {}) {
|
|
13
|
+
const url = new URL(`${root.replace(/\/$/, '')}/${String(proxyPath || '').replace(/^\//, '')}`);
|
|
14
|
+
Object.entries(query || {}).forEach(([key, value]) => {
|
|
15
|
+
if (value !== undefined && value !== null && value !== '') {
|
|
16
|
+
url.searchParams.set(key, String(value));
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
return url.toString();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function unwrapData(json) {
|
|
23
|
+
if (!json || typeof json !== 'object') return {};
|
|
24
|
+
return json.data?.data || json.data || json;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function pickBusinessStatus(json) {
|
|
28
|
+
const topCode = Number(json?.code);
|
|
29
|
+
if (Number.isFinite(topCode) && topCode !== 0 && topCode !== 200) {
|
|
30
|
+
return topCode;
|
|
31
|
+
}
|
|
32
|
+
const nestedCode = Number(json?.data?.status_code);
|
|
33
|
+
if (Number.isFinite(nestedCode) && nestedCode !== 0 && nestedCode !== 200) {
|
|
34
|
+
return nestedCode;
|
|
35
|
+
}
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function redactSecret(value) {
|
|
40
|
+
return String(value || '')
|
|
41
|
+
.replace(/Bearer\s+[^"'\s,}]+/gi, 'Bearer [REDACTED]')
|
|
42
|
+
.replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, 'sk-[REDACTED]')
|
|
43
|
+
.replace(/\br:[A-Za-z0-9]{4,}\b/g, 'r:[REDACTED]')
|
|
44
|
+
.replace(/("Authorization"\s*:\s*")([^"]+)(")/gi, '$1[REDACTED]$3')
|
|
45
|
+
.replace(/("api[_-]?token"\s*:\s*")([^"]+)(")/gi, '$1[REDACTED]$3');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isSuccessCode(json) {
|
|
49
|
+
const topCode = Number(json?.code);
|
|
50
|
+
if (Number.isFinite(topCode) && ![0, 200].includes(topCode)) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
const statusCode = Number(json?.data?.status_code);
|
|
54
|
+
if (Number.isFinite(statusCode) && ![0, 200].includes(statusCode)) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function classifyApiError({ response, json, message }) {
|
|
61
|
+
const httpStatus = response?.status || 0;
|
|
62
|
+
const businessStatus = pickBusinessStatus(json);
|
|
63
|
+
const status = businessStatus || httpStatus;
|
|
64
|
+
const text = String(message || '');
|
|
65
|
+
if (/company或用户信息不存在|用户信息不存在|用户不存在|账[号户]信息不存在|未登录|登录失效|请输入.*token|sessiontoken/i.test(text)) {
|
|
66
|
+
return 'auth';
|
|
67
|
+
}
|
|
68
|
+
if (status === 401 || /invalid token|无效.*token|token.*invalid|未授权|unauthorized/i.test(text)) {
|
|
69
|
+
return 'auth';
|
|
70
|
+
}
|
|
71
|
+
if (status === 402 || /余额不足|额度不足|没开通|未开通|开通.*权限|权限.*额度|insufficient|balance|quota|payment|充值/i.test(text)) {
|
|
72
|
+
return 'billing';
|
|
73
|
+
}
|
|
74
|
+
if (status === 403 && /余额不足|额度不足|没开通|未开通|insufficient|balance|quota|payment/i.test(text)) {
|
|
75
|
+
return 'billing';
|
|
76
|
+
}
|
|
77
|
+
if (/参数|入参|keyword|关键词|invalid request|bad request/i.test(text) || [400, 422].includes(status)) {
|
|
78
|
+
return 'request';
|
|
79
|
+
}
|
|
80
|
+
if (status >= 500) {
|
|
81
|
+
return 'upstream';
|
|
82
|
+
}
|
|
83
|
+
if (status === 403 || /permission|权限|无权限/i.test(text)) {
|
|
84
|
+
return 'permission';
|
|
85
|
+
}
|
|
86
|
+
return 'upstream';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
90
|
+
|
|
91
|
+
async function requestJson(url, token, { method = 'GET', body, retries = 3 } = {}) {
|
|
92
|
+
let lastError;
|
|
93
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
94
|
+
let response;
|
|
95
|
+
let text;
|
|
96
|
+
try {
|
|
97
|
+
response = await fetch(url, {
|
|
98
|
+
method,
|
|
99
|
+
headers: {
|
|
100
|
+
Authorization: `Bearer ${token}`,
|
|
101
|
+
Accept: 'application/json',
|
|
102
|
+
...(body ? { 'Content-Type': 'application/json' } : {})
|
|
103
|
+
},
|
|
104
|
+
body: body ? JSON.stringify(body) : undefined
|
|
105
|
+
});
|
|
106
|
+
text = await response.text();
|
|
107
|
+
} catch (networkError) {
|
|
108
|
+
lastError = networkError;
|
|
109
|
+
if (attempt < retries) {
|
|
110
|
+
await delay(400 * (attempt + 1));
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const error = new Error(redactSecret(networkError && networkError.message ? networkError.message : 'fetch failed'));
|
|
114
|
+
error.kind = 'upstream';
|
|
115
|
+
error.httpStatus = 0;
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
let json;
|
|
119
|
+
try {
|
|
120
|
+
json = JSON.parse(text);
|
|
121
|
+
} catch {
|
|
122
|
+
json = undefined;
|
|
123
|
+
}
|
|
124
|
+
if (!response.ok || (json && !isSuccessCode(json))) {
|
|
125
|
+
const message = json?.message || json?.msg || json?.data?.msg || json?.data?.message || json?.mess || text || `HTTP ${response.status}`;
|
|
126
|
+
const error = new Error(redactSecret(message));
|
|
127
|
+
error.httpStatus = pickBusinessStatus(json) || response.status || 500;
|
|
128
|
+
error.kind = classifyApiError({ response, json, message });
|
|
129
|
+
error.responseBody = redactSecret(text);
|
|
130
|
+
if (error.httpStatus >= 500 && attempt < retries) {
|
|
131
|
+
lastError = error;
|
|
132
|
+
await delay(400 * (attempt + 1));
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
return { json, data: unwrapData(json), rawText: text, httpStatus: response.status };
|
|
138
|
+
}
|
|
139
|
+
throw lastError;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function callSocialGateway({ proxyPath, method = 'GET', query = {}, body, token, baseUrl, retries = 3 } = {}) {
|
|
143
|
+
const root = resolveSocialRoot(baseUrl);
|
|
144
|
+
const url = buildGatewayUrl(root, proxyPath, query);
|
|
145
|
+
const upper = String(method || 'GET').toUpperCase();
|
|
146
|
+
return requestJson(url, token, {
|
|
147
|
+
method: upper,
|
|
148
|
+
body: upper === 'GET' ? undefined : body,
|
|
149
|
+
retries
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = {
|
|
154
|
+
SOCIAL_GATEWAY_ROOT,
|
|
155
|
+
resolveSocialRoot,
|
|
156
|
+
buildGatewayUrl,
|
|
157
|
+
classifyApiError,
|
|
158
|
+
redactSecret,
|
|
159
|
+
unwrapData,
|
|
160
|
+
requestJson,
|
|
161
|
+
callSocialGateway
|
|
162
|
+
};
|
package/mcp/src/server.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
|
|
3
3
|
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
4
4
|
const { z } = require('zod');
|
|
@@ -14,8 +14,9 @@ const { runVocCompetitorMap } = require('./tools/voc-competitor-map-run');
|
|
|
14
14
|
const { runVocSpeakingScript } = require('./tools/voc-speaking-script-run');
|
|
15
15
|
const { runBusinessWorkflow } = require('./tools/voc-business-workflow-run');
|
|
16
16
|
const { analyzeFmodeImage } = require('./tools/fmode-image-analysis');
|
|
17
|
+
const { searchVocApis, getVocApiDoc, callVocApi } = require('./tools/voc-api-catalog-run');
|
|
17
18
|
const { buildVocRechargeInfo, buildMissingTokenMessage } = require('./core/payment-links');
|
|
18
|
-
|
|
19
|
+
|
|
19
20
|
function asToolResult(result) {
|
|
20
21
|
const normalized = normalizeToolResult(result);
|
|
21
22
|
return {
|
|
@@ -56,11 +57,11 @@ function normalizeToolResult(result = {}) {
|
|
|
56
57
|
}
|
|
57
58
|
return normalized;
|
|
58
59
|
}
|
|
59
|
-
|
|
60
|
-
function createServer() {
|
|
60
|
+
|
|
61
|
+
function createServer() {
|
|
61
62
|
const server = new McpServer({
|
|
62
63
|
name: 'voc-intelligence',
|
|
63
|
-
version: '0.3.
|
|
64
|
+
version: '0.3.17'
|
|
64
65
|
});
|
|
65
66
|
|
|
66
67
|
server.registerTool(
|
|
@@ -438,13 +439,13 @@ function createServer() {
|
|
|
438
439
|
|
|
439
440
|
server.registerTool(
|
|
440
441
|
'voc_xiaohongshu_token_check',
|
|
441
|
-
{
|
|
442
|
-
title: 'Check Xiaohongshu Token',
|
|
442
|
+
{
|
|
443
|
+
title: 'Check Xiaohongshu Token',
|
|
443
444
|
description: 'Check whether a Xiaohongshu/TikHub API token is configured for live collection.',
|
|
444
445
|
inputSchema: {
|
|
445
446
|
xiaohongshuToken: z.string().optional().describe('Optional request-scoped collection token. It is never echoed back.'),
|
|
446
447
|
vocToken: z.string().optional().describe('Optional VOC social token, same convention as douyin-speaking-daily. It is never echoed back.')
|
|
447
|
-
},
|
|
448
|
+
},
|
|
448
449
|
outputSchema: {
|
|
449
450
|
status: z.string(),
|
|
450
451
|
assistantMessage: z.string(),
|
|
@@ -498,31 +499,31 @@ function createServer() {
|
|
|
498
499
|
return asToolResult(result);
|
|
499
500
|
}
|
|
500
501
|
);
|
|
501
|
-
|
|
502
|
+
|
|
502
503
|
server.registerTool(
|
|
503
504
|
'voc_xiaohongshu_trend_run',
|
|
504
|
-
{
|
|
505
|
-
title: 'Run Xiaohongshu Trend Intelligence',
|
|
506
|
-
description: [
|
|
507
|
-
'Generate first-round Xiaohongshu trend observations and validation questions from an industry profile.',
|
|
508
|
-
'P0 supports sample mode for demos and course recording.',
|
|
509
|
-
'Returns assistantMessage as the user-facing body; first-round output is a hypothesis that needs user calibration.'
|
|
510
|
-
].join(' '),
|
|
511
|
-
inputSchema: {
|
|
512
|
-
collectionMode: z.enum(['sample', 'live']).optional().describe('Use sample for P0 demo. Live will be enabled after VOC provider integration.'),
|
|
513
|
-
profile: z.string().optional().describe('Optional profile JSON path.'),
|
|
514
|
-
output: z.string().optional().describe('Optional output directory.'),
|
|
515
|
-
memory: z.string().optional().describe('Optional preference memory JSON path.'),
|
|
516
|
-
memoryPath: z.string().optional().describe('Optional preference memory JSON path.'),
|
|
517
|
-
project: z.string().optional(),
|
|
518
|
-
industry: z.string().optional(),
|
|
519
|
-
businessType: z.string().optional(),
|
|
520
|
-
targetAudience: z.array(z.string()).optional(),
|
|
521
|
-
keywords: z.array(z.string()).optional(),
|
|
522
|
-
trendQuestions: z.array(z.string()).optional(),
|
|
523
|
-
mustTrackSignals: z.array(z.string()).optional(),
|
|
524
|
-
keywordLimit: z.number().int().min(1).max(10).optional(),
|
|
525
|
-
notesPerKeyword: z.number().int().min(1).max(10).optional(),
|
|
505
|
+
{
|
|
506
|
+
title: 'Run Xiaohongshu Trend Intelligence',
|
|
507
|
+
description: [
|
|
508
|
+
'Generate first-round Xiaohongshu trend observations and validation questions from an industry profile.',
|
|
509
|
+
'P0 supports sample mode for demos and course recording.',
|
|
510
|
+
'Returns assistantMessage as the user-facing body; first-round output is a hypothesis that needs user calibration.'
|
|
511
|
+
].join(' '),
|
|
512
|
+
inputSchema: {
|
|
513
|
+
collectionMode: z.enum(['sample', 'live']).optional().describe('Use sample for P0 demo. Live will be enabled after VOC provider integration.'),
|
|
514
|
+
profile: z.string().optional().describe('Optional profile JSON path.'),
|
|
515
|
+
output: z.string().optional().describe('Optional output directory.'),
|
|
516
|
+
memory: z.string().optional().describe('Optional preference memory JSON path.'),
|
|
517
|
+
memoryPath: z.string().optional().describe('Optional preference memory JSON path.'),
|
|
518
|
+
project: z.string().optional(),
|
|
519
|
+
industry: z.string().optional(),
|
|
520
|
+
businessType: z.string().optional(),
|
|
521
|
+
targetAudience: z.array(z.string()).optional(),
|
|
522
|
+
keywords: z.array(z.string()).optional(),
|
|
523
|
+
trendQuestions: z.array(z.string()).optional(),
|
|
524
|
+
mustTrackSignals: z.array(z.string()).optional(),
|
|
525
|
+
keywordLimit: z.number().int().min(1).max(10).optional(),
|
|
526
|
+
notesPerKeyword: z.number().int().min(1).max(10).optional(),
|
|
526
527
|
maxCommentPages: z.number().int().min(0).max(5).optional(),
|
|
527
528
|
sort: z.enum(['general', 'time_descending', 'popularity_descending']).optional(),
|
|
528
529
|
noteType: z.enum(['_0', '_1', '_2']).optional(),
|
|
@@ -531,24 +532,24 @@ function createServer() {
|
|
|
531
532
|
xiaohongshuToken: z.string().optional().describe('Optional request-scoped collection token. It is used only for this run and never echoed back.'),
|
|
532
533
|
vocToken: z.string().optional().describe('Optional VOC social token, same convention as douyin-speaking-daily. It is used only for this run and never echoed back.')
|
|
533
534
|
},
|
|
534
|
-
outputSchema: {
|
|
535
|
-
status: z.string(),
|
|
536
|
-
assistantMessage: z.string(),
|
|
537
|
-
summary: z.object({}).passthrough().optional(),
|
|
538
|
-
data: z.object({}).passthrough().optional(),
|
|
539
|
-
files: z.array(z.string()).optional(),
|
|
540
|
-
nextActions: z.array(z.string()).optional(),
|
|
535
|
+
outputSchema: {
|
|
536
|
+
status: z.string(),
|
|
537
|
+
assistantMessage: z.string(),
|
|
538
|
+
summary: z.object({}).passthrough().optional(),
|
|
539
|
+
data: z.object({}).passthrough().optional(),
|
|
540
|
+
files: z.array(z.string()).optional(),
|
|
541
|
+
nextActions: z.array(z.string()).optional(),
|
|
541
542
|
warnings: z.array(z.any()).optional(),
|
|
542
|
-
errors: z.array(z.any()).optional()
|
|
543
|
-
}
|
|
544
|
-
},
|
|
545
|
-
async input => {
|
|
546
|
-
const result = await runXiaohongshuTrend({
|
|
547
|
-
...input,
|
|
548
|
-
collectionMode: input.collectionMode || 'sample'
|
|
549
|
-
});
|
|
550
|
-
return asToolResult(result);
|
|
551
|
-
}
|
|
543
|
+
errors: z.array(z.any()).optional()
|
|
544
|
+
}
|
|
545
|
+
},
|
|
546
|
+
async input => {
|
|
547
|
+
const result = await runXiaohongshuTrend({
|
|
548
|
+
...input,
|
|
549
|
+
collectionMode: input.collectionMode || 'sample'
|
|
550
|
+
});
|
|
551
|
+
return asToolResult(result);
|
|
552
|
+
}
|
|
552
553
|
);
|
|
553
554
|
|
|
554
555
|
server.registerTool(
|
|
@@ -604,39 +605,39 @@ function createServer() {
|
|
|
604
605
|
return asToolResult(result);
|
|
605
606
|
}
|
|
606
607
|
);
|
|
607
|
-
|
|
608
|
+
|
|
608
609
|
server.registerTool(
|
|
609
610
|
'voc_xiaohongshu_preference_update',
|
|
610
|
-
{
|
|
611
|
-
title: 'Update Xiaohongshu Trend Preference',
|
|
612
|
-
description: [
|
|
613
|
-
'Save user feedback for the next Xiaohongshu trend refinement.',
|
|
614
|
-
'Use after the user answers validation questions or says what to keep, block, downgrade, or focus on next time.'
|
|
615
|
-
].join(' '),
|
|
616
|
-
inputSchema: {
|
|
617
|
-
message: z.string().optional().describe('Natural-language user feedback.'),
|
|
618
|
-
feedback: z.string().optional().describe('Natural-language user feedback.'),
|
|
619
|
-
memory: z.string().optional().describe('Optional preference memory JSON path.'),
|
|
620
|
-
memoryPath: z.string().optional().describe('Optional preference memory JSON path.'),
|
|
621
|
-
preferredDirections: z.array(z.string()).optional(),
|
|
622
|
-
blockedDirections: z.array(z.string()).optional(),
|
|
623
|
-
focusModes: z.array(z.string()).optional()
|
|
624
|
-
},
|
|
625
|
-
outputSchema: {
|
|
626
|
-
status: z.string(),
|
|
627
|
-
assistantMessage: z.string(),
|
|
628
|
-
summary: z.object({}).passthrough().optional(),
|
|
629
|
-
data: z.object({}).passthrough().optional(),
|
|
630
|
-
files: z.array(z.string()).optional(),
|
|
631
|
-
nextActions: z.array(z.string()).optional(),
|
|
632
|
-
warnings: z.array(z.string()).optional(),
|
|
633
|
-
errors: z.array(z.any()).optional()
|
|
634
|
-
}
|
|
635
|
-
},
|
|
636
|
-
async input => {
|
|
637
|
-
const result = await updateXiaohongshuPreference(input);
|
|
638
|
-
return asToolResult(result);
|
|
639
|
-
}
|
|
611
|
+
{
|
|
612
|
+
title: 'Update Xiaohongshu Trend Preference',
|
|
613
|
+
description: [
|
|
614
|
+
'Save user feedback for the next Xiaohongshu trend refinement.',
|
|
615
|
+
'Use after the user answers validation questions or says what to keep, block, downgrade, or focus on next time.'
|
|
616
|
+
].join(' '),
|
|
617
|
+
inputSchema: {
|
|
618
|
+
message: z.string().optional().describe('Natural-language user feedback.'),
|
|
619
|
+
feedback: z.string().optional().describe('Natural-language user feedback.'),
|
|
620
|
+
memory: z.string().optional().describe('Optional preference memory JSON path.'),
|
|
621
|
+
memoryPath: z.string().optional().describe('Optional preference memory JSON path.'),
|
|
622
|
+
preferredDirections: z.array(z.string()).optional(),
|
|
623
|
+
blockedDirections: z.array(z.string()).optional(),
|
|
624
|
+
focusModes: z.array(z.string()).optional()
|
|
625
|
+
},
|
|
626
|
+
outputSchema: {
|
|
627
|
+
status: z.string(),
|
|
628
|
+
assistantMessage: z.string(),
|
|
629
|
+
summary: z.object({}).passthrough().optional(),
|
|
630
|
+
data: z.object({}).passthrough().optional(),
|
|
631
|
+
files: z.array(z.string()).optional(),
|
|
632
|
+
nextActions: z.array(z.string()).optional(),
|
|
633
|
+
warnings: z.array(z.string()).optional(),
|
|
634
|
+
errors: z.array(z.any()).optional()
|
|
635
|
+
}
|
|
636
|
+
},
|
|
637
|
+
async input => {
|
|
638
|
+
const result = await updateXiaohongshuPreference(input);
|
|
639
|
+
return asToolResult(result);
|
|
640
|
+
}
|
|
640
641
|
);
|
|
641
642
|
|
|
642
643
|
server.registerTool(
|
|
@@ -672,24 +673,116 @@ function createServer() {
|
|
|
672
673
|
return asToolResult(result);
|
|
673
674
|
}
|
|
674
675
|
);
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
676
|
+
|
|
677
|
+
server.registerTool(
|
|
678
|
+
'voc_api_search',
|
|
679
|
+
{
|
|
680
|
+
title: 'Search VOC Forwarding API Catalog',
|
|
681
|
+
description: [
|
|
682
|
+
'Search the VOC social forwarding interface catalog (douyin, xiaohongshu, and other TikHub-backed platforms).',
|
|
683
|
+
'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.'
|
|
684
|
+
].join(' '),
|
|
685
|
+
inputSchema: {
|
|
686
|
+
query: z.string().optional().describe('Free-text search over interface id/title/summary/tags, e.g. 抖音 评论 / search notes.'),
|
|
687
|
+
platform: z.string().optional().describe('Optional platform filter, e.g. douyin, xiaohongshu.'),
|
|
688
|
+
tag: z.string().optional().describe('Optional tag filter.'),
|
|
689
|
+
limit: z.number().int().min(1).max(100).optional()
|
|
690
|
+
},
|
|
691
|
+
outputSchema: {
|
|
692
|
+
status: z.string(),
|
|
693
|
+
assistantMessage: z.string(),
|
|
694
|
+
summary: z.object({}).passthrough().optional(),
|
|
695
|
+
data: z.object({}).passthrough().optional(),
|
|
696
|
+
files: z.array(z.string()).optional(),
|
|
697
|
+
nextActions: z.array(z.string()).optional(),
|
|
698
|
+
warnings: z.array(z.any()).optional(),
|
|
699
|
+
errors: z.array(z.any()).optional()
|
|
700
|
+
}
|
|
701
|
+
},
|
|
702
|
+
async input => asToolResult(await searchVocApis(input))
|
|
703
|
+
);
|
|
704
|
+
|
|
705
|
+
server.registerTool(
|
|
706
|
+
'voc_api_doc',
|
|
707
|
+
{
|
|
708
|
+
title: 'Read VOC Forwarding API Parameter Doc',
|
|
709
|
+
description: [
|
|
710
|
+
'Read the detailed parameter documentation for one VOC forwarding interface, plus a ready-to-use call template.',
|
|
711
|
+
'Use after voc_api_search to learn an interface\u2019s required/optional parameters before calling voc_api_call.'
|
|
712
|
+
].join(' '),
|
|
713
|
+
inputSchema: {
|
|
714
|
+
id: z.string().optional().describe('Interface id from the catalog, e.g. douyin.search_general.'),
|
|
715
|
+
proxyPath: z.string().optional().describe('Alternatively, the upstream proxy path, e.g. douyin/search/fetch_general_search_v2.')
|
|
716
|
+
},
|
|
717
|
+
outputSchema: {
|
|
718
|
+
status: z.string(),
|
|
719
|
+
assistantMessage: z.string(),
|
|
720
|
+
summary: z.object({}).passthrough().optional(),
|
|
721
|
+
data: z.object({}).passthrough().optional(),
|
|
722
|
+
files: z.array(z.string()).optional(),
|
|
723
|
+
nextActions: z.array(z.string()).optional(),
|
|
724
|
+
warnings: z.array(z.any()).optional(),
|
|
725
|
+
errors: z.array(z.any()).optional()
|
|
726
|
+
}
|
|
727
|
+
},
|
|
728
|
+
async input => asToolResult(await getVocApiDoc(input))
|
|
729
|
+
);
|
|
730
|
+
|
|
731
|
+
server.registerTool(
|
|
732
|
+
'voc_api_call',
|
|
733
|
+
{
|
|
734
|
+
title: 'Call A VOC Forwarding API',
|
|
735
|
+
description: [
|
|
736
|
+
'Invoke any VOC social forwarding interface and return the upstream data.',
|
|
737
|
+
'Provide a catalog id (preferred) or a rawPath + method for unlisted interfaces, plus a params object assembled from the interface doc.',
|
|
738
|
+
'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.'
|
|
739
|
+
].join(' '),
|
|
740
|
+
inputSchema: {
|
|
741
|
+
id: z.string().optional().describe('Catalog interface id, e.g. douyin.search_general.'),
|
|
742
|
+
rawPath: z.string().optional().describe('Upstream proxy path for interfaces not in the catalog, e.g. douyin/search/fetch_general_search_v2.'),
|
|
743
|
+
proxyPath: z.string().optional().describe('Alias of rawPath.'),
|
|
744
|
+
method: z.enum(['GET', 'POST', 'PUT', 'DELETE']).optional().describe('HTTP method, only needed for rawPath calls. Defaults to GET.'),
|
|
745
|
+
params: z.object({}).passthrough().optional().describe('Parameter object assembled from the interface doc.'),
|
|
746
|
+
query: z.object({}).passthrough().optional().describe('Optional explicit query parameters.'),
|
|
747
|
+
body: z.object({}).passthrough().optional().describe('Optional explicit request body.'),
|
|
748
|
+
retries: z.number().int().min(0).max(8).optional(),
|
|
749
|
+
baseUrl: z.string().optional(),
|
|
750
|
+
vocToken: z.string().optional().describe('Optional VOC social r: session token. Used only for this run and never echoed back.'),
|
|
751
|
+
token: z.string().optional().describe('Alias of vocToken. Never echoed back.'),
|
|
752
|
+
douyinToken: z.string().optional(),
|
|
753
|
+
xiaohongshuToken: z.string().optional()
|
|
754
|
+
},
|
|
755
|
+
outputSchema: {
|
|
756
|
+
status: z.string(),
|
|
757
|
+
assistantMessage: z.string(),
|
|
758
|
+
summary: z.object({}).passthrough().optional(),
|
|
759
|
+
data: z.object({}).passthrough().optional(),
|
|
760
|
+
files: z.array(z.string()).optional(),
|
|
761
|
+
nextActions: z.array(z.string()).optional(),
|
|
762
|
+
warnings: z.array(z.any()).optional(),
|
|
763
|
+
errors: z.array(z.any()).optional()
|
|
764
|
+
}
|
|
765
|
+
},
|
|
766
|
+
async input => asToolResult(await callVocApi(input))
|
|
767
|
+
);
|
|
768
|
+
|
|
769
|
+
return server;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async function main() {
|
|
773
|
+
const server = createServer();
|
|
774
|
+
const transport = new StdioServerTransport();
|
|
775
|
+
await server.connect(transport);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (require.main === module) {
|
|
779
|
+
main().catch(error => {
|
|
780
|
+
console.error(error);
|
|
781
|
+
process.exit(1);
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
module.exports = {
|
|
786
|
+
createServer
|
|
694
787
|
};
|
|
695
|
-
|
|
788
|
+
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
const { readVocToken } = require('../core/credentials');
|
|
2
|
+
const {
|
|
3
|
+
listEndpoints,
|
|
4
|
+
findEndpoint,
|
|
5
|
+
searchEndpoints,
|
|
6
|
+
summarizeEndpoint,
|
|
7
|
+
loadCatalog
|
|
8
|
+
} = require('../core/api-catalog');
|
|
9
|
+
const { callSocialGateway, redactSecret } = require('../providers/voc-gateway');
|
|
10
|
+
const {
|
|
11
|
+
buildVocRechargeInfo,
|
|
12
|
+
buildMissingTokenMessage,
|
|
13
|
+
buildRechargeRequiredMessage,
|
|
14
|
+
buildWrongTokenTypeMessage
|
|
15
|
+
} = require('../core/payment-links');
|
|
16
|
+
const { okResult, errorResult } = require('../core/result-envelope');
|
|
17
|
+
|
|
18
|
+
function isSkToken(token) {
|
|
19
|
+
return Boolean(token) && /^\s*sk-/i.test(token);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function buildCallTemplate(endpoint) {
|
|
23
|
+
const args = { id: endpoint.id, params: {} };
|
|
24
|
+
for (const param of endpoint.params || []) {
|
|
25
|
+
if (param.required || param.default !== undefined) {
|
|
26
|
+
args.params[param.name] = param.default !== undefined ? param.default : `<${param.name}>`;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return args;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function searchVocApis(input = {}) {
|
|
33
|
+
const catalog = loadCatalog();
|
|
34
|
+
const query = input.query || input.q || input.keyword || '';
|
|
35
|
+
const matches = (query || input.platform || input.tag)
|
|
36
|
+
? searchEndpoints({ query, platform: input.platform, tag: input.tag, limit: input.limit || 20 })
|
|
37
|
+
: listEndpoints();
|
|
38
|
+
const results = matches.map(summarizeEndpoint);
|
|
39
|
+
const lines = [
|
|
40
|
+
'## VOC 转发接口清单',
|
|
41
|
+
'',
|
|
42
|
+
`网关:${catalog.gateway?.social?.baseUrl || ''}(通用转发,任意 proxyPath 透传到上游 TikHub;每次调用计费 1 次)。`,
|
|
43
|
+
query ? `匹配关键词「${query}」的接口(${results.length} 条):` : `全部已登记接口(${results.length} 条):`,
|
|
44
|
+
'',
|
|
45
|
+
...results.map(
|
|
46
|
+
item =>
|
|
47
|
+
`- ${item.id} | ${item.title}(${item.method} ${item.proxyPath})必填参数: ${item.requiredParams.join(', ') || '无'}`
|
|
48
|
+
),
|
|
49
|
+
'',
|
|
50
|
+
'下一步:用 voc_api_doc 读取某个接口的详细参数文档,再用 voc_api_call 传参调用。',
|
|
51
|
+
'清单里没有的接口,可直接用 voc_api_call 传 rawPath + method + params 调用。'
|
|
52
|
+
];
|
|
53
|
+
return okResult({
|
|
54
|
+
assistantMessage: lines.join('\n'),
|
|
55
|
+
summary: { total: results.length, query: query || null },
|
|
56
|
+
data: { endpoints: results, gateway: catalog.gateway, platforms: catalog.platforms }
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function getVocApiDoc(input = {}) {
|
|
61
|
+
const idOrPath = input.id || input.endpointId || input.proxyPath || input.rawPath || input.path;
|
|
62
|
+
if (!idOrPath) {
|
|
63
|
+
return errorResult('请提供接口 id 或 proxyPath(可先用 voc_api_search 查清单)。', {
|
|
64
|
+
data: { endpoints: listEndpoints().map(summarizeEndpoint) }
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const endpoint = findEndpoint(idOrPath);
|
|
68
|
+
if (!endpoint) {
|
|
69
|
+
return errorResult(`清单里没有找到接口「${idOrPath}」。可用 voc_api_search 查询,或直接用 voc_api_call 传 rawPath 调用未登记接口。`, {
|
|
70
|
+
data: { endpoints: listEndpoints().map(summarizeEndpoint) }
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
const paramLines = (endpoint.params || []).map(p => {
|
|
74
|
+
const flag = p.required ? '必填' : '可选';
|
|
75
|
+
const def = p.default !== undefined ? `,默认 ${JSON.stringify(p.default)}` : '';
|
|
76
|
+
return `- ${p.name}(${p.in}, ${p.type}, ${flag}${def}):${p.desc || ''}`;
|
|
77
|
+
});
|
|
78
|
+
const template = buildCallTemplate(endpoint);
|
|
79
|
+
const lines = [
|
|
80
|
+
`## ${endpoint.title}(${endpoint.id})`,
|
|
81
|
+
'',
|
|
82
|
+
endpoint.summary || '',
|
|
83
|
+
'',
|
|
84
|
+
`请求:${endpoint.method} ${endpoint.proxyPath}(计费 ${endpoint.billing || 1} 次)`,
|
|
85
|
+
'',
|
|
86
|
+
'参数:',
|
|
87
|
+
...paramLines,
|
|
88
|
+
'',
|
|
89
|
+
endpoint.responseHint ? `返回:${endpoint.responseHint}` : '',
|
|
90
|
+
'',
|
|
91
|
+
'调用示例(传给 voc_api_call):',
|
|
92
|
+
'```json',
|
|
93
|
+
JSON.stringify(template, null, 2),
|
|
94
|
+
'```',
|
|
95
|
+
'',
|
|
96
|
+
'token 用从充值/开通页复制的、以 r: 开头的会话 token(不要用 sk- 开头的 AIGate Key)。'
|
|
97
|
+
].filter(line => line !== '');
|
|
98
|
+
return okResult({
|
|
99
|
+
assistantMessage: lines.join('\n'),
|
|
100
|
+
summary: { id: endpoint.id, method: endpoint.method, proxyPath: endpoint.proxyPath },
|
|
101
|
+
data: { endpoint, callTemplate: template }
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function resolveParamValue(name, params, input) {
|
|
106
|
+
if (params && Object.prototype.hasOwnProperty.call(params, name)) return params[name];
|
|
107
|
+
if (Object.prototype.hasOwnProperty.call(input, name)) return input[name];
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function buildRequestParts(endpoint, input) {
|
|
112
|
+
const params = input.params && typeof input.params === 'object' ? input.params : {};
|
|
113
|
+
const query = {};
|
|
114
|
+
const body = {};
|
|
115
|
+
const missing = [];
|
|
116
|
+
const target = (param) => {
|
|
117
|
+
const where = param.in || endpoint.paramsIn || (endpoint.method === 'GET' ? 'query' : 'body');
|
|
118
|
+
return where === 'query' ? query : body;
|
|
119
|
+
};
|
|
120
|
+
for (const param of endpoint.params || []) {
|
|
121
|
+
let value = resolveParamValue(param.name, params, input);
|
|
122
|
+
if ((value === undefined || value === null || value === '') && param.default !== undefined) {
|
|
123
|
+
value = param.default;
|
|
124
|
+
}
|
|
125
|
+
if (value === undefined || value === null || value === '') {
|
|
126
|
+
if (param.required) missing.push(param.name);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
target(param)[param.name] = value;
|
|
130
|
+
}
|
|
131
|
+
return { query, body: endpoint.method === 'GET' ? undefined : body, missing };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function buildRawRequestParts(input) {
|
|
135
|
+
const method = String(input.method || 'GET').toUpperCase();
|
|
136
|
+
const explicitQuery = input.query && typeof input.query === 'object' ? input.query : null;
|
|
137
|
+
const explicitBody = input.body && typeof input.body === 'object' ? input.body : null;
|
|
138
|
+
const params = input.params && typeof input.params === 'object' ? input.params : {};
|
|
139
|
+
if (method === 'GET') {
|
|
140
|
+
return { method, query: explicitQuery || params, body: undefined };
|
|
141
|
+
}
|
|
142
|
+
return { method, query: explicitQuery || {}, body: explicitBody || params };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function callVocApi(input = {}) {
|
|
146
|
+
const idOrPath = input.id || input.endpointId;
|
|
147
|
+
const rawPath = input.rawPath || input.proxyPath || (idOrPath ? null : input.path);
|
|
148
|
+
const endpoint = idOrPath || rawPath ? findEndpoint(idOrPath || rawPath) : undefined;
|
|
149
|
+
|
|
150
|
+
if (!endpoint && !rawPath) {
|
|
151
|
+
return errorResult('请提供接口 id(先用 voc_api_search 查清单),或提供 rawPath + method 调用未登记接口。', {
|
|
152
|
+
data: { endpoints: listEndpoints().map(summarizeEndpoint) }
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const token = readVocToken(input);
|
|
157
|
+
if (!token) {
|
|
158
|
+
const recharge = await buildVocRechargeInfo();
|
|
159
|
+
return {
|
|
160
|
+
status: 'needs_token',
|
|
161
|
+
assistantMessage: buildMissingTokenMessage(recharge.paymentUrl, recharge, { platformLabel: '社交平台' }),
|
|
162
|
+
summary: { endpoint: endpoint?.id || rawPath },
|
|
163
|
+
data: { recharge },
|
|
164
|
+
files: [],
|
|
165
|
+
nextActions: [
|
|
166
|
+
`打开充值/开通链接:${recharge.paymentUrl}`,
|
|
167
|
+
'配置以 r: 开头的会话 token 后重新调用'
|
|
168
|
+
],
|
|
169
|
+
warnings: [],
|
|
170
|
+
errors: []
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (isSkToken(token)) {
|
|
174
|
+
const recharge = await buildVocRechargeInfo();
|
|
175
|
+
return {
|
|
176
|
+
status: 'needs_valid_token',
|
|
177
|
+
assistantMessage: buildWrongTokenTypeMessage(recharge.paymentUrl, recharge, { platformLabel: '社交平台' }),
|
|
178
|
+
summary: { endpoint: endpoint?.id || rawPath, tokenType: 'sk-' },
|
|
179
|
+
data: { recharge },
|
|
180
|
+
files: [],
|
|
181
|
+
nextActions: [
|
|
182
|
+
'从充值/开通页复制以 r: 开头的会话 token(不是 sk- 开头的 AIGate API Key)',
|
|
183
|
+
`打开充值/开通链接:${recharge.paymentUrl}`
|
|
184
|
+
],
|
|
185
|
+
warnings: [],
|
|
186
|
+
errors: []
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let proxyPath;
|
|
191
|
+
let method;
|
|
192
|
+
let query;
|
|
193
|
+
let body;
|
|
194
|
+
if (endpoint) {
|
|
195
|
+
proxyPath = endpoint.proxyPath;
|
|
196
|
+
method = endpoint.method;
|
|
197
|
+
const parts = buildRequestParts(endpoint, input);
|
|
198
|
+
if (parts.missing.length) {
|
|
199
|
+
return errorResult(
|
|
200
|
+
`缺少必填参数:${parts.missing.join(', ')}。这不是类目/关键词/余额问题,请补齐参数后再调用(可用 voc_api_doc 查看参数说明)。`,
|
|
201
|
+
{
|
|
202
|
+
status: 'needs_input',
|
|
203
|
+
summary: { endpoint: endpoint.id, missing: parts.missing },
|
|
204
|
+
data: { endpoint: summarizeEndpoint(endpoint), missing: parts.missing }
|
|
205
|
+
}
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
query = parts.query;
|
|
209
|
+
body = parts.body;
|
|
210
|
+
} else {
|
|
211
|
+
proxyPath = rawPath;
|
|
212
|
+
const parts = buildRawRequestParts(input);
|
|
213
|
+
method = parts.method;
|
|
214
|
+
query = parts.query;
|
|
215
|
+
body = parts.body;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const result = await callSocialGateway({
|
|
220
|
+
proxyPath,
|
|
221
|
+
method,
|
|
222
|
+
query,
|
|
223
|
+
body,
|
|
224
|
+
token,
|
|
225
|
+
baseUrl: input.baseUrl,
|
|
226
|
+
retries: Number.isFinite(input.retries) ? input.retries : 3
|
|
227
|
+
});
|
|
228
|
+
return okResult({
|
|
229
|
+
assistantMessage: `调用成功:${proxyPath}(${method})。`,
|
|
230
|
+
summary: {
|
|
231
|
+
endpoint: endpoint?.id || proxyPath,
|
|
232
|
+
method,
|
|
233
|
+
proxyPath,
|
|
234
|
+
billing: endpoint?.billing || 1,
|
|
235
|
+
httpStatus: result.httpStatus
|
|
236
|
+
},
|
|
237
|
+
data: { result: result.data, raw: result.json }
|
|
238
|
+
});
|
|
239
|
+
} catch (error) {
|
|
240
|
+
const kind = String(error && (error.kind || error.errorKind) || 'upstream');
|
|
241
|
+
const httpStatus = Number(error && error.httpStatus) || 0;
|
|
242
|
+
const safeMessage = redactSecret(error && error.message ? error.message : 'request failed');
|
|
243
|
+
|
|
244
|
+
if (kind === 'auth') {
|
|
245
|
+
const recharge = await buildVocRechargeInfo({ token });
|
|
246
|
+
return {
|
|
247
|
+
status: 'needs_valid_token',
|
|
248
|
+
assistantMessage: [
|
|
249
|
+
'当前 VOC-AI 数据服务 token 没有通过校验,暂时不能调用该接口。',
|
|
250
|
+
'',
|
|
251
|
+
'这是 token 无效或类型不对,不是关键词/类目/参数问题:需要从充值/开通页复制的、以 r: 开头的会话 token(不要用 sk- 开头的 AIGate API Key)。',
|
|
252
|
+
'',
|
|
253
|
+
`请打开充值/开通链接:${recharge.paymentUrl}`
|
|
254
|
+
].join('\n'),
|
|
255
|
+
summary: { endpoint: endpoint?.id || proxyPath, errorKind: kind, httpStatus },
|
|
256
|
+
data: { recharge },
|
|
257
|
+
files: [],
|
|
258
|
+
nextActions: [`打开充值/开通链接:${recharge.paymentUrl}`, '配置以 r: 开头的 session token 后重试'],
|
|
259
|
+
warnings: [],
|
|
260
|
+
errors: []
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
if (kind === 'billing') {
|
|
264
|
+
const recharge = await buildVocRechargeInfo({ token });
|
|
265
|
+
return {
|
|
266
|
+
status: 'needs_recharge',
|
|
267
|
+
assistantMessage: buildRechargeRequiredMessage(recharge.paymentUrl, recharge, { platformLabel: '社交平台' }),
|
|
268
|
+
summary: { endpoint: endpoint?.id || proxyPath, errorKind: kind, httpStatus },
|
|
269
|
+
data: { recharge },
|
|
270
|
+
files: [],
|
|
271
|
+
nextActions: [`打开充值链接补充额度:${recharge.paymentUrl}`],
|
|
272
|
+
warnings: [],
|
|
273
|
+
errors: []
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
if (kind === 'request') {
|
|
277
|
+
return {
|
|
278
|
+
status: 'needs_input',
|
|
279
|
+
assistantMessage: [
|
|
280
|
+
`接口 ${proxyPath} 返回了入参/请求错误(${safeMessage})。`,
|
|
281
|
+
'',
|
|
282
|
+
'这不是余额不足,也不是类目不支持:请检查参数是否符合接口文档(可用 voc_api_doc 查看),修正后重试。'
|
|
283
|
+
].join('\n'),
|
|
284
|
+
summary: { endpoint: endpoint?.id || proxyPath, errorKind: kind, httpStatus },
|
|
285
|
+
data: { message: safeMessage },
|
|
286
|
+
files: [],
|
|
287
|
+
nextActions: ['用 voc_api_doc 核对参数', '修正参数后重新调用'],
|
|
288
|
+
warnings: [],
|
|
289
|
+
errors: [{ message: safeMessage, kind, httpStatus }]
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
status: 'upstream_unstable',
|
|
294
|
+
assistantMessage: [
|
|
295
|
+
`接口 ${proxyPath} 这次调用失败,是上游数据接口返回错误或暂时不稳定(如 5xx、连接超时、fetch failed)。`,
|
|
296
|
+
'',
|
|
297
|
+
'这不是关键词问题,也不是类目不支持、也不是余额不足:通常稍后重试即可(本工具已自动重试若干次)。'
|
|
298
|
+
].join('\n'),
|
|
299
|
+
summary: { endpoint: endpoint?.id || proxyPath, errorKind: kind, httpStatus },
|
|
300
|
+
data: { message: safeMessage },
|
|
301
|
+
files: [],
|
|
302
|
+
nextActions: ['稍后重试该接口调用'],
|
|
303
|
+
warnings: [],
|
|
304
|
+
errors: [{ message: safeMessage, kind, httpStatus }]
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
module.exports = {
|
|
310
|
+
searchVocApis,
|
|
311
|
+
getVocApiDoc,
|
|
312
|
+
callVocApi
|
|
313
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vocmarket/voc-skill",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.17",
|
|
4
4
|
"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.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"bin": {
|
package/scripts/smoke-mcp.js
CHANGED
|
@@ -303,6 +303,44 @@ async function main() {
|
|
|
303
303
|
throw new Error(`Expected VOC business workflow result, got: ${JSON.stringify(businessWorkflowStructured)}`);
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
+
for (const tool of ['voc_api_search', 'voc_api_doc', 'voc_api_call']) {
|
|
307
|
+
if (!toolNames.includes(tool)) {
|
|
308
|
+
throw new Error(`Expected catalog tool ${tool}, got: ${toolNames.join(', ')}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const apiSearchResult = await client.callTool({
|
|
313
|
+
name: 'voc_api_search',
|
|
314
|
+
arguments: { platform: 'douyin' }
|
|
315
|
+
});
|
|
316
|
+
const apiSearchStructured = apiSearchResult.structuredContent || {};
|
|
317
|
+
if (apiSearchStructured.status !== 'ok' || !apiSearchStructured.data?.endpoints?.length) {
|
|
318
|
+
throw new Error(`Expected voc_api_search to list douyin endpoints, got: ${JSON.stringify(apiSearchStructured)}`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const apiDocResult = await client.callTool({
|
|
322
|
+
name: 'voc_api_doc',
|
|
323
|
+
arguments: { id: 'douyin.search_general' }
|
|
324
|
+
});
|
|
325
|
+
const apiDocStructured = apiDocResult.structuredContent || {};
|
|
326
|
+
if (apiDocStructured.status !== 'ok' ||
|
|
327
|
+
!String(apiDocStructured.data?.endpoint?.proxyPath || '').includes('fetch_general_search_v2')) {
|
|
328
|
+
throw new Error(`Expected voc_api_doc to return douyin search doc, got: ${JSON.stringify(apiDocStructured)}`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const apiCallSentinel = 'sk-SMOKE_WRONG_TOKEN_TYPE';
|
|
332
|
+
const apiCallResult = await client.callTool({
|
|
333
|
+
name: 'voc_api_call',
|
|
334
|
+
arguments: { id: 'douyin.search_general', params: { keyword: '律师' }, vocToken: apiCallSentinel }
|
|
335
|
+
});
|
|
336
|
+
const apiCallStructured = apiCallResult.structuredContent || {};
|
|
337
|
+
if (apiCallStructured.status !== 'needs_valid_token') {
|
|
338
|
+
throw new Error(`Expected voc_api_call to reject sk- token as needs_valid_token, got: ${JSON.stringify(apiCallStructured)}`);
|
|
339
|
+
}
|
|
340
|
+
if (JSON.stringify(apiCallStructured).includes(apiCallSentinel)) {
|
|
341
|
+
throw new Error('voc_api_call leaked the request-scoped token');
|
|
342
|
+
}
|
|
343
|
+
|
|
306
344
|
console.log(JSON.stringify({
|
|
307
345
|
status: 'ok',
|
|
308
346
|
tools: toolNames,
|
package/scripts/smoke-package.js
CHANGED
|
@@ -958,7 +958,10 @@ async function main() {
|
|
|
958
958
|
'voc_problem_deep_dive_run',
|
|
959
959
|
'voc_content_plan_run',
|
|
960
960
|
'voc_speaking_script_run',
|
|
961
|
-
'voc_competitor_map_run'
|
|
961
|
+
'voc_competitor_map_run',
|
|
962
|
+
'voc_api_search',
|
|
963
|
+
'voc_api_doc',
|
|
964
|
+
'voc_api_call'
|
|
962
965
|
];
|
|
963
966
|
for (const tool of requiredTools) {
|
|
964
967
|
if (!toolNames.includes(tool)) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-voc-intelligence",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.17",
|
|
4
4
|
"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.",
|
|
5
5
|
"plugin": "voc-intelligence",
|
|
6
6
|
"skills": [
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"voc-speaking-script",
|
|
13
13
|
"voc-competitor-map",
|
|
14
14
|
"voc-business-workflow",
|
|
15
|
-
"fmode-image-analysis"
|
|
15
|
+
"fmode-image-analysis",
|
|
16
|
+
"voc-api-catalog"
|
|
16
17
|
],
|
|
17
18
|
"mcpTools": [
|
|
18
19
|
"fmode_image_analysis",
|
|
@@ -27,8 +28,12 @@
|
|
|
27
28
|
"voc_xiaohongshu_preference_update",
|
|
28
29
|
"voc_douyin_token_check",
|
|
29
30
|
"voc_douyin_trend_run",
|
|
30
|
-
"voc_douyin_preference_update"
|
|
31
|
+
"voc_douyin_preference_update",
|
|
32
|
+
"voc_api_search",
|
|
33
|
+
"voc_api_doc",
|
|
34
|
+
"voc_api_call"
|
|
31
35
|
],
|
|
36
|
+
"apiCatalog": "mcp/catalog/voc-social-endpoints.json",
|
|
32
37
|
"entrySkill": "xiaohongshu-trend-intelligence",
|
|
33
38
|
"capabilityMap": "docs/capability-map.md",
|
|
34
39
|
"bossPromptTemplates": "docs/boss-prompt-templates.md",
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: voc-api-catalog
|
|
3
|
+
description: 清单驱动的 VOC 社交数据采集。把所有转发接口做成清单,按需查清单、读参数文档,自己拼参数完成任意行业/任意方向的社交数据采集(抖音、小红书及 TikHub 上游其他平台)。当用户要采集某关键词的社交内容/评论、要调用某个社交数据接口、或现有专用工具不覆盖某接口时使用。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# VOC 接口清单驱动采集
|
|
7
|
+
|
|
8
|
+
## 核心理念
|
|
9
|
+
|
|
10
|
+
VOC 后端是一个**通用转发网关**(`https://server.fmode.cn/api/voc-social/<proxyPath>`),任意 `proxyPath` 都会被透传到上游 TikHub。所以不需要为每个接口写死一个工具:你只要
|
|
11
|
+
|
|
12
|
+
1. **查清单**:用 `voc_api_search` 找到要用的接口;
|
|
13
|
+
2. **读参数文档**:用 `voc_api_doc` 读该接口的详细参数;
|
|
14
|
+
3. **拼参数调用**:用 `voc_api_call` 自己拼好参数发起真实调用。
|
|
15
|
+
|
|
16
|
+
清单里没有的接口,也可以直接用 `voc_api_call` 传 `rawPath` + `method` + `params` 调用,无需改代码。
|
|
17
|
+
|
|
18
|
+
## 工具
|
|
19
|
+
|
|
20
|
+
- `voc_api_search`:检索接口清单。入参 `query`(关键词,可选)、`platform`(如 douyin / xiaohongshu,可选)、`tag`(可选)。返回接口 id、标题、method、proxyPath、必填参数。
|
|
21
|
+
- `voc_api_doc`:读取单个接口的完整参数文档。入参 `id`(或 `proxyPath`)。返回参数表 + 可直接复制的调用模板。
|
|
22
|
+
- `voc_api_call`:发起真实调用。入参:
|
|
23
|
+
- `id`:清单里的接口 id(推荐),或
|
|
24
|
+
- `rawPath` + `method`:调用未登记接口;
|
|
25
|
+
- `params`:参数对象(按 doc 拼)。也可显式用 `query` / `body` 分开传;
|
|
26
|
+
- token:从 `vocToken` / `token` 或环境读取,**绝不回显**。
|
|
27
|
+
|
|
28
|
+
## 标准流程
|
|
29
|
+
|
|
30
|
+
1. 理解任务 → 确定平台和要采集的内容(搜索 / 评论 / 详情)。
|
|
31
|
+
2. `voc_api_search`(带行业无关的关键词或 platform)找到接口。
|
|
32
|
+
3. `voc_api_doc` 读参数,确认必填项和取值含义。
|
|
33
|
+
4. `voc_api_call` 用真实关键词调用;翻页/拉评论时把上一次返回里的 cursor / id 透传进下一次调用。
|
|
34
|
+
5. 用返回数据继续后续分析(可交给 voc-issue-pool、voc-content-plan 等技能)。
|
|
35
|
+
|
|
36
|
+
## Token
|
|
37
|
+
|
|
38
|
+
采集需要从充值/开通页复制的、以 `r:` 开头的会话 token(session token)。
|
|
39
|
+
**不要**用 `~/.claude/voc-credentials.json` 里 `sk-` 开头的 AIGate API Key —— 后端不认它,会被工具直接判为 `needs_valid_token`。
|
|
40
|
+
|
|
41
|
+
## 禁止事项(护栏)
|
|
42
|
+
|
|
43
|
+
- 禁止把**入参/参数错误**(`needs_input`)误报成「采集不到数据 / 该关键词没结果 / 类目不支持」。参数错就如实说参数错,并用 `voc_api_doc` 核对后重试。
|
|
44
|
+
- 禁止把**上游不稳定**(`upstream_unstable`,如 5xx / 连接超时 / fetch failed)误报成「关键词问题 / 类目不支持 / 余额不足」。如实说明是上游接口波动,稍后重试。
|
|
45
|
+
- 禁止把 **token 无效/类型不对**(`needs_valid_token`)误报成「余额不足」或「类目不支持」。
|
|
46
|
+
- 禁止输出「只支持餐饮 / 某行业不可用 / 系统只支持某类目」这类结论——**任何行业、任何关键词都能采集**,前提只是 token 类型正确且有额度。
|
|
47
|
+
- 失败时禁止退回 web search / yt-dlp / ffmpeg / 让用户手动传媒体文件来「绕过」采集。
|
|
48
|
+
|
|
49
|
+
## 状态码含义
|
|
50
|
+
|
|
51
|
+
- `ok`:调用成功,`data.result` 是上游返回数据。
|
|
52
|
+
- `needs_token` / `needs_valid_token`:没配置 token,或 token 类型不对(sk-)。提示配置 `r:` token。
|
|
53
|
+
- `needs_recharge`:额度不足,提示充值。
|
|
54
|
+
- `needs_input`:必填参数缺失或参数错误(不是类目/余额问题)。
|
|
55
|
+
- `upstream_unstable`:上游接口报错或波动,稍后重试。
|