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

fix(voc): remove legacy recharge guidance and finalize media checks

gangvy 2 месяцев назад
Родитель
Сommit
20a48c6b3c
21 измененных файлов с 214 добавлено и 201 удалено
  1. 2 1
      claude-code/claude-code-voc-intelligence/docs/live-manual-acceptance-checklist.md
  2. 38 8
      claude-code/claude-code-voc-intelligence/docs/voc-cost-metering-and-balance-implementation-spec.md
  3. 2 1
      claude-code/claude-code-voc-intelligence/mcp/src/core/credentials.js
  4. 12 6
      claude-code/claude-code-voc-intelligence/mcp/src/features/media/media-discovery.js
  5. 23 4
      claude-code/claude-code-voc-intelligence/mcp/src/features/media/media-pipeline.js
  6. 22 133
      claude-code/claude-code-voc-intelligence/mcp/src/features/xiaohongshu-trend/assets.js
  7. 2 2
      claude-code/claude-code-voc-intelligence/mcp/src/providers/voc-gateway.js
  8. 5 4
      claude-code/claude-code-voc-intelligence/mcp/src/server.js
  9. 15 14
      claude-code/claude-code-voc-intelligence/mcp/src/tools/douyin-trend-run.js
  10. 23 6
      claude-code/claude-code-voc-intelligence/mcp/src/tools/voc-api-catalog-run.js
  11. 15 14
      claude-code/claude-code-voc-intelligence/mcp/src/tools/xiaohongshu-trend-run.js
  12. 5 0
      claude-code/claude-code-voc-intelligence/scripts/smoke-mcp.js
  13. 19 1
      claude-code/claude-code-voc-intelligence/scripts/smoke-media-pipeline.js
  14. 16 0
      claude-code/claude-code-voc-intelligence/scripts/smoke-package.js
  15. 1 1
      claude-code/claude-code-voc-intelligence/skills/douyin-trend-intelligence/references/live-mode.md
  16. 2 1
      claude-code/claude-code-voc-intelligence/skills/voc-api-catalog/SKILL.md
  17. 4 0
      claude-code/claude-code-voc-intelligence/skills/xiaohongshu-trend-intelligence/references/live-mode.md
  18. 2 2
      dist/claude-code-voc-intelligence-suite-manifest.json
  19. BIN
      dist/claude-code-voc-intelligence.zip
  20. 3 3
      dist/npm/claude-code-voc-npm-package-manifest.json
  21. 3 0
      scripts/deploy/claude-code-voc-intelligence-suite.js

+ 2 - 1
claude-code/claude-code-voc-intelligence/docs/live-manual-acceptance-checklist.md

@@ -21,7 +21,7 @@ node ".claude/plugins/voc-intelligence/mcp/src/tools/xiaohongshu-trend-run.js" -
 - `status` 为 `ok`,或在无 token/余额不足时返回友好充值提示。
 - 不出现原始 `403`、权限栈、接口响应体。
 - 报告里有证据样本、原帖链接、评论证据。
-- `files` 中包含 Markdown、JSON,若有图片则包含本地 `assets`。
+- `files` 中包含 Markdown、JSON 和 `assets/media-manifest.json`;报告媒体引用均为本地 `assets` 路径,manifest 中 `cloudUrl` 必须为 `null`。
 
 ## 抖音 live 最小验收
 
@@ -35,6 +35,7 @@ node ".claude/plugins/voc-intelligence/mcp/src/tools/douyin-trend-run.js" --coll
 - 不出现原始 `403`、权限栈、接口响应体。
 - 报告里有视频证据、原视频链接、评论证据。
 - 输出内容仍是“第一轮初步判断 / 机会假设 / 待确认问题”,不能把首轮样本写成最终结论。
+- 即使传入 `--cache-assets false`,仍必须生成本地 `assets/media-manifest.json`,并出现兼容参数弃用提示。
 
 ## 抖音搜索错误快速验收
 

+ 38 - 8
claude-code/claude-code-voc-intelligence/docs/voc-cost-metering-and-balance-implementation-spec.md

@@ -1,7 +1,7 @@
 # VOC 用量核算、成本预估与余额看板实现方案
 
-> 文档状态:可进入技术评审
-> 编写日期:2026-09-01
+> 文档状态:技能包改动已落实,待后端重启后的端到端联调
+> 编写日期:2026-09-01;最近复核:2026-09-02
 > 涉及仓库:`claude-code-voc-intelligence`、`future-server`、`fmode-studio`、`voc-profit-web`
 
 ## 1. 背景与目标
@@ -742,7 +742,7 @@ skills/voc-cost-controller/references/workflow-cost-models.md
 9. 单文件失败自动重试;最终失败时记录原因并在报告中显示“媒体缓存失败”,不回退为远程热链。
 10. 任务结束前执行媒体完整性检查;存在未处理媒体时任务状态为 `partial`,不得声称完整交付。
 
-现有 `cacheEvidenceAssets()` 可以作为图片下载的第一版基础,但需要扩展到视频、音频、详情长图、去重、断点续传和 URL 重写;线上 Storage 上传不在本轮范围内。
+原有 `cacheEvidenceAssets()` 的封面专用实现已经收敛为兼容适配器,实际执行统一调用 `features/media/media-pipeline.js`,覆盖图片、视频、音频、详情长图、买家秀、去重、manifest 和 URL 重写;线上 Storage 上传保持关闭。
 
 ## 9. 通用成本模型与首批报告样例
 
@@ -1236,7 +1236,7 @@ npm run smoke:package
 | 高成本低价值项优化建议 | 已完成(首批任务) | Agent 估算报告输出 valueScore、可删项和节省额;管理员看板 `/usage-diagnostics` 已按任务和接口展示成本排行、缓存命中和预计节省。 |
 | 充值后从检查点继续且不重复调用 | 已完成(工具级续跑) | VOC 402 分支保留部分结果并写检查点;支付后以同一 `usageTraceId` + `resume` 续跑,已完成业务键和分页不会重复调用。跨会话自动唤醒仍由宿主运行时负责。 |
 | Agent 返回免登录链接或支付二维码 | 已完成 | VOC 402 由统一 recharge client 复用现有 `pay_code2/order_status2`,二维码失败保留 Token Balance URL;Studio/宿主已对模型 402 直接打开现有充值入口。 |
