@vocmarket/voc-skill 0.3.20 → 0.3.22

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.
@@ -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.17",
4
+ "version": "0.3.22",
5
5
  "author": {
6
6
  "name": "nkkj-BrainHack"
7
7
  }
@@ -66,11 +66,10 @@ npx @vocmarket/voc-skill@latest workspace --smoke
66
66
  技能包会按以下顺序读取 token:
67
67
 
68
68
  ```text
69
- 工具入参 vocToken / xiaohongshuToken
70
- workspace .env.local
71
- VOC_TOKEN
72
- VOC_SOCIAL_TOKEN
73
- ~/.claude/voc-credentials.json
69
+ 工具入参 vocToken(兼容 xiaohongshuToken / douyinToken)
70
+ workspace .env.local 的 VOC_TOKEN / VOC_SOCIAL_TOKEN
71
+ 环境变量 VOC_TOKEN / VOC_SOCIAL_TOKEN
72
+ ~/.claude/voc-credentials.json 的 vocToken / token
74
73
  ```
75
74
 
76
75
  注意:
@@ -723,8 +723,8 @@
723
723
  "platform": "xiaohongshu",
724
724
  "channel": "social",
725
725
  "title": "小红书笔记评论",
726
- "summary": "按 note_id 拉取小红书笔记的一级评论。VOC 主力接口。",
727
- "proxyPath": "xiaohongshu/app/get_note_comments",
726
+ "summary": "按 note_id 拉取小红书笔记的一级评论(App V2,VOC 口碑主力接口)。",
727
+ "proxyPath": "xiaohongshu/app_v2/get_note_comments",
728
728
  "method": "GET",
729
729
  "paramsIn": "query",
730
730
  "tags": [
@@ -742,34 +742,34 @@
742
742
  "in": "query",
743
743
  "type": "string",
744
744
  "required": true,
745
- "desc": "笔记 id(note_id)。"
745
+ "desc": "笔记 id(note_id),来自 search_notes 返回的 note.id。"
746
746
  },
747
747
  {
748
- "name": "start",
748
+ "name": "share_text",
749
749
  "in": "query",
750
750
  "type": "string",
751
751
  "required": false,
752
752
  "default": "",
753
- "desc": "分页游标,首页留空,翻页用上一次返回的 start。"
753
+ "desc": "可选:小红书分享文案/链接,与 note_id 二选一。"
754
754
  },
755
755
  {
756
- "name": "sort_strategy",
756
+ "name": "xsec_token",
757
757
  "in": "query",
758
- "type": "integer",
758
+ "type": "string",
759
759
  "required": false,
760
- "default": 1,
761
- "desc": "排序:1 默认。"
760
+ "default": "",
761
+ "desc": "可选安全令牌,来自 search_notes 返回的 note.xsec_token;部分笔记风控需要。"
762
762
  }
763
763
  ],
764
- "responseHint": "返回评论列表,含 comment_id、文本、点赞、子评论数;翻页用返回的 start。"
764
+ "responseHint": "返回评论列表,含 comment_id、文本、点赞、子评论数;翻页用返回的游标。"
765
765
  },
766
766
  {
767
767
  "id": "xiaohongshu.sub_comments",
768
768
  "platform": "xiaohongshu",
769
769
  "channel": "social",
770
770
  "title": "小红书评论回复(楼中楼)",
771
- "summary": "按 note_id + comment_id 拉取某条评论下的回复,用于深挖讨论。",
772
- "proxyPath": "xiaohongshu/app/get_sub_comments",
771
+ "summary": "按 note_id + comment_id 拉取某条评论下的回复(App V2),用于深挖讨论。",
772
+ "proxyPath": "xiaohongshu/app_v2/get_note_sub_comments",
773
773
  "method": "GET",
774
774
  "paramsIn": "query",
775
775
  "tags": [
@@ -835,7 +835,7 @@
835
835
  "in": "query",
836
836
  "type": "string",
837
837
  "required": true,
838
- "desc": "用户 id(user_id),来自笔记/搜索返回。"
838
+ "desc": "用户 id(24 位 hex),来自 search_notes 返回的 note.user.userid;注意不是数字 red_id。"
839
839
  }
840
840
  ],
841
841
  "responseHint": "返回用户资料对象,含粉丝数、获赞收藏、简介。"
@@ -68,33 +68,21 @@ function readVocToken(input = {}) {
68
68
  const workspaceEnv = readEnvLocalFiles();
69
69
  const claudeCreds = readJsonMaybe(path.join(os.homedir(), '.claude', 'voc-credentials.json'));
70
70
  return firstNonEmpty([
71
+ input.vocToken,
71
72
  input.xiaohongshuToken,
72
73
  input.douyinToken,
73
- input.vocToken,
74
+ input['voc-token'],
74
75
  input['xiaohongshu-token'],
75
76
  input['douyin-token'],
76
- input['voc-token'],
77
77
  input.token,
78
78
  input.apiToken,
79
79
  input['api-token'],
80
80
  workspaceEnv.VOC_TOKEN,
81
81
  workspaceEnv.VOC_SOCIAL_TOKEN,
82
- workspaceEnv.XIAOHONGSHU_API_TOKEN,
83
- workspaceEnv.DOUYIN_API_TOKEN,
84
- workspaceEnv.DOUYIN_TOKEN,
85
- workspaceEnv.VOC_DOUYIN_TOKEN,
86
82
  process.env.VOC_TOKEN,
87
83
  process.env.VOC_SOCIAL_TOKEN,
88
- process.env.XIAOHONGSHU_API_TOKEN,
89
- process.env.DOUYIN_API_TOKEN,
90
- process.env.DOUYIN_TOKEN,
91
- process.env.VOC_DOUYIN_TOKEN,
92
- process.env.FMODE_XIAOHONGSHU_TOKEN,
93
- process.env.FMODE_SOCIAL_TOKEN,
94
84
  claudeCreds.vocToken,
95
85
  claudeCreds.token,
96
- claudeCreds.xiaohongshuToken,
97
- claudeCreds.douyinToken,
98
86
  ]);
99
87
  }