-| 采集多媒体并固定本地缓存 | 已完成(小红书/抖音 live) | 通用 discovery/manifest/pipeline 已接入;`effectiveUrl` 固定为本地路径,线上 Storage 上传已关闭。其他采集入口需继续接入 pipeline。 |
+| 采集多媒体并固定本地缓存 | 已完成(所有当前 VOC 采集入口) | 小红书/抖音 sample 与 live、通用 `voc_api_call` 均经过 discovery/manifest/pipeline;`effectiveUrl` 固定为本地路径,线上 Storage 上传已关闭。 |
 | 凭证不写入代码、日志、报告 | 已完成(静态与 smoke 范围) | 请求凭证只作运行时输入,输出经过脱敏;发布包扫描和安装 smoke 通过。 |
 
 当前可以验收的 P0 交付是:统一报价、真实扣费归集、充值入口、Balance 用量页、管理员任务级成本诊断、两类首批工作流、live 媒体持久化和本地续跑检查点。后续工作集中在“为尚未接入的历史批处理 Skill 增加规划适配器、跨会话自动唤醒、更多采集入口接入媒体流水线、基于真实账号的端到端回放和历史预估校准”,不影响本轮已交付闭环。
@@ -1268,7 +1268,7 @@ npm run smoke:package
 2. `usageTraceId` 只是 Agent 本地生成并写入日志的执行追踪标签,用于把不同报告的调用频次和实际消费分开;不需要服务端任务表和任务 CRUD。
 3. 报价由 Agent 完成。服务端只通过统一 query 接口提供当前余额、筛选后的有效价目和实际用量,不根据业务类型生成调用计划或最终报价。
 4. 充值中心已有 Token 免登录、余额展示、支付二维码/按钮、支付轮询和到账刷新。本需求复用现有能力:Agent 默认按建议金额组装现有支付请求并返回二维码,另一入口是带 Token 的 Balance 自助链接。
-5. “所有采集多媒体必须缓存”会改变当前 live 工具行为。旧参数继续保留,但 `cacheAssets=false` 不再允许跳过 live 数据的持久化,`assetLimit` 只控制报告展示数量,不再控制实际缓存数量。
+5. “所有采集多媒体必须缓存”已统一落地。旧参数继续保留以兼容调用,但 `cacheAssets=false` 在 sample/live 都不再跳过持久化并返回弃用告警;`assetLimit` 不再控制实际缓存数量。
 6. MCP 使用 stdio 通信,只能在当前 Agent 会话存活时轮询并续跑。真正跨会话自动唤醒需要已有 Agent runtime/callback 或新增后台任务队列,不属于单独修改 VOC 技能包就能完成的能力。
 7. `future-server` 生产环境通过根目录 `server.js` 加载 `.modules/*.min.cjs`。改完模块源码后必须重新构建并更新对应运行时产物,否则现网不会获得新逻辑。
 8. 管理员利润看板和用户 Balance 页是两个权限面。前者可以查看全局经营数据,后者只能查询当前身份对应的用量;不能直接复用管理员接口给普通用户。
@@ -1316,7 +1316,7 @@ npm run smoke:package
 | 新增 | `mcp/src/core/recharge-client.js` | 计算差额后默认调用现有建单/支付码链路返回二维码,同时提供带 Token 的 Balance 自助链接,并在到账后查询余额 | 中 |
 | 新增 | `mcp/src/features/media/media-pipeline.js` | 发现图片/视频/音频,下载、去重、校验、写 manifest 并固定使用本地 `effectiveUrl` | 中 |
 | 新增 | `mcp/src/features/media/media-discovery.js` | 从不同接口的嵌套响应中按字段映射和 MIME 类型发现媒体,避免用单一字段名硬编码 | 中 |
-| 修改 | `mcp/src/features/xiaohongshu-trend/assets.js` | 迁移到通用媒体流水线;保留旧导出作为兼容适配器 | 中 |
+| 修改 | `mcp/src/features/xiaohongshu-trend/assets.js` | 仅保留旧导出的兼容适配器,内部委托通用本地-only 媒体流水线;不再执行封面专用下载 | 中 |
 | 修改 | `mcp/src/tools/voc-api-catalog-run.js` | 支持任务上下文、usage、默认输出目录和通用媒体副作用 | 中 |
 | 修改 | `mcp/src/tools/xiaohongshu-trend-run.js` | 接入预估、预算、检查点、全量媒体缓存和充值续跑 | 中 |
 | 修改 | `mcp/src/tools/douyin-trend-run.js` | 与小红书工作流保持同一规则 | 中 |
@@ -1447,8 +1447,8 @@ https://app.fmode.cn/dev/studio/balance/?token=USER_TOKEN
 
 | 现有参数 | 当前语义 | 新语义 | 兼容行为 |
 | --- | --- | --- | --- |
-| `cacheAssets` | 是否缓存少量首图 | 是否在报告中展示缓存媒体的兼容开关 | sample 模式可按旧值;live 模式始终持久化,传 `false` 时返回弃用 warning |
-| `assetLimit` | 最多缓存多少张首图,最大 30 | 报告最多展示多少项媒体 | 不限制实际缓存;schema 可保留原字段名并新增 `mediaDisplayLimit` |
+| `cacheAssets` | 是否缓存少量首图 | 兼容字段,所有模式始终持久化全部媒体 | 传 `false` 时返回弃用 warning,不改变缓存行为 |
+| `assetLimit` | 最多缓存多少张首图,最大 30 | 兼容字段,不限制实际缓存 | 保留 schema 以兼容旧调用;报告展示数量由报告自身控制 |
 | `output` | trend 工具输出目录 | 报告和媒体根目录 | 保持兼容 |
 | 无输出目录的 `voc_api_call` | 只返回 JSON | 默认创建任务输出目录和 manifest | 可通过结构化 `files` 告知目录,不改变原始 `data.result` |
 
@@ -1934,3 +1934,33 @@ Studio 已有 `/balance`、Token 免登录、NewAPI 余额、支付二维码、
 6. Studio 继续承担现有充值体验,并在 Agent 自身无法回复的模型 402 场景兜底打开充值入口。
 
 这六项同时完成,才能满足“所有任务统一扣 NewAPI、Agent 能估价、余额不足能直接付费、充值后能继续、Balance 能看清用量”的完整业务目标。
+
+### 21.11 2026-09-02 技能包旧逻辑复核结果
+
+本次复核覆盖所有 VOC MCP 工具、三类网关、趋势采集器、业务闭环、图片分析、充值/计费客户端、媒体模块、Skill 说明和发布产物。结论如下:
+
+| 检查项 | 结果 | 处理 |
+| --- | --- | --- |
+| 线上 Storage 上传、预签名和完成确认 | 已关闭 | `media-pipeline` 不读取 Storage 凭证,不调用线上 Storage;manifest 历史 `cloudUrl` 会被标准化为 `null`。 |
+| 趋势工具的旧封面缓存分支 | 已收口 | sample/live 均调用通用 pipeline;`assets.js` 仅保留兼容适配器,不再自行下载或限制首图。 |
+| 通用 `voc_api_call` 媒体响应 | 已补齐 | 成功和 stale 响应都执行本地缓存、manifest 和 URL 替换,`files` 返回媒体产物。 |
+| `cacheAssets=false` / `assetLimit` 绕过全量缓存 | 已移除 | 参数保留兼容,`false` 只产生弃用告警;实际缓存不受旧参数限制。 |
+| 充值路径 | 保持现状 | 402 只返回当前 Session 的 Tokenized Balance 链接;显式 `createPaymentQr=true` 才调用现有支付码链路。缺 token、401、403 不生成充值入口。 |
+| 任务支付语义 | 保持现状 | `usageTraceId` 仅为执行追踪标签,所有规模任务仍按 NewAPI 实际成功调用逐次扣费,不建立任务预付款。 |
+
+本次代码变更只涉及技能包的媒体入口、兼容参数说明和通用 API 响应落盘,不改变后端计费、充值中心或 Studio 的支付主体逻辑。后端模块产物由后端发布流程负责同步。
+
+### 21.12 2026-09-02 发布前复核结果
+
+技能包发布前回归已完成,结果如下:
+
+| 回归项 | 结果 | 说明 |
+| --- | --- | --- |
+| `npm run claude-voc:build` | 通过 | 安装 smoke 覆盖 sample、偏好、MCP、媒体、充值提示和跨行业流程 |
+| `npm run claude-voc:npm-pack` | 通过 | tarball 安装、npx 安装和 workspace 安装三种模式均通过 |
+| `npm run claude-voc:acceptance` | 通过 | 全包验收通过,MCP 工具清单包含 billing、cost、usage 工具 |
+| 媒体 pipeline 回归 | 通过 | 所有媒体写入本地 `assets/`,manifest 的 `cloudUrl=null`,报告使用本地路径 |
+| 旧逻辑扫描 | 通过 | 源码、zip 和 npm 产物均未发现线上 Storage 上传、预签名或上传完成调用 |
+| 空白差异检查 | 通过 | `git diff --check` 无错误;仅有 Git 的换行符提示 |
+
+当前仍需真实环境联调的项目只剩后端三个模块重启后的账单查询、生产 NewAPI 用量、Tokenized Balance 充值到账刷新和检查点续跑;这些由后端发布完成后按第 21.8 节执行,不阻塞技能包本地打包验收。

+ 2 - 1
claude-code/claude-code-voc-intelligence/mcp/src/core/credentials.js

@@ -190,7 +190,8 @@ function readSessionToken(input = {}) {
 }
 
 // NewAPI(fmode-api)计费 token,以 sk- 开头。
-// 计费已迁移到 NewAPI:优先用它发请求,鉴权失败再回退平台 sessionToken(apig,原路径保留)。
+// 正常计费统一落到 NewAPI:优先用 sk- 发请求,鉴权失败时才用 Session
+// 作为同一用户的身份回退;服务端会把 Session 映射到同一 NewAPI principal。
 // 关键:这把 sk- 就是 Claude Code 的 env.ANTHROPIC_AUTH_TOKEN(在 ~/.claude/settings.json),
 // 默认装完技能不必再手动配置——这里直接从 settings.json / 进程环境读取它,避免「明明配了 SK 却判缺 token、被甩进老付费框」的死循环。
 // 取值优先级:显式入参 → .env.local / 环境变量 FMODE_API_KEY·NEWAPI_TOKEN

+ 12 - 6
claude-code/claude-code-voc-intelligence/mcp/src/features/media/media-discovery.js