100
88
 
@@ -102,9 +90,34 @@ function readXiaohongshuToken(input = {}) {
102
90
  return readVocToken(input);
103
91
  }
104
92
 
93
+ // NewAPI(fmode-api)计费 token,以 sk- 开头。
94
+ // 计费已迁移到 NewAPI:优先用它发请求,鉴权失败再回退平台 sessionToken。
95
+ // 取值优先级:显式入参 → .env.local / 环境变量 → ~/.claude/voc-credentials.json 的 fmodeApiKey。
96
+ function readNewApiToken(input = {}) {
97
+ const workspaceEnv = readEnvLocalFiles();
98
+ const claudeCreds = readJsonMaybe(path.join(os.homedir(), '.claude', 'voc-credentials.json'));
99
+ return firstNonEmpty([
100
+ input.newapiToken,
101
+ input.newApiToken,
102
+ input.fmodeApiKey,
103
+ input.fmodeApiToken,
104
+ workspaceEnv.NEWAPI_TOKEN,
105
+ workspaceEnv.NEW_API_TOKEN,
106
+ workspaceEnv.FMODE_API_KEY,
107
+ workspaceEnv.FMODE_API_TOKEN,
108
+ process.env.NEWAPI_TOKEN,
109
+ process.env.NEW_API_TOKEN,
110
+ process.env.FMODE_API_KEY,
111
+ process.env.FMODE_API_TOKEN,
112
+ claudeCreds.fmodeApiKey,
113
+ claudeCreds.fmodeApiToken,
114
+ ]);
115
+ }
116
+
105
117
  module.exports = {
106
118
  readVocToken,
107
119
  readXiaohongshuToken,
120
+ readNewApiToken,
108
121
  readEnvFileMaybe,
109
122
  readWorkspaceEnvLocal,
110
123
  envLocalCandidates
@@ -2,6 +2,9 @@ const VOC_SOCIAL_APIG_ID = 'Vo3ROWEvDy';
2
2
  const VOC_RECHARGE_FUN_ID = 'HOkkX72PMF';
3
3
  const VOC_RECHARGE_BASE_URL = 'https://app.fmode.cn/dev/apig-pay/';
4
4
  const PARSE_USERS_ME_URL = 'https://server.fmode.cn/parse/users/me';
5
+
6
+ // NewAPI(fmode-api)余额充值入口。携带 ?balance=fmodeapi 会自动打开余额充值弹窗。
7
+ const FMODE_API_RECHARGE_URL = 'https://app.fmode.cn/dev/studio/?balance=fmodeapi';
5
8
  const DEFAULT_WORKSHOP_PACKAGE_SLUG = '99';
6
9
  const VOC_WORKSHOP_PACKAGE_SLUGS = {
7
10
  trial199: '19-9',
@@ -123,6 +126,32 @@ function buildWrongTokenTypeMessage(paymentUrl, recharge = {}, options = {}) {
123
126
  ].join('\n');
124
127
  }
125
128
 
129
+ function buildFmodeApiRechargeUrl() {
130
+ return FMODE_API_RECHARGE_URL;
131
+ }
132
+
133
+ // NewAPI(fmode-api)余额不足(HTTP 402)时的充值提示。
134
+ function buildFmodeApiRechargeMessage(paymentUrl = FMODE_API_RECHARGE_URL, options = {}) {
135
+ const platformLabel = options.platformLabel || '小红书';
136
+ return [
137
+ `VOC-AI 数据服务余额不足(fmode-api 计费额度已用尽),暂时不能继续真实 ${platformLabel} 采集。`,
138
+ '',
139
+ `请打开充值链接补充余额:${paymentUrl}`,
140
+ '',
141
+ '链接会自动打开余额充值弹窗;充值后用相同参数重试即可。底层鉴权/上游响应不会展示给用户。'
142
+ ].join('\n');
143
+ }
144
+
145
+ // 用户被禁用或无该接口权限(HTTP 403,区别于余额不足)。
146
+ function buildPermissionDeniedMessage(options = {}) {
147
+ const platformLabel = options.platformLabel || '小红书';
148
+ return [
149
+ `当前账号被禁用或没有该 ${platformLabel} 接口的访问权限,暂时不能调用(这不是余额不足、也不是关键词/类目问题)。`,
150
+ '',
151
+ '请联系服务方确认账号状态或接口权限后重试。底层鉴权/上游响应不会展示给用户。'
152
+ ].join('\n');
153
+ }
154
+
126
155
  module.exports = {
127
156
  VOC_SOCIAL_APIG_ID,
128
157
  VOC_RECHARGE_FUN_ID,
@@ -132,5 +161,8 @@ module.exports = {
132
161
  buildVocWorkshopUrl,
133
162
  buildMissingTokenMessage,
134
163
  buildRechargeRequiredMessage,
135
- buildWrongTokenTypeMessage
164
+ buildWrongTokenTypeMessage,
165
+ buildFmodeApiRechargeUrl,
166
+ buildFmodeApiRechargeMessage,
167
+ buildPermissionDeniedMessage
136
168
  };
@@ -139,15 +139,25 @@ async function requestJson(url, token, { method = 'GET', body, retries = 3 } = {
139
139
  throw lastError;
140
140
  }
141
141
 
142
- async function callSocialGateway({ proxyPath, method = 'GET', query = {}, body, token, baseUrl, retries = 3 } = {}) {
142
+ // 优先用 newapi 的 sk- token 发请求;若该 token 鉴权失败(kind === 'auth',例如服务端
143
+ // 尚未迁移 / 该号未入仓 / token 未被接受)且提供了回退 token(平台 sessionToken),自动用
144
+ // 回退 token 重试一次。402(余额不足)/403(无权限)不回退。
145
+ async function callSocialGateway({ proxyPath, method = 'GET', query = {}, body, token, fallbackToken, baseUrl, retries = 3 } = {}) {
143
146
  const root = resolveSocialRoot(baseUrl);
144
147
  const url = buildGatewayUrl(root, proxyPath, query);
145
148
  const upper = String(method || 'GET').toUpperCase();
146
- return requestJson(url, token, {
149
+ const requestOptions = {
147
150
  method: upper,
148
151
  body: upper === 'GET' ? undefined : body,
149
152
  retries
150
- });
153
+ };
154
+ try {
155
+ return await requestJson(url, token, requestOptions);
156
+ } catch (error) {
157
+ const canFallback = fallbackToken && fallbackToken !== token && error && error.kind === 'auth';
158
+ if (!canFallback) throw error;
159
+ return requestJson(url, fallbackToken, requestOptions);
160
+ }
151
161
  }
152
162
 
153
163
  module.exports = {
package/mcp/src/server.js CHANGED
@@ -735,7 +735,7 @@ function createServer() {
735
735
  description: [
736
736
  'Invoke any VOC social forwarding interface and return the upstream data.',
737
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.'
738
+ 'Prefer the NewAPI (fmode-api) sk- billing token; if it is missing or unauthorized, automatically falls back to an r: session token. Input/parameter errors, upstream instability, auth, and billing are reported distinctly and are never disguised as \u201cno data / category unsupported\u201d.'
739
739
  ].join(' '),
740
740
  inputSchema: {
741
741
  id: z.string().optional().describe('Catalog interface id, e.g. douyin.search_general.'),
@@ -747,6 +747,9 @@ function createServer() {
747
747
  body: z.object({}).passthrough().optional().describe('Optional explicit request body.'),
748
748
  retries: z.number().int().min(0).max(8).optional(),
749
749
  baseUrl: z.string().optional(),
750
+ newapiToken: z.string().optional().describe('Preferred NewAPI (fmode-api) sk- billing token. Used only for this run and never echoed back.'),
751
+ newApiToken: z.string().optional().describe('Alias of newapiToken.'),
752
+ fmodeApiKey: z.string().optional().describe('Alias of newapiToken.'),
750
753
  vocToken: z.string().optional().describe('Optional VOC social r: session token. Used only for this run and never echoed back.'),
751
754
  token: z.string().optional().describe('Alias of vocToken. Never echoed back.'),
752
755
  douyinToken: z.string().optional(),
@@ -1,4 +1,4 @@
1
- const { readVocToken } = require('../core/credentials');
1
+ const { readVocToken, readNewApiToken } = require('../core/credentials');
2
2
  const {
3
3
  listEndpoints,
4
4
  findEndpoint,
@@ -11,8 +11,9 @@ const { callSocialGateway, redactSecret } = require('../providers/voc-gateway');
11
11
  const {
12
12
  buildVocRechargeInfo,
13
13
  buildMissingTokenMessage,
14
- buildRechargeRequiredMessage,
15
- buildWrongTokenTypeMessage
14
+ buildFmodeApiRechargeUrl,
15
+ buildFmodeApiRechargeMessage,
16
+ buildPermissionDeniedMessage
16
17
  } = require('../core/payment-links');
17
18
  const { okResult, errorResult } = require('../core/result-envelope');
18
19
 
@@ -95,7 +96,7 @@ async function getVocApiDoc(input = {}) {
95
96
  JSON.stringify(template, null, 2),
96
97
  '```',
97
98
  '',
98
- 'token 用从充值/开通页复制的、以 r: 开头的会话 token(不要用 sk- 开头的 AIGate Key)。'
99
+ 'token 优先用 NewAPI(fmode-api)的 sk- token(计费已迁移);没有 sk- 时回退用充值/开通页复制的、以 r: 开头的会话 token。'
99
100
  ].filter(line => line !== '');
100
101
  return okResult({
101
102
  assistantMessage: lines.join('\n'),
@@ -156,7 +157,17 @@ async function callVocApi(input = {}) {
156
157
  }
157
158
  if (endpoint) endpoint = hydrateEndpoint(endpoint);
158
159
 
159
- const token = readVocToken(input);
160
+ // 计费优先走 NewAPI(fmode-api)的 sk- token;该 token 鉴权失败(含「未入仓」类报错)时
161
+ // 自动回退平台 sessionToken 重试。注意:以前因为 sk- 直接请求会命中「入仓」错误,这里曾经
162
+ // 直接拒绝 sk- token;现在改为「优先 sk-、鉴权失败再回退 sessionToken」,并对外只给友好提示。
163
+ const explicitNewApi = readNewApiToken(input);
164
+ const vocToken = readVocToken(input);
165
+ // 兼容用户把 sk- 直接填进 VOC_TOKEN 的情况:该槽里若是 sk-,也当作 newapi token。
166
+ const skFromVocToken = isSkToken(vocToken) ? vocToken : '';
167
+ const newapiToken = explicitNewApi || skFromVocToken;
168
+ const sessionToken = isSkToken(vocToken) ? '' : vocToken;
169
+ const token = newapiToken || sessionToken;
170
+ const fallbackToken = (newapiToken && sessionToken && newapiToken !== sessionToken) ? sessionToken : '';
160
171
  if (!token) {
161
172
  const recharge = await buildVocRechargeInfo();
162
173
  return {
@@ -167,23 +178,7 @@ async function callVocApi(input = {}) {
167
178
  files: [],
168
179
  nextActions: [
169
180
  `打开充值/开通链接:${recharge.paymentUrl}`,
170
- '配置以 r: 开头的会话 token 后重新调用'
171
- ],
172
- warnings: [],
173
- errors: []
174
- };
175
- }
176
- if (isSkToken(token)) {
177
- const recharge = await buildVocRechargeInfo();
178
- return {
179
- status: 'needs_valid_token',
180
- assistantMessage: buildWrongTokenTypeMessage(recharge.paymentUrl, recharge, { platformLabel: '社交平台' }),
181
- summary: { endpoint: endpoint?.id || rawPath, tokenType: 'sk-' },
182
- data: { recharge },
183
- files: [],
184
- nextActions: [
185
- '从充值/开通页复制以 r: 开头的会话 token(不是 sk- 开头的 AIGate API Key)',
186
- `打开充值/开通链接:${recharge.paymentUrl}`
181
+ '配置 NewAPI 的 fmode-api token(sk-)或以 r: 开头的会话 token 后重新调用'
187
182
  ],
188
183
  warnings: [],
189
184
  errors: []
@@ -228,6 +223,7 @@ async function callVocApi(input = {}) {
228
223
  query,
229
224
  body,
230
225
  token,
226
+ fallbackToken,
231
227
  baseUrl: input.baseUrl,
232
228
  retries: Number.isFinite(input.retries) ? input.retries : 3
233
229
  });
@@ -248,33 +244,48 @@ async function callVocApi(input = {}) {
248
244
  const safeMessage = redactSecret(error && error.message ? error.message : 'request failed');
249
245
 
250
246
  if (kind === 'auth') {
251
- const recharge = await buildVocRechargeInfo({ token });
247
+ // sk- 与 sessionToken(若有)都未通过校验。不把底层「未入仓」/鉴权报错甩给用户,只给友好提示。
248
+ const recharge = await buildVocRechargeInfo({ token: sessionToken || token });
252
249
  return {
253
250
  status: 'needs_valid_token',
254
251
  assistantMessage: [
255
252
  '当前 VOC-AI 数据服务 token 没有通过校验,暂时不能调用该接口。',
256
253
  '',
257
- '这是 token 无效或类型不对,不是关键词/类目/参数问题:需要从充值/开通页复制的、以 r: 开头的会话 token(不要用 sk- 开头的 AIGate API Key)。',
254
+ '这是 token 缺失/无效/失效,不是关键词/类目/参数问题:请配置有效的 NewAPI(fmode-api)token(sk-)或以 r: 开头的会话 token。',
258
255
  '',
259
- `请打开充值/开通链接:${recharge.paymentUrl}`
256
+ `如需开通/充值,请打开:${recharge.paymentUrl}`
260
257
  ].join('\n'),
261
258
  summary: { endpoint: endpoint?.id || proxyPath, errorKind: kind, httpStatus },
262
259
  data: { recharge },
263
260
  files: [],
264
- nextActions: [`打开充值/开通链接:${recharge.paymentUrl}`, '配置以 r: 开头的 session token 后重试'],
261
+ nextActions: [`打开充值/开通链接:${recharge.paymentUrl}`, '配置 NewAPI token(sk-)或以 r: 开头的 session token 后重试'],
265
262
  warnings: [],
266
263
  errors: []
267
264
  };
268
265
  }
269
266
  if (kind === 'billing') {
270
- const recharge = await buildVocRechargeInfo({ token });
267
+ // 402:NewAPI(fmode-api)余额不足,跳转 fmode-api 余额充值弹窗。
268
+ const fmodeApiRechargeUrl = buildFmodeApiRechargeUrl();
271
269
  return {
272
270
  status: 'needs_recharge',
273
- assistantMessage: buildRechargeRequiredMessage(recharge.paymentUrl, recharge, { platformLabel: '社交平台' }),
271
+ assistantMessage: buildFmodeApiRechargeMessage(fmodeApiRechargeUrl, { platformLabel: '社交平台' }),
274
272
  summary: { endpoint: endpoint?.id || proxyPath, errorKind: kind, httpStatus },
275
- data: { recharge },
273
+ data: { recharge: { paymentUrl: fmodeApiRechargeUrl, kind: 'fmode-api' } },
274
+ files: [],
275
+ nextActions: [`打开充值链接补充余额:${fmodeApiRechargeUrl}`],
276
+ warnings: [],
277
+ errors: []
278
+ };
279
+ }
280
+ if (kind === 'permission') {
281
+ // 403:用户被禁用/无权限,区别于余额不足,不引导充值。
282
+ return {
283
+ status: 'needs_permission',
284
+ assistantMessage: buildPermissionDeniedMessage({ platformLabel: '社交平台' }),
285
+ summary: { endpoint: endpoint?.id || proxyPath, errorKind: kind, httpStatus },
286
+ data: {},
276
287
  files: [],
277
- nextActions: [`打开充值链接补充额度:${recharge.paymentUrl}`],
288
+ nextActions: ['联系服务方确认账号状态或接口权限后重试'],
278
289
  warnings: [],
279
290
  errors: []
280
291
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vocmarket/voc-skill",
3
- "version": "0.3.20",
3
+ "version": "0.3.22",
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": {
@@ -304,13 +304,13 @@ async function main() {
304
304
  const apiCallSentinel = 'sk-SMOKE_WRONG_TOKEN_TYPE';
305
305
  const apiCallResult = await client.callTool({
306
306
  name: 'voc_api_call',
307
- arguments: { id: 'douyin.search_general', params: { keyword: '律师' }, vocToken: apiCallSentinel }
307
+ arguments: { id: 'douyin.search_general', params: { keyword: '律师' }, newapiToken: apiCallSentinel, vocToken: 'r:SMOKE_INVALID_SESSION_TOKEN' }
308
308
  });
309
309
  const apiCallStructured = apiCallResult.structuredContent || {};
310
310
  if (apiCallStructured.status !== 'needs_valid_token') {
311
- throw new Error(`Expected voc_api_call to reject sk- token as needs_valid_token, got: ${JSON.stringify(apiCallStructured)}`);
311
+ throw new Error(`Expected voc_api_call to map invalid sk-/r: tokens to needs_valid_token (token problem, not 余额不足), got: ${JSON.stringify(apiCallStructured)}`);
312
312
  }
313
- if (JSON.stringify(apiCallStructured).includes(apiCallSentinel)) {
313
+ if (JSON.stringify(apiCallStructured).includes(apiCallSentinel) || JSON.stringify(apiCallStructured).includes('r:SMOKE_INVALID_SESSION_TOKEN')) {
314
314
  throw new Error('voc_api_call leaked the request-scoped token');
315
315
  }
316
316
 
@@ -94,12 +94,6 @@ async function smokeNoTokenRechargePrompt() {
94
94
  HOME: process.env.HOME,
95
95
  VOC_TOKEN: process.env.VOC_TOKEN,
96
96
  VOC_SOCIAL_TOKEN: process.env.VOC_SOCIAL_TOKEN,
97
- XIAOHONGSHU_API_TOKEN: process.env.XIAOHONGSHU_API_TOKEN,
98
- FMODE_XIAOHONGSHU_TOKEN: process.env.FMODE_XIAOHONGSHU_TOKEN,
99
- FMODE_SOCIAL_TOKEN: process.env.FMODE_SOCIAL_TOKEN,
100
- DOUYIN_API_TOKEN: process.env.DOUYIN_API_TOKEN,
101
- DOUYIN_TOKEN: process.env.DOUYIN_TOKEN,
102
- VOC_DOUYIN_TOKEN: process.env.VOC_DOUYIN_TOKEN
103
97
  };
104
98
  const tempHome = path.join(os.tmpdir(), 'claude-voc-no-token-smoke');
105
99
  const tempWorkspace = path.join(os.tmpdir(), 'claude-voc-no-token-workspace-smoke');
@@ -112,12 +106,6 @@ async function smokeNoTokenRechargePrompt() {
112
106
  [
113
107
  'VOC_TOKEN',
114
108
  'VOC_SOCIAL_TOKEN',
115
- 'XIAOHONGSHU_API_TOKEN',
116
- 'FMODE_XIAOHONGSHU_TOKEN',
117
- 'FMODE_SOCIAL_TOKEN',
118
- 'DOUYIN_API_TOKEN',
119
- 'DOUYIN_TOKEN',
120
- 'VOC_DOUYIN_TOKEN'
121
109
  ].forEach(key => delete process.env[key]);
122
110
  const result = await runXiaohongshuTrend({
123
111
  collectionMode: 'live',
@@ -150,12 +138,6 @@ async function smokeDouyinNoTokenRechargePrompt() {
150
138
  HOME: process.env.HOME,
151
139
  VOC_TOKEN: process.env.VOC_TOKEN,
152
140
  VOC_SOCIAL_TOKEN: process.env.VOC_SOCIAL_TOKEN,
153
- XIAOHONGSHU_API_TOKEN: process.env.XIAOHONGSHU_API_TOKEN,
154
- FMODE_XIAOHONGSHU_TOKEN: process.env.FMODE_XIAOHONGSHU_TOKEN,
155
- FMODE_SOCIAL_TOKEN: process.env.FMODE_SOCIAL_TOKEN,
156
- DOUYIN_API_TOKEN: process.env.DOUYIN_API_TOKEN,
157
- DOUYIN_TOKEN: process.env.DOUYIN_TOKEN,
158
- VOC_DOUYIN_TOKEN: process.env.VOC_DOUYIN_TOKEN
159
141
  };
160
142
  const tempHome = path.join(os.tmpdir(), 'claude-voc-douyin-no-token-smoke');
161
143
  const tempWorkspace = path.join(os.tmpdir(), 'claude-voc-douyin-no-token-workspace-smoke');
@@ -168,12 +150,6 @@ async function smokeDouyinNoTokenRechargePrompt() {
168
150
  [
169
151
  'VOC_TOKEN',
170
152
  'VOC_SOCIAL_TOKEN',
171
- 'XIAOHONGSHU_API_TOKEN',
172
- 'FMODE_XIAOHONGSHU_TOKEN',
173
- 'FMODE_SOCIAL_TOKEN',
174
- 'DOUYIN_API_TOKEN',
175
- 'DOUYIN_TOKEN',
176
- 'VOC_DOUYIN_TOKEN'
177
153
  ].forEach(key => delete process.env[key]);
178
154
  const result = await runDouyinTrend({
179
155
  collectionMode: 'live',
@@ -206,12 +182,6 @@ function smokeWorkspaceEnvLocalToken() {
206
182
  HOME: process.env.HOME,
207
183
  VOC_TOKEN: process.env.VOC_TOKEN,
208
184
  VOC_SOCIAL_TOKEN: process.env.VOC_SOCIAL_TOKEN,
209
- XIAOHONGSHU_API_TOKEN: process.env.XIAOHONGSHU_API_TOKEN,
210
- FMODE_XIAOHONGSHU_TOKEN: process.env.FMODE_XIAOHONGSHU_TOKEN,
211
- FMODE_SOCIAL_TOKEN: process.env.FMODE_SOCIAL_TOKEN,
212
- DOUYIN_API_TOKEN: process.env.DOUYIN_API_TOKEN,
213
- DOUYIN_TOKEN: process.env.DOUYIN_TOKEN,
214
- VOC_DOUYIN_TOKEN: process.env.VOC_DOUYIN_TOKEN
215
185
  };
216
186
  const tempHome = path.join(os.tmpdir(), 'claude-voc-env-local-home-smoke');
217
187
  const tempWorkspace = path.join(os.tmpdir(), 'claude-voc-env-local-workspace-smoke');
@@ -224,12 +194,6 @@ function smokeWorkspaceEnvLocalToken() {
224
194
  [
225
195
  'VOC_TOKEN',
226
196
  'VOC_SOCIAL_TOKEN',
227
- 'XIAOHONGSHU_API_TOKEN',
228
- 'FMODE_XIAOHONGSHU_TOKEN',
229
- 'FMODE_SOCIAL_TOKEN',
230
- 'DOUYIN_API_TOKEN',
231
- 'DOUYIN_TOKEN',
232
- 'VOC_DOUYIN_TOKEN'
233
197
  ].forEach(key => delete process.env[key]);
234
198
  fs.writeFileSync(path.join(tempWorkspace, '.env.local'), 'VOC_TOKEN=workspace-smoke-token\n');
235
199
  const token = readVocToken({});
@@ -255,18 +219,13 @@ function smokeAncestorEnvLocalToken() {
255
219
  HOME: process.env.HOME,
256
220
  VOC_TOKEN: process.env.VOC_TOKEN,
257
221
  VOC_SOCIAL_TOKEN: process.env.VOC_SOCIAL_TOKEN,
258
- XIAOHONGSHU_API_TOKEN: process.env.XIAOHONGSHU_API_TOKEN,
259
- FMODE_XIAOHONGSHU_TOKEN: process.env.FMODE_XIAOHONGSHU_TOKEN,
260
- FMODE_SOCIAL_TOKEN: process.env.FMODE_SOCIAL_TOKEN,
261
- DOUYIN_API_TOKEN: process.env.DOUYIN_API_TOKEN,
262
- DOUYIN_TOKEN: process.env.DOUYIN_TOKEN,
263
- VOC_DOUYIN_TOKEN: process.env.VOC_DOUYIN_TOKEN
264
222
  };
265
223
  const tempHome = path.join(os.tmpdir(), 'claude-voc-ancestor-env-home-smoke');
266
224
  const tempWorkspace = path.join(os.tmpdir(), 'claude-voc-ancestor-env-workspace-smoke');
267
225
  const nested = path.join(tempWorkspace, 'nested', 'plugin');
268
226
  fs.mkdirSync(tempHome, { recursive: true });
269
227
  fs.mkdirSync(nested, { recursive: true });
228
+ fs.writeFileSync(path.join(tempWorkspace, '.env.local'), 'VOC_TOKEN=ancestor-smoke-token\n');
270
229
  try {
271
230
  process.chdir(nested);
272
231
  process.env.USERPROFILE = tempHome;
@@ -274,14 +233,7 @@ function smokeAncestorEnvLocalToken() {
274
233
  [
275
234
  'VOC_TOKEN',
276
235
  'VOC_SOCIAL_TOKEN',
277
- 'XIAOHONGSHU_API_TOKEN',
278
- 'FMODE_XIAOHONGSHU_TOKEN',
279
- 'FMODE_SOCIAL_TOKEN',
280
- 'DOUYIN_API_TOKEN',
281
- 'DOUYIN_TOKEN',
282
- 'VOC_DOUYIN_TOKEN'
283
236
  ].forEach(key => delete process.env[key]);
284
- fs.writeFileSync(path.join(tempWorkspace, '.env.local'), 'VOC_DOUYIN_TOKEN=ancestor-smoke-token\n');
285
237
  const token = readVocToken({});
286
238
  if (token !== 'ancestor-smoke-token') {
287
239
  throw new Error('ancestor .env.local token was not used');
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-voc-intelligence",
3
- "version": "0.3.17",
3
+ "version": "0.3.22",
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": [
@@ -33,10 +33,16 @@ VOC 后端是一个**通用转发网关**(`https://server.fmode.cn/api/voc-soc
33
33
  4. `voc_api_call` 用真实关键词调用;翻页/拉评论时把上一次返回里的 cursor / id 透传进下一次调用。
34
34
  5. 用返回数据继续后续分析(可交给 voc-issue-pool、voc-content-plan 等技能)。
35
35
 
36
- ## Token
36
+ ## Token(计费已迁移 NewAPI)
37
37
 
38
- 采集需要从充值/开通页复制的、以 `r:` 开头的会话 token(session token)。
39
- **不要**用 `~/.claude/voc-credentials.json` 里 `sk-` 开头的 AIGate API Key —— 后端不认它,会被工具直接判为 `needs_valid_token`。
38
+ 请求头统一 `Authorization: Bearer ${token}`,**优先用 NewAPI(fmode-api)的 `sk-` token**:
39
+
40
+ - 取值优先级:入参 `newapiToken`/`fmodeApiKey` → `.env.local` / 环境变量 `FMODE_API_KEY`·`NEWAPI_TOKEN` → `~/.claude/voc-credentials.json` 的 `fmodeApiKey`(`sk-` 开头)。
41
+ - `sk-` 鉴权失败(401,含「未入仓」类报错)且存在 `r:` 开头的会话 token 时,工具会**自动回退**用 `r:` sessionToken 重试一次(过渡期:服务端未迁完 / 该号未入仓)。
42
+ - 兼容:若把 `sk-` 直接填进 `VOC_TOKEN`,也会被当作 NewAPI token 使用。
43
+ - token **绝不回显**;底层鉴权/入仓原始报错不甩给用户,只给友好提示。
44
+
45
+ > 错误码(401/402/403 等)的完整对照、真实报文样例与处理纪律见 **`references/error-codes.md`**。
40
46
 
41
47
  ## 禁止事项(护栏)
42
48
 
@@ -46,10 +52,13 @@ VOC 后端是一个**通用转发网关**(`https://server.fmode.cn/api/voc-soc
46
52
  - 禁止输出「某行业不可用 / 系统只支持某类目」这类结论——**任何行业、任何关键词都能采集**,前提只是 token 类型正确且有额度。
47
53
  - 失败时禁止退回 web search / yt-dlp / ffmpeg / 让用户手动传媒体文件来「绕过」采集。
48
54
 
49
- ## 状态码含义
55
+ ## 状态码含义(详表见 `references/error-codes.md`)
50
56
 
51
57
  - `ok`:调用成功,`data.result` 是上游返回数据。
52
- - `needs_token` / `needs_valid_token`:没配置 token,或 token 类型不对(sk-)。提示配置 `r:` token。
53
- - `needs_recharge`:额度不足,提示充值。
58
+ - `needs_token`:没配置任何 token。`needs_valid_token`:token 缺失/失效/无效(HTTP 401)——**不是没钱**,提示配置有效 `sk-`(或回退 `r:`)token。
59
+ - `needs_recharge`:**仅 HTTP 402(余额不足)**,提示充值,跳 `https://app.fmode.cn/dev/studio/?balance=fmodeapi`。
60
+ - `needs_permission`:**HTTP 403(账号禁用/无权限)**,**≠ 余额不足**,不引导充值,提示联系服务方确认权限。
54
61
  - `needs_input`:必填参数缺失或参数错误(不是类目/余额问题)。
55
- - `upstream_unstable`:上游接口报错或波动,稍后重试。
62
+ - `upstream_unstable`:上游接口报错或波动(5xx / fetch failed),稍后重试。
63
+
64
+ > ⚠️ 不要把 **403/401** 一律说成「余额不足」。只有 **402** 才是真的没钱要充值;403 是权限、401 是 token 问题。
@@ -0,0 +1,68 @@
1
+ # VOC 社交数据采集 · 错误码速查
2
+
3
+ > 给 Agent 按需查阅。**核心纪律:不是所有报错都是「余额不足」。** 只有 **HTTP 402** 才提示充值;
4
+ > **401 = token 问题**(缺失/失效/类型不对),**403 = 账号禁用/无权限**,都**不是没钱**,不要引导充值。
5
+
6
+ 适用范围:`voc_api_call` / `voc_api_doc`(voc-social 通用转发网关 `https://server.fmode.cn/api/voc-social/<proxyPath>`)。
7
+
8
+ ## 一、总览表(HTTP 状态 → 含义 → 工具返回 → 给客户的话术 → Agent 动作)
9
+
10
+ | HTTP | 后端 `mess` 样例 | 判定 kind | 工具 status | 给客户 Agent 的话术 | Agent 该做什么 |
11
+ |------|------------------|-----------|-------------|----------------------|----------------|
12
+ | 200 | —(`{"code":200,"data":{...}}`) | — | `ok` | 正常返回 | 直接用 `data.result` |
13
+ | 401 | `Invalid NewAPI token` / `Missing NewAPI token` / `请输入API_KEY或用户sessionToken` / `当前用户不存在,无使用权限` | `auth` | `needs_valid_token`(无任何 token 时 `needs_token`) | 「token 缺失/失效/类型不对,**不是没钱、也不是关键词/类目问题**」 | 配置有效的 NewAPI `sk-` token(或回退 `r:` sessionToken)后重试 |
14
+ | 402 | `余额不足` / `额度不足` | `billing` | `needs_recharge` | 「余额不足,请充值」 | 给充值链接 `https://app.fmode.cn/dev/studio/?balance=fmodeapi`(自动打开余额弹窗) |
15
+ | 403 | `用户禁用` / `无权限` / `permission`(**不含**余额/开通关键词) | `permission` | `needs_permission` | 「账号被禁用或没有该接口权限,**≠ 余额不足**」 | 联系服务方确认账号状态/接口权限,**不要**引导充值 |
16
+ | 403 | 含 `未开通` / `没开通` / `余额` / `额度` / `insufficient` / `balance` | `billing` | `needs_recharge` | 「需开通/充值后使用」 | 给充值/开通链接 |
17
+ | 400 / 422 | `参数` / `入参` / `keyword` / `bad request` | `request` | `needs_input` | 「参数有误,**不是没数据/不是类目不支持**」 | 用 `voc_api_doc` 核对参数后重试 |
18
+ | 5xx / 连接失败 | `fetch failed` / 超时 / `500` | `upstream` | `upstream_unstable` | 「上游接口波动」 | 稍后重试,不要误报成关键词/余额问题 |
19
+
20
+ ## 二、真实报文样例(实打抓取)
21
+
22
+ ```text
23
+ # 有效 sk- token(NewAPI / fmode-api)
24
+ HTTP 200 {"code":200,"data":{"code":0,"data":{...}}}
25
+
26
+ # sk- token 无效/写错
27
+ HTTP 401 {"code":401,"mess":"Invalid NewAPI token"}
28
+
29
+ # 发的是旧版 Parse sessionToken(r:),后端已迁 NewAPI 计费
30
+ HTTP 401 {"code":401,"mess":"Missing NewAPI token"}
31
+
32
+ # 完全没带 Authorization
33
+ HTTP 401 {"code":401,"mess":"请输入API_KEY或用户sessionToken"}
34
+
35
+ # token 对应的用户不存在
36
+ HTTP 401 {"code":401,"mess":"当前用户不存在,无使用权限"}
37
+
38
+ # 余额不足(NewAPI 计费额度用尽)—— 仅此情形才引导充值
39
+ HTTP 402 {"code":402,"mess":"余额不足"}
40
+
41
+ # 用户被禁用 / 无该接口权限(区别于余额不足)
42
+ HTTP 403 {"code":403,"mess":"用户禁用或无权限"}
43
+ ```
44
+
45
+ ## 三、Token 优先级与回退(避开「入仓」错误)
46
+
47
+ 计费已迁移到 **NewAPI(fmode-api)**,请求头统一 `Authorization: Bearer ${token}`:
48
+
49
+ 1. **优先**用 NewAPI 的 `sk-` token(取值:入参 `newapiToken/fmodeApiKey` → `.env.local` / 环境变量 `FMODE_API_KEY`/`NEWAPI_TOKEN` → `~/.claude/voc-credentials.json` 的 `fmodeApiKey`)。
50
+ - 兼容历史:若 `VOC_TOKEN` 槽里填的就是 `sk-`,也当作 NewAPI token 使用。
51
+ 2. 若 `sk-` token **鉴权失败(kind=auth / 401,含「未入仓」类报错)** 且存在平台 `r:` sessionToken,**自动回退** sessionToken 重试一次。这正是规避「用户拿 token 请求时遇到入仓错误」的方式——不再因为 token 是 `sk-` 就直接拒绝。
52
+ 3. **402(余额不足)/ 403(无权限)不回退**——它们是终态,按上表处理。
53
+ 4. 底层「未入仓」/鉴权原始报错**不甩给用户**,对外只给上表的友好提示。
54
+
55
+ ## 四、判定规则摘要(`classifyApiError`)
56
+
57
+ 判定顺序(命中即返回):
58
+
59
+ 1. 文案含「用户/账号信息不存在、未登录、登录失效、请输入…token、sessiontoken」→ `auth`
60
+ 2. `status===401` 或文案含「invalid token / 无效token / 未授权 / unauthorized」→ `auth`
61
+ 3. `status===402` 或文案含「余额不足 / 额度不足 / 未开通 / 开通…权限 / insufficient / balance / quota / payment / 充值」→ `billing`
62
+ 4. `status===403` **且**含余额/开通类关键词 → `billing`
63
+ 5. 文案含「参数 / 入参 / keyword / 关键词 / bad request」或 `status∈{400,422}` → `request`
64
+ 6. `status>=500` → `upstream`
65
+ 7. `status===403` 或文案含「permission / 权限 / 无权限」→ `permission`
66
+ 8. 其它 → `upstream`
67
+
68
+ > 注意第 4 与第 7 条的区别:**带「余额/开通」关键词的 403 才当充值**;**纯权限 403 一律 `permission`,不充值**。