@@ -6,13 +6,19 @@ const MEDIA_KEY_RE = /(image|img|video|audio|media|cover|avatar|picture|photo|bu
 function discoverMedia(value, options = {}) {
   const found = [];
   const seen = new Set();
-  const maxItems = Math.max(1, Number(options.maxItems) || 1000);
-  function visit(node, key = '', parent = {}) {
+  // Collection reports must persist every discovered media item. A caller can
+  // still pass maxItems for an explicit low-level compatibility use, but the
+  // normal pipeline has no implicit 1,000-item truncation.
+  const maxItems = options.maxItems == null ? Number.POSITIVE_INFINITY : Math.max(1, Number(options.maxItems) || 1);
+  function visit(node, key = '', parent = {}, mediaContext = false) {
     if (found.length >= maxItems || node == null) return;
+    const inMediaContext = mediaContext || MEDIA_KEY_RE.test(key);
     if (typeof node === 'string') {
       const text = node.trim();
-      if ((URL_RE.test(text) || /^data:(image|video|audio)\//i.test(text)) && (MEDIA_KEY_RE.test(key) || /\.(?:jpe?g|png|gif|webp|svg|mp4|mov|webm|mp3|wav|m4a)(?:[?#]|$)/i.test(text))) {
-        const dedupe = `${key}|${text}`;
+      if ((URL_RE.test(text) || /^data:(image|video|audio)\//i.test(text)) && (inMediaContext || /\.(?:jpe?g|png|gif|webp|svg|mp4|mov|webm|mp3|wav|m4a)(?:[?#]|$)/i.test(text))) {
+        // The same URL often appears in both coverUrl and imageUrls. Dedupe by
+        // source URL so it is fetched and written only once.
+        const dedupe = text;
         if (!seen.has(dedupe)) {
           seen.add(dedupe);
           found.push({
@@ -30,9 +36,9 @@ function discoverMedia(value, options = {}) {
     if (seen.has(node)) return;
     seen.add(node);
     if (Array.isArray(node)) {
-      node.forEach(item => visit(item, key, parent));
+      node.forEach(item => visit(item, key, parent, inMediaContext));
     } else {
-      for (const [childKey, child] of Object.entries(node)) visit(child, childKey, node);
+      for (const [childKey, child] of Object.entries(node)) visit(child, childKey, node, inMediaContext);
     }
   }
   visit(value);

+ 23 - 4
claude-code/claude-code-voc-intelligence/mcp/src/features/media/media-pipeline.js

@@ -23,9 +23,28 @@ function extension(mime = '', url = '') {
 async function fetchMedia(sourceUrl, options = {}) {
   const parsed = dataUrl(sourceUrl);
   if (parsed) return parsed;
-  const response = await (options.fetchImpl || fetch)(sourceUrl, { signal: options.signal, headers: { Accept: '*/*' } });
-  if (!response.ok) throw new Error(`media request failed with HTTP ${response.status}`);
-  return { mimeType: response.headers.get('content-type') || '', buffer: Buffer.from(await response.arrayBuffer()) };
+  const fetchImpl = options.fetchImpl || fetch;
+  const retries = Math.max(0, Number(options.retries ?? 2));
+  const timeoutMs = Math.max(1000, Number(options.timeoutMs ?? 8000));
+  let lastError;
+  for (let attempt = 0; attempt <= retries; attempt++) {
+    const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
+    const timeout = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
+    try {
+      const response = await fetchImpl(sourceUrl, {
+        signal: options.signal || (controller ? controller.signal : undefined),
+        headers: { Accept: '*/*' }
+      });
+      if (!response.ok) throw new Error(`media request failed with HTTP ${response.status}`);
+      return { mimeType: response.headers.get('content-type') || '', buffer: Buffer.from(await response.arrayBuffer()) };
+    } catch (error) {
+      lastError = error;
+      if (attempt < retries) await new Promise(resolve => setTimeout(resolve, 250 * (attempt + 1)));
+    } finally {
+      if (timeout) clearTimeout(timeout);
+    }
+  }
+  throw lastError || new Error('media request failed');
 }
 
 async function processMedia(value, options = {}) {
@@ -62,7 +81,7 @@ async function processMedia(value, options = {}) {
       item.bytes = fetched.buffer.length;
       item.sha256 = sha256;
       item.cacheStatus = 'cached';
-      files.push(absolute);
+      if (!files.includes(absolute)) files.push(absolute);
       item.effectiveUrl = item.localPath;
       replacements.set(source.sourceUrl, item.effectiveUrl);
     } catch (error) {

+ 22 - 133
claude-code/claude-code-voc-intelligence/mcp/src/features/xiaohongshu-trend/assets.js

@@ -1,12 +1,6 @@
-const fs = require('fs');
-const path = require('path');
+'use strict';
 
-const DEFAULT_TIMEOUT_MS = 8000;
-const DEFAULT_LIMIT = 10;
-
-function ensureDir(dirPath) {
-  fs.mkdirSync(dirPath, { recursive: true });
-}
+const { processMedia, replaceMediaUrls } = require('../media/media-pipeline');
 
 function safeName(value, fallback = 'asset') {
   const cleaned = String(value || '')
@@ -17,142 +11,37 @@ function safeName(value, fallback = 'asset') {
   return cleaned || fallback;
 }
 
-function extFromContentType(contentType = '') {
-  const value = String(contentType || '').split(';')[0].trim().toLowerCase();
-  if (value === 'image/jpeg' || value === 'image/jpg') return '.jpg';
-  if (value === 'image/png') return '.png';
-  if (value === 'image/webp') return '.webp';
-  if (value === 'image/gif') return '.gif';
-  if (value === 'image/svg+xml') return '.svg';
-  return '';
-}
-
-function extFromUrl(url = '') {
-  try {
-    const parsed = new URL(String(url));
-    const ext = path.extname(parsed.pathname).toLowerCase();
-    if (['.jpg', '.jpeg', '.png', '.webp', '.gif', '.svg'].includes(ext)) return ext === '.jpeg' ? '.jpg' : ext;
-  } catch {
-    // Ignore invalid URLs; caller will decide whether to skip.
-  }
-  return '';
-}
-
 function parseDataUrl(value = '') {
   const match = String(value).match(/^data:([^,]*),(.*)$/s);
   if (!match) return null;
   const meta = match[1] || '';
-  const [rawContentType] = meta.split(';');
-  const contentType = rawContentType || 'application/octet-stream';
-  const isBase64 = meta.split(';').some(part => part.toLowerCase() === 'base64');
+  const contentType = meta.split(';')[0] || 'application/octet-stream';
   const body = match[2] || '';
   return {
     contentType,
-    buffer: isBase64 ? Buffer.from(body, 'base64') : Buffer.from(decodeURIComponent(body), 'utf8')
+    buffer: meta.split(';').some(part => part.toLowerCase() === 'base64')
+      ? Buffer.from(body, 'base64')
+      : Buffer.from(decodeURIComponent(body), 'utf8')
   };
 }
 
-async function fetchWithTimeout(url, timeoutMs = DEFAULT_TIMEOUT_MS) {
-  const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
-  const timeout = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
-  try {
-    const response = await fetch(url, {
-      signal: controller ? controller.signal : undefined,
-      headers: {
-        Accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
-        Referer: 'https://www.xiaohongshu.com/',
-        Origin: 'https://www.xiaohongshu.com',
-        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36'
-      }
-    });
-    const arrayBuffer = await response.arrayBuffer();
-    return {
-      ok: response.ok,
-      status: response.status,
-      contentType: response.headers.get('content-type') || '',
-      buffer: Buffer.from(arrayBuffer)
-    };
-  } finally {
-    if (timeout) clearTimeout(timeout);
-  }
-}
-
-async function readImageSource(url, timeoutMs) {
-  if (!url) return null;
-  const data = parseDataUrl(url);
-  if (data) return data;
-  if (!/^https?:\/\//i.test(String(url))) return null;
-  const fetched = await fetchWithTimeout(url, timeoutMs);
-  if (!fetched.ok) {
-    const error = new Error(`image request failed with HTTP ${fetched.status}`);
-    error.httpStatus = fetched.status;
-    throw error;
-  }
+// Compatibility adapter for callers of the former cover-only helper. All
+// callers now share the all-media, local-only manifest pipeline.
+async function cacheEvidenceAssets({ notes = [], outputDir, usageTraceId, platform = 'xiaohongshu', timeoutMs } = {}) {
+  const result = await processMedia({ notes }, {
+    outputDir,
+    usageTraceId,
+    platform,
+    ...(timeoutMs ? { timeoutMs } : {})
+  });
+  const normalized = replaceMediaUrls(notes, result.replacements);
+  normalized.forEach((note, index) => {
+    if (notes[index] && note && typeof note === 'object') Object.assign(notes[index], note);
+  });
   return {
-    contentType: fetched.contentType,
-    buffer: fetched.buffer
+    ...result,
+    files: [...result.files, result.manifestFile].filter(Boolean)
   };
 }
 
-function firstImageUrl(note = {}) {
-  return note.coverUrl || (Array.isArray(note.imageUrls) ? note.imageUrls[0] : '') || '';
-}
-
-function normalizeLimit(value) {
-  if (value === undefined || value === null || value === '') return DEFAULT_LIMIT;
-  const parsed = Number(value);
-  if (!Number.isFinite(parsed)) return DEFAULT_LIMIT;
-  return Math.max(0, parsed);
-}
-
-async function cacheEvidenceAssets({ notes = [], outputDir, limit = DEFAULT_LIMIT, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
-  const warnings = [];
-  const files = [];
-  if (!outputDir || !Array.isArray(notes) || !notes.length) {
-    return { warnings, files };
-  }
-
-  const assetsDir = path.join(outputDir, 'assets');
-  ensureDir(assetsDir);
-  const maxItems = normalizeLimit(limit);
-
-  for (const note of notes.slice(0, maxItems)) {
-    const imageUrl = firstImageUrl(note);
-    if (!imageUrl) continue;
-    const noteId = safeName(note.id || note.noteId || note.title, 'note');
-    try {
-      const source = await readImageSource(imageUrl, timeoutMs);
-      if (!source || !source.buffer || !source.buffer.length) {
-        warnings.push({
-          stage: 'asset_cache',
-          noteId: note.id || '',
-          message: '未识别到可缓存的图片地址'
-        });
-        continue;
-      }
-      const ext = extFromContentType(source.contentType) || extFromUrl(imageUrl) || '.jpg';
-      const filename = `${noteId}-cover${ext}`;
-      const absolutePath = path.join(assetsDir, filename);
-      fs.writeFileSync(absolutePath, source.buffer);
-      const relativePath = path.posix.join('assets', filename);
-      note.localCoverPath = relativePath;
-      note.localCoverAbsolutePath = absolutePath;
-      note.coverOriginalUrl = note.coverOriginalUrl || imageUrl;
-      files.push(absolutePath);
-    } catch (error) {
-      warnings.push({
-        stage: 'asset_cache',
-        noteId: note.id || '',
-        message: error.message || String(error)
-      });
-    }
-  }
-
-  return { warnings, files };
-}
-
-module.exports = {
-  cacheEvidenceAssets,
-  parseDataUrl,
-  safeName
-};
+module.exports = { cacheEvidenceAssets, parseDataUrl, safeName };

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

@@ -152,8 +152,8 @@ async function requestJson(url, token, { method = 'GET', body, retries = 3, head
 }
 
 // 优先用 newapi 的 sk- token 发请求;若该 token 鉴权失败(kind === 'auth',例如服务端
-// 尚未迁移 / 该号未入仓 / token 未被接受)且提供了回退 token(平台 sessionToken),自动用
-// 回退 token 重试一次。402(余额不足)/403(无权限)不回退。
+// 尚未接受该 token)且提供了回退 token(平台 Session),自动用同一用户身份重试一次。
+// 服务端对两种凭证统一扣 NewAPI;402(余额不足)/403(无权限)不回退。
 async function callSocialGateway({ proxyPath, method = 'GET', query = {}, body, token, fallbackToken, baseUrl, retries = 3, headers = {} } = {}) {
   const root = resolveSocialRoot(baseUrl);
   const url = buildGatewayUrl(root, proxyPath, query);

+ 5 - 4
claude-code/claude-code-voc-intelligence/mcp/src/server.js

@@ -568,8 +568,8 @@ function createServer() {
         requireEstimate: z.boolean().optional(),
         sort: z.enum(['general', 'time_descending', 'popularity_descending']).optional(),
         noteType: z.enum(['_0', '_1', '_2']).optional(),
-        cacheAssets: z.boolean().optional().describe('Whether to cache evidence images into the output assets directory. Defaults to true.'),
-        assetLimit: z.number().int().min(0).max(30).optional().describe('Maximum number of note cover images to cache.'),
+        cacheAssets: z.boolean().optional().describe('Deprecated compatibility flag. All response media is always cached locally; false no longer skips persistence.'),
+        assetLimit: z.number().int().min(0).max(30).optional().describe('Deprecated compatibility field. It does not limit local media persistence.'),
         xiaohongshuToken: z.string().optional().describe('Optional request-scoped collection token. It is used only for this run and never echoed back.'),
         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.'),
         usageTraceId: z.string().max(120).optional(),
@@ -644,8 +644,8 @@ function createServer() {
         publishTime: z.enum(['0', '1', '7', '180']).optional(),
         filterDuration: z.enum(['0', '0-1', '1-5', '5-10000']).optional(),
         contentType: z.enum(['0', '1', '2', '3']).optional(),
-        cacheAssets: z.boolean().optional().describe('Whether to cache evidence images into the output assets directory. Defaults to true.'),
-        assetLimit: z.number().int().min(0).max(30).optional().describe('Maximum number of video cover images to cache.'),
+        cacheAssets: z.boolean().optional().describe('Deprecated compatibility flag. All response media is always cached locally; false no longer skips persistence.'),
+        assetLimit: z.number().int().min(0).max(30).optional().describe('Deprecated compatibility field. It does not limit local media persistence.'),
         douyinToken: z.string().optional().describe('Optional request-scoped collection token. It is used only for this run and never echoed back.'),
         vocToken: z.string().optional().describe('Optional VOC social token, same convention as xiaohongshu trend. It is used only for this run and never echoed back.'),
         usageTraceId: z.string().max(120).optional(),
@@ -811,6 +811,7 @@ function createServer() {
       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.',
+        'All media in a successful response is persisted locally and replaced with a local path; the skill never uploads media to online Storage.',
         '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.'
       ].join(' '),
       inputSchema: {

+ 15 - 14
claude-code/claude-code-voc-intelligence/mcp/src/tools/douyin-trend-run.js

@@ -5,7 +5,6 @@ const { okResult, errorResult } = require('../core/result-envelope');
 const { readCredentialContext } = require('../core/credentials');
 const { buildSampleDataset } = require('../features/douyin-trend/sample-data');
 const { collectLiveDataset } = require('../features/douyin-trend/live-collector');
-const { cacheEvidenceAssets } = require('../features/xiaohongshu-trend/assets');
 const { processMedia, replaceMediaUrls } = require('../features/media/media-pipeline');
 const { buildTrendReport, writeReport } = require('../features/douyin-trend/report');
 const { trendMemoryPath, readTrendMemory } = require('../features/douyin-trend/preference-memory');
@@ -350,8 +349,7 @@ async function runDouyinTrend(input = {}) {
         const mediaResult = await processMedia(dataset, {
           outputDir,
           usageTraceId: usageContext.usageTraceId,
-          platform: 'douyin',
-          maxItems: input.assetLimit ?? input['asset-limit'] ?? 1000
+          platform: 'douyin'
         });
         dataset = replaceMediaUrls(dataset, mediaResult.replacements);
         warnings.push(...mediaResult.warnings);
@@ -617,15 +615,17 @@ async function runDouyinTrend(input = {}) {
     return errorResult(`Unsupported collectionMode: ${collectionMode}`);
   }
 
-  const cacheAssetsEnabled = input.cacheAssets !== false &&
-    input['cache-assets'] !== false &&
-    String(input.cacheAssets || input['cache-assets'] || '').toLowerCase() !== 'false';
+  const cacheAssetsDisabled = input.cacheAssets === false ||
+    input['cache-assets'] === false ||
+    String(input.cacheAssets || input['cache-assets'] || '').toLowerCase() === 'false';
+  if (cacheAssetsDisabled) {
+    warnings.push('cacheAssets=false 已弃用:为满足媒体完整性要求,本次仍会缓存全部采集媒体。');
+  }
   if (collectionMode === 'live') {
     const mediaResult = await processMedia(dataset, {
       outputDir,
       usageTraceId: usageContext.usageTraceId,
-      platform: 'douyin',
-      maxItems: input.assetLimit ?? input['asset-limit'] ?? 1000
+      platform: 'douyin'
     });
     dataset = replaceMediaUrls(dataset, mediaResult.replacements);
     warnings.push(...mediaResult.warnings);
@@ -637,14 +637,15 @@ async function runDouyinTrend(input = {}) {
       [...(checkpoint?.completedKeywords || []), ...(liveDataset.completedKeywords || [])],
       []
     );
-  } else if (cacheAssetsEnabled) {
-    const assetResult = await cacheEvidenceAssets({
-      notes: dataset.videos,
+  } else {
+    const mediaResult = await processMedia(dataset, {
       outputDir,
-      limit: input.assetLimit ?? input['asset-limit'] ?? 10
+      usageTraceId: usageContext.usageTraceId,
+      platform: 'douyin'
     });
-    warnings.push(...assetResult.warnings);
-    assetFiles.push(...assetResult.files);
+    dataset = replaceMediaUrls(dataset, mediaResult.replacements);
+    warnings.push(...mediaResult.warnings);
+    assetFiles.push(...mediaResult.files, mediaResult.manifestFile);
   }
 
   const report = buildTrendReport({ profile, dataset, collectionMode, memory });

+ 23 - 6
claude-code/claude-code-voc-intelligence/mcp/src/tools/voc-api-catalog-run.js

@@ -1,3 +1,4 @@
+const path = require('path');
 const { readCredentialContext } = require('../core/credentials');
 const {
   listEndpoints,
@@ -18,6 +19,7 @@ const {
 } = require('../core/payment-links');
 const { okResult, errorResult } = require('../core/result-envelope');
 const { contextForTool, createStepContext, usageHeaders, writeUsageContext } = require('../core/usage-context');
+const { processMedia, replaceMediaUrls } = require('../features/media/media-pipeline');
 
 function buildCallTemplate(endpoint) {
   const args = { id: endpoint.id, params: {} };
@@ -255,7 +257,8 @@ async function callVocApi(input = {}) {
       : callSocialGateway;
   const usageContext = contextForTool({ ...input, skillId: input.skillId || 'voc-api-catalog' });
   const stepContext = createStepContext(usageContext, input.stepId || endpoint?.id || proxyPath);
-  const usageContextFile = writeUsageContext(usageContext, input.outputDir || input.output);
+  const outputDir = path.resolve(input.outputDir || input.output || path.join('outputs', usageContext.usageTraceId));
+  const usageContextFile = writeUsageContext(usageContext, outputDir);
 
   try {
     const result = await callGateway({
@@ -269,6 +272,17 @@ async function callVocApi(input = {}) {
       retries: Number.isFinite(input.retries) ? input.retries : 3,
       headers: usageHeaders(stepContext)
     });
+    // Generic catalog calls can return covers, detail images, videos, audio,
+    // or buyer-show media just like the dedicated trend tools. Persist all of
+    // them through the local-only pipeline before exposing the response.
+    const mediaResult = await processMedia(result.data, {
+      outputDir,
+      usageTraceId: usageContext.usageTraceId,
+      platform: channel
+    });
+    const normalizedData = replaceMediaUrls(result.data, mediaResult.replacements);
+    const normalizedRaw = replaceMediaUrls(result.json, mediaResult.replacements);
+    const mediaFiles = [...mediaResult.files, mediaResult.manifestFile].filter(Boolean);
     const freshness = assessFreshness(result.json, endpoint);
     if (freshness.stale) {
       const ageLabel = freshness.ageHours.toFixed(1);
@@ -288,12 +302,13 @@ async function callVocApi(input = {}) {
           httpStatus: result.httpStatus,
           authMode: result.authMode,
           fallbackUsed: Boolean(result.fallbackUsed),
+          mediaCount: mediaResult.items.length,
           ...freshness
         },
-        data: { result: result.data, raw: result.json },
-        files: [],
+        data: { result: normalizedData, raw: normalizedRaw },
+        files: [...mediaFiles, ...(usageContextFile ? [usageContextFile] : [])],
         nextActions: ['优先改用淘宝详情 V7 重试', '稍后重试并重新检查 recordTime'],
-        warnings: [`数据快照超过 ${freshness.maxAgeHours} 小时时效门槛`],
+        warnings: [`数据快照超过 ${freshness.maxAgeHours} 小时时效门槛`, ...mediaResult.warnings],
         errors: []
       };
     }
@@ -308,10 +323,12 @@ async function callVocApi(input = {}) {
         httpStatus: result.httpStatus,
         authMode: result.authMode,
         fallbackUsed: Boolean(result.fallbackUsed),
+        mediaCount: mediaResult.items.length,
         ...freshness
       },
-      data: { result: result.data, raw: result.json, usage: stepContext },
-      files: usageContextFile ? [usageContextFile] : []
+      data: { result: normalizedData, raw: normalizedRaw, usage: stepContext },
+      files: [...mediaFiles, ...(usageContextFile ? [usageContextFile] : [])],
+      warnings: mediaResult.warnings
     });
   } catch (error) {
     const kind = String(error && (error.kind || error.errorKind) || 'upstream');

+ 15 - 14
claude-code/claude-code-voc-intelligence/mcp/src/tools/xiaohongshu-trend-run.js

@@ -5,7 +5,6 @@ const { okResult, errorResult } = require('../core/result-envelope');
 const { readCredentialContext } = require('../core/credentials');
 const { buildSampleDataset } = require('../features/xiaohongshu-trend/sample-data');
 const { collectLiveDataset } = require('../features/xiaohongshu-trend/live-collector');
-const { cacheEvidenceAssets } = require('../features/xiaohongshu-trend/assets');
 const { processMedia, replaceMediaUrls } = require('../features/media/media-pipeline');
 const { buildTrendReport, writeReport } = require('../features/xiaohongshu-trend/report');
 const { trendMemoryPath, readTrendMemory } = require('../features/xiaohongshu-trend/preference-memory');
@@ -315,8 +314,7 @@ async function runXiaohongshuTrend(input = {}) {
         const mediaResult = await processMedia(dataset, {
           outputDir,
           usageTraceId: usageContext.usageTraceId,
-          platform: 'xiaohongshu',
-          maxItems: input.assetLimit ?? input['asset-limit'] ?? 1000
+          platform: 'xiaohongshu'
         });
         dataset = replaceMediaUrls(dataset, mediaResult.replacements);
         warnings.push(...mediaResult.warnings);
@@ -560,15 +558,17 @@ async function runXiaohongshuTrend(input = {}) {
     return errorResult(`Unsupported collectionMode: ${collectionMode}`);
   }
 
-  const cacheAssetsEnabled = input.cacheAssets !== false &&
-    input['cache-assets'] !== false &&
-    String(input.cacheAssets || input['cache-assets'] || '').toLowerCase() !== 'false';
+  const cacheAssetsDisabled = input.cacheAssets === false ||
+    input['cache-assets'] === false ||
+    String(input.cacheAssets || input['cache-assets'] || '').toLowerCase() === 'false';
+  if (cacheAssetsDisabled) {
+    warnings.push('cacheAssets=false 已弃用:为满足媒体完整性要求,本次仍会缓存全部采集媒体。');
+  }
   if (collectionMode === 'live') {
     const mediaResult = await processMedia(dataset, {
       outputDir,
       usageTraceId: usageContext.usageTraceId,
-      platform: 'xiaohongshu',
-      maxItems: input.assetLimit ?? input['asset-limit'] ?? 1000
+      platform: 'xiaohongshu'
     });
     dataset = replaceMediaUrls(dataset, mediaResult.replacements);
     warnings.push(...mediaResult.warnings);
@@ -580,14 +580,15 @@ async function runXiaohongshuTrend(input = {}) {
       [...(checkpoint?.completedKeywords || []), ...(liveDataset.completedKeywords || [])],
       []
     );
-  } else if (cacheAssetsEnabled) {
-    const assetResult = await cacheEvidenceAssets({
-      notes: dataset.notes,
+  } else {
+    const mediaResult = await processMedia(dataset, {
       outputDir,
-      limit: input.assetLimit ?? input['asset-limit'] ?? 10
+      usageTraceId: usageContext.usageTraceId,
+      platform: 'xiaohongshu'
     });
-    warnings.push(...assetResult.warnings);
-    assetFiles.push(...assetResult.files);
+    dataset = replaceMediaUrls(dataset, mediaResult.replacements);
+    warnings.push(...mediaResult.warnings);
+    assetFiles.push(...mediaResult.files, mediaResult.manifestFile);
   }
 
   const report = buildTrendReport({ profile, dataset, collectionMode, memory });

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

@@ -126,6 +126,7 @@ async function main() {
       name: 'voc_xiaohongshu_trend_run',
       arguments: {
         collectionMode: 'sample',
+        cacheAssets: false,
         profile: 'memory-templates/xiaohongshu-trend-profile.json',
         memory: '../outputs/claude-code-xhs-mcp-smoke/xiaohongshu-trend-memory.json',
         output: '../outputs/claude-code-xhs-mcp-smoke'
@@ -150,6 +151,10 @@ async function main() {
     if (!assetFiles.length) {
       throw new Error('Expected cached asset files in sample report result');
     }
+    if (!assetFiles.some(file => /media-manifest\.json$/i.test(file)) ||
+      !(structured.warnings || []).some(warning => String(warning).includes('cacheAssets=false'))) {
+      throw new Error('Expected sample media manifest and deprecated cacheAssets warning');
+    }
 
     const issuePoolResult = await client.callTool({
       name: 'voc_issue_pool_run',

+ 19 - 1
claude-code/claude-code-voc-intelligence/scripts/smoke-media-pipeline.js

@@ -5,7 +5,7 @@ const fs = require('fs');
 const os = require('os');
 const path = require('path');
 const { discoverMedia } = require('../mcp/src/features/media/media-discovery');
-const { processMedia, replaceMediaUrls } = require('../mcp/src/features/media/media-pipeline');
+const { processMedia, replaceMediaUrls, fetchMedia } = require('../mcp/src/features/media/media-pipeline');
 const { manifestPath } = require('../mcp/src/features/media/media-manifest');
 
 async function main() {
@@ -14,6 +14,24 @@ async function main() {
   const input = { id: 'note-1', coverUrl: image, media: { videoUrl: video }, text: 'unchanged' };
   const discovered = discoverMedia(input);
   assert.equal(discovered.length, 2);
+  const nested = discoverMedia({ video: { play_addr: { url_list: [video] } } });
+  assert.equal(nested.length, 1, 'nested media URL lists should be discovered');
+  let fetchAttempts = 0;
+  const retried = await fetchMedia('https://media.example.invalid/retry.png', {
+    retries: 2,
+    timeoutMs: 1000,
+    fetchImpl: async () => {
+      fetchAttempts += 1;
+      if (fetchAttempts === 1) throw new Error('transient fixture error');
+      return {
+        ok: true,
+        headers: { get: () => 'image/png' },
+        arrayBuffer: async () => Buffer.from('retried-media')
+      };
+    }
+  });
+  assert.equal(fetchAttempts, 2, 'media downloads should retry transient failures');
+  assert.equal(retried.buffer.toString(), 'retried-media');
   const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voc-media-smoke-'));
   fs.mkdirSync(path.dirname(manifestPath(outputDir)), { recursive: true });
   fs.writeFileSync(manifestPath(outputDir), JSON.stringify({

+ 16 - 0
claude-code/claude-code-voc-intelligence/scripts/smoke-package.js

@@ -34,6 +34,21 @@ const OUTPUT_ROOT = path.resolve(ROOT, 'outputs', 'claude-code-package-smoke');
 const MEMORY_PATH = path.join(OUTPUT_ROOT, 'xiaohongshu-trend-memory.json');
 const DOUYIN_MEMORY_PATH = path.join(OUTPUT_ROOT, 'douyin-trend-memory.json');
 
+function smokeNoLegacyRechargeInstruction() {
+  const files = [
+    path.join(ROOT, 'skills', 'voc-api-catalog', 'SKILL.md'),
+    path.join(ROOT, 'skills', 'xiaohongshu-trend-intelligence', 'SKILL.md'),
+    path.join(ROOT, 'skills', 'douyin-trend-intelligence', 'SKILL.md')
+  ];
+  const legacyPattern = /走原\s*apig|原始付费路径|旧\s*APIG\s*充值|balance=fmodeapi|apig-pay/i;
+  for (const file of files) {
+    const body = fs.readFileSync(file, 'utf8');
+    if (legacyPattern.test(body)) {
+      throw new Error(`legacy recharge instruction remains in ${path.relative(ROOT, file)}`);
+    }
+  }
+}
+
 async function smokeMcpTools() {
   const client = new Client({
     name: 'voc-intelligence-package-smoke',
@@ -723,6 +738,7 @@ async function smokeDouyinSearchInputErrors() {
 }
 
 async function main() {
+  smokeNoLegacyRechargeInstruction();
   const sample = await runXiaohongshuTrend({
     collectionMode: 'sample',
     profile: path.join(ROOT, 'memory-templates', 'xiaohongshu-trend-profile.json'),

+ 1 - 1
claude-code/claude-code-voc-intelligence/skills/douyin-trend-intelligence/references/live-mode.md

@@ -11,7 +11,7 @@
 - `keywordLimit = 1`
 - `videosPerKeyword = 1`
 - `maxCommentPages = 1`
-- `cacheAssets = true`
+- 所有响应媒体始终写入本地 `assets/` 和 `media-manifest.json`;旧 `cacheAssets` 参数仅作兼容,传 `false` 也不会跳过持久化
 
 ## 失败提示
 

+ 2 - 1
claude-code/claude-code-voc-intelligence/skills/voc-api-catalog/SKILL.md

@@ -90,7 +90,7 @@ VOC 后端是一个**通用转发网关**(`https://server.fmode.cn/api/voc-soc
 > **这把 `sk-` 计费 token 是什么、从哪来**:它就是 **Claude Code 自己的 `ANTHROPIC_AUTH_TOKEN`**——配在用户级 **`~/.claude/settings.json` 的 `env.ANTHROPIC_AUTH_TOKEN`**(`sk-` 开头,且 `ANTHROPIC_BASE_URL` 指向 `api.fmode.cn`)。Claude Code 用户**装完技能即用,无需任何额外配置**:工具会自动从这里(以及进程环境)读到它。所以 newapi(fmode-api)≠ 某个要单独开通的东西,它用的就是你正在用的这把 fmode key。
 
 - 取值优先级:入参 `newapiToken`/`fmodeApiKey` → `.env.local` / 环境变量 `FMODE_API_KEY`·`NEWAPI_TOKEN` → `~/.claude/voc-credentials.json` 的 `fmodeApiKey` → `~/.fmode/config.json` 的 `newapiToken`/`fmodeApiToken` → **`~/.claude/settings.json` 的 `env.ANTHROPIC_AUTH_TOKEN`(Claude Code 默认入口,`sk-` 开头)**。
-- `sk-` 鉴权失败(401,含「未入仓」类报错)且存在 `r:` 开头的会话 token 时,工具会**自动回退**用 `r:` sessionToken 走原 apig 接口重试一次(过渡期:服务端未迁完 / 该号未入仓)。此回退是**正常**的原始付费路径,不是出故障。
+- `sk-` 鉴权失败(401,含「未入仓」类报错)且存在 `r:` 开头的会话 token 时,工具会**自动回退**用 `r:` sessionToken 通过同一 VOC 网关重试一次。服务端会把该 Session 映射到当前用户的 NewAPI 计费身份;这不是旧 APIG 扣费路径,也不会建立第二套余额。
 - 兼容:若把 `sk-` 直接填进 `VOC_TOKEN`,也会被当作 NewAPI token 使用。
 - token **绝不回显**;底层鉴权/入仓原始报错不甩给用户,只给友好提示。
 
@@ -126,3 +126,4 @@ VOC 后端是一个**通用转发网关**(`https://server.fmode.cn/api/voc-soc
 
 - 任何通过本技能采集到的图片、视频、音频或封面,交给统一媒体流水线下载到任务本地 `assets/` 并写入清单。
 - 报告和标准化结果只使用本地媒体路径;本技能包不执行线上 Storage 上传,也不请求预签名上传地址。
+- `voc_api_call`、趋势采集和业务工作流都走同一媒体 pipeline;成功或可交付的响应会先写入本地 `assets/` 与 `media-manifest.json`,`cacheAssets=false` 不会关闭持久化。

+ 4 - 0
claude-code/claude-code-voc-intelligence/skills/xiaohongshu-trend-intelligence/references/live-mode.md

@@ -14,6 +14,10 @@ live 模式默认保持小规模:
 - `notesPerKeyword = 1`
 - `maxCommentPages = 1`
 
+## 媒体持久化
+
+响应中的图片、视频、音频、封面、买家秀和详情素材始终写入本地 `assets/`,并登记到 `media-manifest.json`。旧 `cacheAssets` 参数仅作兼容,传 `false` 也不会跳过持久化;报告只引用本地路径。
+
 这样可以先验证流程,不会一上来就把运行成本拉高。
 
 ## token 处理

+ 2 - 2
dist/claude-code-voc-intelligence-suite-manifest.json

@@ -1,9 +1,9 @@
 {
   "name": "claude-code-voc-intelligence",
   "version": "0.3.33",
-  "generatedAt": "2026-09-02T03:47:56.265Z",
+  "generatedAt": "2026-09-02T04:57:05.870Z",
   "packageZip": "dist/claude-code-voc-intelligence.zip",
-  "packageZipBytes": 2332018,
+  "packageZipBytes": 2333932,
   "plugin": "voc-intelligence",
   "skills": [
     "xiaohongshu-trend-intelligence",

BIN
dist/claude-code-voc-intelligence.zip


+ 3 - 3
dist/npm/claude-code-voc-npm-package-manifest.json

@@ -1,10 +1,10 @@
 {
   "name": "@vocmarket/voc-skill",
   "version": "0.3.33",
-  "generatedAt": "2026-09-02T03:49:40.966Z",
+  "generatedAt": "2026-09-02T04:58:52.704Z",
   "tarball": "dist/npm/vocmarket-voc-skill-0.3.33.tgz",
-  "tarballBytes": 662873,
-  "unpackedBytes": 4317876,
+  "tarballBytes": 664624,
+  "unpackedBytes": 4322384,
   "entryCount": 166,
   "installCommands": [
     "npm install -g @vocmarket/voc-skill",

+ 3 - 0
scripts/deploy/claude-code-voc-intelligence-suite.js

@@ -21,6 +21,7 @@ const CDN_DOMAIN = 'https://repos.fmode.cn';
 const BUCKET = 'nova-repos';
 
 const REQUIRED_FILES = [
+  'SKILL.md',
   '.claude-plugin/plugin.json',
   '.mcp.json',
   'bin/claude-voc.js',
@@ -202,8 +203,10 @@ function validateSuite() {
   const pluginJson = readJson(path.join(SUITE_DIR, '.claude-plugin', 'plugin.json'));
   const manifest = readJson(MANIFEST_PATH);
   const mcpJson = readJson(path.join(SUITE_DIR, '.mcp.json'));
+  const rootSkill = fs.readFileSync(path.join(SUITE_DIR, 'SKILL.md'), 'utf8');
   if (packageJson.name !== NPM_PACKAGE_NAME) missing.push('package.json:name mismatch');
   if (pluginJson.name !== 'voc-intelligence') missing.push('.claude-plugin/plugin.json:name mismatch');
+  if (!/^description:\s*\S.+$/m.test(rootSkill)) missing.push('SKILL.md missing description');
   if (!manifest.skills.includes('xiaohongshu-trend-intelligence')) missing.push('manifest missing xiaohongshu-trend-intelligence');
   if (!mcpJson.mcpServers || !(mcpJson.mcpServers.voc || mcpJson.mcpServers['voc-intelligence'])) {
     missing.push('.mcp.json missing VOC MCP server');