Forráskód Böngészése

Refine VOC skill workflow and reporting

gangvy 3 hónapja
szülő
commit
f7118dbafb
100 módosított fájl, 13867 hozzáadás és 102 törlés
  1. 1 1
      claude-code-voc-intelligence/.claude-plugin/plugin.json
  2. 1 0
      claude-code-voc-intelligence/README.md
  3. 682 0
      claude-code-voc-intelligence/docs/boss-prompt-templates.md
  4. 1 0
      claude-code-voc-intelligence/docs/capability-map.md
  5. 136 0
      claude-code-voc-intelligence/docs/claude-code-troubleshooting-playbook.md
  6. 14 1
      claude-code-voc-intelligence/docs/live-manual-acceptance-checklist.md
  7. 30 2
      claude-code-voc-intelligence/mcp/src/core/credentials.js
  8. 153 16
      claude-code-voc-intelligence/mcp/src/features/douyin-trend/live-collector.js
  9. 1 1
      claude-code-voc-intelligence/mcp/src/features/douyin-trend/preference-memory.js
  10. 160 7
      claude-code-voc-intelligence/mcp/src/features/voc-content-plan/content-plan.js
  11. 102 5
      claude-code-voc-intelligence/mcp/src/features/voc-issue-pool/issue-pool.js
  12. 5 5
      claude-code-voc-intelligence/mcp/src/features/voc-problem-deep-dive/deep-dive.js
  13. 3 1
      claude-code-voc-intelligence/mcp/src/features/xiaohongshu-trend/assets.js
  14. 49 6
      claude-code-voc-intelligence/mcp/src/features/xiaohongshu-trend/live-collector.js
  15. 5 3
      claude-code-voc-intelligence/mcp/src/features/xiaohongshu-trend/report.js
  16. 30 0
      claude-code-voc-intelligence/mcp/src/providers/douyin-api.js
  17. 67 31
      claude-code-voc-intelligence/mcp/src/server.js
  18. 114 4
      claude-code-voc-intelligence/mcp/src/tools/douyin-trend-run.js
  19. 48 0
      claude-code-voc-intelligence/mcp/src/tools/xiaohongshu-trend-run.js
  20. 2 2
      claude-code-voc-intelligence/package-lock.json
  21. 1 1
      claude-code-voc-intelligence/package.json
  22. 30 0
      claude-code-voc-intelligence/scripts/smoke-mcp.js
  23. 267 1
      claude-code-voc-intelligence/scripts/smoke-package.js
  24. 3 1
      claude-code-voc-intelligence/skill-package-manifest.json
  25. 17 0
      claude-code-voc-intelligence/skills/douyin-trend-intelligence/SKILL.md
  26. 25 0
      claude-code-voc-intelligence/skills/douyin-trend-intelligence/references/live-mode.md
  27. 8 1
      claude-code-voc-intelligence/skills/voc-content-plan/SKILL.md
  28. 7 0
      claude-code-voc-intelligence/skills/voc-issue-pool/SKILL.md
  29. 16 0
      claude-code-voc-intelligence/skills/voc-problem-deep-dive/SKILL.md
  30. 6 3
      claude-code-voc-intelligence/skills/xiaohongshu-trend-intelligence/references/output-format.md
  31. 4 4
      dist/claude-code-voc-intelligence-suite-manifest.json
  32. BIN
      dist/claude-code-voc-intelligence.zip
  33. 6 6
      dist/npm/claude-code-voc-npm-package-manifest.json
  34. BIN
      dist/workshop-manual-install/VOC-workshop-ready-claudecode.zip
  35. 8 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/.claude-plugin/plugin.json
  36. 6 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/.gitignore
  37. 11 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/.mcp.json
  38. 333 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/README.md
  39. 790 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/bin/claude-voc.js
  40. 194 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/docs/capability-map.md
  41. 491 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/docs/context-budget-implementation-plan.md
  42. 43 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/docs/customer-quickstart.md
  43. 43 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/docs/deliverables/competitor-map-report.md
  44. 45 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/docs/deliverables/seven-day-content-plan.md
  45. 49 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/docs/deliverables/single-problem-action-plan.md
  46. 41 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/docs/deliverables/trend-insight-report.md
  47. 42 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/docs/deliverables/voc-issue-pool-report.md
  48. 31 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/docs/demo-runbook.md
  49. 50 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/docs/live-manual-acceptance-checklist.md
  50. 93 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/docs/npm-packaging-notes.md
  51. 86 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/docs/payment-package-links.md
  52. 207 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/install.js
  53. 85 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/core/credentials.js
  54. 106 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/core/evidence-quality.js
  55. 31 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/core/memory-store.js
  56. 117 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/core/payment-links.js
  57. 31 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/core/result-envelope.js
  58. 259 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/douyin-trend/live-collector.js
  59. 130 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/douyin-trend/preference-memory.js
  60. 318 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/douyin-trend/report.js
  61. 103 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/douyin-trend/sample-data.js
  62. 79 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/douyin-trend/signal-extractor.js
  63. 1 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/douyin-trend/topic-builder.js
  64. 214 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/voc-business-workflow/business-workflow.js
  65. 198 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/voc-competitor-map/competitor-map.js
  66. 206 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/voc-content-plan/content-plan.js
  67. 391 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/voc-issue-pool/issue-pool.js
  68. 662 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/voc-problem-deep-dive/deep-dive.js
  69. 293 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/voc-speaking-script/speaking-script.js
  70. 156 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/xiaohongshu-trend/assets.js
  71. 335 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/xiaohongshu-trend/industry-presets.js
  72. 294 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/xiaohongshu-trend/live-collector.js
  73. 130 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/xiaohongshu-trend/preference-memory.js
  74. 317 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/xiaohongshu-trend/report.js
  75. 79 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/xiaohongshu-trend/sample-data.js
  76. 77 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/xiaohongshu-trend/signal-extractor.js
  77. 84 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/xiaohongshu-trend/topic-builder.js
  78. 140 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/providers/douyin-api.js
  79. 102 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/providers/xiaohongshu-api.js
  80. 585 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/server.js
  81. 70 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/tools/douyin-preference-update.js
  82. 354 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/tools/douyin-trend-run.js
  83. 52 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/tools/voc-business-workflow-run.js
  84. 59 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/tools/voc-competitor-map-run.js
  85. 59 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/tools/voc-content-plan-run.js
  86. 59 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/tools/voc-issue-pool-run.js
  87. 59 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/tools/voc-problem-deep-dive-run.js
  88. 59 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/tools/voc-speaking-script-run.js
  89. 85 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/tools/xiaohongshu-preference-update.js
  90. 356 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/tools/xiaohongshu-trend-run.js
  91. 33 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/memory-templates/douyin-trend-profile.json
  92. 33 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/memory-templates/xiaohongshu-trend-profile.json
  93. 1159 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/package-lock.json
  94. 65 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/package.json
  95. 126 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/scripts/smoke-cross-industry.js
  96. 272 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/scripts/smoke-mcp.js
  97. 739 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/scripts/smoke-package.js
  98. 60 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/skill-package-manifest.json
  99. 61 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/skills/douyin-trend-intelligence/SKILL.md
  100. 27 0
      dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/skills/douyin-trend-intelligence/references/live-mode.md

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

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

+ 1 - 0
claude-code-voc-intelligence/README.md

@@ -217,6 +217,7 @@ npm --prefix claude-code-voc-intelligence run smoke:package
 更完整的能力索引见:
 
 - `docs/capability-map.md`
+- `docs/boss-prompt-templates.md`
 - `docs/deliverables/trend-insight-report.md`
 - `docs/deliverables/competitor-map-report.md`
 - `docs/deliverables/voc-issue-pool-report.md`

+ 682 - 0
claude-code-voc-intelligence/docs/boss-prompt-templates.md

@@ -0,0 +1,682 @@
+# 老板通用提示词模板
+
+这份模板给没有提示词基础的老板、门店负责人、营销负责人使用。使用方法很简单:
+
+1. 先选一个你要做的模式。
+2. 复制模板。
+3. 把方括号里的内容换成你的品牌、城市、品类、竞品、痛点。
+4. 如果你不确定,就写“不确定”,让工具先帮你补采或整理。
+
+## 0. 通用信息模板
+
+如果你第一次使用,建议先把这些信息发给 Claude Code。后面所有模式都可以复用。
+
+```text
+我的品牌/门店是:[品牌名]
+城市/商圈:[城市、商圈、门店位置]
+行业/品类:[餐饮/家装/医美/教育/零售/本地生活等]
+主打产品/服务:[核心产品、套餐、服务项目]
+人均/客单价:[大概价格,不知道就写不确定]
+主要客群:[谁来买,比如情侣、家庭、宝妈、老板、年轻人、学生等]
+主要消费场景:[聚餐/约会/生日/装修/送礼/学习/改善型消费等]
+已知竞品:[竞品1、竞品2、竞品3;不知道就写不知道]
+当前最头疼的问题:
+1. [问题1]
+2. [问题2]
+3. [问题3]
+我希望输出:[报告/问题清单/行动建议/内容选题/口播》
+6. [竞品名2]
+7. [竞品名3]
+
+重点追踪这些信号:
+[价格、套餐、排队、服务、出品、效果、口碑、避坑、新鲜感、拍照、复购、竞品对比]
+
+请保留:
+1. 原帖/视频链接
+2. 图片或视频封面
+3. 用户评论原话
+4. 互动数据
+5. 样本是否足够的判断
+
+如果余额不足或 token 异常,请保留已经采到的部分样本,并告诉我下一步怎么补采。
+```
+
+## 3. 补采矩阵
+
+适合样本不够、评论太少、采到内容跑偏时使用。
+
+```text
+现在样本不足,请先不要做最终报告。
+请基于我的品牌和痛点,生成一份补采矩阵。
+
+品牌/门店:[品牌名]
+城市/区域:[城市/商圈]
+行业/品类:[行业品类]
+已知竞品:[竞品列表]
+当前痛点:
+1. [痛点1]
+2. [痛点2]
+3. [痛点3]
+
+请输出一张补采矩阵,包含:
+1. 平台:小红书/抖音
+2. 关键词
+3. 对应痛点
+4. 要找的证据类型
+5. 最低样本量
+6. 判断样本有效/无效的标准
+7. 采到后应该进入哪个分析模块
+
+不要泛泛写“多采一些”,要给我能直接复制运行的关键词。
+```
+
+## 4. 竞品图谱
+
+适合你不知道竞品是谁,或者想判断用户会把你和哪些店/品牌比较。
+
+```text
+请帮我做一份竞品图谱。
+
+品牌/门店:[品牌名]
+城市/区域:[城市/商圈]
+行业/品类:[行业品类]
+人均/客单价:[价格,不确定就写不确定]
+主要消费场景:[聚餐/约会/家庭/商务/送礼/学习等]
+已知竞品:[如果有就写,没有就写不知道]
+当前痛点:
+1. [痛点1]
+2. [痛点2]
+3. [痛点3]
+
+请重点回答:
+1. 同城同品类可能有哪些竞品类型
+2. 同价格带竞品怎么判断
+3. 同消费场景竞品怎么判断
+4. 用户可能拿我和哪些品牌/门店比较
+5. 我下一步应该优先看哪几类竞品
+6. 建议先深挖哪 1-3 家,并说明原因
+
+如果需要真实采集,请先生成关键词并做小样本 live 验证。
+```
+
+## 5. VOC 问题池
+
+适合你已经有评论、报告、采集结果,想知道“先改哪个问题”。
+
+```text
+请把这些用户声音整理成 VOC 问题池。
+
+品牌/门店:[品牌名]
+行业/品类:[行业品类]
+使用场景:[门店经营/销售转化/内容营销/复购提升/服务改善]
+目标用户:[目标客群]
+
+用户评论/证据如下:
+1. [用户原话1]
+2. [用户原话2]
+3. [用户原话3]
+4. [用户原话4]
+
+请输出:
+1. 问题优先级排序
+2. 每个问题的证据原话
+3. 影响的是新客、客单、复购、口碑、内容信任,还是现场效率
+4. 本周建议先处理什么
+5. 哪个问题适合继续做单点深挖
+
+注意:只把真实评论当证据,不要把报告标题、品牌介绍、分析结论当证据。
+```
+
+## 6. 单点深挖
+
+适合你已经知道一个问题最重要,想知道老板怎么解决。
+
+```text
+请继续深挖这个 VOC 问题:[问题名]
+
+品牌/门店:[品牌名]
+行业/品类:[行业品类]
+使用场景:[门店经营/销售转化/内容营销/复购提升]
+目标用户:[目标客群]
+
+相关用户原话:
+1. [用户原话1]
+2. [用户原话2]
+3. [用户原话3]
+
+请站在老板/运营视角输出:
+1. 这个问题的本质是什么
+2. 它影响新客、客单、复购、口碑还是效率
+3. 顾客为什么会产生这个感受
+4. 门店/团队本周可以做的 3-5 个动作
+5. 哪些动作不建议做,为什么
+6. 7 天验证计划
+7. 判断动作有效的指标
+8. 可以转成哪些内容选题
+```
+
+## 7. 7 天内容计划
+
+适合你想把 VOC 问题变成小红书/抖音选题。
+
+```text
+请根据这些 VOC 问题,给我做一份 7 天内容计划。
+
+品牌/门店:[品牌名]
+平台:[小红书/抖音/视频号/大众点评]
+行业/品类:[行业品类]
+目标用户:[目标客群]
+本周想解决的问题:[价格解释/套餐不清楚/排队/服务/新鲜感/竞品对比/信任不足]
+
+用户原话:
+1. [用户原话1]
+2. [用户原话2]
+3. [用户原话3]
+
+请输出 7 天内容表:
+1. 每天主题
+2. 对应的用户问题
+3. 标题
+4. 前 3 秒钩子
+5. 拍摄画面建议
+6. 口播要点
+7. 评论区引导
+8. 验证指标
+
+不要只给泛泛的爆款标题,要让每条内容都对应一个真实用户顾虑。
+```
+
+## 8. 口播脚本共创
+
+适合你要一条能拍的视频口播,但希望它来自 VOC,而不是空泛广告。
+
+```text
+请基于 VOC 用户声音,帮我写一条口播脚本。
+
+品牌/门店:[品牌名]
+平台:[抖音/小红书/视频号]
+主题:[这条视频要讲什么]
+目标用户:[谁会看]
+用户顾虑:[用户最担心什么]
+证据原话:
+1. [用户原话1]
+2. [用户原话2]
+
+脚本要求:
+1. 先给 60 秒版本
+2. 再给 30 秒压缩版
+3. 给我前 3 秒开头
+4. 给镜头画面建议
+5. 给标题备选
+6. 给评论区引导
+7. 口吻要像老板本人说话,不要像广告文案
+8. 不要夸大,不要虚假承诺
+```
+
+## 9. 偏好记忆更新
+
+适合你觉得上一版方向不对,要让下次更贴近你。
+
+```text
+请更新这次分析/内容的偏好记忆。
+
+保留这些方向:
+1. [希望保留的表达/动作/内容方向]
+2. [希望保留的表达/动作/内容方向]
+
+避免这些方向:
+1. [不想再出现的方向]
+2. [不适合门店执行的动作]
+
+下次请更偏向:
+[老板视角/门店动作/销售话术/内容选题/竞品对比/真实评论/低成本执行]
+
+原因:
+[简单说明为什么]
+```
+
+## 10. 安装、token、余额检查
+
+适合 live 跑不起来、提示 token 或余额问题时使用。
+
+```text
+请帮我检查 VOC live 采集配置。
+
+我要检查:
+1. 小红书 live 是否可用
+2. 抖音 live 是否可用
+3. token 是否已读取
+4. 如果余额不足,请给我充值链接
+5. 不要暴露底层 403、权限报错、token 内容
+
+如果已经采到部分数据,请保留部分样本和报告文件,不要丢弃。
+```
+
+## 11. 老板最常用的一句话模板
+
+如果你懒得填很多信息,可以直接这样问:
+
+```text
+我是[城市][行业/品类]老板,品牌叫[品牌名],现在最头疼的是[痛点]。
+请先帮我判断需要采哪些用户声音和竞品数据;
+如果样本够,就给我 VOC 诊断;
+如果样本不够,就先给我补采矩阵;
+最后告诉我本周先改哪里、下周发什么内容。
+```
+
+## 12. 盛焰铁板烧可直接使用的版本
+
+```text
+我是南昌做铁板烧的门店老板,品牌叫盛焰铁板烧。
+
+品牌背景:
+盛焰铁板烧是江西本地连锁铁板烧品牌,直营 9 家,主打到店现制、聚餐、套餐和铁板烧体验。
+
+已知竞品:
+立方铁、火地、大神太神、炙焰、大渔、航海家。
+
+当前痛点:
+1. 日常营销能够带来 TC,但是活动折扣大,客单受影响,最后营业额没有提升。
+2. 营销玩法同质化严重,大多数属于低价竞争,自发传播裂变能力弱。
+3. 品牌模式趋于老化,顾客没有新鲜感,核心竞争力不突出,消费者选择优先级不足。
+4. 铁板烧都是当顾客面现场制作,效率不高,且无法快速通过漂亮菜的模式传递给顾客,无法形成顾客自传播。
+
+请基于以上信息做 VOC 经营诊断。
+要求:
+1. 先判断现有样本是否足够。
+2. 如果不足,先生成小红书和抖音补采矩阵,关键词要具体到南昌铁板烧、盛焰、竞品名、价格、套餐、值不值、排队、聚餐、新鲜感。
+3. 采集或整理时必须保留原帖/视频链接、图片/封面、用户评论原话。
+4. 问题池只能使用真实用户评论,不要把品牌介绍或报告标题当证据。
+5. 最后围绕四个痛点输出:问题判断、用户证据、竞品画像、品类心智、老板本周动作、下周内容方向。
+```
+
+## 13. 各模式深入追问模板
+
+很多老板第一次拿到报告后,不知道下一句该怎么问。下面这些模板用于“已经跑完第一轮以后继续深入”。你只需要复制对应模板,把方括号换成你的问题、竞品、内容或动作。
+
+### 13.1 完整 VOC 经营闭环后,继续深入
+
+适合第一轮已经输出了“市场声音、问题池、动作、内容方向”,但你想让结论更准。
+
+```text
+请基于刚才的 VOC 经营闭环结果继续深入。
+
+我想重点看:[价格/套餐/排队/服务/新鲜感/竞品/内容传播/复购]
+
+请不要重新泛泛分析,请直接围绕上一轮结果做:
+1. 哪些判断有真实评论证据支撑
+2. 哪些判断只是初步假设
+3. 哪些地方样本不足,需要补采
+4. 如果只选一个问题本周解决,应该选哪个,为什么
+5. 这个问题对应的门店动作、销售话术、内容选题分别是什么
+6. 7 天后怎么判断这个方向有效还是无效
+
+如果证据不足,请先给我补采矩阵,不要硬下结论。
+```
+
+更强的老板追问版:
+
+```text
+请把上一轮 VOC 经营闭环变成老板决策版。
+
+要求:
+1. 不要讲太多概念,只讲我该怎么决策
+2. 每个结论后面必须标注:证据强 / 证据弱 / 需要补采
+3. 把“本周必须做”“可以试”“先不要做”分开
+4. 每个动作都写清楚负责人、执行方式、观察指标
+5. 最后给我一个 7 天执行清单
+```
+
+### 13.2 live 真实采集后,继续深入
+
+适合已经采到一批小红书/抖音样本,你想继续围绕某个信号扩大采集。
+
+```text
+请基于刚才 live 采集到的样本继续深入。
+
+我想重点追:[关键词/竞品名/用户痛点/消费场景]
+例如:[南昌铁板烧 值不值 / 盛焰铁板烧 / 立方铁 / 大渔 / 排队 / 套餐]
+
+请输出:
+1. 当前样本里已经出现的真实用户原话
+2. 这些原话分别指向什么问题
+3. 哪些样本和我的品牌高度相关
+4. 哪些样本只是泛行业参考,不能直接用
+5. 下一轮应该补采哪些关键词
+6. 每个关键词最低要采多少条笔记/视频、多少条评论
+7. 采完以后应该进入问题池、竞品图谱,还是单点深挖
+
+请继续保留原帖/视频链接、图片/封面和评论原话。
+```
+
+如果采集跑偏:
+
+```text
+刚才 live 采集的内容有点跑偏,请帮我纠偏。
+
+跑偏表现:
+1. [比如:采到了泛营销内容,不是门店消费内容]
+2. [比如:采到了别的城市,不是本地]
+3. [比如:采到了行业教程,不是用户评论]
+
+请重新设计关键词,要求:
+1. 更贴近真实消费者说法
+2. 加入城市、商圈、品类、竞品名
+3. 区分品牌词、品类词、痛点词、场景词、竞品词
+4. 每组关键词说明它要验证什么
+5. 给我一版可以直接重新 live 采集的关键词清单
+```
+
+### 13.3 补采矩阵后,继续深入
+
+适合已经拿到补采矩阵,你想让它变成实际执行计划。
+
+```text
+请把刚才的补采矩阵变成可执行采集计划。
+
+要求:
+1. 按优先级排序,先采最能影响经营判断的关键词
+2. 每个平台最多先选 [3/5/8] 个关键词
+3. 每个关键词说明:为什么采、采多少、看什么评论
+4. 标注哪些关键词用于验证我的产品声音
+5. 标注哪些关键词用于验证竞品画像
+6. 标注哪些关键词用于验证品类心智
+7. 最后给我一版“第一轮小样本采集”和“第二轮放大采集”的安排
+```
+
+采完以后继续问:
+
+```text
+我已经按补采矩阵采完一轮,请帮我判断样本是否够用。
+
+请检查:
+1. 样本数量是否够
+2. 评论是否是真实用户声音
+3. 有没有原帖/视频链接
+4. 有没有图片/封面
+5. 有没有覆盖我的核心痛点
+6. 有没有覆盖主要竞品
+7. 现在能不能进入 VOC 报告
+
+如果不能,请告诉我还缺哪一类证据。
+```
+
+### 13.4 竞品图谱后,继续深入
+
+适合第一版竞品图谱已经出来,你想深挖某一家竞品。
+
+```text
+请基于刚才的竞品图谱,继续深挖这一家竞品:[竞品名]
+
+我的品牌:[品牌名]
+竞品:[竞品名]
+城市/场景:[城市/商圈/消费场景]
+
+请重点分析:
+1. 用户为什么会选择这家竞品
+2. 它在哪些场景下会抢走我的顾客
+3. 它的价格、套餐、出品、环境、服务、传播点分别强在哪里
+4. 用户对它的吐槽是什么
+5. 它的优势哪些值得学,哪些不适合我学
+6. 我的品牌应该怎么错位竞争
+7. 可以转成哪些门店动作
+8. 可以转成哪些内容选题
+
+要求:尽量基于真实评论、链接和图片,不要只做主观判断。
+```
+
+如果想比较多家竞品:
+
+```text
+请把这些竞品做成横向对比表:[竞品1、竞品2、竞品3]
+
+对比维度:
+1. 用户选择理由
+2. 人均/套餐感知
+3. 消费场景
+4. 适合人群
+5. 内容传播点
+6. 用户吐槽点
+7. 对我的威胁程度
+8. 我可以错位的机会
+9. 下一步优先深挖顺序
+
+请最后告诉我:我最应该先盯哪一家,为什么。
+```
+
+### 13.5 VOC 问题池后,继续深入
+
+适合问题池已经出来,你想处理最重要的问题。
+
+```text
+请基于刚才的 VOC 问题池继续深入。
+
+我想优先处理的问题是:[问题名]
+
+请输出:
+1. 这个问题的证据原话有哪些
+2. 它影响新客、客单、复购、口碑、内容信任,还是现场效率
+3. 它为什么比其他问题更优先
+4. 它背后可能有哪几种原因
+5. 哪些原因需要门店验证
+6. 本周最小成本动作是什么
+7. 如果动作有效,数据会怎么变化
+8. 如果动作无效,下一步该换哪个方向
+```
+
+如果你觉得问题池不准:
+
+```text
+我觉得刚才的问题池不太准,请重新校准。
+
+我认为不准的地方:
+1. [比如:其它待归类太多]
+2. [比如:把报告正文当成证据]
+3. [比如:没有围绕我的四个痛点]
+
+请重新处理:
+1. 只使用真实用户评论作为证据
+2. 把报告标题、品牌介绍、分析段落全部排除
+3. 按我的业务痛点重新归类
+4. 每个问题必须给出证据原话
+5. 样本不足的地方标注“需要补采”
+```
+
+### 13.6 单点深挖后,继续深入
+
+适合单点行动建议已经出来,你想把它变成可执行方案。
+
+```text
+请把刚才的单点深挖变成门店执行方案。
+
+问题:[问题名]
+我能投入的资源:[比如:店长、前厅、厨师、拍摄人员、预算]
+执行周期:[3 天/7 天/14 天]
+
+请输出:
+1. 今日就能做的动作
+2. 3 天内能验证的动作
+3. 7 天内能看到数据变化的动作
+4. 每个动作的负责人
+5. 每个动作的标准话术或 SOP
+6. 需要记录的数据
+7. 复盘时怎么判断有效/无效
+8. 如果无效,下一步替代动作是什么
+```
+
+如果动作不适合你:
+
+```text
+刚才的动作里有些不适合我。
+
+不适合的动作:
+1. [动作1],原因:[为什么不适合]
+2. [动作2],原因:[为什么不适合]
+
+请记住这些限制,并重新给我一版动作方案。
+要求:
+1. 更低成本
+2. 更适合门店现场执行
+3. 不增加太多员工负担
+4. 能在 7 天内验证
+5. 保留真实用户证据
+```
+
+### 13.7 7 天内容计划后,继续深入
+
+适合内容计划出来后,你想改成更适合账号、老板口吻或某个平台。
+
+```text
+请基于刚才的 7 天内容计划继续优化。
+
+我要优化的方向是:[老板口吻/更像探店/更适合小红书/更适合抖音/更强转化/更少广告感]
+
+请重新输出:
+1. 每天选题是否对应真实用户问题
+2. 哪些选题最容易拍
+3. 哪些选题最容易带来到店咨询
+4. 哪些选题可能太硬广,需要改
+5. 每条内容的前 3 秒钩子
+6. 每条内容的拍摄画面
+7. 每条内容的评论区引导
+8. 发布后看哪些指标
+```
+
+如果要围绕一个痛点连续发:
+
+```text
+请围绕这个痛点做连续 7 条内容:[痛点名]
+
+要求:
+1. 7 条不能重复
+2. 每条都从不同用户顾虑切入
+3. 每条都要有真实评论依据
+4. 每条都要有标题、钩子、口播要点、镜头建议
+5. 最后告诉我哪 2 条最适合先拍
+```
+
+### 13.8 口播脚本后,继续深入
+
+适合脚本出来后,你想让它更像老板本人、更短、更狠、更真实。
+
+```text
+请继续修改刚才的口播脚本。
+
+我希望改成:[更像老板本人/更口语/更短/更有冲突/更适合小红书/更适合抖音/更少广告感]
+
+请保留:
+1. 真实用户顾虑
+2. 核心证据
+3. 不夸大承诺
+
+请修改:
+1. 前 3 秒开头更抓人
+2. 中间解释更具体
+3. 结尾更容易引导评论或到店
+4. 给我 60 秒版、30 秒版、15 秒版
+5. 给我一版提词器格式
+```
+
+如果想做 A/B 测试:
+
+```text
+请把刚才的口播脚本改成 3 个版本做 A/B 测试。
+
+版本 A:老板解释型
+版本 B:用户吐槽回应型
+版本 C:竞品对比型
+
+每个版本请输出:
+1. 标题
+2. 前 3 秒
+3. 30 秒口播
+4. 镜头建议
+5. 评论区引导
+6. 适合验证什么指标
+```
+
+### 13.9 偏好记忆后,继续深入
+
+适合你想让后续输出越来越像你的业务风格。
+
+```text
+请基于我的反馈继续更新偏好记忆。
+
+这次我觉得有效的部分:
+1. [有效方向1]
+2. [有效方向2]
+
+这次我觉得无效的部分:
+1. [无效方向1]
+2. [无效方向2]
+
+下次请默认:
+1. 多给门店动作
+2. 少讲抽象概念
+3. 所有判断都带证据强弱
+4. 内容更像老板说话
+5. 样本不足先补采,不要硬写结论
+
+请把这些规则记住,并在下一轮应用。
+```
+
+### 13.10 token / 余额检查后,继续深入
+
+适合 live 采集提示 token、余额、权限、部分采集时继续处理。
+
+```text
+请继续处理刚才的 live 采集状态。
+
+当前状态:[needs_token / needs_recharge / partial_needs_recharge / no_live_samples / 不确定]
+
+请告诉我:
+1. 现在还能不能继续真实采集
+2. 已经采到的数据有没有保存
+3. 保存在哪里
+4. 已采样本能不能先做初步假设
+5. 如果要继续补采,需要补哪个平台
+6. 如果需要充值,请给我充值链接
+7. 不要暴露 token、403、权限接口原文
+```
+
+### 13.11 报告复盘模板
+
+适合一轮 VOC 报告做完后,过 7 天回来复盘。
+
+```text
+请帮我复盘上一轮 VOC 动作。
+
+上一轮优先问题:[问题名]
+执行过的动作:
+1. [动作1]
+2. [动作2]
+3. [动作3]
+
+这 7 天观察到的数据/反馈:
+1. [数据或反馈1]
+2. [数据或反馈2]
+3. [数据或反馈3]
+
+请判断:
+1. 哪些动作有效
+2. 哪些动作无效
+3. 用户问题有没有变少
+4. 是否需要更新问题池状态
+5. 下一轮应该继续优化原问题,还是换一个问题
+6. 下周内容应该继续打这个点,还是换方向
+```
+
+### 13.12 从报告转成客户交付版
+
+适合服务商、咨询顾问、培训老师把内部分析整理成给客户看的版本。
+
+```text
+请把刚才的分析整理成客户交付版报告。
+
+要求:
+1. 删除工具运行过程,只保留客户能看懂的结论
+2. 每个结论都带用户证据或标注“待补采”
+3. 分成:现状判断、用户声音、竞品观察、核心问题、机会方向、7 天动作
+4. 不要写得太学术,要像给老板开会用的材料
+5. 最后给客户一个选择题:下一步先深挖哪一个问题/哪一家竞品/哪一个内容方向
+```

+ 1 - 0
claude-code-voc-intelligence/docs/capability-map.md

@@ -136,6 +136,7 @@
 | `mcp/src/core/payment-links.js` | 充值/开通提示 |
 | `mcp/src/core/evidence-quality.js` | 证据质量门槛、无样本降级建议 |
 | `docs/live-manual-acceptance-checklist.md` | live 采集人工验收 |
+| `docs/claude-code-troubleshooting-playbook.md` | Claude Code 故障定位、误报修复、关键词修复和发散排查 |
 | `docs/payment-package-links.md` | 支付链接和套餐入口说明 |
 | `docs/context-budget-implementation-plan.md` | 上下文省 token 工作流 |
 | `docs/deliverables/trend-insight-report.md` | 行业用户洞察趋势清单模板 |

+ 136 - 0
claude-code-voc-intelligence/docs/claude-code-troubleshooting-playbook.md

@@ -0,0 +1,136 @@
+# Claude Code VOC Troubleshooting Playbook
+
+This playbook helps Claude Code and maintainers quickly map live VOC collection symptoms to the right fix path. Use it when a real user conversation gets stuck, a platform collector reports the wrong state, or Claude Code does not know whether to retry, recharge, or switch tools.
+
+## Fast Triage
+
+| Symptom in Claude Code | Likely Cause | User-Facing State | First Fix Path | Do Not Do |
+| --- | --- | --- | --- | --- |
+| Douyin live says balance is insufficient, but the account has balance | Search API or keyword request failed and was classified as billing | `needs_keyword_fix` | Check Douyin API error classification and keyword repair | Do not show recharge link unless status is really `needs_recharge` |
+| Douyin returns `社交平台接口请求错误` or HTTP 5xx | Upstream search or input issue | `needs_keyword_fix`, or auto-repair then `ok` | Normalize keyword, retry with a shorter natural keyword | Do not expose raw 5xx body to user |
+| User passes `茶饮18元22元` and search is unstable | Compact keyword lacks natural search spacing | Usually auto-repaired | Normalize to `茶饮 18元 22元` | Do not require user to learn CLI flags |
+| User passes empty `--keywords ""` | CLI parser receives an explicit empty value | `needs_keyword_fix` | Keep explicit empty as empty; do not fall back to default keywords | Do not silently search generic defaults |
+| User asks “进入真实采集 live 模式” but workflow keeps deep-diving | Skill handoff rules are too weak | Should call platform live collector | Update deep-dive/business workflow skill instructions and `nextActions` | Do not keep asking 7-day validation questions |
+| MCP output validation fails because warnings contain objects | Structured output schema expects strings | Tool call error | Normalize warnings at MCP server boundary | Do not remove evidence warnings entirely |
+| Live collection says no token even after user configured one | Wrong working directory or unsupported credential source | `needs_token` | Check `.env.local` search path and `~/.claude/voc-credentials.json` | Do not print token |
+| First report sounds final or overconfident | Report layer ignored evidence quality | `ok`, but poor UX | Enforce “初步判断 / 机会假设 / 待校准” and evidence quality | Do not write final business conclusions from first sample |
+
+## Douyin Search Error Fix Pattern
+
+When Douyin search fails, fix in this order:
+
+1. Classify provider errors before building user messages.
+2. Treat billing only as billing when the provider clearly says balance, quota, payment, or recharge.
+3. Treat HTTP 400/422 and generic search 5xx as search/input repair.
+4. Normalize keywords before calling the API.
+5. Retry once with a shorter natural keyword.
+6. If retry fails, return `needs_keyword_fix` with 2-3 concrete keyword examples.
+
+Current key files:
+
+| Concern | File |
+| --- | --- |
+| Provider error classification | `mcp/src/providers/douyin-api.js` |
+| Keyword normalization and auto repair | `mcp/src/features/douyin-trend/live-collector.js` |
+| User-facing status selection | `mcp/src/tools/douyin-trend-run.js` |
+| MCP structured output normalization | `mcp/src/server.js` |
+| Douyin skill behavior rules | `skills/douyin-trend-intelligence/SKILL.md` |
+| Live mode reference | `skills/douyin-trend-intelligence/references/live-mode.md` |
+| Manual live acceptance checks | `docs/live-manual-acceptance-checklist.md` |
+| Regression smoke tests | `scripts/smoke-package.js` |
+
+## Status Decision Rules
+
+Use these states consistently:
+
+| Condition | Status | Message Strategy |
+| --- | --- | --- |
+| No token configured | `needs_token` | Show payment/open link and token config path |
+| Token invalid | `needs_valid_token` | Ask user to reconfigure token; show payment/open link only as support path |
+| Clear balance/quota/payment issue | `needs_recharge` | Show recharge/payment link |
+| Search input, empty keyword, overlong keyword, generic search 5xx | `needs_keyword_fix` | Give corrected keyword examples; no payment link |
+| Token valid but no usable samples | `no_live_samples` | Suggest broader/narrower keywords; no payment link |
+| Some samples collected but later billing blocked | `partial_needs_recharge` | Preserve partial report and show recharge link |
+| Normal collection | `ok` | Return report body and evidence quality |
+
+## Keyword Repair Recipes
+
+Claude Code should prefer short, natural platform search phrases:
+
+```text
+品类 + 价格/问题 + 人群/场景
+```
+
+Examples:
+
+```text
+茶饮 18元 22元
+茶饮 性价比 新客
+奶茶 套餐 点单 避坑
+本地茶饮 人均 点单
+茶饮 探店 值不值
+```
+
+Repair rules:
+
+- Insert spacing around Chinese and numbers: `茶饮18元22元` -> `茶饮 18元 22元`.
+- Keep keywords under 60 characters.
+- If price-specific search fails, try intent-specific search: `茶饮 性价比 新客`.
+- If intent search fails, try behavior-specific search: `奶茶 套餐 点单 避坑`.
+- If all fail, return `needs_keyword_fix` and tell the user what was tried.
+
+## Implementation Checklist For Similar Bugs
+
+When a new misclassification appears:
+
+1. Reproduce with a small command or mocked provider response.
+2. Add an error `kind` at provider boundary, for example `billing`, `auth`, `request`, `upstream_search`, `permission`.
+3. Make the CLI/tool status choose from `kind`, not raw text only.
+4. Normalize user input before API calls.
+5. Add one automatic repair if it is safe and low-cost.
+6. Add smoke coverage for both the bad path and the repaired path.
+7. Update the skill instructions so Claude Code knows what to do in conversation.
+8. Update manual acceptance docs with one command a trainer can run.
+9. Run package smoke and build.
+
+## Divergent Debugging Ideas
+
+If the obvious fix does not work, branch into these checks:
+
+- Credential source: is Claude Code running from the workspace that contains `.env.local`?
+- MCP schema: did the tool return object warnings where the schema expects strings?
+- CLI parsing: did `--keywords ""` become boolean `true` or an empty string?
+- Encoding: did Chinese error text become mojibake or `????`, breaking regex matching?
+- Upstream semantics: does provider return business error codes inside HTTP 200?
+- Partial success: did videos collect but comments fail, requiring partial report instead of total failure?
+- Report quality: did auto-repair succeed but report still say the original keyword?
+- Skill routing: did the user ask for live collection, but a deep-dive skill kept control?
+- Safety: does any output accidentally echo `VOC_TOKEN`, `Bearer`, or `r:...`?
+
+## Verification Commands
+
+Run these after changing collector behavior:
+
+```powershell
+node --check claude-code-voc-intelligence\mcp\src\providers\douyin-api.js
+node --check claude-code-voc-intelligence\mcp\src\features\douyin-trend\live-collector.js
+node --check claude-code-voc-intelligence\mcp\src\tools\douyin-trend-run.js
+node --check claude-code-voc-intelligence\scripts\smoke-package.js
+npm --prefix claude-code-voc-intelligence run mcp:smoke
+npm --prefix claude-code-voc-intelligence run smoke:package
+npm run claude-voc:build
+```
+
+Live manual check, only when a real token is configured:
+
+```powershell
+node .claude\plugins\voc-intelligence\mcp\src\tools\douyin-trend-run.js --collection-mode live --industry "茶饮" --business-type "茶饮连锁" --target-audience "新客" --keywords "茶饮18元22元" --keyword-limit 1 --videos-per-keyword 1 --max-comment-pages 0 --cache-assets false --output "outputs/live-acceptance-douyin-keyword"
+```
+
+Expected:
+
+- Compact keyword is normalized to `茶饮 18元 22元`.
+- Search 5xx is auto-repaired or returned as `needs_keyword_fix`.
+- Recharge URL appears only for token, valid-token, or real balance states.
+- No token value appears in output.
+

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

@@ -36,6 +36,20 @@ node ".claude/plugins/voc-intelligence/mcp/src/tools/douyin-trend-run.js" --coll
 - 报告里有视频证据、原视频链接、评论证据。
 - 输出内容仍是“第一轮初步判断 / 机会假设 / 待确认问题”,不能把首轮样本写成最终结论。
 
+## 抖音搜索错误快速验收
+
+用于确认“搜索请求错误误报余额不足”的问题已经被拦住:
+
+```bash
+node ".claude/plugins/voc-intelligence/mcp/src/tools/douyin-trend-run.js" --collection-mode live --industry "茶饮" --business-type "茶饮连锁" --target-audience "新客" --keywords "茶饮18元22元" --keyword-limit 1 --videos-per-keyword 1 --max-comment-pages 0 --cache-assets false --output "outputs/live-acceptance-douyin-keyword" --result-prefix DOUYIN_TREND_RESULT
+```
+
+验收标准:
+- `茶饮18元22元` 应被规范成 `茶饮 18元 22元`,报告中的关注关键词也应显示规范化后的词。
+- 如果上游返回 `社交平台接口请求错误` 或 5xx,工具应先自动换短关键词重试。
+- 自动修复仍失败时,状态应为 `needs_keyword_fix`,不应出现充值链接。
+- 只有 `needs_token`、`needs_recharge`、`needs_valid_token` 才展示充值/开通链接。
+
 ## 单点 VOC 记忆验收
 
 ```bash
@@ -47,4 +61,3 @@ node ".claude/plugins/voc-intelligence/mcp/src/tools/voc-problem-deep-dive-run.j
 - 输出有“优先级判断、老板视角影响、可执行解决动作、7 天验证计划、下轮验证指标”。
 - “等位饮品”只能出现在“已应用的使用记忆”,不能出现在“可执行解决动作”。
 - 默认记忆文件按项目/行业/场景/人群隔离,不应污染其它行业或其它客户。
-

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

@@ -41,8 +41,31 @@ function readWorkspaceEnvLocal() {
   return readEnvFileMaybe(path.join(process.cwd(), '.env.local'));
 }
 
+function envLocalCandidates() {
+  const candidates = [];
+  let current = process.cwd();
+  while (current && !candidates.includes(path.join(current, '.env.local'))) {
+    candidates.push(path.join(current, '.env.local'));
+    const parent = path.dirname(current);
+    if (parent === current) break;
+    current = parent;
+  }
+  candidates.push(path.resolve(__dirname, '..', '..', '..', '.env.local'));
+  return [...new Set(candidates)];
+}
+
+function readEnvLocalFiles() {
+  return envLocalCandidates().reduce((merged, filePath) => {
+    const next = readEnvFileMaybe(filePath);
+    for (const [key, value] of Object.entries(next)) {
+      if (!merged[key]) merged[key] = value;
+    }
+    return merged;
+  }, {});
+}
+
 function readVocToken(input = {}) {
-  const workspaceEnv = readWorkspaceEnvLocal();
+  const workspaceEnv = readEnvLocalFiles();
   const claudeCreds = readJsonMaybe(path.join(os.homedir(), '.claude', 'voc-credentials.json'));
   return firstNonEmpty([
     input.xiaohongshuToken,
@@ -59,11 +82,13 @@ function readVocToken(input = {}) {
     workspaceEnv.XIAOHONGSHU_API_TOKEN,
     workspaceEnv.DOUYIN_API_TOKEN,
     workspaceEnv.DOUYIN_TOKEN,
+    workspaceEnv.VOC_DOUYIN_TOKEN,
     process.env.VOC_TOKEN,
     process.env.VOC_SOCIAL_TOKEN,
     process.env.XIAOHONGSHU_API_TOKEN,
     process.env.DOUYIN_API_TOKEN,
     process.env.DOUYIN_TOKEN,
+    process.env.VOC_DOUYIN_TOKEN,
     process.env.TIKHUB_API_TOKEN,
     process.env.FMODE_XIAOHONGSHU_TOKEN,
     process.env.FMODE_SOCIAL_TOKEN,
@@ -81,5 +106,8 @@ function readXiaohongshuToken(input = {}) {
 
 module.exports = {
   readVocToken,
-  readXiaohongshuToken
+  readXiaohongshuToken,
+  readEnvFileMaybe,
+  readWorkspaceEnvLocal,
+  envLocalCandidates
 };

+ 153 - 16
claude-code-voc-intelligence/mcp/src/features/douyin-trend/live-collector.js

@@ -13,6 +13,31 @@ function normalizeCount(value) {
   return Math.round(base);
 }
 
+const MAX_SEARCH_KEYWORD_LENGTH = 60;
+
+function normalizeSearchKeyword(value) {
+  if (typeof value !== 'string') return '';
+  return value
+    .replace(/[\r\n\t]+/g, ' ')
+    .replace(/([\u4e00-\u9fff])(\d)/g, '$1 $2')
+    .replace(/(\d)([\u4e00-\u9fff])/g, '$1$2 ')
+    .replace(/\s+/g, ' ')
+    .trim();
+}
+
+function keywordValidationIssue(value) {
+  if (typeof value !== 'string') return '关键词需要是文本';
+  const keyword = normalizeSearchKeyword(value);
+  if (!keyword) return '关键词为空';
+  if (!/[\p{L}\p{N}]/u.test(keyword)) return '关键词缺少可搜索的文字或数字';
+  if (keyword.length > MAX_SEARCH_KEYWORD_LENGTH) return `关键词过长,建议控制在 ${MAX_SEARCH_KEYWORD_LENGTH} 个字符以内`;
+  return '';
+}
+
+function isUsableKeyword(value) {
+  return !keywordValidationIssue(value);
+}
+
 function videoScore(video) {
   return normalizeCount(video.likeCount) +
     normalizeCount(video.commentCount) * 3 +
@@ -179,33 +204,139 @@ function extractComments(data = {}) {
   return Array.isArray(comments) ? comments : [];
 }
 
+function isSearchRepairableError(error = {}) {
+  const kind = String(error.kind || error.errorKind || '');
+  const status = Number(error.httpStatus || 0);
+  return ['request', 'upstream_search', 'invalid_keyword'].includes(kind) ||
+    [400, 422].includes(status) ||
+    status >= 500;
+}
+
+function firstTargetAudience(profile = {}) {
+  const audience = Array.isArray(profile.targetAudience) ? profile.targetAudience[0] : profile.targetAudience;
+  return normalizeSearchKeyword(String(audience || ''));
+}
+
+function buildKeywordRepairCandidates(keyword, profile = {}) {
+  const current = normalizeSearchKeyword(keyword);
+  const industry = normalizeSearchKeyword(String(profile.industry || ''));
+  const audience = firstTargetAudience(profile);
+  const withoutPrice = normalizeSearchKeyword(current.replace(/\d+(?:\.\d+)?\s*(?:元|块|rmb|RMB)?/g, ' '));
+  const base = industry || current.split(' ')[0] || current;
+  return [
+    withoutPrice,
+    normalizeSearchKeyword([base, '性价比', audience].filter(Boolean).join(' ')),
+    normalizeSearchKeyword([base, '套餐', '点单'].join(' ')),
+    normalizeSearchKeyword([base, '避坑', audience].filter(Boolean).join(' '))
+  ]
+    .filter(candidate => candidate && candidate !== current && isUsableKeyword(candidate))
+    .filter((candidate, index, list) => list.indexOf(candidate) === index)
+    .slice(0, 2);
+}
+
+async function searchTopVideos(api, keyword, options, videosPerKeyword) {
+  const search = await api.searchVideos({
+    keyword,
+    cursor: 0,
+    sortType: options.sortType || options.sort || '1',
+    publishTime: options.publishTime || options.publish_time || '7',
+    filterDuration: options.filterDuration || options.filter_duration || '0',
+    contentType: options.contentType || options.content_type || '1',
+    searchId: options.searchId || options.search_id || '',
+    backtrace: options.backtrace || ''
+  });
+  return extractSearchVideos(search.data)
+    .map(raw => normalizeVideo(raw, keyword))
+    .sort((a, b) => videoScore(b) - videoScore(a))
+    .slice(0, videosPerKeyword);
+}
+
+async function searchTopVideosWithRepair({ api, keyword, profile, options, videosPerKeyword, warnings }) {
+  try {
+    return {
+      keyword,
+      videos: await searchTopVideos(api, keyword, options, videosPerKeyword)
+    };
+  } catch (error) {
+    if (options.disableKeywordRepair || !isSearchRepairableError(error)) {
+      throw error;
+    }
+    let lastError = error;
+    for (const retryKeyword of buildKeywordRepairCandidates(keyword, profile)) {
+      warnings.push({
+        stage: 'search_videos_retry',
+        kind: 'auto_keyword_repair',
+        keyword,
+        retryKeyword,
+        message: '抖音搜索入参或上游请求失败,已自动换成更稳的短关键词重试。',
+        httpStatus: error.httpStatus || 0
+      });
+      try {
+        return {
+          keyword: retryKeyword,
+          videos: await searchTopVideos(api, retryKeyword, options, videosPerKeyword)
+        };
+      } catch (retryError) {
+        lastError = retryError;
+      }
+    }
+    throw lastError;
+  }
+}
+
 async function collectLiveDataset({ profile, token, options = {} }) {
   const api = new DouyinApi({ token, baseUrl: options.baseUrl });
   const keywordLimit = Math.max(1, Number(options.keywordLimit || 1));
   const videosPerKeyword = Math.max(1, Number(options.videosPerKeyword || options.notesPerKeyword || 1));
   const maxCommentPages = Math.max(0, Number(options.maxCommentPages ?? 1));
-  const keywords = (profile.keywords || []).slice(0, keywordLimit);
+  const rawKeywords = Array.isArray(profile.keywords) ? profile.keywords : [];
   const videos = [];
   const comments = [];
   const warnings = [];
   const errors = [];
+  const keywords = [];
+  const effectiveKeywords = [];
+
+  for (const rawKeyword of rawKeywords) {
+    const issue = keywordValidationIssue(rawKeyword);
+    if (issue) {
+      warnings.push({
+        stage: 'keyword_normalize',
+        kind: 'invalid_keyword',
+        keyword: typeof rawKeyword === 'string' ? normalizeSearchKeyword(rawKeyword).slice(0, MAX_SEARCH_KEYWORD_LENGTH) : '',
+        message: issue,
+        httpStatus: 0
+      });
+      continue;
+    }
+    const keyword = normalizeSearchKeyword(rawKeyword);
+    if (!keywords.includes(keyword)) keywords.push(keyword);
+    if (keywords.length >= keywordLimit) break;
+  }
+
+  if (!keywords.length) {
+    errors.push({
+      stage: 'search_videos',
+      kind: 'invalid_keyword',
+      message: '请提供至少一个可用于抖音搜索的关键词,例如“茶饮 18元 22元”或“茶饮 性价比 新客”。',
+      httpStatus: 0
+    });
+    return { videos, comments, warnings, errors, keywords };
+  }
 
   for (const keyword of keywords) {
     try {
-      const search = await api.searchVideos({
+      const result = await searchTopVideosWithRepair({
+        api,
         keyword,
-        cursor: 0,
-        sortType: options.sortType || options.sort || '1',
-        publishTime: options.publishTime || options.publish_time || '7',
-        filterDuration: options.filterDuration || options.filter_duration || '0',
-        contentType: options.contentType || options.content_type || '1',
-        searchId: options.searchId || options.search_id || '',
-        backtrace: options.backtrace || ''
+        profile,
+        options,
+        videosPerKeyword,
+        warnings
       });
-      const rawVideos = extractSearchVideos(search.data)
-        .map(raw => normalizeVideo(raw, keyword))
-        .sort((a, b) => videoScore(b) - videoScore(a))
-        .slice(0, videosPerKeyword);
+      const rawVideos = result.videos;
+      const effectiveKeyword = result.keyword;
+      if (!effectiveKeywords.includes(effectiveKeyword)) effectiveKeywords.push(effectiveKeyword);
 
       for (const video of rawVideos) {
         videos.push(video);
@@ -214,7 +345,7 @@ async function collectLiveDataset({ profile, token, options = {} }) {
           try {
             const commentResult = await api.getVideoComments({ awemeId: video.id, cursor, count: 20 });
             extractComments(commentResult.data).forEach(rawComment => {
-              comments.push(normalizeComment(rawComment, video.id, keyword));
+              comments.push(normalizeComment(rawComment, video.id, effectiveKeyword));
             });
             cursor = Number(commentResult.data.cursor || 0);
             if (!commentResult.data.has_more || !cursor) break;
@@ -234,6 +365,7 @@ async function collectLiveDataset({ profile, token, options = {} }) {
       errors.push({
         stage: 'search_videos',
         keyword,
+        kind: error.kind || error.errorKind || '',
         message: error.message,
         httpStatus: error.httpStatus
       });
@@ -244,7 +376,8 @@ async function collectLiveDataset({ profile, token, options = {} }) {
     videos,
     comments,
     warnings,
-    errors
+    errors,
+    keywords: effectiveKeywords.length ? effectiveKeywords : keywords
   };
 }
 
@@ -255,5 +388,9 @@ module.exports = {
   extractSearchVideos,
   extractComments,
   pickSourceUrl,
-  sourceUrlVerified
+  sourceUrlVerified,
+  normalizeSearchKeyword,
+  keywordValidationIssue,
+  buildKeywordRepairCandidates,
+  isUsableKeyword
 };

+ 1 - 1
claude-code-voc-intelligence/mcp/src/features/douyin-trend/preference-memory.js

@@ -19,7 +19,7 @@ function appendNote(notes, note) {
 
 function defaultTrendMemory() {
   return {
-    version: '0.3.10',
+    version: '0.3.12',
     preferredDirections: [],
     blockedDirections: [],
     focusModes: [],

+ 160 - 7
claude-code-voc-intelligence/mcp/src/features/voc-content-plan/content-plan.js

@@ -74,6 +74,79 @@ const DEFAULT_VOC_ISSUES = [
   }
 ];
 
+const TEA_VOC_ISSUES = [
+  {
+    issue: '第一次来怎么点最稳',
+    insight: '新客不是不想买,而是不知道先点哪一杯最能判断这家店稳不稳。',
+    title: '第一次来这家店,照这个顺序点不容易踩雷',
+    hook: '第一次来别乱点,先用这 3 步判断值不值。',
+    script: '第一次喝一家茶饮,别一上来就冲最复杂的新品。先点一杯招牌基础款,看茶底、奶感和甜度稳不稳;第二杯再选你平时喜欢的方向,比如茶感重、奶感重或者清爽型;第三步才考虑加料和特殊口味。这样你不是盲试,而是在判断这家店到底值不值得复购。',
+    shots: ['门头/点单台', '菜单首单推荐区', '三杯不同口味摆拍', '店员指菜单说明'],
+    commentPrompt: '第一次来你最怕点错什么?评论区说口味,我帮你选更稳的点法。',
+    metric: '评论里“第一次来/怎么点/推荐哪个/踩雷”的提问是否增加'
+  },
+  {
+    issue: '18-22 元贵在哪/值在哪',
+    insight: '18-22 元不是绝对贵,关键是用户能不能看见原料、茶底、口味稳定性和适合人群。',
+    title: '18-22 元到底贵不贵?先看这 3 个地方',
+    hook: '你以为贵的是价格,其实要看这杯值不值。',
+    script: '很多人看到 18 到 22 元会先问贵不贵。我的建议是别只看标价,先看三件事:茶底是不是清楚,甜度和口味是不是稳定,配料是不是适合你。如果一杯喝完你觉得茶味、奶感、甜度都在线,而且不会喝完后悔,那它讨论的就不是便宜,而是值不值。',
+    shots: ['价格牌特写', '茶底/原料展示', '出杯过程', '成品和口味标签'],
+    commentPrompt: '你判断一杯茶饮值不值,最看价格、茶底还是口味稳定?',
+    metric: '评论里“贵/值不值”减少,“茶底/口味/适合谁”的讨论增加'
+  },
+  {
+    issue: '券后价和套餐怎么买最划算',
+    insight: '用户不怕优惠少,怕看不懂规则、加料后超预算、套餐不适合自己。',
+    title: '券后价到底怎么算?新客先看这两行',
+    hook: '别只看标价,真正花多少钱在结算页。',
+    script: '很多新客不是不想下单,是看不懂券后价。你先看两件事:第一,券后总价到底是多少;第二,加料、规格、配送费会不会让预算变高。第一次来不要只冲最低价,先选规则最清楚、口味最稳的套餐。看懂总价和条件,再判断这杯是不是划算。',
+    shots: ['手机点单页', '优惠券/套餐页', '券前券后对比', '结算页总价特写'],
+    commentPrompt: '你最想看券后价怎么算,还是套餐怎么点不超预算?',
+    metric: '评论里“券后/套餐/多少钱/怎么买”的提问是否增加'
+  },
+  {
+    issue: '茶底/甜度/配料怎么选',
+    insight: '茶底、甜度、配料和身体反应已经进入新客决策,不是喝完才考虑。',
+    title: '茶底怎么选?下午喝怕睡不着的先看这条',
+    hook: '怕踩雷,先别看颜值,先看茶底和甜度。',
+    script: '有条评论问得很真实:波霸奶茶用什么茶底,下午喝会不会晚上睡不着。第一次来,先看茶底、甜度和配料。平时不喝浓茶的人,别一上来选茶感特别重的;不爱甜的人,先从低甜度开始;对配料敏感的人,先看清楚加料和奶基底。适合你,才是真的值。',
+    shots: ['茶底展示', '甜度选择页', '配料/加料特写', '店员说明适合人群'],
+    commentPrompt: '你更关心茶底、甜度、配料表,还是喝完会不会睡不着?',
+    metric: '评论里“茶底/甜度/配料/睡不着”的问题数量'
+  },
+  {
+    issue: '哪几款不容易踩雷',
+    insight: '避坑内容比单纯推荐更接近新客转化,因为它降低第一次尝试风险。',
+    title: '第一次来,先点这几类,不容易踩雷',
+    hook: '怕踩雷的,先别追新品,先记住这 3 类。',
+    script: '新客最怕的不是贵,是喝完觉得不适合自己。第一次来建议先点三类:基础招牌款,口味大众款,你平时就喝得惯的方向。先用稳的款判断这家店的基本水平,再去试新品和特殊口味。第一次体验稳了,才有下一次复购。',
+    shots: ['三款稳定单品', '适合人群标签', '试喝反应', '菜单避坑提示'],
+    commentPrompt: '你最怕踩雷的是太甜、太淡、茶味重,还是加料不好吃?',
+    metric: '“避坑/踩雷/推荐哪个/哪款稳”类评论和收藏率'
+  },
+  {
+    issue: '外卖到手怎么避免翻车',
+    insight: '外卖场景会放大冰化、分层、口感变化,新客第一次外卖体验会影响复购。',
+    title: '外卖点茶饮,先看这 3 点,不容易翻车',
+    hook: '外卖点错了,到手那一刻就后悔。',
+    script: '茶饮外卖最容易翻车的地方,不是只有配送慢。你要看这杯适不适合外卖,冰量会不会影响口感,打包到手会不会分层。第一次点外卖,建议选口感更稳定、打包后变化小的款,不要先点特别依赖现做状态的口味。到手体验稳,才有复购。',
+    shots: ['打包流程', '封口和保温细节', '外卖到手状态', '冰量/分层对比'],
+    commentPrompt: '你点茶饮外卖最怕冰化、分层,还是口感变淡?',
+    metric: '外卖相关评论、“到手/冰化/翻车”关键词变化'
+  },
+  {
+    issue: '买一送一/团购规则怎么讲清楚',
+    insight: '低价和买一送一能触发注意,但新客还需要确认规则、口味和有没有坑。',
+    title: '买一送一到底怎么算?先看懂规则再下单',
+    hook: '看起来很划算,但规则你真的看懂了吗?',
+    script: '买一送一、团购、低价新品,确实很容易让人心动。但新客真正关心的是有没有坑。下单前先看三件事:券后到底多少钱,能不能叠加,口味和规格有没有限制。规则讲清楚,顾客才不是因为便宜冲动一次,而是因为第一次体验清楚、放心,愿意再来。',
+    shots: ['活动海报', '团购/买一送一点单页', '规则限制特写', '两杯到手展示'],
+    commentPrompt: '你最常看不懂的是团购规则、券后价,还是买一送一限制?',
+    metric: '“划算/有坑/团购/券后价/怎么买”类评论数量'
+  }
+];
+
 function splitList(value) {
   if (!value) return [];
   if (Array.isArray(value)) return value.flatMap(splitList);
@@ -88,15 +161,91 @@ function readTextIfExists(filePath) {
 }
 
 function collectIssues(input = {}) {
-  const explicit = splitList(input.issues || input.problems || input.vocIssues || input.evidence || input.comments);
+  const explicit = splitList(input.issues || input.problems || input.vocIssues || input.contentDirections || input.directions || input.evidence || input.comments);
   if (explicit.length) return explicit.slice(0, 12);
-  const text = String(input.reportText || input.evidenceText || '').trim() || readTextIfExists(input.report || input.reportPath);
-  const lines = splitList(text).filter(line => /排队|贵|踩雷|服务|招牌|套餐|人均|不好吃|落差|等位|怎么点/.test(line));
+  const text = String(input.reportText || input.evidenceText || input.context || input.background || '').trim() || readTextIfExists(input.report || input.reportPath);
+  const lines = splitList(text).filter(line => /排队|贵|踩雷|服务|招牌|套餐|人均|不好吃|落差|等位|怎么点|券后|团购|买一送一|茶底|甜度|配料|外卖|翻车|值不值|首单|口味/.test(line));
   return lines.length ? lines.slice(0, 12) : DEFAULT_VOC_ISSUES.map(item => item.issue);
 }
 
-function inferTemplate(issue, index) {
+function isTeaScenario(input = {}, issue = '') {
+  const text = [
+    input.industry,
+    input.category,
+    input.businessType,
+    input.evidenceText,
+    input.reportText,
+    input.context,
+    input.background,
+    issue
+  ].filter(Boolean).join(' ');
+  return /茶饮|奶茶|新茶饮|茶底|甜度|波霸|买一送一|券后|外卖|冰化|配料表/.test(text);
+}
+
+function selectTeaTemplateByIntent(issue) {
+  const text = String(issue || '');
+  const findByIssue = (pattern) => TEA_VOC_ISSUES.find(item => pattern.test(item.issue));
+
+  const intentRules = [
+    {
+      textPattern: /买一送一|规则|有坑|低价|9\.9|11\.9|21/,
+      issuePattern: /买一送一/
+    },
+    {
+      textPattern: /券后|套餐|划算|优惠/,
+      issuePattern: /券后价/
+    },
+    {
+      textPattern: /第一次|首单|怎么点|点单/,
+      issuePattern: /第一次来/
+    },
+    {
+      textPattern: /18|22|贵|值不值|性价比|价格/,
+      issuePattern: /18-22/
+    },
+    {
+      textPattern: /茶底|甜度|配料|睡不着|波霸|成分/,
+      issuePattern: /茶底/
+    },
+    {
+      textPattern: /踩雷|避坑|哪几款|稳/,
+      issuePattern: /踩雷/
+    },
+    {
+      textPattern: /外卖|到手|冰化|分层|塌口感|翻车/,
+      issuePattern: /外卖/
+    }
+  ];
+
+  for (const rule of intentRules) {
+    if (rule.textPattern.test(text)) {
+      const matched = findByIssue(rule.issuePattern);
+      if (matched) return matched;
+    }
+  }
+
+  return null;
+}
+
+function inferTemplate(issue, index, input = {}) {
   const text = String(issue || '');
+  if (isTeaScenario(input, text)) {
+    const intentMatched = selectTeaTemplateByIntent(text);
+    if (intentMatched) return intentMatched;
+
+    const teaMatched = TEA_VOC_ISSUES.find(item =>
+      text.includes(item.issue) ||
+      item.issue.includes(text) ||
+      (/第一次|首单|怎么点|点单/.test(text) && item.issue.includes('第一次来')) ||
+      (/18|22|贵|值不值|性价比|价格/.test(text) && item.issue.includes('18-22')) ||
+      (/买一送一|规则|有坑|低价|9\.9|11\.9|21/.test(text) && item.issue.includes('买一送一')) ||
+      (/券后|套餐|划算|优惠/.test(text) && item.issue.includes('券后价')) ||
+      (/茶底|甜度|配料|睡不着|波霸|成分/.test(text) && item.issue.includes('茶底')) ||
+      (/踩雷|避坑|哪几款|稳/.test(text) && item.issue.includes('踩雷')) ||
+      (/外卖|到手|冰化|分层|塌口感|翻车/.test(text) && item.issue.includes('外卖'))
+    );
+    return teaMatched || TEA_VOC_ISSUES[index % TEA_VOC_ISSUES.length];
+  }
   const matched = DEFAULT_VOC_ISSUES.find(item =>
     text.includes(item.issue) ||
     text.includes(item.issue.replace('第一次来', '')) ||
@@ -119,14 +268,16 @@ function buildContentPlan(input = {}) {
   const issues = collectIssues(input);
   const plan = Array.from({ length: days }, (_, index) => {
     const sourceIssue = issues[index % issues.length];
-    const template = inferTemplate(sourceIssue, index);
+    const template = inferTemplate(sourceIssue, index, input);
     return {
       day: index + 1,
       userIssue: sourceIssue || template.issue,
       insight: template.insight,
       title: template.title,
       hook: template.hook,
-      script: template.script.replace(/我们店/g, brand === '你的门店' ? '我们店' : brand),
+      script: isTeaScenario(input, sourceIssue)
+        ? template.script
+        : template.script.replace(/我们店/g, brand === '你的门店' ? '我们店' : brand),
       shots: template.shots,
       commentPrompt: template.commentPrompt,
       validationMetric: template.metric,
@@ -162,7 +313,9 @@ function buildContentPlan(input = {}) {
     ].join('\n')),
     '',
     '## 拍摄提醒',
-    '- 先拍真实门店、真实菜品、真实服务流程,不要只拍滤镜氛围。',
+    isTeaScenario(input, issues.join(' '))
+      ? '- 先拍真实门店、真实饮品、真实点单/出杯/打包流程,不要只拍滤镜氛围。'
+      : '- 先拍真实门店、真实产品、真实服务流程,不要只拍滤镜氛围。',
     '- 每条内容只解决一个用户问题,避免一条视频讲太多。',
     '- 发完后把评论里的新问题继续放回 VOC 问题池,下一轮再优化。',
     '',

+ 102 - 5
claude-code-voc-intelligence/mcp/src/features/voc-issue-pool/issue-pool.js

@@ -105,7 +105,7 @@ function memoryPath(input = {}) {
 
 function defaultMemory(input = {}) {
   return {
-    version: '0.3.10',
+    version: '0.3.12',
     scopeKey: memoryScopeKey(input),
     scope: memoryScope(input),
     issues: {},
@@ -129,6 +129,81 @@ function readTextIfExists(filePath) {
   return fs.readFileSync(absolute, 'utf8').replace(/^\uFEFF/, '');
 }
 
+function normalizeEvidenceLine(value) {
+  return String(value || '')
+    .replace(/^\s*(?:[-*]|\d+[.)]|>\s*)\s*/, '')
+    .replace(/^\s*(?:高赞评论|关键评论|用户原话|评论|证据|原文摘录|视频文案|可解读点)\s*[::]\s*/, '')
+    .replace(/\s+/g, ' ')
+    .trim();
+}
+
+function looksGeneratedReportLine(line) {
+  const text = String(line || '').trim();
+  if (!text) return true;
+  if (/^#{1,6}\s+/.test(text)) return true;
+  if (/^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+$/.test(text)) return true;
+  if (/^!\[.*\]\(.*\)$/.test(text)) return true;
+  if (/^\[.*\]\(.*\)$/.test(text)) return true;
+  if (/^https?:\/\//i.test(text)) return true;
+  if (/^(品牌|城市|直营网点|业务特征|已知竞品|平台|模式|采集规模|说明|项目\/品牌|行业\/品类|使用场景|目标用户|问题数|状态|优先级|影响环节|建议动作|下一步|原因|判断逻辑|常见比较点|可解读点)\s*[::]/.test(text)) return true;
+  if (/^(一|二|三|四|五|六|七|八|九|十)、/.test(text)) return true;
+  if (/^(当前经营痛点|同价格带|同消费场景|建议下一步|为什么先看|本次采集说明)/.test(text)) return true;
+  return false;
+}
+
+function looksLikeVoiceLine(line) {
+  const text = normalizeEvidenceLine(line);
+  if (text.length < 3 || text.length > 180) return false;
+  if (looksGeneratedReportLine(text)) return false;
+  if (/[??!!~~]/.test(text)) return true;
+  if (/(多少钱|贵|便宜|值不值|划算|人均|套餐|排队|等位|等太久|服务|态度|好吃|不好吃|一般|踩雷|出片|拍照|环境|位置|在哪|校内|校外|想去|不想去|下次|推荐|避雷|大渔|立方铁|火地|炙焰|盛焰)/.test(text)) return true;
+  return false;
+}
+
+function extractEvidenceFromJson(value, result = []) {
+  if (!value || result.length >= 120) return result;
+  if (Array.isArray(value)) {
+    value.forEach(item => extractEvidenceFromJson(item, result));
+    return result;
+  }
+  if (typeof value !== 'object') return result;
+
+  for (const key of ['text', 'content', 'comment', 'voice', 'rawText']) {
+    if (typeof value[key] === 'string' && looksLikeVoiceLine(value[key])) {
+      result.push(normalizeEvidenceLine(value[key]));
+    }
+  }
+  ['comments', 'evidence', 'voices', 'samples'].forEach(key => extractEvidenceFromJson(value[key], result));
+  return result;
+}
+
+function extractEvidenceFromReportText(text) {
+  const lines = String(text || '').split(/\r?\n/);
+  const evidence = [];
+  let inEvidenceSection = false;
+
+  for (const rawLine of lines) {
+    const trimmed = rawLine.trim();
+    if (/^#{1,6}\s+/.test(trimmed)) {
+      inEvidenceSection = /证据|样本|评论|用户原话|真实样本|live\s*采集/i.test(trimmed);
+      continue;
+    }
+
+    const labelMatch = trimmed.match(/(?:高赞评论|关键评论|用户原话|评论|原文摘录|视频文案)\s*[::]\s*(.+)$/);
+    if (labelMatch) {
+      const line = normalizeEvidenceLine(labelMatch[1]);
+      if (looksLikeVoiceLine(line)) evidence.push(line);
+      continue;
+    }
+
+    if (!inEvidenceSection) continue;
+    const line = normalizeEvidenceLine(trimmed);
+    if (looksLikeVoiceLine(line)) evidence.push(line);
+  }
+
+  return [...new Set(evidence)].slice(0, 120);
+}
+
 function collectEvidence(input = {}) {
   const rawExplicit = input.evidence || input.comments || input.voices || input.issues || input.issueTexts;
   const explicit = Array.isArray(rawExplicit)
@@ -138,7 +213,12 @@ function collectEvidence(input = {}) {
       .map(item => item.trim())
       .filter(Boolean);
   if (explicit.length) return explicit.slice(0, 80);
-  const text = String(input.evidenceText || input.reportText || '').trim() || readTextIfExists(input.report || input.reportPath);
+  const reportPath = input.report || input.reportPath;
+  const text = String(input.evidenceText || input.reportText || '').trim() || readTextIfExists(reportPath);
+  if (reportPath && /\.json$/i.test(String(reportPath))) {
+    const fromJson = extractEvidenceFromJson(readJson(reportPath, {}));
+    if (fromJson.length) return fromJson.slice(0, 80);
+  }
   if (!text) {
     return [
       '想吃但怕排队,到了现场才发现等位时间不确定。',
@@ -148,14 +228,31 @@ function collectEvidence(input = {}) {
       '服务忙起来没人理,问问题不知道找谁。'
     ];
   }
+  const fromReport = extractEvidenceFromReportText(text);
+  if (fromReport.length) return fromReport.slice(0, 80);
+  if (input.report || input.reportPath || input.reportText || input.evidenceText) return [];
   return text
     .split(/\r?\n/)
-    .map(line => line.replace(/^[-*\d.]+\s*/, '').trim())
-    .filter(line => line.length >= 6)
+    .map(normalizeEvidenceLine)
+    .filter(looksLikeVoiceLine)
     .slice(0, 80);
 }
 
 function matchCategory(text) {
+  const normalized = String(text || '');
+  const directRules = [
+    ['waiting', /(排队|等位|等待|叫号|翻台|排了很久|等太久)/],
+    ['price', /(多少钱|价格|人均|贵|便宜|性价比|值不值|不值|划算|套餐|预算)/],
+    ['taste_quality', /(不好吃|好吃|味道|出品|不稳定|翻车|口味|咸|淡|新鲜)/],
+    ['service', /(服务|态度|没人理|员工|老板|体验|环境|卫生|位置|校内|校外)/],
+    ['content_expectation', /(网红|视频|图片|滤镜|种草|实际|没有想象|落差|出片|拍照|发朋友圈|打卡)/]
+  ];
+  for (const [id, pattern] of directRules) {
+    if (pattern.test(normalized)) {
+      const category = ISSUE_CATEGORIES.find(item => item.id === id);
+      if (category) return category;
+    }
+  }
   return ISSUE_CATEGORIES.find(category => category.terms.some(term => text.includes(term))) || {
     id: 'other',
     title: '其它待归类问题',
@@ -277,7 +374,7 @@ function writeMemory(input = {}, issues = []) {
   const feedback = String(input.feedback || input.message || '').trim();
   const next = {
     ...current,
-    version: '0.3.10',
+    version: '0.3.12',
     scopeKey: memoryScopeKey(input),
     scope: memoryScope(input),
     issues: nextIssues,

+ 5 - 5
claude-code-voc-intelligence/mcp/src/features/voc-problem-deep-dive/deep-dive.js

@@ -234,7 +234,7 @@ function memoryPath(input = {}) {
 
 function defaultMemory() {
   return {
-    version: '0.3.10',
+    version: '0.3.12',
     scopeKey: '',
     scope: {},
     preferredActions: [],
@@ -369,7 +369,7 @@ function updateMemory(input = {}, result = {}) {
   actionStats = bumpActionStats(actionStats, [input.rejectedActions, parsed.rejectedActions], 'rejectedCount');
   const next = {
     ...current,
-    version: '0.3.10',
+    version: '0.3.12',
     scopeKey: memoryScopeKey(input),
     scope: memoryScope(input),
     preferredActions: appendUnique(current.preferredActions, [input.preferredActions, parsed.preferredActions]),
@@ -637,9 +637,9 @@ function buildDeepDive(input = {}) {
     },
     files: [],
     nextActions: [
-      `先确认“${finalIssue}”主要影响新客还是复购`,
-      '选择一个门店动作和一个内容动作做 7 天验证',
-      '下一轮把验证结果告诉我,我会更新记忆并调整建议'
+      '如果用户说“进入真实采集/live/补采/采集小红书或抖音”,下一步必须优先调用对应平台的 live 趋势采集工具,不要继续停留在单点深挖。',
+      `如果已经完成 live 采集,先把真实评论/高赞评论整理进 voc_issue_pool_run,再决定是否继续深挖“${finalIssue}”。`,
+      '如果用户要报告或策略稿,把本轮深挖结论与 live 采集证据合并输出,不要只重复 7 天验证计划。'
     ],
     warnings: [],
     errors: []

+ 3 - 1
claude-code-voc-intelligence/mcp/src/features/xiaohongshu-trend/assets.js

@@ -60,7 +60,9 @@ async function fetchWithTimeout(url, timeoutMs = DEFAULT_TIMEOUT_MS) {
       signal: controller ? controller.signal : undefined,
       headers: {
         Accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
-        'User-Agent': 'claude-code-voc-intelligence/asset-cache'
+        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();

+ 49 - 6
claude-code-voc-intelligence/mcp/src/features/xiaohongshu-trend/live-collector.js

@@ -37,6 +37,14 @@ function pickFirstString(...values) {
     .find(Boolean) || '';
 }
 
+function pickFirstVerifiedUrl(...values) {
+  return values
+    .flat()
+    .filter(value => typeof value === 'string')
+    .map(value => value.trim())
+    .find(value => value && hasXsecToken(value)) || '';
+}
+
 function nestedValue(object, path) {
   return path.split('.').reduce((current, key) => (
     current && typeof current === 'object' ? current[key] : undefined
@@ -53,6 +61,10 @@ function pickWebUrl(note = {}, raw = {}) {
     'note_card.mini_program_info.webpage_url',
     'share_info.link',
     'note_card.share_info.link',
+    'shareLink',
+    'share_url',
+    'shareUrl',
+    'link',
     'webpage_url',
     'webUrl',
     'url'
@@ -89,17 +101,36 @@ function collectImageUrlsFromValue(value, result = []) {
     'middle_url',
     'thumbnail',
     'image_url',
-    'imageUrl'
+    'imageUrl',
+    'cover',
+    'cover_url',
+    'coverUrl',
+    'poster',
+    'poster_url',
+    'posterUrl',
+    'master_url'
   ].forEach(key => collectImageUrlsFromValue(value[key], result));
 
   [
     'url_list',
     'urlList',
+    'urlListHigh',
     'info_list',
     'infoList',
     'images',
     'image_list',
-    'imageList'
+    'imageList',
+    'image_info_list',
+    'imageInfoList',
+    'image_infos',
+    'imageInfos',
+    'display_image_info',
+    'displayImageInfo',
+    'image',
+    'image_info',
+    'imageInfo',
+    'multiMedia',
+    'multimedia'
   ].forEach(key => collectImageUrlsFromValue(value[key], result));
 
   return result;
@@ -112,8 +143,18 @@ function extractImageUrls(note = {}, raw = {}) {
     raw.cover,
     note.note_card && note.note_card.cover,
     raw.note_card && raw.note_card.cover,
+    note.note_card && note.note_card.display_image_info,
+    raw.note_card && raw.note_card.display_image_info,
+    note.note_card && note.note_card.image_info_list,
+    raw.note_card && raw.note_card.image_info_list,
     note.image_list,
     raw.image_list,
+    note.image_info_list,
+    raw.image_info_list,
+    note.display_image_info,
+    raw.display_image_info,
+    note.cover_info,
+    raw.cover_info,
     note.images_list,
     raw.images_list,
     note.imageList,
@@ -135,6 +176,7 @@ function normalizeNote(raw, keyword, detail) {
   const id = note.id || note.note_id || raw.id || raw.note_id;
   const user = note.user || raw.user || note.user_info || raw.user_info || {};
   const accessibleUrl = pickWebUrl(note, raw);
+  const verifiedUrl = pickFirstVerifiedUrl(accessibleUrl);
   const fallbackUrl = id ? `https://www.xiaohongshu.com/discovery/item/${id}` : '';
   const imageUrls = extractImageUrls(note, raw);
   const content = note.desc || raw.desc || note.content || raw.content || '';
@@ -154,16 +196,17 @@ function normalizeNote(raw, keyword, detail) {
     commentCount: normalizeCount(note.comments_count ?? raw.comments_count ?? note.commentCount ?? raw.commentCount),
     shareCount: normalizeCount(note.shared_count ?? raw.shared_count ?? note.shareCount ?? raw.shareCount),
     timestamp: note.timestamp || raw.timestamp || '',
-    url: accessibleUrl || fallbackUrl,
-    webUrl: accessibleUrl,
+    url: verifiedUrl,
+    webUrl: verifiedUrl,
     shareUrl: pickFirstString(
       nestedValue(note, 'share_info.link'),
       nestedValue(raw, 'share_info.link'),
       nestedValue(note, 'note_card.share_info.link'),
       nestedValue(raw, 'note_card.share_info.link')
     ),
-    sourceUrl: accessibleUrl || fallbackUrl,
-    sourceUrlVerified: hasXsecToken(accessibleUrl),
+    sourceUrl: verifiedUrl,
+    sourceUrlVerified: Boolean(verifiedUrl),
+    candidateUrl: accessibleUrl || fallbackUrl,
     fallbackUrl,
     coverUrl: imageUrls[0] || '',
     imageUrls,

+ 5 - 3
claude-code-voc-intelligence/mcp/src/features/xiaohongshu-trend/report.js

@@ -101,8 +101,10 @@ function buildEvidenceReason(note, topComment) {
 
 function buildSourceLine(note) {
   const url = note.sourceUrl || note.webUrl || note.url || '';
-  if (!url) return '   - 原帖:暂无可访问链接';
-  const label = note.sourceUrlVerified === false ? '打开原帖(链接待核验)' : '打开原帖';
+  if (!url || note.sourceUrlVerified === false) {
+    return '   - 原帖:平台未返回可核验原帖链接,建议复制标题到小红书内搜索。';
+  }
+  const label = '打开原帖';
   return `   - 原帖:[${label}](${markdownUrl(url)})`;
 }
 
@@ -117,7 +119,7 @@ function buildEvidenceSection(notes, comments) {
       `   - 作者:${note.author || '未标注'} | 互动:${formatCount(note.likeCount)}赞 / ${formatCount(note.collectCount)}藏 / ${formatCount(note.commentCount)}评`,
       buildSourceLine(note),
       `   - 为什么值得看:${buildEvidenceReason(note, topComment)}`,
-      coverUrl ? `   - 图片:\n\n![原帖首图](${markdownUrl(coverUrl)})` : '   - 图片:暂无可展示首图,可点开原帖查看。',
+      coverUrl ? `   - 图片:\n\n![原帖首图](${markdownUrl(coverUrl)})` : '   - 图片:平台未返回可缓存首图,建议复制标题到小红书内搜索查看。',
       excerpt ? `   - 原文摘录:${excerpt}` : '',
       topCommentText ? `   - 高赞评论:${topCommentText}` : ''
     ].filter(Boolean).join('\n');

+ 30 - 0
claude-code-voc-intelligence/mcp/src/providers/douyin-api.js

@@ -56,6 +56,35 @@ function isSuccessCode(json) {
   return true;
 }
 
+function classifyApiError({ response, json, message }) {
+  const httpStatus = response?.status || 0;
+  const businessStatus = pickBusinessStatus(json);
+  const status = businessStatus || httpStatus;
+  const text = String(message || '');
+  if (status === 401 || /invalid token|无效.*token|token.*invalid/i.test(text)) {
+    return 'auth';
+  }
+  if (status === 402 || /余额不足|insufficient|balance|quota|payment/i.test(text)) {
+    return 'billing';
+  }
+  if (status === 403 && /余额不足|insufficient|balance|quota|payment/i.test(text)) {
+    return 'billing';
+  }
+  if ([400, 422].includes(status) || /参数|入参|keyword|关键词|invalid request|bad request/i.test(text)) {
+    return 'request';
+  }
+  if (status >= 500 && /社交平台接口请求错误|request error|接口请求错误/i.test(text)) {
+    return 'upstream_search';
+  }
+  if (status >= 500) {
+    return 'upstream_search';
+  }
+  if (status === 403 || /permission|权限|无权限/i.test(text)) {
+    return 'permission';
+  }
+  return 'upstream';
+}
+
 async function requestJson(url, token, { method = 'GET', body } = {}) {
   const response = await fetch(url, {
     method,
@@ -77,6 +106,7 @@ async function requestJson(url, token, { method = 'GET', body } = {}) {
     const message = json?.message || json?.msg || json?.data?.msg || json?.data?.message || text || `HTTP ${response.status}`;
     const error = new Error(redactSecret(message));
     error.httpStatus = pickBusinessStatus(json) || response.status || 500;
+    error.kind = classifyApiError({ response, json, message });
     error.responseBody = redactSecret(text);
     throw error;
   }

+ 67 - 31
claude-code-voc-intelligence/mcp/src/server.js

@@ -16,22 +16,50 @@ const { runBusinessWorkflow } = require('./tools/voc-business-workflow-run');
 const { buildVocRechargeInfo, buildMissingTokenMessage } = require('./core/payment-links');
 
 function asToolResult(result) {
+  const normalized = normalizeToolResult(result);
   return {
     content: [
       {
         type: 'text',
-        text: result.assistantMessage || JSON.stringify(result, null, 2)
+        text: normalized.assistantMessage || JSON.stringify(normalized, null, 2)
       }
     ],
-    structuredContent: result,
-    isError: result.status !== 'ok'
+    structuredContent: normalized,
+    isError: normalized.status !== 'ok'
   };
 }
 
+function normalizeWarning(value) {
+  if (typeof value === 'string') return value;
+  if (!value || typeof value !== 'object') return String(value || '');
+  const parts = [
+    value.stage,
+    value.keyword && `keyword=${value.keyword}`,
+    value.noteId && `noteId=${value.noteId}`,
+    value.videoId && `videoId=${value.videoId}`,
+    value.httpStatus && `http=${value.httpStatus}`,
+    value.message
+  ].filter(Boolean);
+  return parts.join(' | ') || JSON.stringify(value);
+}
+
+function normalizeToolResult(result = {}) {
+  const normalized = { ...result };
+  if (Object.prototype.hasOwnProperty.call(result, 'warnings')) {
+    normalized.warnings = Array.isArray(result.warnings)
+      ? result.warnings.map(normalizeWarning).filter(Boolean)
+      : [];
+  }
+  if (Object.prototype.hasOwnProperty.call(result, 'errors')) {
+    normalized.errors = Array.isArray(result.errors) ? result.errors : [];
+  }
+  return normalized;
+}
+
 function createServer() {
   const server = new McpServer({
     name: 'voc-intelligence',
-    version: '0.3.10'
+    version: '0.3.15'
   });
 
   server.registerTool(
@@ -48,7 +76,7 @@ function createServer() {
         store: z.string().optional(),
         industry: z.string().optional(),
         category: z.string().optional(),
-        platform: z.enum(['douyin', 'xiaohongshu', '抖音', '小红书']).optional(),
+        platform: z.enum(['douyin', 'xiaohongshu', '\u6296\u97f3', '\u5c0f\u7ea2\u4e66']).optional(),
         collectionMode: z.enum(['sample', 'live']).optional(),
         scenario: z.string().optional(),
         scene: z.string().optional(),
@@ -73,8 +101,8 @@ function createServer() {
       outputSchema: {
         status: z.string(),
         assistantMessage: z.string(),
-        summary: z.record(z.any()).optional(),
-        data: z.record(z.any()).optional(),
+        summary: z.object({}).passthrough().optional(),
+        data: z.object({}).passthrough().optional(),
         files: z.array(z.string()).optional(),
         nextActions: z.array(z.string()).optional(),
         warnings: z.array(z.any()).optional(),
@@ -124,8 +152,8 @@ function createServer() {
       outputSchema: {
         status: z.string(),
         assistantMessage: z.string(),
-        summary: z.record(z.any()).optional(),
-        data: z.record(z.any()).optional(),
+        summary: z.object({}).passthrough().optional(),
+        data: z.object({}).passthrough().optional(),
         files: z.array(z.string()).optional(),
         nextActions: z.array(z.string()).optional(),
         warnings: z.array(z.any()).optional(),
@@ -154,18 +182,25 @@ function createServer() {
         issues: z.array(z.string()).optional(),
         problems: z.array(z.string()).optional(),
         vocIssues: z.array(z.string()).optional(),
+        contentDirections: z.array(z.string()).optional(),
+        directions: z.array(z.string()).optional(),
         evidence: z.array(z.string()).optional(),
         comments: z.array(z.string()).optional(),
         evidenceText: z.string().optional(),
         reportText: z.string().optional(),
+        context: z.string().optional(),
+        background: z.string().optional(),
+        businessGoal: z.string().optional(),
+        priceBand: z.string().optional(),
+        targetAudience: z.string().optional(),
         report: z.string().optional(),
         reportPath: z.string().optional()
       },
       outputSchema: {
         status: z.string(),
         assistantMessage: z.string(),
-        summary: z.record(z.any()).optional(),
-        data: z.record(z.any()).optional(),
+        summary: z.object({}).passthrough().optional(),
+        data: z.object({}).passthrough().optional(),
         files: z.array(z.string()).optional(),
         nextActions: z.array(z.string()).optional(),
         warnings: z.array(z.any()).optional(),
@@ -207,8 +242,8 @@ function createServer() {
       outputSchema: {
         status: z.string(),
         assistantMessage: z.string(),
-        summary: z.record(z.any()).optional(),
-        data: z.record(z.any()).optional(),
+        summary: z.object({}).passthrough().optional(),
+        data: z.object({}).passthrough().optional(),
         files: z.array(z.string()).optional(),
         nextActions: z.array(z.string()).optional(),
         warnings: z.array(z.any()).optional(),
@@ -258,8 +293,8 @@ function createServer() {
       outputSchema: {
         status: z.string(),
         assistantMessage: z.string(),
-        summary: z.record(z.any()).optional(),
-        data: z.record(z.any()).optional(),
+        summary: z.object({}).passthrough().optional(),
+        data: z.object({}).passthrough().optional(),
         files: z.array(z.string()).optional(),
         nextActions: z.array(z.string()).optional(),
         warnings: z.array(z.any()).optional(),
@@ -313,8 +348,8 @@ function createServer() {
       outputSchema: {
         status: z.string(),
         assistantMessage: z.string(),
-        summary: z.record(z.any()).optional(),
-        data: z.record(z.any()).optional(),
+        summary: z.object({}).passthrough().optional(),
+        data: z.object({}).passthrough().optional(),
         files: z.array(z.string()).optional(),
         nextActions: z.array(z.string()).optional(),
         warnings: z.array(z.any()).optional(),
@@ -340,7 +375,7 @@ function createServer() {
         status: z.string(),
         assistantMessage: z.string(),
         configured: z.boolean(),
-        data: z.record(z.any()).optional()
+        data: z.object({}).passthrough().optional()
       }
     },
     async input => {
@@ -351,7 +386,7 @@ function createServer() {
         configured: Boolean(token),
         data: recharge ? { recharge } : {},
         assistantMessage: token
-          ? '小红书 live 采集 Token 已配置,可以尝试小规模真实采集。'
+          ? '\u5c0f\u7ea2\u4e66 live \u91c7\u96c6 Token \u5df2\u914d\u7f6e\uff0c\u53ef\u4ee5\u5c1d\u8bd5\u5c0f\u89c4\u6a21\u771f\u5b9e\u91c7\u96c6\u3002'
           : buildMissingTokenMessage(recharge.paymentUrl)
       };
       return asToolResult(result);
@@ -372,7 +407,7 @@ function createServer() {
         status: z.string(),
         assistantMessage: z.string(),
         configured: z.boolean(),
-        data: z.record(z.any()).optional()
+        data: z.object({}).passthrough().optional()
       }
     },
     async input => {
@@ -383,8 +418,8 @@ function createServer() {
         configured: Boolean(token),
         data: recharge ? { recharge } : {},
         assistantMessage: token
-          ? '抖音 live 采集 Token 已配置,可以开始小规模真实采集。'
-          : buildMissingTokenMessage(recharge.paymentUrl, recharge, { platformLabel: '抖音' })
+          ? '\u6296\u97f3 live \u91c7\u96c6 Token \u5df2\u914d\u7f6e\uff0c\u53ef\u4ee5\u5f00\u59cb\u5c0f\u89c4\u6a21\u771f\u5b9e\u91c7\u96c6\u3002'
+          : buildMissingTokenMessage(recharge.paymentUrl, recharge, { platformLabel: '\u6296\u97f3' })
       };
       return asToolResult(result);
     }
@@ -425,11 +460,11 @@ function createServer() {
       outputSchema: {
         status: z.string(),
         assistantMessage: z.string(),
-        summary: z.record(z.any()).optional(),
-        data: z.record(z.any()).optional(),
+        summary: z.object({}).passthrough().optional(),
+        data: z.object({}).passthrough().optional(),
         files: z.array(z.string()).optional(),
         nextActions: z.array(z.string()).optional(),
-        warnings: z.array(z.string()).optional(),
+        warnings: z.array(z.any()).optional(),
         errors: z.array(z.any()).optional()
       }
     },
@@ -479,8 +514,8 @@ function createServer() {
       outputSchema: {
         status: z.string(),
         assistantMessage: z.string(),
-        summary: z.record(z.any()).optional(),
-        data: z.record(z.any()).optional(),
+        summary: z.object({}).passthrough().optional(),
+        data: z.object({}).passthrough().optional(),
         files: z.array(z.string()).optional(),
         nextActions: z.array(z.string()).optional(),
         warnings: z.array(z.any()).optional(),
@@ -516,8 +551,8 @@ function createServer() {
       outputSchema: {
         status: z.string(),
         assistantMessage: z.string(),
-        summary: z.record(z.any()).optional(),
-        data: z.record(z.any()).optional(),
+        summary: z.object({}).passthrough().optional(),
+        data: z.object({}).passthrough().optional(),
         files: z.array(z.string()).optional(),
         nextActions: z.array(z.string()).optional(),
         warnings: z.array(z.string()).optional(),
@@ -550,8 +585,8 @@ function createServer() {
       outputSchema: {
         status: z.string(),
         assistantMessage: z.string(),
-        summary: z.record(z.any()).optional(),
-        data: z.record(z.any()).optional(),
+        summary: z.object({}).passthrough().optional(),
+        data: z.object({}).passthrough().optional(),
         files: z.array(z.string()).optional(),
         nextActions: z.array(z.string()).optional(),
         warnings: z.array(z.any()).optional(),
@@ -583,3 +618,4 @@ if (require.main === module) {
 module.exports = {
   createServer
 };
+

+ 114 - 4
claude-code-voc-intelligence/mcp/src/tools/douyin-trend-run.js

@@ -45,6 +45,7 @@ function parseArgs(argv) {
 
 function splitList(value) {
   if (!value) return [];
+  if (typeof value === 'boolean') return [];
   if (Array.isArray(value)) return value.map(String).map(item => item.trim()).filter(Boolean);
   return String(value).split(/[,,、\n\r]+/).map(item => item.trim()).filter(Boolean);
 }
@@ -75,6 +76,8 @@ function readJsonIfExists(filePath) {
 function findAuthError(errors = []) {
   return errors.find(error => {
     const message = String(error && error.message || '');
+    const kind = String(error && (error.kind || error.errorKind) || '');
+    if (['request', 'upstream_search', 'invalid_keyword'].includes(kind)) return false;
     return Number(error && error.httpStatus) === 401 ||
       /Invalid API token|无效的API令牌|invalid token/i.test(message);
   });
@@ -84,15 +87,47 @@ function findGatewayError(errors = []) {
   return errors.find(error => {
     const status = Number(error && error.httpStatus);
     const message = String(error && error.message || '');
+    const kind = String(error && (error.kind || error.errorKind) || '');
+    if (['request', 'upstream_search', 'invalid_keyword'].includes(kind)) return false;
     return status === 402 ||
-      status === 403 ||
-      /社交平台接口请求错误|没有开通|余额不足|无权限|权限或余额|permission|quota|payment|insufficient|balance/i.test(message);
+      kind === 'billing' ||
+      /没有开通|余额不足|权限或余额|quota|payment|insufficient|balance/i.test(message) ||
+      (status === 403 && /permission|无权限|权限/i.test(message));
   });
 }
 
+function findSearchInputError(errors = []) {
+  return errors.find(error => {
+    const status = Number(error && error.httpStatus);
+    const message = String(error && error.message || '');
+    const kind = String(error && (error.kind || error.errorKind) || '');
+    return ['request', 'upstream_search', 'invalid_keyword'].includes(kind) ||
+      ([400, 422].includes(status) && /社交平台接口请求错误|参数|入参|keyword|关键词|request error|bad request/i.test(message)) ||
+      (status >= 500 && kind !== 'billing');
+  });
+}
+
+function buildSearchInputMessage(error = {}) {
+  const keyword = error.keyword ? `“${error.keyword}”` : '当前关键词';
+  return [
+    '抖音 live 搜索没有成功,但这次更像是关键词或搜索入参问题,不是余额不足。',
+    '',
+    `出问题的关键词:${keyword}`,
+    '',
+    '建议把关键词改成更自然的短语,并在品类、价格、人群/场景之间加空格,例如:',
+    '- 茶饮 18元 22元',
+    '- 茶饮 性价比 新客',
+    '- 奶茶 套餐 点单 避坑',
+    '',
+    '我已经避免把这类搜索请求错误误报成“余额不足”。'
+  ].join('\n');
+}
+
 function loadProfile(args) {
   const fromFile = readJsonIfExists(args.profile);
   const defaults = defaultProfile();
+  const hasKeywordsArg = Object.prototype.hasOwnProperty.call(args, 'keywords');
+  const parsedKeywords = splitList(args.keywords);
   return {
     ...fromFile,
     project: args.project || fromFile.project || defaults.project,
@@ -101,8 +136,8 @@ function loadProfile(args) {
     targetAudience: splitList(args.targetAudience || args['target-audience']).length
       ? splitList(args.targetAudience || args['target-audience'])
       : (fromFile.targetAudience || defaults.targetAudience),
-    keywords: splitList(args.keywords).length
-      ? splitList(args.keywords)
+    keywords: hasKeywordsArg
+      ? parsedKeywords
       : (fromFile.keywords || defaults.keywords),
     trendQuestions: splitList(args.trendQuestions || args['trend-questions']).length
       ? splitList(args.trendQuestions || args['trend-questions'])
@@ -168,12 +203,63 @@ async function runDouyinTrend(input = {}) {
       videos: liveDataset.videos,
       comments: liveDataset.comments
     };
+    if (liveDataset.keywords?.length) {
+      profile.keywords = liveDataset.keywords;
+    }
     warnings.push(...liveDataset.warnings);
     errors.push(...liveDataset.errors);
 
     const collectionIssue = findGatewayError([...errors, ...warnings]);
     if (collectionIssue) {
       const recharge = await buildVocRechargeInfo({ token });
+      if (dataset.videos.length) {
+        const partialCacheAssetsEnabled = input.cacheAssets !== false &&
+          input['cache-assets'] !== false &&
+          String(input.cacheAssets || input['cache-assets'] || '').toLowerCase() !== 'false';
+        if (partialCacheAssetsEnabled) {
+          const assetResult = await cacheEvidenceAssets({
+            notes: dataset.videos,
+            outputDir,
+            limit: input.assetLimit ?? input['asset-limit'] ?? 10
+          });
+          warnings.push(...assetResult.warnings);
+          assetFiles.push(...assetResult.files);
+        }
+        const report = buildTrendReport({ profile, dataset, collectionMode, memory });
+        const files = writeReport(outputDir, report);
+        return {
+          status: 'partial_needs_recharge',
+          assistantMessage: [
+            buildRechargeRequiredMessage(recharge.paymentUrl, recharge, { platformLabel: '抖音' }),
+            '',
+            `已保留本次已采到的部分样本:${dataset.videos.length} 条视频 / ${dataset.comments.length} 条评论。`,
+            `部分报告已保存到:${outputDir}`,
+            '',
+            report.assistantMessage
+          ].join('\n'),
+          summary: {
+            ...report.summary,
+            videoCount: dataset.videos.length,
+            commentCount: dataset.comments.length,
+            failedStage: collectionIssue.stage || '',
+            failedKeyword: collectionIssue.keyword || ''
+          },
+          data: {
+            ...report.data,
+            profile,
+            recharge,
+            memoryPath
+          },
+          files: [...files, ...assetFiles],
+          nextActions: [
+            `打开充值链接:${recharge.paymentUrl}`,
+            '支付完成后用同一组关键词继续补采',
+            '先基于已保存的部分样本做初步问题池,但不要当最终结论'
+          ],
+          warnings,
+          errors: []
+        };
+      }
       return {
         status: 'needs_recharge',
         assistantMessage: buildRechargeRequiredMessage(recharge.paymentUrl, recharge, { platformLabel: '抖音' }),
@@ -199,6 +285,30 @@ async function runDouyinTrend(input = {}) {
     }
 
     if (!dataset.videos.length) {
+      const searchInputError = findSearchInputError(errors);
+      if (searchInputError) {
+        return {
+          status: 'needs_keyword_fix',
+          assistantMessage: buildSearchInputMessage(searchInputError),
+          summary: {
+            videoCount: 0,
+            commentCount: dataset.comments.length,
+            errorCount: errors.length,
+            failedStage: searchInputError.stage || 'search_videos',
+            failedKeyword: searchInputError.keyword || ''
+          },
+          data: { profile },
+          files: [],
+          nextActions: [
+            '把关键词改成“品类 + 价格/问题 + 人群/场景”的短语后重试 live 模式',
+            '多个关键词用数组或逗号分隔,避免传空字符串',
+            '如果仍失败,再切换到更宽泛的关键词做第一轮真实样本'
+          ],
+          warnings: [],
+          errors: []
+        };
+      }
+
       const authError = findAuthError(errors);
       if (authError) {
         const recharge = await buildVocRechargeInfo({ token });

+ 48 - 0
claude-code-voc-intelligence/mcp/src/tools/xiaohongshu-trend-run.js

@@ -172,6 +172,54 @@ async function runXiaohongshuTrend(input = {}) {
     const collectionIssue = findGatewayError([...errors, ...warnings]);
     if (collectionIssue) {
       const recharge = await buildVocRechargeInfo({ token });
+      if (dataset.notes.length) {
+        const partialCacheAssetsEnabled = input.cacheAssets !== false &&
+          input['cache-assets'] !== false &&
+          String(input.cacheAssets || input['cache-assets'] || '').toLowerCase() !== 'false';
+        if (partialCacheAssetsEnabled) {
+          const assetResult = await cacheEvidenceAssets({
+            notes: dataset.notes,
+            outputDir,
+            limit: input.assetLimit ?? input['asset-limit'] ?? 10
+          });
+          warnings.push(...assetResult.warnings);
+          assetFiles.push(...assetResult.files);
+        }
+        const report = buildTrendReport({ profile, dataset, collectionMode, memory });
+        const files = writeReport(outputDir, report);
+        return {
+          status: 'partial_needs_recharge',
+          assistantMessage: [
+            buildRechargeRequiredMessage(recharge.paymentUrl, recharge),
+            '',
+            `已保留本次已采到的部分样本:${dataset.notes.length} 条笔记 / ${dataset.comments.length} 条评论。`,
+            `部分报告已保存到:${outputDir}`,
+            '',
+            report.assistantMessage
+          ].join('\n'),
+          summary: {
+            ...report.summary,
+            noteCount: dataset.notes.length,
+            commentCount: dataset.comments.length,
+            failedStage: collectionIssue.stage || '',
+            failedKeyword: collectionIssue.keyword || ''
+          },
+          data: {
+            ...report.data,
+            profile,
+            recharge,
+            memoryPath
+          },
+          files: [...files, ...assetFiles],
+          nextActions: [
+            `打开充值链接:${recharge.paymentUrl}`,
+            '支付完成后用同一组关键词继续补采',
+            '先基于已保存的部分样本做初步问题池,但不要当最终结论'
+          ],
+          warnings,
+          errors: []
+        };
+      }
       return {
         status: 'needs_recharge',
         assistantMessage: buildRechargeRequiredMessage(recharge.paymentUrl, recharge),

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

@@ -1,12 +1,12 @@
 {
   "name": "@gangvy/claude-code-voc-intelligence",
-  "version": "0.3.10",
+  "version": "0.3.15",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "@gangvy/claude-code-voc-intelligence",
-      "version": "0.3.10",
+      "version": "0.3.15",
       "dependencies": {
         "@modelcontextprotocol/sdk": "^1.29.0",
         "zod": "^4.4.3"

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

@@ -1,6 +1,6 @@
 {
   "name": "@gangvy/claude-code-voc-intelligence",
-  "version": "0.3.10",
+  "version": "0.3.15",
   "description": "Claude Code VOC intelligence skills for Xiaohongshu and Douyin trend reports, live social VOC collection, single-issue deep dives, boss/operator action plans, and multi-turn memory.",
   "type": "commonjs",
   "bin": {

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

@@ -172,6 +172,9 @@ async function main() {
       !deepDiveStructured.data?.memory?.preferredActions?.includes('预点单')) {
       throw new Error('Expected deep dive MCP call to parse feedback into scoped memory');
     }
+    if (!JSON.stringify(deepDiveStructured.nextActions || []).includes('live')) {
+      throw new Error('Expected deep dive next actions to hand off to live collection instead of trapping the workflow');
+    }
 
     const contentPlanResult = await client.callTool({
       name: 'voc_content_plan_run',
@@ -190,6 +193,33 @@ async function main() {
       throw new Error(`Expected VOC content plan result, got: ${JSON.stringify(contentPlanStructured)}`);
     }
 
+    const teaContentPlanResult = await client.callTool({
+      name: 'voc_content_plan_run',
+      arguments: {
+        brand: '茶饮新客转化',
+        industry: '茶饮',
+        issues: [
+          '第一次来怎么点最稳',
+          '18-22 元贵在哪/值在哪',
+          '券后价和套餐怎么买最划算',
+          '茶底/甜度/配料怎么选',
+          '哪几款不容易踩雷',
+          '外卖到手怎么避免翻车',
+          '买一送一/团购规则怎么讲清楚'
+        ],
+        evidenceText: '小红书评论:波霸奶茶用的什么茶底?我要避雷!!下午喝的,晚上睁眼到天亮。抖音线索:9.9 新品、11.9 单人下午茶、21 元买一送一能触发注意,但还需要解释规则、口味和是否有坑。'
+      }
+    });
+    const teaContentPlanStructured = teaContentPlanResult.structuredContent || {};
+    const teaContentPlanMessage = String(teaContentPlanStructured.assistantMessage || '');
+    if (teaContentPlanStructured.status !== 'ok' ||
+      !teaContentPlanMessage.includes('茶底怎么选') ||
+      !teaContentPlanMessage.includes('买一送一到底怎么算') ||
+      teaContentPlanMessage.includes('招牌菜') ||
+      teaContentPlanMessage.includes('下饭菜')) {
+      throw new Error(`Expected tea-specific VOC content plan result, got: ${JSON.stringify(teaContentPlanStructured)}`);
+    }
+
     const speakingScriptResult = await client.callTool({
       name: 'voc_speaking_script_run',
       arguments: {

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

@@ -5,6 +5,7 @@ const fs = require('fs');
 const { runXiaohongshuTrend } = require('../mcp/src/tools/xiaohongshu-trend-run');
 const { updateXiaohongshuPreference } = require('../mcp/src/tools/xiaohongshu-preference-update');
 const { normalizeNote } = require('../mcp/src/features/xiaohongshu-trend/live-collector');
+const { buildTrendReport } = require('../mcp/src/features/xiaohongshu-trend/report');
 const { XiaohongshuApi } = require('../mcp/src/providers/xiaohongshu-api');
 const { runDouyinTrend } = require('../mcp/src/tools/douyin-trend-run');
 const { updateDouyinPreference } = require('../mcp/src/tools/douyin-preference-update');
@@ -14,7 +15,12 @@ const { runVocContentPlan } = require('../mcp/src/tools/voc-content-plan-run');
 const { runVocSpeakingScript } = require('../mcp/src/tools/voc-speaking-script-run');
 const { runVocCompetitorMap } = require('../mcp/src/tools/voc-competitor-map-run');
 const { runBusinessWorkflow } = require('../mcp/src/tools/voc-business-workflow-run');
-const { normalizeVideo } = require('../mcp/src/features/douyin-trend/live-collector');
+const {
+  normalizeVideo,
+  normalizeSearchKeyword,
+  keywordValidationIssue,
+  buildKeywordRepairCandidates
+} = require('../mcp/src/features/douyin-trend/live-collector');
 const { DouyinApi } = require('../mcp/src/providers/douyin-api');
 const { readVocToken } = require('../mcp/src/core/credentials');
 const { smokeCrossIndustry } = require('./smoke-cross-industry');
@@ -240,6 +246,102 @@ function smokeWorkspaceEnvLocalToken() {
   }
 }
 
+function smokeAncestorEnvLocalToken() {
+  const originalCwd = process.cwd();
+  const originalEnv = {
+    USERPROFILE: process.env.USERPROFILE,
+    HOME: process.env.HOME,
+    VOC_TOKEN: process.env.VOC_TOKEN,
+    VOC_SOCIAL_TOKEN: process.env.VOC_SOCIAL_TOKEN,
+    XIAOHONGSHU_API_TOKEN: process.env.XIAOHONGSHU_API_TOKEN,
+    TIKHUB_API_TOKEN: process.env.TIKHUB_API_TOKEN,
+    FMODE_XIAOHONGSHU_TOKEN: process.env.FMODE_XIAOHONGSHU_TOKEN,
+    FMODE_SOCIAL_TOKEN: process.env.FMODE_SOCIAL_TOKEN,
+    DOUYIN_API_TOKEN: process.env.DOUYIN_API_TOKEN,
+    DOUYIN_TOKEN: process.env.DOUYIN_TOKEN,
+    VOC_DOUYIN_TOKEN: process.env.VOC_DOUYIN_TOKEN
+  };
+  const tempHome = path.join(os.tmpdir(), 'claude-voc-ancestor-env-home-smoke');
+  const tempWorkspace = path.join(os.tmpdir(), 'claude-voc-ancestor-env-workspace-smoke');
+  const nested = path.join(tempWorkspace, 'nested', 'plugin');
+  fs.mkdirSync(tempHome, { recursive: true });
+  fs.mkdirSync(nested, { recursive: true });
+  try {
+    process.chdir(nested);
+    process.env.USERPROFILE = tempHome;
+    process.env.HOME = tempHome;
+    [
+      'VOC_TOKEN',
+      'VOC_SOCIAL_TOKEN',
+      'XIAOHONGSHU_API_TOKEN',
+      'TIKHUB_API_TOKEN',
+      'FMODE_XIAOHONGSHU_TOKEN',
+      'FMODE_SOCIAL_TOKEN',
+      'DOUYIN_API_TOKEN',
+      'DOUYIN_TOKEN',
+      'VOC_DOUYIN_TOKEN'
+    ].forEach(key => delete process.env[key]);
+    fs.writeFileSync(path.join(tempWorkspace, '.env.local'), 'VOC_DOUYIN_TOKEN=ancestor-smoke-token\n');
+    const token = readVocToken({});
+    if (token !== 'ancestor-smoke-token') {
+      throw new Error('ancestor .env.local token was not used');
+    }
+  } finally {
+    Object.entries(originalEnv).forEach(([key, value]) => {
+      if (value === undefined) {
+        delete process.env[key];
+      } else {
+        process.env[key] = value;
+      }
+    });
+    process.chdir(originalCwd);
+  }
+}
+
+async function smokeIssuePoolReportEvidenceFiltering() {
+  const reportPath = path.join(OUTPUT_ROOT, 'issue-pool-report-filter-smoke.md');
+  const memoryPath = path.join(OUTPUT_ROOT, 'issue-pool-report-filter-memory.json');
+  fs.writeFileSync(reportPath, [
+    '# 盛焰铁板烧竞品图谱',
+    '',
+    '## 品牌背景',
+    '- 品牌:盛焰铁板烧',
+    '- 城市:南昌',
+    '',
+    '## 当前经营痛点',
+    '1. 日常营销能够带来 TC,但是活动折扣大。',
+    '',
+    '## live 采集真实样本',
+    '1. [南昌铁板烧样本](https://example.test/note)',
+    '   - 关键评论:多少钱啊',
+    '   - 可解读点:用户第一反应不是口味,而是价格。',
+    '2. [南昌排队样本](https://example.test/note2)',
+    '   - 高赞评论:排队太久了,下次不想等',
+    '',
+    '## 下一步建议',
+    '- 先选一家做深度拆解。'
+  ].join('\n'));
+  const result = await runVocIssuePool({
+    project: '盛焰铁板烧',
+    industry: '餐饮',
+    scenario: '门店经营',
+    audience: '到店顾客',
+    report: reportPath,
+    memory: memoryPath
+  });
+  const issues = result.data?.issues || [];
+  const evidence = issues.flatMap(issue => issue.evidence || []);
+  if (!issues.some(issue => issue.id === 'price') || !issues.some(issue => issue.id === 'waiting')) {
+    throw new Error('issue pool did not classify filtered report comments');
+  }
+  if (issues.some(issue => issue.id === 'other' && issue.count > 0)) {
+    throw new Error('issue pool classified generated report prose as other evidence');
+  }
+  if (evidence.some(item => /^#|品牌:|城市:|当前经营痛点|下一步建议/.test(String(item)))) {
+    throw new Error(`issue pool leaked report headings/prose into evidence: ${JSON.stringify(evidence)}`);
+  }
+}
+
 function smokeAccessibleUrlNormalization() {
   const note = normalizeNote({
     id: '69f72f860000000035038a78',
@@ -268,6 +370,45 @@ function smokeAccessibleUrlNormalization() {
   if (note.coverUrl !== 'https://img.example.com/xhs-cover.jpg') {
     throw new Error(`expected coverUrl from image_list, got ${note.coverUrl}`);
   }
+
+  const unverified = normalizeNote({
+    id: '69f72f860000000035038a79',
+    title: '没有 xsec 的候选链接不应直接展示',
+    desc: '避免把可能 404 的候选链接当原帖。',
+    user: { nickname: '链接测试' },
+    webpage_url: 'https://www.xiaohongshu.com/discovery/item/69f72f860000000035038a79',
+    display_image_info: {
+      url_list: ['https://img.example.com/xhs-display-cover.webp']
+    }
+  }, '全屋定制');
+  if (unverified.sourceUrl || unverified.url || unverified.sourceUrlVerified) {
+    throw new Error(`unverified xiaohongshu URL should not become clickable sourceUrl: ${unverified.sourceUrl}`);
+  }
+  if (!/xiaohongshu\.com\/discovery\/item/.test(unverified.candidateUrl || '')) {
+    throw new Error(`expected candidateUrl to preserve raw fallback for debugging, got ${unverified.candidateUrl}`);
+  }
+  if (unverified.coverUrl !== 'https://img.example.com/xhs-display-cover.webp') {
+    throw new Error(`expected coverUrl from display_image_info, got ${unverified.coverUrl}`);
+  }
+
+  const report = buildTrendReport({
+    profile: {
+      industry: '测试行业',
+      businessType: '测试业务',
+      targetAudience: ['测试人群'],
+      keywords: ['全屋定制']
+    },
+    dataset: { notes: [unverified], comments: [] },
+    collectionMode: 'live',
+    memory: {}
+  });
+  const body = String(report.assistantMessage || '');
+  if (body.includes('[打开原帖') || body.includes('链接待核验')) {
+    throw new Error('unverified Xiaohongshu links should not be rendered as clickable evidence links');
+  }
+  if (!body.includes('平台未返回可核验原帖链接')) {
+    throw new Error('unverified Xiaohongshu links should explain why no clickable original link is shown');
+  }
 }
 
 function smokeDouyinUrlNormalization() {
@@ -421,6 +562,122 @@ async function smokeBusinessStatusErrors() {
   }
 }
 
+async function smokeDouyinSearchInputErrors() {
+  const normalized = normalizeSearchKeyword('茶饮18元22元');
+  if (normalized !== '茶饮 18元 22元') {
+    throw new Error(`expected douyin keyword spacing normalization, got ${normalized}`);
+  }
+  const repairCandidates = buildKeywordRepairCandidates('茶饮 18元 22元', {
+    industry: '茶饮',
+    targetAudience: ['新客']
+  });
+  if (!repairCandidates.includes('茶饮 性价比 新客')) {
+    throw new Error(`expected douyin keyword repair candidates, got ${repairCandidates.join(', ')}`);
+  }
+  if (!keywordValidationIssue('茶饮'.repeat(40))) {
+    throw new Error('expected overlong douyin keyword to be rejected before search');
+  }
+
+  const overlong = await runDouyinTrend({
+    collectionMode: 'live',
+    vocToken: 'r:smoke-token',
+    keywords: '茶饮'.repeat(40),
+    output: path.join(OUTPUT_ROOT, 'douyin-overlong-keyword')
+  });
+  if (overlong.status !== 'needs_keyword_fix') {
+    throw new Error(`expected overlong keyword to return needs_keyword_fix, got ${overlong.status}`);
+  }
+
+  const originalFetch = global.fetch;
+  try {
+    global.fetch = async () => ({
+      ok: false,
+      status: 500,
+      text: async () => JSON.stringify({
+        code: 500,
+        message: '社交平台接口请求错误'
+      })
+    });
+    const result = await runDouyinTrend({
+      collectionMode: 'live',
+      vocToken: 'r:smoke-token',
+      keywords: '茶饮 18元 22元',
+      keywordLimit: 1,
+      videosPerKeyword: 1,
+      maxCommentPages: 0,
+      output: path.join(OUTPUT_ROOT, 'douyin-search-input-error')
+    });
+    if (result.status !== 'needs_keyword_fix') {
+      throw new Error(`expected upstream search error to return needs_keyword_fix, got ${result.status}`);
+    }
+    if (/充值链接|apig-pay/.test(String(result.assistantMessage || ''))) {
+      throw new Error('upstream search error was misreported as recharge/balance');
+    }
+  } finally {
+    global.fetch = originalFetch;
+  }
+
+  let callCount = 0;
+  try {
+    global.fetch = async () => {
+      callCount += 1;
+      if (callCount === 1) {
+        return {
+          ok: false,
+          status: 500,
+          text: async () => JSON.stringify({
+            code: 500,
+            message: '社交平台接口请求错误'
+          })
+        };
+      }
+      return {
+        ok: true,
+        status: 200,
+        text: async () => JSON.stringify({
+          code: 0,
+          data: {
+            data: {
+              business_data: [
+                {
+                  data: {
+                    aweme_info: {
+                      aweme_id: 'douyin-repair-smoke-1',
+                      desc: '茶饮性价比新客点单避坑',
+                      author: { nickname: 'smoke' },
+                      statistics: { digg_count: 1, comment_count: 0, share_count: 0 }
+                    }
+                  }
+                }
+              ]
+            }
+          }
+        })
+      };
+    };
+    const repaired = await runDouyinTrend({
+      collectionMode: 'live',
+      vocToken: 'r:smoke-token',
+      industry: '茶饮',
+      targetAudience: '新客',
+      keywords: '茶饮 18元 22元',
+      keywordLimit: 1,
+      videosPerKeyword: 1,
+      maxCommentPages: 0,
+      cacheAssets: false,
+      output: path.join(OUTPUT_ROOT, 'douyin-auto-keyword-repair')
+    });
+    if (repaired.status !== 'ok' || repaired.summary?.videoCount !== 1) {
+      throw new Error(`expected auto keyword repair to recover live search, got ${repaired.status}`);
+    }
+    if (!(repaired.warnings || []).some(warning => warning && warning.kind === 'auto_keyword_repair')) {
+      throw new Error('expected auto keyword repair warning to be preserved');
+    }
+  } finally {
+    global.fetch = originalFetch;
+  }
+}
+
 async function main() {
   const sample = await runXiaohongshuTrend({
     collectionMode: 'sample',
@@ -504,6 +761,9 @@ async function main() {
   if (!deepDive.summary.memoryPath || !(deepDive.files || []).includes(deepDive.summary.memoryPath)) {
     throw new Error('VOC problem deep dive did not persist memory');
   }
+  if (!JSON.stringify(deepDive.nextActions || []).includes('live')) {
+    throw new Error('VOC problem deep dive next actions did not hand off to live collection');
+  }
   const deepDiveWithMemory = await runVocProblemDeepDive({
     issue: '排队',
     industry: '本地生活餐饮',
@@ -670,7 +930,10 @@ async function main() {
   await smokeNoTokenRechargePrompt();
   await smokeDouyinNoTokenRechargePrompt();
   smokeWorkspaceEnvLocalToken();
+  smokeAncestorEnvLocalToken();
+  await smokeIssuePoolReportEvidenceFiltering();
   await smokeBusinessStatusErrors();
+  await smokeDouyinSearchInputErrors();
   await smokeCrossIndustry({ root: ROOT });
 
   const toolNames = await smokeMcpTools();
@@ -705,6 +968,8 @@ async function main() {
       'sample report with memory',
       'no-token recharge prompt',
       'workspace .env.local token source',
+      'ancestor .env.local token source',
+      'VOC issue pool report evidence filtering',
       'douyin sample report',
       'douyin sample report with memory',
       'VOC issue pool',
@@ -721,6 +986,7 @@ async function main() {
       'VOC business workflow',
       'douyin no-token recharge prompt',
       'business-status recharge guard',
+      'douyin search input error guard',
       'cross-industry sample smoke',
       'MCP tools'
     ],

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

@@ -1,6 +1,6 @@
 {
   "name": "claude-code-voc-intelligence",
-  "version": "0.3.10",
+  "version": "0.3.15",
   "description": "Claude Code native VOC intelligence package for cross-industry social VOC workflows. Includes Xiaohongshu and Douyin trend intelligence, live token checks, evidence-card reports, single-issue VOC deep dives, boss/operator action plans, and scoped multi-turn memory.",
   "plugin": "voc-intelligence",
   "skills": [
@@ -29,7 +29,9 @@
   ],
   "entrySkill": "xiaohongshu-trend-intelligence",
   "capabilityMap": "docs/capability-map.md",
+  "bossPromptTemplates": "docs/boss-prompt-templates.md",
   "liveAcceptanceChecklist": "docs/live-manual-acceptance-checklist.md",
+  "troubleshootingPlaybook": "docs/claude-code-troubleshooting-playbook.md",
   "paymentLinksDoc": "docs/payment-package-links.md",
   "contextBudgetDoc": "docs/context-budget-implementation-plan.md",
   "primaryUseCases": [

+ 17 - 0
claude-code-voc-intelligence/skills/douyin-trend-intelligence/SKILL.md

@@ -24,6 +24,23 @@ allowed-tools: Read Write Bash(node *)
 - If the tool returns `needs_token`, `needs_recharge`, or `needs_valid_token`, paste its `assistantMessage` to the user and keep the payment URL visible.
 - The payment URL must be shown when there is no token or insufficient balance: `https://app.fmode.cn/dev/apig-pay/?apigid=Vo3ROWEvDy&fun_id=HOkkX72PMF`
 
+## Douyin Live Quick Repair
+
+When `voc_douyin_trend_run` returns `needs_keyword_fix`, do not tell the user to recharge. Treat it as a search keyword/input issue.
+
+Claude Code should immediately retry once with a short natural keyword before asking the user, unless the user explicitly asked not to retry. Use this shape:
+
+- `品类 + 价格/问题 + 人群/场景`
+- Examples: `茶饮 18元 22元`, `茶饮 性价比 新客`, `奶茶 套餐 点单 避坑`
+
+If a compact keyword is provided, add spaces between category, numbers, and scenario words. Example: `茶饮18元22元` -> `茶饮 18元 22元`.
+
+Only show the recharge/payment URL for `needs_token`, `needs_recharge`, or `needs_valid_token`. Search upstream failures, empty keywords, overlong keywords, and generic `社交平台接口请求错误` should be handled as keyword/input repair first.
+
+If retry still returns `needs_keyword_fix`, tell the user the exact corrected keyword you tried and suggest 2-3 alternatives. Keep the response short and continue the VOC workflow once a retry succeeds.
+
+For deeper debugging or when the same failure repeats, read `docs/claude-code-troubleshooting-playbook.md` and follow the symptom matrix before changing code.
+
 ## sample 命令
 
 ```bash

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

@@ -25,3 +25,28 @@
 4. 没有采到有效样本
 
 不要把底层接口报错直接丢给用户。
+
+## 抖音关键词快速修复
+
+如果状态是 `needs_keyword_fix`,这不是充值问题。Claude Code 应该先快速修复关键词:
+
+- 空关键词:要求补一个明确关键词,不要自动使用默认词假装采集成功。
+- 紧凑关键词:加空格,例如 `茶饮18元22元` -> `茶饮 18元 22元`。
+- 过长关键词:缩短到 60 字以内,保留品类、问题、人群/场景。
+- 上游返回 `社交平台接口请求错误`:先换成更自然的短关键词重试一次。
+
+推荐格式:
+
+```text
+品类 + 价格/问题 + 人群/场景
+```
+
+示例:
+
+```text
+茶饮 18元 22元
+茶饮 性价比 新客
+奶茶 套餐 点单 避坑
+```
+
+只有 `needs_token`、`needs_recharge`、`needs_valid_token` 才展示充值/开通链接。

+ 8 - 1
claude-code-voc-intelligence/skills/voc-content-plan/SKILL.md

@@ -17,11 +17,12 @@ allowed-tools: Read Write Bash(node *)
 - 每条内容都要包含:用户问题、选题标题、前 3 秒钩子、口播脚本、镜头建议、评论区引导、验证指标。
 - 输出默认 7 天计划;用户明确要求 3 天、15 天时再调整。
 - 不覆盖创始人故事。用户要创始人故事时,说明这不属于 VOC 技能包主线,可另起品牌故事工作流。
+- `voc-content-plan` 负责 7 天内容计划和可拍摄的轻量口播脚本;当用户选中某一天内容继续深改、要 60 秒/30 秒/提词器版或多轮改稿定稿时,再进入 `voc-speaking-script`。
 
 ## CLI 命令
 
 ```bash
-node "<插件目录>/mcp/src/tools/voc-content-plan-run.js" --brand "样板门店A" --industry "本地生活餐饮" --issues "第一次来怕踩雷;觉得人均贵;怕排队久" --assistant-message-only
+node "<插件目录>/mcp/src/tools/voc-content-plan-run.js" --brand "样板门店A" --industry "本地生活餐饮" --issues "第一次来怕踩雷;觉得人均贵;怕排队久" --evidence-text "用户评论:第一次来不知道怎么点,怕踩雷;人均有点高,不知道套餐值不值。" --assistant-message-only
 ```
 
 带报告文件:
@@ -30,6 +31,12 @@ node "<插件目录>/mcp/src/tools/voc-content-plan-run.js" --brand "样板门
 node "<插件目录>/mcp/src/tools/voc-content-plan-run.js" --brand "样板门店A" --report "outputs/douyin-trend-report.md" --assistant-message-only
 ```
 
+茶饮新客转化示例:
+
+```bash
+node "<插件目录>/mcp/src/tools/voc-content-plan-run.js" --brand "茶饮新客转化" --industry "茶饮" --issues "第一次来怎么点最稳;18-22 元贵在哪/值在哪;券后价和套餐怎么买最划算;茶底/甜度/配料怎么选;哪几款不容易踩雷;外卖到手怎么避免翻车;买一送一/团购规则怎么讲清楚" --evidence-text "小红书评论:波霸奶茶用的什么茶底?我要避雷!!下午喝的,晚上睁眼到天亮。抖音线索:9.9 新品、11.9 单人下午茶、21 元买一送一能触发注意,但还需要解释规则、口味和是否有坑。" --assistant-message-only
+```
+
 ## 输出结构
 
 工具输出的 `assistantMessage` 已经是面向用户的正文,直接转述即可。它包含:

+ 7 - 0
claude-code-voc-intelligence/skills/voc-issue-pool/SKILL.md

@@ -19,6 +19,13 @@ allowed-tools: Read Write Bash(node *)
 
 ## CLI 命令
 
+## Evidence Safety Rules
+
+- If the input is a generated Markdown report, do not treat headings, brand background, analysis paragraphs, or next-step suggestions as VOC evidence.
+- Prefer original user comments from sections named `证据样本`, `关键评论`, `高赞评论`, `用户原话`, `live 采集真实样本`, or report JSON `data.comments`.
+- If fewer than 3 real user comments are available for a business diagnosis, say the evidence is insufficient and generate a补采矩阵 first. Do not produce a final品类心智/VOC结论 from report prose.
+- When the user asks for a VOC report around business pain points, first build or request a clean evidence set, then run `voc_issue_pool_run`; only after that should a single top issue be sent to `voc_problem_deep_dive_run`.
+
 ```bash
 node "<插件目录>/mcp/src/tools/voc-issue-pool-run.js" --project "样板门店A" --industry "本地生活餐饮" --scenario "门店经营" --audience "到店顾客" --evidence "排队久,但是不知道值不值得等。,人均有点高,第一次来不知道怎么点不踩雷。,服务忙起来没人理。" --assistant-message-only
 ```

+ 16 - 0
claude-code-voc-intelligence/skills/voc-problem-deep-dive/SKILL.md

@@ -21,6 +21,22 @@ allowed-tools: Read Write Bash(node *)
 
 ## CLI 命令
 
+## Evidence Preconditions
+
+- Do not use this skill for a broad request like `竞品分析`, `VOC报告`, or `品类心智` unless a single issue and real evidence comments are already available.
+- If the user asks for broad diagnosis and evidence is insufficient, route first to live trend collection and `voc_issue_pool_run`; then deep-dive only the top validated issue.
+- Never deep-dive generated report prose as if it were user voice. Pass only comments, user quotes, or evidence-card text into `evidence`.
+
+## Live Collection Handoff
+
+- If the user says `live`, `真实采集`, `进入真实采集`, `补采`, `采集小红书`, or `采集抖音`, this skill must yield to the platform trend skills immediately.
+- Do not continue repeating the same single-issue deep dive after the user asks for live collection.
+- For 小红书, call `voc_xiaohongshu_trend_run` with `collectionMode: "live"`.
+- For 抖音, call `voc_douyin_trend_run` with `collectionMode: "live"`.
+- If the user asks for both platforms, run both platform tools before writing the strategy/report.
+- After live collection succeeds, extract real comments/high-like comments/evidence cards into `voc_issue_pool_run`; only then run this deep-dive skill again if a single top issue still needs analysis.
+- If live collection returns `needs_token`, `needs_recharge`, or `needs_valid_token`, repeat the tool's user-facing `assistantMessage` and do not replace it with a generic strategy suggestion.
+
 ```bash
 node "<插件目录>/mcp/src/tools/voc-problem-deep-dive-run.js" --issue "排队" --industry "本地生活餐饮" --scenario "门店经营" --audience "到店顾客" --assistant-message-only
 ```

+ 6 - 3
claude-code-voc-intelligence/skills/xiaohongshu-trend-intelligence/references/output-format.md

@@ -39,12 +39,16 @@
 - 原帖标题
 - 作者
 - 互动数据
-- 可访问原帖链接
+- 可核验原帖链接,或明确说明平台未返回可核验链接
 - 首图
 - 原文摘录
 - 高赞评论
 
-原帖链接优先使用带 `xsec_token` 的可访问链接,不要把不可访问的 `/explore/<noteId>` 当主链接。
+原帖链接只使用带 `xsec_token` 的可核验链接。不要把不可访问的 `/explore/<noteId>` 或缺少 `xsec_token` 的拼接链接当主链接。
+
+如果平台没有返回可核验链接,不要渲染“打开原帖”链接,改为提示:平台未返回可核验原帖链接,建议复制标题到小红书内搜索。
+
+首图优先使用已缓存到本地 `assets/` 的图片;如果平台未返回图片或图片缓存失败,要明确说明“平台未返回可缓存首图”,不要提示用户点击一个不可核验的原帖链接。
 
 ## 文字风格
 
@@ -65,4 +69,3 @@
 6. 高赞评论
 
 这样用户可以直接核验趋势判断是不是贴近真实场景。
-

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

@@ -1,9 +1,9 @@
 {
   "name": "claude-code-voc-intelligence",
-  "version": "0.3.10",
-  "generatedAt": "2026-05-28T13:36:48.872Z",
+  "version": "0.3.15",
+  "generatedAt": "2026-05-30T12:18:58.723Z",
   "packageZip": "dist/claude-code-voc-intelligence.zip",
-  "packageZipBytes": 181716,
+  "packageZipBytes": 207800,
   "plugin": "voc-intelligence",
   "skills": [
     "xiaohongshu-trend-intelligence",
@@ -38,5 +38,5 @@
   "buildCommand": "node scripts/deploy/claude-code-voc-intelligence-suite.js --validate --build --test-package",
   "uploadCommand": "node scripts/deploy/claude-code-voc-intelligence-suite.js --validate --build --test-package --upload",
   "uploaded": false,
-  "copiedFiles": 82
+  "copiedFiles": 84
 }

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


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

@@ -1,11 +1,11 @@
 {
   "name": "@gangvy/claude-code-voc-intelligence",
-  "version": "0.3.10",
-  "generatedAt": "2026-05-28T13:06:25.921Z",
-  "tarball": "dist/npm/gangvy-claude-code-voc-intelligence-0.3.10.tgz",
-  "tarballBytes": 111120,
-  "unpackedBytes": 418730,
-  "entryCount": 76,
+  "version": "0.3.15",
+  "generatedAt": "2026-05-30T12:20:00.976Z",
+  "tarball": "dist/npm/gangvy-claude-code-voc-intelligence-0.3.15.tgz",
+  "tarballBytes": 143824,
+  "unpackedBytes": 523998,
+  "entryCount": 82,
   "installCommands": [
     "npm install -g @gangvy/claude-code-voc-intelligence",
     "claude-voc install",

BIN
dist/workshop-manual-install/VOC-workshop-ready-claudecode.zip


+ 8 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/.claude-plugin/plugin.json

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

+ 6 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/.gitignore

@@ -0,0 +1,6 @@
+node_modules/
+memory/
+outputs/
+.env
+.env.local
+.tmp/

+ 11 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/.mcp.json

@@ -0,0 +1,11 @@
+{
+  "mcpServers": {
+    "voc": {
+      "command": "node",
+      "args": [
+        "E:\\workspace\\openclaw-voc-skill\\claude-code-voc-intelligence\\mcp\\src\\server.js"
+      ],
+      "cwd": "E:\\workspace\\openclaw-voc-skill\\claude-code-voc-intelligence"
+    }
+  }
+}

+ 333 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/README.md

@@ -0,0 +1,333 @@
+# Claude Code VOC Intelligence
+
+面向 Claude Code 的 VOC 用户洞察技能包。它不是单纯的报告生成器,而是一套“采集真实用户声音 -> 看市场 -> 看竞品 -> 整理问题池 -> 深挖关键问题 -> 生成内容选题 -> 沉淀偏好记忆 -> 下一轮继续优化”的工作流。
+
+当前能力覆盖:
+
+- VOC 经营闭环:适合用户只说“看顾客关心什么、先改哪里、下周发什么”时,一次串起趋势、问题池、单点深挖、内容计划和口播稿。
+- 小红书趋势情报:适合看行业趋势、用户顾虑、内容选题和销售话术。
+- 抖音趋势情报:适合看短视频平台上的用户真实反馈、评论问题、内容机会和门店转化线索。
+- VOC 竞品图谱:适合在竞品不清楚时先找候选竞品,再判断别人被夸什么、被骂什么、我方怎么错位竞争。
+- VOC 问题池:适合把评论、报告证据或用户反馈整理成可排序、可跟进、可继续深挖的问题列表。
+- 单点 VOC 深挖:适合把一个具体问题转成老板能执行的经营动作、内容选题和 7 天验证计划。
+- VOC 内容选题:适合把用户问题转成 7 天短视频选题、口播脚本、镜头建议和验证指标。
+- VOC 口播脚本共创:适合把一条 VOC 选题继续打磨成 60 秒稿、30 秒稿、提词器版,并支持多轮改稿和定稿记忆。
+
+这套技能包不覆盖创始人故事、品牌自传或老板个人 IP 长文。口播脚本要做,但必须来自 VOC 用户问题、竞品评论差异、问题池 Top 问题或单点深挖动作。
+
+## 什么时候用
+
+用户说这些话时,Claude Code 应优先使用本技能包,而不是让用户自己设计提示词:
+
+- “帮我看看这个行业用户最近在关心什么”
+- “跑一份小红书/抖音趋势情报”
+- “先用演示样例跑通流程,不要真实采集”
+- “切到 live,真实采集一下”
+- “帮我看看竞品都在做什么”
+- “我不知道竞品是谁,帮我先做一份竞品图谱”
+- “这些评论里用户到底在吐槽什么”
+- “把这些评论整理成问题池”
+- “哪些问题最影响生意,先改哪个”
+- “这个问题老板该怎么解决”
+- “继续深挖排队/价格/服务/味道这个问题”
+- “下周账号发什么内容”
+- “根据这些顾客吐槽生成 7 天口播脚本”
+- “这个动作不适合我们,下次别推荐”
+- “这个有效,后面优先按这个方向来”
+- “帮我看看顾客关心什么,告诉我先改哪里、下周发什么”
+- “跑一遍完整 VOC 经营闭环”
+
+## 能交付什么
+
+第一轮趋势情报会输出:
+
+- 第一轮初步判断
+- 数据口径
+- 样本观察
+- 机会假设
+- 用户顾虑/决策阻力
+- 证据样本
+- 可继续验证的选题方向
+- 待确认问题
+
+VOC 问题池会输出:
+
+- 问题优先级
+- 每个问题的状态、证据数、影响环节和建议动作
+- 本周建议推进
+- 下一步深挖入口
+- 问题池记忆文件
+
+VOC 竞品图谱会输出:
+
+- 竞品候选清单
+- 用户可能被竞品吸引的点
+- 用户可能吐槽竞品的点
+- 我方错位机会
+- 可转成内容的选题方向
+- 下一步问题池入口
+
+单点 VOC 深挖会输出:
+
+- 一句话判断
+- 优先级判断
+- 用户原话证据
+- 表层问题 vs 深层问题
+- 老板视角影响
+- 今天就能做、7 天内验证、长期沉淀
+- 可转化内容选题
+- 7 天验证计划
+- 下轮验证指标
+- 已应用的使用记忆
+
+VOC 内容选题会输出:
+
+- 7 天内容表
+- 每天对应的用户问题
+- 前 3 秒钩子
+- 30-60 秒口播脚本
+- 镜头建议
+- 评论区引导
+- 验证指标
+
+## 安装
+
+推荐安装到当前工作区,适合 VSCode Claude Code:
+
+```powershell
+npx --yes @gangvy/claude-code-voc-intelligence@latest workspace --smoke
+```
+
+安装后会生成:
+
+```text
+.mcp.json
+.claude/skills/xiaohongshu-trend-intelligence/SKILL.md
+.claude/skills/douyin-trend-intelligence/SKILL.md
+.claude/skills/voc-issue-pool/SKILL.md
+.claude/skills/voc-problem-deep-dive/SKILL.md
+.claude/skills/voc-content-plan/SKILL.md
+.claude/skills/voc-speaking-script/SKILL.md
+.claude/skills/voc-competitor-map/SKILL.md
+.claude/skills/voc-business-workflow/SKILL.md
+.claude/plugins/voc-intelligence
+```
+
+也可以全局安装:
+
+```powershell
+npm install -g @gangvy/claude-code-voc-intelligence
+claude-voc install --smoke
+```
+
+或免全局安装:
+
+```powershell
+npx --yes @gangvy/claude-code-voc-intelligence@latest install --smoke
+```
+
+## 推荐用户话术
+
+小红书演示样例:
+
+```text
+帮我做一份家装全屋定制行业的小红书趋势情报。
+先用演示样例跑通流程,不要真实采集。
+在聊天里给我第一轮样本观察和待确认问题。
+```
+
+抖音演示样例:
+
+```text
+帮我做一份本地生活餐饮行业的抖音趋势情报。
+先用演示样例跑通流程,不要真实采集。
+在聊天里给我第一轮样本观察和待确认问题。
+```
+
+问题池:
+
+```text
+把这些评论整理成问题池,告诉我哪些问题最影响生意,先改哪个。
+```
+
+单点 VOC 深挖:
+
+```text
+继续深挖“排队”这个问题,站在老板视角告诉我怎么解决,给出今天能做的动作和 7 天验证计划。
+```
+
+竞品图谱:
+
+```text
+我是南昌做烧烤的,不知道竞品是谁,帮我先做一份竞品图谱和错位竞争建议。
+```
+
+7 天内容选题:
+
+```text
+根据“排队久、人均贵、第一次来怕踩雷”这些顾客问题,帮我生成 7 天短视频选题和口播脚本。
+```
+
+多轮记忆反馈:
+
+```text
+这个店不想做等位饮品,更想先改评论区回复和预点单,后面按这个方向继续优化。
+```
+
+## Claude Code 应如何调用
+
+优先使用技能入口:
+
+- `xiaohongshu-trend-intelligence`
+- `douyin-trend-intelligence`
+- `voc-issue-pool`
+- `voc-problem-deep-dive`
+- `voc-content-plan`
+- `voc-speaking-script`
+- `voc-competitor-map`
+- `voc-business-workflow`
+
+MCP 工具:
+
+- `voc_business_workflow_run`
+- `voc_xiaohongshu_trend_run`
+- `voc_xiaohongshu_preference_update`
+- `voc_xiaohongshu_token_check`
+- `voc_douyin_trend_run`
+- `voc_douyin_preference_update`
+- `voc_douyin_token_check`
+- `voc_issue_pool_run`
+- `voc_problem_deep_dive_run`
+- `voc_content_plan_run`
+- `voc_competitor_map_run`
+- `voc_speaking_script_run`
+
+命令行入口:
+
+```powershell
+npm --prefix claude-code-voc-intelligence run xhs:sample
+npm --prefix claude-code-voc-intelligence run dy:sample
+npm --prefix claude-code-voc-intelligence run voc:issue-pool
+npm --prefix claude-code-voc-intelligence run voc:deep-dive
+npm --prefix claude-code-voc-intelligence run voc:business-workflow
+npm --prefix claude-code-voc-intelligence run mcp:smoke
+npm --prefix claude-code-voc-intelligence run smoke:package
+```
+
+更完整的能力索引见:
+
+- `docs/capability-map.md`
+- `docs/deliverables/trend-insight-report.md`
+- `docs/deliverables/competitor-map-report.md`
+- `docs/deliverables/voc-issue-pool-report.md`
+- `docs/deliverables/single-problem-action-plan.md`
+- `docs/deliverables/seven-day-content-plan.md`
+- `docs/live-manual-acceptance-checklist.md`
+- `docs/payment-package-links.md`
+- `docs/context-budget-implementation-plan.md`
+- `skills/voc-content-plan/references/restaurant-content-framework.md`
+- `skills/voc-content-plan/references/restaurant-speaking-script-patterns.md`
+- `skills/voc-competitor-map/references/restaurant-competitor-framework.md`
+- `skills/voc-competitor-map/references/restaurant-differentiation-patterns.md`
+
+## 工作坊输出口径
+
+面向老板或线下工作坊时,优先按这五段表达:
+
+1. 你这轮能拿到什么。
+2. 我会怎么做。
+3. 第一版结果。
+4. 你需要确认什么。
+5. 下一步建议。
+
+每个模块都要对应一个交付物:趋势清单、竞品图谱、问题清单、单点行动计划、7 天内容选题。不要在前台讲 MCP、schema、提示词工程。
+
+## VOC 经营闭环
+
+当用户想一次拿到“市场真实声音 -> 先改哪个问题 -> 老板能做什么 -> 下周发什么 -> 第一条口播稿”时,优先使用:
+
+```text
+voc_business_workflow_run
+```
+
+典型话术:
+
+```text
+帮我看一下南昌餐饮最近顾客在关心什么,并告诉我先改哪里、下周发什么。
+```
+
+这条链路会自动串起趋势情报、VOC 问题池、单点深挖、7 天内容计划和口播脚本。第一轮仍然是初步判断,必须提示用户结合门店、销售、私域反馈校准。
+
+## token 和充值口径
+
+sample 模式不需要 token。live 模式需要 VOC-AI 数据服务 token。
+
+允许读取 token 的位置:
+
+- 本次请求参数
+- 当前工作区 `.env.local`
+- 系统环境变量
+- `~/.claude/voc-credentials.json`
+
+Claude Code 技能包不读取 `~/.openclaw/voc-credentials.json`。
+
+如果没有 token 或余额不足,应给用户充值/开通链接,不要暴露底层接口 403、权限堆栈或上游响应体:
+
+```text
+https://app.fmode.cn/dev/apig-pay/?apigid=Vo3ROWEvDy&fun_id=HOkkX72PMF
+```
+
+## 记忆能力
+
+趋势情报支持偏好记忆,适合记录“保留哪些方向、屏蔽哪些方向、下一轮关注什么”。
+
+问题池支持分桶记忆,适合记录同一客户/门店的已发现问题、状态和历史证据。
+连续更新同一个问题池时,会输出“问题变化”,对比上轮/本轮证据数,标记新增、变多、变少、持平或本轮未再出现的问题。
+
+单点 VOC 深挖支持分桶记忆。默认按 `project/brand/store + industry + scenario + audience` 隔离,避免不同客户、不同行业串味。
+当用户反馈“这个有效/无效”后,工具会记录动作验证次数,后续建议会优先推有效动作、降低无效动作,并解释为什么这样排序。
+
+示例:
+
+```text
+这个动作不适合我们,下次别推荐等位饮品。
+这个有效,后面优先做评论区回复和预点单。
+```
+
+工具会把反馈沉淀为偏好动作、屏蔽动作、验证有效动作或验证无效动作,让后续建议逐步贴近真实业务。
+
+## 发布前验证
+
+```powershell
+npm --prefix claude-code-voc-intelligence run mcp:smoke
+npm --prefix claude-code-voc-intelligence run smoke:package
+npm run claude-voc:build
+npm run claude-voc:npm-publish
+```
+
+真实采集不默认放入自动 smoke。发布前或课程现场前,按 `docs/live-manual-acceptance-checklist.md` 做一次小规模 live 验收。
+## VOC 口播脚本共创
+
+`voc-content-plan` 负责把 VOC 问题转成 7 天内容计划;`voc-speaking-script` 负责把其中一条选题继续打磨成能拍的口播稿。
+
+典型话术:
+
+```text
+选题 2,进入脚本共创。
+```
+
+```text
+开头更狠一点,老板视角,少讲概念,多给一个真实场景。
+```
+
+```text
+定稿,并把这个风格记住。以后少讲工具清单,多讲老板判断。
+```
+
+对应 MCP 工具:
+
+```text
+voc_speaking_script_run
+```
+
+它会输出 60 秒稿、30 秒压缩版、提词器版、标题备选、镜头建议、评论区引导和质量门槛,并把“保留/不要/定稿”等反馈写入脚本记忆。完整的抖音日更、对标账号监控、逐字稿拆解仍属于 `douyin-speaking-daily` 的深度能力;本包提供的是 VOC 到口播共创的轻量出口。

+ 790 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/bin/claude-voc.js

@@ -0,0 +1,790 @@
+#!/usr/bin/env node
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { spawnSync } = require('child_process');
+
+const SOURCE_ROOT = path.resolve(__dirname, '..');
+const WORKSPACE_ROOT = process.cwd();
+const DEFAULT_TARGET = path.join(os.homedir(), '.claude', 'plugins', 'voc-intelligence');
+const WORKSPACE_TARGET = path.join(WORKSPACE_ROOT, '.claude', 'plugins', 'voc-intelligence');
+const WORKSPACE_PLUGINS_ROOT = path.join(WORKSPACE_ROOT, '.claude', 'plugins');
+const MIN_NODE_MAJOR = 18;
+
+const EXCLUDED_DIRS = new Set([
+  'node_modules',
+  'memory',
+  'outputs',
+  '.tmp',
+  '.git',
+  '.claude'
+]);
+
+const EXCLUDED_FILES = new Set([
+  '.env',
+  '.env.local',
+  '.npmrc'
+]);
+
+function usage() {
+  return [
+    'Claude VOC skill package installer',
+    '',
+    'Usage:',
+    '  claude-voc install',
+    '  claude-voc workspace',
+    '  claude-voc install --workspace --smoke',
+    '  claude-voc check',
+    '  claude-voc smoke',
+    '  claude-voc path',
+    '',
+    'npx:',
+    '  npx @gangvy/claude-code-voc-intelligence install',
+    '  npx @gangvy/claude-code-voc-intelligence workspace --smoke',
+    '',
+    'Options:',
+    '  --workspace      Install into ./.claude/plugins/voc-intelligence',
+    '  --target <dir>   Install into a custom directory',
+    '  --force          Allow overwriting an existing custom target',
+    '  --skip-install   Skip npm install after copying files',
+    '  --smoke          Run sample/preference/MCP smoke checks after install',
+    '  --help, -h       Show help'
+  ].join('\n');
+}
+
+function expandHome(value) {
+  return value.replace(/^~(?=$|[\\/])/, os.homedir());
+}
+
+function parseArgs(argv) {
+  const first = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'install';
+  const opts = {
+    command: first,
+    target: DEFAULT_TARGET,
+    skipInstall: false,
+    smoke: false,
+    force: false,
+    help: false
+  };
+
+  if (first === 'workspace' || first === 'install-workspace') {
+    opts.command = 'install';
+    opts.target = WORKSPACE_TARGET;
+  }
+
+  for (let i = first === argv[0] ? 1 : 0; i < argv.length; i++) {
+    const token = argv[i];
+    if (token === '--target') {
+      opts.target = argv[++i];
+    } else if (token.startsWith('--target=')) {
+      opts.target = token.slice('--target='.length);
+    } else if (token === '--workspace') {
+      opts.target = WORKSPACE_TARGET;
+    } else if (token === '--force') {
+      opts.force = true;
+    } else if (token === '--skip-install') {
+      opts.skipInstall = true;
+    } else if (token === '--smoke') {
+      opts.smoke = true;
+    } else if (token === '--help' || token === '-h') {
+      opts.help = true;
+    }
+  }
+
+  opts.target = path.resolve(expandHome(opts.target));
+  return opts;
+}
+
+function run(command, args, cwd) {
+  const useCmd = process.platform === 'win32' && command === 'npm';
+  const executable = useCmd ? 'cmd.exe' : command;
+  const finalArgs = useCmd ? ['/d', '/s', '/c', 'npm', ...args] : args;
+  const child = spawnSync(executable, finalArgs, {
+    cwd,
+    encoding: 'utf8',
+    stdio: 'inherit',
+    maxBuffer: 1024 * 1024 * 100
+  });
+  if (child.status !== 0) {
+    const detail = child.error ? `: ${child.error.message}` : '';
+    throw new Error(`${command} ${args.join(' ')} failed with exit ${child.status}${detail}`);
+  }
+}
+
+function checkNodeVersion() {
+  const major = Number(process.versions.node.split('.')[0]);
+  if (!Number.isFinite(major) || major < MIN_NODE_MAJOR) {
+    throw new Error(`Node.js ${MIN_NODE_MAJOR}+ is required. Current version: ${process.version}`);
+  }
+}
+
+function ensureDir(dirPath) {
+  fs.mkdirSync(dirPath, { recursive: true });
+}
+
+function isInside(parentDir, childDir) {
+  const relative = path.relative(path.resolve(parentDir), path.resolve(childDir));
+  return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
+}
+
+function isSafeDefaultTarget(targetDir) {
+  return path.resolve(targetDir) === path.resolve(DEFAULT_TARGET);
+}
+
+function isSafeWorkspaceTarget(targetDir) {
+  return isInside(WORKSPACE_PLUGINS_ROOT, targetDir);
+}
+
+function canOverwriteTarget(targetDir, opts) {
+  return opts.force || isSafeDefaultTarget(targetDir) || isSafeWorkspaceTarget(targetDir);
+}
+
+function shouldCopyEntry(entry) {
+  if (entry.isDirectory()) return !EXCLUDED_DIRS.has(entry.name);
+  if (entry.isFile()) {
+    if (EXCLUDED_FILES.has(entry.name)) return false;
+    if (entry.name.endsWith('.tgz')) return false;
+    return true;
+  }
+  return false;
+}
+
+function copyDirRecursive(srcDir, destDir) {
+  ensureDir(destDir);
+  let count = 0;
+  for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
+    if (!shouldCopyEntry(entry)) continue;
+    const src = path.join(srcDir, entry.name);
+    const dest = path.join(destDir, entry.name);
+    if (entry.isDirectory()) {
+      count += copyDirRecursive(src, dest);
+    } else if (entry.isFile()) {
+      ensureDir(path.dirname(dest));
+      fs.copyFileSync(src, dest);
+      count++;
+    }
+  }
+  return count;
+}
+
+function assertInstalled(targetDir) {
+  const required = [
+    'package.json',
+    'install.js',
+    '.claude-plugin/plugin.json',
+    '.mcp.json',
+    'skills/xiaohongshu-trend-intelligence/SKILL.md',
+    'skills/douyin-trend-intelligence/SKILL.md',
+    'skills/voc-issue-pool/SKILL.md',
+    'skills/voc-problem-deep-dive/SKILL.md',
+    'skills/voc-content-plan/SKILL.md',
+    'skills/voc-speaking-script/SKILL.md',
+    'skills/voc-competitor-map/SKILL.md',
+    'skills/voc-business-workflow/SKILL.md',
+    'mcp/src/server.js'
+  ];
+  const missing = required.filter(rel => !fs.existsSync(path.join(targetDir, rel)));
+  if (missing.length) {
+    throw new Error(`Missing required files in install target: ${missing.join(', ')}`);
+  }
+}
+
+function writeMcpConfig(targetDir) {
+  const serverPath = path.join(targetDir, 'mcp', 'src', 'server.js');
+  const mcpConfig = {
+    mcpServers: {
+      voc: {
+        command: 'node',
+        args: [serverPath],
+        cwd: targetDir
+      }
+    }
+  };
+  fs.writeFileSync(path.join(targetDir, '.mcp.json'), `${JSON.stringify(mcpConfig, null, 2)}\n`, 'utf8');
+}
+
+function readJsonIfExists(filePath) {
+  if (!fs.existsSync(filePath)) return {};
+  return JSON.parse(fs.readFileSync(filePath, 'utf8'));
+}
+
+function writeWorkspaceMcpConfig(targetDir) {
+  const mcpConfigPath = path.join(WORKSPACE_ROOT, '.mcp.json');
+  const existing = readJsonIfExists(mcpConfigPath);
+  const mcpConfig = {
+    ...existing,
+    mcpServers: {
+      ...(existing.mcpServers || {}),
+      voc: {
+        command: 'node',
+        args: [path.join(targetDir, 'mcp', 'src', 'server.js')],
+        cwd: targetDir
+      }
+    }
+  };
+  fs.writeFileSync(mcpConfigPath, `${JSON.stringify(mcpConfig, null, 2)}\n`, 'utf8');
+}
+
+function asForwardSlash(filePath) {
+  return path.resolve(filePath).replace(/\\/g, '/');
+}
+
+function quotedPath(filePath) {
+  return `"${asForwardSlash(filePath)}"`;
+}
+
+function workspaceTrendSkillText(options) {
+  const trendRunner = path.join(options.targetDir, 'mcp', 'src', 'tools', options.trendTool);
+  const preferenceUpdater = path.join(options.targetDir, 'mcp', 'src', 'tools', options.preferenceTool);
+  const profilePath = path.join(options.targetDir, 'memory-templates', options.profileFile);
+  const defaultMemoryPath = path.join(WORKSPACE_ROOT, 'outputs', options.memoryDir, options.memoryFile);
+  const liveLimits = options.platform === '抖音'
+    ? '--keyword-limit 1 --videos-per-keyword 1 --max-comment-pages 1'
+    : '--keyword-limit 1 --notes-per-keyword 1 --max-comment-pages 1';
+
+  return [
+    '---',
+    `name: ${options.skillName}`,
+    `description: ${options.description}`,
+    'allowed-tools: Read Write Bash(node *)',
+    '---',
+    '',
+    `# ${options.platform}趋势情报官`,
+    '',
+    `你是面向业务用户的“${options.platform}趋势情报官”。用户要的是第一轮样本观察、机会假设和待确认问题,不是技术日志。`,
+    '',
+    '## 必须执行',
+    '',
+    `- 用户要求${options.platform}趋势情报、内容趋势、评论分析、真实采集、live采集或社媒 VOC 时,必须运行本技能包的工具或 MCP,不要只凭经验回答。`,
+    '- 用户说“live / 真实采集 / 进入 live / 实时采集 / 抓真实数据”时,必须运行 live 模式工具。不要回复“我不能实时抓取外部数据”。本技能包已经提供本地 VOC 采集入口。',
+    '- live 模式返回 `needs_token`、`needs_recharge` 或 `needs_valid_token` 时,必须把工具输出里的 `assistantMessage` 原样转述给用户,尤其不能省略充值/开通链接。',
+    '- 如果工具输出里有 `nextActions`,至少转述其中带 `apig-pay` 的充值链接。',
+    '- 第一轮只说“初步判断 / 机会假设 / 待校准”,不要说成最终结论。',
+    '- 后续用户说“保留、不要、更偏、这个不准”等反馈时,运行偏好更新命令沉淀记忆。',
+    '',
+    '## MANDATORY Live Rule',
+    '',
+    '- If the user asks for live collection, real collection, or entering live mode, run the local VOC tool in live mode.',
+    '- Do not answer that you cannot fetch external data. The local VOC tool is the approved collection entry.',
+    '- If the tool returns `needs_token`, `needs_recharge`, or `needs_valid_token`, paste its `assistantMessage` to the user and keep the payment URL visible.',
+    '- The payment URL must be shown when there is no token or insufficient balance: `https://app.fmode.cn/dev/apig-pay/?apigid=Vo3ROWEvDy&fun_id=HOkkX72PMF`',
+    '',
+    '## sample 命令',
+    '',
+    '```bash',
+    `node ${quotedPath(trendRunner)} --collection-mode sample --profile ${quotedPath(profilePath)} --output ${JSON.stringify(options.sampleOutput)} --assistant-message-only`,
+    '```',
+    '',
+    '## live 命令',
+    '',
+    '```bash',
+    `node ${quotedPath(trendRunner)} --collection-mode live --profile ${quotedPath(profilePath)} --output ${JSON.stringify(options.liveOutput)} ${liveLimits} --assistant-message-only`,
+    '```',
+    '',
+    '运行后把命令输出正文直接发给用户。不要展示 JSON、summary、data、files 或调试日志。',
+    '',
+    '## 偏好更新命令',
+    '',
+    '```bash',
+    `node ${quotedPath(preferenceUpdater)} --message "<用户反馈原文>" --memory ${quotedPath(defaultMemoryPath)} --result-prefix ${options.resultPrefix}`,
+    '```',
+    '',
+    '## 充值链接口径',
+    '',
+    '没有 token 或余额不足时,用户必须看到:',
+    '',
+    '```text',
+    'https://app.fmode.cn/dev/apig-pay/?apigid=Vo3ROWEvDy&fun_id=HOkkX72PMF',
+    '```',
+    '',
+    '套餐入口可作为补充:',
+    '',
+    '```text',
+    'https://app.fmode.cn/dev/apig-pay/#/workshop/99',
+    '```',
+    ''
+  ].join('\n');
+}
+
+function workspaceSkillText(targetDir) {
+  return workspaceTrendSkillText({
+    targetDir,
+    skillName: 'xiaohongshu-trend-intelligence',
+    platform: '小红书',
+    description: 'Build Xiaohongshu trend intelligence reports. Use when the user asks for 小红书趋势情报、行业趋势、内容选题、销售话术、真实采集、live采集、用户顾虑 or social VOC analysis.',
+    trendTool: 'xiaohongshu-trend-run.js',
+    preferenceTool: 'xiaohongshu-preference-update.js',
+    profileFile: 'xiaohongshu-trend-profile.json',
+    memoryDir: 'claude-code-xhs-memory',
+    memoryFile: 'xiaohongshu-trend-memory.json',
+    sampleOutput: 'outputs/claude-code-xhs-sample',
+    liveOutput: 'outputs/claude-code-xhs-live',
+    resultPrefix: 'XHS_PREF_RESULT'
+  });
+  const trendRunner = path.join(targetDir, 'mcp', 'src', 'tools', 'xiaohongshu-trend-run.js');
+  const preferenceUpdater = path.join(targetDir, 'mcp', 'src', 'tools', 'xiaohongshu-preference-update.js');
+  const profilePath = path.join(targetDir, 'memory-templates', 'xiaohongshu-trend-profile.json');
+  const defaultMemoryPath = path.join(WORKSPACE_ROOT, 'outputs', 'claude-code-xhs-memory', 'xiaohongshu-trend-memory.json');
+
+  return [
+    '---',
+    'name: xiaohongshu-trend-intelligence',
+    'description: Build Xiaohongshu trend intelligence reports from industry direction, audience, keywords, notes, and comments. Use when the user asks for 小红书趋势情报、行业趋势、女性客户消费决策、设计元素洞察、家装全屋定制趋势、内容选题 or social VOC analysis.',
+    'allowed-tools: Read Write Bash(node *)',
+    '---',
+    '',
+    '# 小红书趋势情报官',
+    '',
+    '你是面向业务用户的“小红书趋势情报官”。用户要的是第一轮样本观察、机会假设和待确认问题,不是技术日志。',
+    '',
+    '## 必须执行',
+    '',
+    '- 当用户要求做小红书趋势情报、行业趋势、女性客户消费决策、家装全屋定制趋势、设计元素洞察或内容选题时,优先运行下面的 sample 工具命令。',
+    '- 不要只凭经验回答,也不要只返回文件路径。',
+    '- 工具输出里会有 `assistantMessage`,请把它的正文直接发给用户。',
+    '- 第一轮只能说“初步判断 / 机会假设 / 待校准”,不要说成最终结论。',
+    '- 后续用户说“保留、不要、更偏、这个不准”等反馈时,运行偏好更新命令沉淀记忆。',
+    '',
+    '## sample 趋势情报命令',
+    '',
+    '```bash',
+    `node ${quotedPath(trendRunner)} --collection-mode sample --profile ${quotedPath(profilePath)} --output "outputs/claude-code-xhs-sample" --assistant-message-only`,
+    '```',
+    '',
+    '运行后把命令输出正文直接发给用户。不要展示 JSON、summary、data、files 或调试日志。',
+    '',
+    '## 偏好更新命令',
+    '',
+    '```bash',
+    `node ${quotedPath(preferenceUpdater)} --message "<用户反馈原文>" --memory ${quotedPath(defaultMemoryPath)} --result-prefix XHS_PREF_RESULT`,
+    '```',
+    '',
+    '## 推荐用户启动话术',
+    '',
+    '```text',
+    '帮我做一份家装全屋定制行业的小红书趋势情报。',
+    '先用演示样例跑通流程,不要真实采集。',
+    '在聊天里给我第一轮样本观察和待确认问题。',
+    '```',
+    ''
+  ].join('\n');
+}
+
+function workspaceDouyinSkillText(targetDir) {
+  return workspaceTrendSkillText({
+    targetDir,
+    skillName: 'douyin-trend-intelligence',
+    platform: '抖音',
+    description: 'Build Douyin trend intelligence reports. Use when the user asks for 抖音趋势情报、视频评论、口播选题、短视频开头、内容趋势、真实采集、live采集、用户顾虑 or social VOC analysis.',
+    trendTool: 'douyin-trend-run.js',
+    preferenceTool: 'douyin-preference-update.js',
+    profileFile: 'douyin-trend-profile.json',
+    memoryDir: 'claude-code-douyin-memory',
+    memoryFile: 'douyin-trend-memory.json',
+    sampleOutput: 'outputs/claude-code-douyin-sample',
+    liveOutput: 'outputs/claude-code-douyin-live',
+    resultPrefix: 'DOUYIN_PREF_RESULT'
+  });
+  const trendRunner = path.join(targetDir, 'mcp', 'src', 'tools', 'douyin-trend-run.js');
+  const preferenceUpdater = path.join(targetDir, 'mcp', 'src', 'tools', 'douyin-preference-update.js');
+  const profilePath = path.join(targetDir, 'memory-templates', 'douyin-trend-profile.json');
+  const defaultMemoryPath = path.join(WORKSPACE_ROOT, 'outputs', 'claude-code-douyin-memory', 'douyin-trend-memory.json');
+
+  return [
+    '---',
+    'name: douyin-trend-intelligence',
+    'description: Build Douyin trend intelligence reports from industry direction, audience, keywords, videos, and comments. Use when the user asks for 抖音趋势情报、视频评论、口播选题、短视频开头、内容趋势 or social VOC analysis.',
+    'allowed-tools: Read Write Bash(node *)',
+    '---',
+    '',
+    '# 抖音趋势情报官',
+    '',
+    '你是面向业务用户的“抖音趋势情报官”。用户要的是第一轮样本观察、机会假设和待确认问题,不是技术日志。',
+    '',
+    '## 必须执行',
+    '',
+    '- 当用户要求做抖音趋势情报、视频评论、口播选题、短视频开头或内容趋势时,优先运行下面的 sample 工具命令。',
+    '- 不要只凭经验回答,也不要只返回文件路径。',
+    '- 工具输出里会有 `assistantMessage`,请把它的正文直接发给用户。',
+    '- 第一轮只能说“初步判断 / 机会假设 / 待校准”,不要说成最终结论。',
+    '- 后续用户说“保留、不要、更偏、这个不准”等反馈时,运行偏好更新命令沉淀记忆。',
+    '',
+    '## sample 趋势情报命令',
+    '',
+    '```bash',
+    `node ${quotedPath(trendRunner)} --collection-mode sample --profile ${quotedPath(profilePath)} --output "outputs/claude-code-douyin-sample" --assistant-message-only`,
+    '```',
+    '',
+    '运行后把命令输出正文直接发给用户。不要展示 JSON、summary、data、files 或调试日志。',
+    '',
+    '## 偏好更新命令',
+    '',
+    '```bash',
+    `node ${quotedPath(preferenceUpdater)} --message "<用户反馈原文>" --memory ${quotedPath(defaultMemoryPath)} --result-prefix DOUYIN_PREF_RESULT`,
+    '```',
+    '',
+    '## 推荐用户启动话术',
+    '',
+    '```text',
+    '帮我做一份家装全屋定制行业的抖音趋势情报。',
+    '先用演示样例跑通流程,不要真实采集。',
+    '在聊天里给我第一轮样本观察和待确认问题。',
+    '```',
+    ''
+  ].join('\n');
+}
+
+function workspaceProblemDeepDiveSkillText(targetDir) {
+  const deepDiveRunner = path.join(targetDir, 'mcp', 'src', 'tools', 'voc-problem-deep-dive-run.js');
+  return [
+    '---',
+    'name: voc-problem-deep-dive',
+    'description: Deep-dive a single VOC problem into root cause, boss/operator impact, concrete actions, content ideas, and validation metrics. Use when the user says 继续挖、深入分析、老板怎么解决、这个问题怎么办、怎么改门店、怎么转成内容、单点VOC or VOC深挖.',
+    'allowed-tools: Read Write Bash(node *)',
+    '---',
+    '',
+    '# 单点 VOC 深挖官',
+    '',
+    '你是面向老板和门店经营者的“单点 VOC 深挖官”。用户不需要学习提示词。用户只要说“这个问题继续挖”“老板怎么解决”“这个差评背后是什么”,你就要把真实用户声音转成可执行经营动作。',
+    '',
+    '## 必须执行',
+    '',
+    '- 当用户说“继续挖、深入分析、老板怎么解决、这个问题怎么办、怎么改、怎么转成内容、单点 VOC”时,必须运行单点深挖工具。',
+    '- 不要输出提示词教学,不要让用户自己设计分析维度。',
+    '- 输出必须站在老板视角:这件事影响新客、复购、客单、口碑,还是现场效率。',
+    '- 每次只深挖一个问题点。如果用户给了多个问题,先选择最影响转化的一个。',
+    '- 如果上一轮趋势报告里有证据样本或高赞评论,要把相关评论作为 evidence 传入工具。',
+    '- 要使用记忆能力:用户说“这个动作不适合”“我们已经试过”“这个有效”“下次更偏内容/门店动作”时,把原话作为 `feedback` 传入;如果能明确识别,也同步传 `blockedActions`、`validatedActions`、`rejectedActions` 或 `preferredActions`。',
+    '- 后续多轮迭代时要带上 `project`/`brand`/`store`、`industry`、`scenario`、`audience`。未显式传 `memory` 时,工具会按这些字段自动隔离记忆,避免不同行业、不同客户串味。',
+    '- 如果用户没有指定记忆文件,不要强行要求用户理解路径;让工具使用默认分桶记忆即可。只有在同一个客户需要固定沉淀时,才显式传同一个 `memory` 路径。',
+    '',
+    '## 命令',
+    '',
+    '```bash',
+    `node ${quotedPath(deepDiveRunner)} --issue "排队" --industry "本地生活餐饮" --scenario "门店经营" --audience "到店顾客" --assistant-message-only`,
+    '```',
+    '',
+    '## 带记忆的多轮迭代',
+    '',
+    '```bash',
+    `node ${quotedPath(deepDiveRunner)} --issue "排队" --industry "本地生活餐饮" --scenario "门店经营" --audience "到店顾客" --memory "outputs/voc-problem-memory.json" --feedback "老板不想做等位饮品,更想先改评论区回复和预点单" --blocked-actions "等位饮品" --preferred-actions "评论区回复,预点单" --assistant-message-only`,
+    '```',
+    '',
+    '自然语言反馈也可以直接传入 `feedback`,工具会尝试自动提取偏好和屏蔽动作:',
+    '',
+    '```bash',
+    `node ${quotedPath(deepDiveRunner)} --issue "排队" --project "样板门店A" --industry "本地生活餐饮" --scenario "门店经营" --audience "到店顾客" --feedback "这个店不想做等位饮品,更想先改评论区回复和预点单" --assistant-message-only`,
+    '```',
+    '',
+    '运行后把命令输出正文直接发给用户。不要展示 JSON、summary、data、files 或调试日志。',
+    ''
+  ].join('\n');
+}
+
+function workspaceIssuePoolSkillText(targetDir) {
+  const issuePoolRunner = path.join(targetDir, 'mcp', 'src', 'tools', 'voc-issue-pool-run.js');
+  return [
+    '---',
+    'name: voc-issue-pool',
+    'description: Turn VOC comments, report evidence, or user feedback into a prioritized issue pool with status, evidence, business impact, next actions, and follow-up deep-dive suggestions. Use when the user asks 哪些问题最影响生意、问题池、问题清单、用户都在吐槽什么、先改哪个、VOC问题管理、问题优先级、把评论整理成问题.',
+    'allowed-tools: Read Write Bash(node *)',
+    '---',
+    '',
+    '# VOC 问题池官',
+    '',
+    '你是面向老板、门店经营者和营销负责人的 VOC 问题池官。用户不需要知道分类法,也不需要学习提示词。用户只要说“这些评论里哪些问题最重要”“先改哪个”“整理成问题池”,你就把真实用户声音整理成可排序、可跟进、可继续深挖的问题列表。',
+    '',
+    '## 必须执行',
+    '',
+    '- 当用户说“问题池、问题清单、用户吐槽什么、哪些问题最影响生意、先改哪个、VOC 问题管理、把评论整理成问题”时,优先运行 `voc_issue_pool_run`。',
+    '- 如果上一轮小红书/抖音报告里有证据样本或高赞评论,把相关评论作为 `evidence`、`evidenceText` 或 `reportPath` 传入。',
+    '- 输出必须站在业务视角:这个问题影响新客、复购、客单、口碑、内容信任,还是现场效率。',
+    '- 不要只做词频统计。每个问题都要包含证据样本、影响环节、建议动作和下一步。',
+    '- 如果用户说某个问题已解决、验证中、暂缓,要通过 `resolvedIssues`、`validatingIssues`、`blockedIssues` 或 `statusUpdates` 更新问题状态。',
+    '- 后续深挖时,引导用户直接说“继续深挖「问题名」”,然后转入 `voc-problem-deep-dive`。',
+    '',
+    '## 命令',
+    '',
+    '```bash',
+    `node ${quotedPath(issuePoolRunner)} --project "样板门店A" --industry "本地生活餐饮" --scenario "门店经营" --audience "到店顾客" --evidence "排队久,但是不知道值不值得等。,人均有点高,第一次来不知道怎么点不踩雷。,服务忙起来没人理。" --assistant-message-only`,
+    '```',
+    '',
+    '带报告文件:',
+    '',
+    '```bash',
+    `node ${quotedPath(issuePoolRunner)} --project "样板门店A" --industry "本地生活餐饮" --report "outputs/douyin-trend-report.md" --assistant-message-only`,
+    '```',
+    '',
+    '运行后把命令输出正文直接发给用户。不要展示 JSON、summary、data、files 或调试日志。',
+    ''
+  ].join('\n');
+}
+
+function workspaceContentPlanSkillText(targetDir) {
+  const runner = path.join(targetDir, 'mcp', 'src', 'tools', 'voc-content-plan-run.js');
+  return [
+    '---',
+    'name: voc-content-plan',
+    'description: Turn VOC issues into a 7-day content plan with short-video topics and speaking scripts. Use when the user asks 下周账号发什么、口播脚本、短视频选题、把用户问题变成内容、7天内容计划.',
+    'allowed-tools: Read Write Bash(node *)',
+    '---',
+    '',
+    '# VOC 内容选题官',
+    '',
+    '当用户说“下周发什么、7 天选题、口播脚本、短视频脚本、账号内容、把问题变成内容”时,运行本工具。',
+    '',
+    '必须把内容绑定到 VOC 用户问题,不要输出空泛爆款标题。不要覆盖创始人故事。',
+    '',
+    '## 命令',
+    '',
+    '```bash',
+    `node ${quotedPath(runner)} --brand "样板门店A" --industry "本地生活餐饮" --issues "第一次来怕踩雷;觉得人均贵;怕排队久" --assistant-message-only`,
+    '```',
+    '',
+    '运行后把正文直接发给用户,不要展示 JSON。',
+    ''
+  ].join('\n');
+}
+
+function workspaceSpeakingScriptSkillText(targetDir) {
+  const runner = path.join(targetDir, 'mcp', 'src', 'tools', 'voc-speaking-script-run.js');
+  return [
+    '---',
+    'name: voc-speaking-script',
+    'description: Turn one VOC-backed topic into a co-created speaking script with 60-second draft, 30-second compressed version, teleprompter text, revision feedback, finalization, and script memory. Use when the user says 选题2进入脚本共创、改稿、定稿、口播稿、提词器、开头更狠、老板视角、少讲概念.',
+    'allowed-tools: Read Write Bash(node *)',
+    '---',
+    '',
+    '# VOC 口播脚本共创',
+    '',
+    '当用户从 7 天内容计划里选择某条选题,或说“进入脚本共创、改稿、定稿、提词器版、开头更狠、老板视角”时,运行本工具。',
+    '',
+    '口播必须来自 VOC 用户问题、趋势证据、问题池 Top 问题、单点深挖动作或竞品评论差异。没有真实证据时要标注低证据风险。',
+    '',
+    '## 命令',
+    '',
+    '```bash',
+    `node ${quotedPath(runner)} --brand "样板门店A" --industry "本地生活餐饮" --topic "第一次来怎么点不踩雷" --user-issue "第一次来怕点错" --evidence "第一次来不知道怎么点,怕踩雷" --assistant-message-only`,
+    '```',
+    '',
+    '改稿:',
+    '',
+    '```bash',
+    `node ${quotedPath(runner)} --brand "样板门店A" --topic "第一次来怎么点不踩雷" --user-issue "第一次来怕点错" --evidence "第一次来不知道怎么点,怕踩雷" --feedback "开头更狠一点,老板视角,少讲概念,多给具体场景" --assistant-message-only`,
+    '```',
+    '',
+    '定稿时传 `--finalize true`,工具会写入脚本记忆。运行后把正文直接发给用户,不要展示 JSON。',
+    ''
+  ].join('\n');
+}
+
+function workspaceCompetitorMapSkillText(targetDir) {
+  const runner = path.join(targetDir, 'mcp', 'src', 'tools', 'voc-competitor-map-run.js');
+  return [
+    '---',
+    'name: voc-competitor-map',
+    'description: Build a competitor map and differentiated opportunity report. Use when the user asks 看竞品、竞品分析、错位竞争、别人为什么吸引顾客、我不知道竞品是谁.',
+    'allowed-tools: Read Write Bash(node *)',
+    '---',
+    '',
+    '# VOC 竞品图谱官',
+    '',
+    '当用户说“看竞品、竞品分析、错位竞争、别人怎么做、我不知道竞品是谁”时,运行本工具。',
+    '',
+    '如果用户没有明确竞品,先按同城、同品类、同价格带、同消费场景、平台声量生成候选竞品类型。不要覆盖创始人故事。',
+    '',
+    '## 命令',
+    '',
+    '```bash',
+    `node ${quotedPath(runner)} --brand "样板门店A" --city "南昌" --category "餐饮" --assistant-message-only`,
+    '```',
+    '',
+    '运行后把正文直接发给用户,不要展示 JSON。',
+    ''
+  ].join('\n');
+}
+
+function workspaceBusinessWorkflowSkillText(targetDir) {
+  const runner = path.join(targetDir, 'mcp', 'src', 'tools', 'voc-business-workflow-run.js');
+  return [
+    '---',
+    'name: voc-business-workflow',
+    'description: Run the full VOC business workflow from market voices to issue pool, top issue action, 7-day content plan, and one speaking script. Use when the user asks 看顾客关心什么、先改哪里、下周发什么、完整VOC闭环、经营诊断.',
+    'allowed-tools: Read Write Bash(node *)',
+    '---',
+    '',
+    '# VOC经营闭环',
+    '',
+    '当用户想一次拿到“看市场、看问题、找动作、做内容”的结果时,运行本工具。不要让用户理解 MCP、schema 或多个工具名。',
+    '',
+    '## 命令',
+    '',
+    '```bash',
+    `node ${quotedPath(runner)} --brand "样板门店A" --industry "本地生活餐饮" --platform douyin --collection-mode sample --keywords "南昌菜,第一次来怎么点,排队" --assistant-message-only`,
+    '```',
+    '',
+    '真实采集时把 `--collection-mode sample` 换成 `--collection-mode live`。如果工具提示没有 token、余额不足或没有样本,直接把工具正文发给用户,并保留充值/降级建议。',
+    '',
+    '## 交付物',
+    '',
+    '必须包含五段:市场和用户声音、先改哪个问题、Top问题怎么解决、下周账号发什么、第一条口播稿。',
+    ''
+  ].join('\n');
+}
+
+function writeWorkspaceSkillEntries(targetDir) {
+  const xhsSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'xiaohongshu-trend-intelligence');
+  const douyinSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'douyin-trend-intelligence');
+  const issuePoolSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'voc-issue-pool');
+  const deepDiveSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'voc-problem-deep-dive');
+  const contentPlanSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'voc-content-plan');
+  const speakingScriptSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'voc-speaking-script');
+  const competitorMapSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'voc-competitor-map');
+  const businessWorkflowSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'voc-business-workflow');
+  ensureDir(xhsSkillDir);
+  ensureDir(douyinSkillDir);
+  ensureDir(issuePoolSkillDir);
+  ensureDir(deepDiveSkillDir);
+  ensureDir(contentPlanSkillDir);
+  ensureDir(speakingScriptSkillDir);
+  ensureDir(competitorMapSkillDir);
+  ensureDir(businessWorkflowSkillDir);
+  fs.writeFileSync(path.join(xhsSkillDir, 'SKILL.md'), workspaceSkillText(targetDir), 'utf8');
+  fs.writeFileSync(path.join(douyinSkillDir, 'SKILL.md'), workspaceDouyinSkillText(targetDir), 'utf8');
+  fs.writeFileSync(path.join(issuePoolSkillDir, 'SKILL.md'), workspaceIssuePoolSkillText(targetDir), 'utf8');
+  fs.writeFileSync(path.join(deepDiveSkillDir, 'SKILL.md'), workspaceProblemDeepDiveSkillText(targetDir), 'utf8');
+  fs.writeFileSync(path.join(contentPlanSkillDir, 'SKILL.md'), workspaceContentPlanSkillText(targetDir), 'utf8');
+  fs.writeFileSync(path.join(speakingScriptSkillDir, 'SKILL.md'), workspaceSpeakingScriptSkillText(targetDir), 'utf8');
+  fs.writeFileSync(path.join(competitorMapSkillDir, 'SKILL.md'), workspaceCompetitorMapSkillText(targetDir), 'utf8');
+  fs.writeFileSync(path.join(businessWorkflowSkillDir, 'SKILL.md'), workspaceBusinessWorkflowSkillText(targetDir), 'utf8');
+}
+
+function writeWorkspaceActivation(targetDir) {
+  if (!isSafeWorkspaceTarget(targetDir)) return;
+  writeWorkspaceMcpConfig(targetDir);
+  writeWorkspaceSkillEntries(targetDir);
+  console.log('Wrote workspace MCP config: .\\.mcp.json');
+  console.log('Wrote workspace skill entry: .\\.claude\\skills\\xiaohongshu-trend-intelligence\\SKILL.md');
+  console.log('Wrote workspace skill entry: .\\.claude\\skills\\douyin-trend-intelligence\\SKILL.md');
+  console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-issue-pool\\SKILL.md');
+  console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-problem-deep-dive\\SKILL.md');
+  console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-content-plan\\SKILL.md');
+  console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-speaking-script\\SKILL.md');
+  console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-competitor-map\\SKILL.md');
+  console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-business-workflow\\SKILL.md');
+}
+
+function printNextSteps(targetDir) {
+  const workspaceMode = isSafeWorkspaceTarget(targetDir);
+  console.log('');
+  console.log('Install complete.');
+  console.log('');
+  if (workspaceMode) {
+    console.log('VSCode workspace mode is ready.');
+    console.log('');
+    console.log('Generated project files:');
+    console.log('  .\\.mcp.json');
+    console.log('  .\\.claude\\skills\\xiaohongshu-trend-intelligence\\SKILL.md');
+    console.log('  .\\.claude\\skills\\douyin-trend-intelligence\\SKILL.md');
+    console.log('  .\\.claude\\skills\\voc-issue-pool\\SKILL.md');
+    console.log('  .\\.claude\\skills\\voc-problem-deep-dive\\SKILL.md');
+    console.log('  .\\.claude\\skills\\voc-content-plan\\SKILL.md');
+    console.log('  .\\.claude\\skills\\voc-speaking-script\\SKILL.md');
+    console.log('  .\\.claude\\skills\\voc-competitor-map\\SKILL.md');
+    console.log('  .\\.claude\\skills\\voc-business-workflow\\SKILL.md');
+    console.log('  .\\.claude\\plugins\\voc-intelligence');
+    console.log('');
+    console.log('Restart the VSCode Claude Code session if it was already open.');
+  } else {
+    console.log('Claude Code CLI launch command:');
+    console.log(`  claude --plugin-dir "${targetDir}"`);
+  }
+  console.log('');
+  console.log('Try this prompt in Claude Code:');
+  console.log('  帮我看一下南昌餐饮最近顾客在关心什么,并告诉我先改哪里、下周发什么。');
+  console.log('');
+  console.log('Or try:');
+  console.log('  帮我做一份家装全屋定制行业的小红书趋势情报。');
+  console.log('  先用演示样例跑通流程,不要真实采集。');
+  console.log('  在聊天里给我第一轮样本观察和待确认问题。');
+  console.log('');
+  console.log('Or try:');
+  console.log('  帮我做一份家装全屋定制行业的抖音趋势情报。');
+  console.log('  先用演示样例跑通流程,不要真实采集。');
+  console.log('  在聊天里给我第一轮样本观察和待确认问题。');
+  console.log('');
+  console.log(`Install target: ${targetDir}`);
+}
+
+function install(opts) {
+  checkNodeVersion();
+  if (path.resolve(SOURCE_ROOT) !== path.resolve(opts.target)) {
+    if (fs.existsSync(opts.target)) {
+      if (!canOverwriteTarget(opts.target, opts)) {
+        throw new Error(`Target already exists. Use --force to overwrite custom target: ${opts.target}`);
+      }
+      fs.rmSync(opts.target, { recursive: true, force: true });
+    }
+    ensureDir(opts.target);
+    const copied = copyDirRecursive(SOURCE_ROOT, opts.target);
+    console.log(`Copied skill package files: ${copied}`);
+  }
+
+  assertInstalled(opts.target);
+  writeMcpConfig(opts.target);
+  writeWorkspaceActivation(opts.target);
+
+  if (!opts.skipInstall) {
+    console.log('Installing runtime dependencies...');
+    run('npm', ['install', '--omit=dev', '--ignore-scripts'], opts.target);
+  }
+
+  if (opts.smoke) {
+    console.log('Running install smoke checks...');
+    run(process.execPath, ['install.js', '--smoke', '--skip-install', '--no-next'], opts.target);
+  } else {
+    run(process.execPath, ['install.js', '--check', '--no-next'], opts.target);
+  }
+
+  printNextSteps(opts.target);
+}
+
+function check(opts) {
+  assertInstalled(opts.target);
+  run(process.execPath, ['install.js', '--check', '--no-next'], opts.target);
+}
+
+function smoke(opts) {
+  assertInstalled(opts.target);
+  run(process.execPath, ['install.js', '--smoke', '--skip-install', '--no-next'], opts.target);
+}
+
+function printPath(opts) {
+  console.log(opts.target);
+  if (isSafeWorkspaceTarget(opts.target)) {
+    console.log('.\\.claude\\plugins\\voc-intelligence');
+  } else {
+    console.log(`claude --plugin-dir "${opts.target}"`);
+  }
+}
+
+function main() {
+  const opts = parseArgs(process.argv.slice(2));
+  if (opts.help || opts.command === 'help') {
+    console.log(usage());
+    return;
+  }
+  if (opts.command === 'install') return install(opts);
+  if (opts.command === 'check') return check(opts);
+  if (opts.command === 'smoke') return smoke(opts);
+  if (opts.command === 'path') return printPath(opts);
+  throw new Error(`Unknown command: ${opts.command}\n\n${usage()}`);
+}
+
+try {
+  main();
+} catch (error) {
+  console.error('');
+  console.error(`claude-voc failed: ${error.message}`);
+  process.exit(1);
+}

+ 194 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/docs/capability-map.md

@@ -0,0 +1,194 @@
+# Claude Code VOC Intelligence 能力地图
+
+这份文档给 Claude Code、维护者和交付人员快速定位:这套技能包能做什么、该用哪个入口、出了问题去哪里看。
+
+## 一句话定位
+
+把小红书、抖音等平台的用户真实声音,转成企业能用的趋势情报、问题优先级、经营动作、内容选题和多轮验证记忆。
+
+## 用户任务到入口映射
+
+| 用户想做的事 | 推荐入口 | MCP 工具 | 输出重点 |
+| --- | --- | --- | --- |
+| 一次跑完整经营闭环 | `voc-business-workflow` | `voc_business_workflow_run` | 市场声音、问题池、Top 问题动作、7 天内容计划、第一条口播稿 |
+| 先演示流程,不真实采集 | 小红书/抖音趋势 skill | `voc_xiaohongshu_trend_run` / `voc_douyin_trend_run`,`collectionMode=sample` | 第一轮样本观察、机会假设、待确认问题 |
+| 真实采集行业趋势 | 小红书/抖音趋势 skill | `voc_xiaohongshu_trend_run` / `voc_douyin_trend_run`,`collectionMode=live` | 真实笔记/视频、评论证据、趋势假设 |
+| 检查 token 是否可用 | 平台趋势 skill | `voc_xiaohongshu_token_check` / `voc_douyin_token_check` | 是否已配置、无 token 时给充值链接 |
+| 用户回答校准问题 | 平台趋势 skill | `voc_xiaohongshu_preference_update` / `voc_douyin_preference_update` | 保存偏好、屏蔽方向、下一轮聚焦 |
+| 把评论整理成问题池 | `voc-issue-pool` | `voc_issue_pool_run` | 问题优先级、状态、证据、影响环节、建议动作 |
+| 判断哪些问题最影响生意 | `voc-issue-pool` | `voc_issue_pool_run` | 本周优先推进的问题和下一步深挖入口 |
+| 深挖一个具体问题 | `voc-problem-deep-dive` | `voc_problem_deep_dive_run` | 老板视角影响、动作清单、7 天验证 |
+| 用户说某动作不适合 | `voc-problem-deep-dive` | `voc_problem_deep_dive_run`,传 `feedback` | 自动解析为屏蔽动作或偏好动作 |
+| 选中一条内容继续改口播 | `voc-speaking-script` | `voc_speaking_script_run` | 60 秒稿、30 秒稿、提词器版、改稿反馈、定稿记忆 |
+| 下轮继续优化 | 同一 skill | 带同一 `memory`,或带 project/industry/scenario/audience 让工具自动分桶 | 应用历史记忆,避免从零开始 |
+
+## 技能入口
+
+### `voc-business-workflow`
+
+适合:
+
+- 用户只想说自然语言,不想选择工具
+- 一次串起市场洞察、问题优先级、单点动作、内容计划和口播稿
+- 远程给客户先跑一份第一轮经营交付
+
+用户典型话术:
+
+```text
+帮我看一下南昌餐饮最近顾客在关心什么,并告诉我先改哪里、下周发什么。
+```
+
+### `xiaohongshu-trend-intelligence`
+
+适合:
+
+- 小红书趋势情报
+- 行业用户顾虑
+- 内容选题
+- 销售话术
+- 小红书评论证据卡
+
+用户典型话术:
+
+```text
+帮我做一份家装全屋定制行业的小红书趋势情报,先用演示样例跑通流程。
+```
+
+### `douyin-trend-intelligence`
+
+适合:
+
+- 抖音趋势情报
+- 本地生活/餐饮评论洞察
+- 短视频内容机会
+- 门店转化线索
+
+用户典型话术:
+
+```text
+帮我看下南昌餐饮行业抖音上用户最近在吐槽什么,先小规模真实采集。
+```
+
+### `voc-problem-deep-dive`
+
+适合:
+
+- 单点 VOC 深挖
+- 差评背后问题拆解
+- 老板怎么解决
+- 门店动作和内容选题
+- 多轮记忆优化
+
+用户典型话术:
+
+```text
+继续深挖“排队”这个问题,告诉老板今天能改什么,7 天怎么验证。
+```
+
+### `voc-issue-pool`
+
+适合:
+
+- 评论问题池
+- 用户吐槽归类
+- 问题优先级排序
+- 问题状态管理
+- 从趋势报告进入单点深挖前的中间层
+
+用户典型话术:
+
+```text
+把这些评论整理成问题池,告诉我哪些问题最影响生意,先改哪个。
+```
+
+## 关键产品原则
+
+- 首轮报告是“初步判断”,不是最终结论。
+- 输出必须带证据,不只给抽象观点。
+- 老板用户不需要学习提示词,Claude Code 要主动选择技能和工具。
+- live 失败时给可执行下一步,不暴露原始接口错误。
+- 多轮反馈要进入记忆,让建议越用越贴近客户。
+- 不同行业、不同客户的记忆要隔离。
+
+## 记忆规则
+
+趋势情报记忆:
+
+- 记录保留方向、屏蔽方向、聚焦模式、用户备注。
+- 适合下一轮趋势报告排序和话术收敛。
+
+单点 VOC 记忆:
+
+- 记录偏好动作、屏蔽动作、偏好内容角度、验证有效动作、验证无效动作。
+- 未显式传 `memory` 时,默认按 `project/brand/store + industry + scenario + audience` 分桶。
+- 显式传 `memory` 时保持兼容,用指定文件沉淀。
+
+## 文件索引
+
+| 文件 | 用途 |
+| --- | --- |
+| `README.md` | 面向使用者和 Claude Code 的总入口说明 |
+| `skills/*/SKILL.md` | Claude Code 触发规则和操作约束 |
+| `mcp/src/server.js` | MCP 工具注册、schema、工具说明 |
+| `mcp/src/tools/*` | CLI/MCP 可调用工具入口 |
+| `mcp/src/features/*` | 趋势报告、单点深挖、记忆、信号提取 |
+| `mcp/src/core/credentials.js` | token 读取口径 |
+| `mcp/src/core/payment-links.js` | 充值/开通提示 |
+| `mcp/src/core/evidence-quality.js` | 证据质量门槛、无样本降级建议 |
+| `docs/live-manual-acceptance-checklist.md` | live 采集人工验收 |
+| `docs/payment-package-links.md` | 支付链接和套餐入口说明 |
+| `docs/context-budget-implementation-plan.md` | 上下文省 token 工作流 |
+| `docs/deliverables/trend-insight-report.md` | 行业用户洞察趋势清单模板 |
+| `docs/deliverables/competitor-map-report.md` | 竞品图谱与错位机会报告模板 |
+| `docs/deliverables/voc-issue-pool-report.md` | VOC 问题清单与优先级模板 |
+| `docs/deliverables/single-problem-action-plan.md` | 单点问题 7 天行动计划模板 |
+| `docs/deliverables/seven-day-content-plan.md` | 7 天内容选题与口播脚本模板 |
+| `scripts/smoke-package.js` | 包级 smoke,覆盖 sample、记忆、token、MCP |
+| `scripts/smoke-mcp.js` | MCP 工具发现和基本调用 smoke |
+
+## 工作坊输出口径
+
+面向餐饮老板或线下工作坊时,Claude Code 输出优先按:
+
+1. 你这轮能拿到什么。
+2. 我会怎么做。
+3. 第一版结果。
+4. 你需要确认什么。
+5. 下一步建议。
+
+每个小节都必须对应一个交付物。不要在前台讲 MCP、schema、collectionMode、提示词工程。
+
+## 维护检查点
+
+每次发布至少确认:
+
+- `package.json`、`package-lock.json`、`skill-package-manifest.json`、`.claude-plugin/plugin.json`、`mcp/src/server.js` 版本一致。
+- `npm --prefix claude-code-voc-intelligence run mcp:smoke` 通过。
+- `npm --prefix claude-code-voc-intelligence run smoke:package` 通过。
+- `npm run claude-voc:build` 通过。
+- npm 发布后 `npm view @gangvy/claude-code-voc-intelligence version` 返回新版本。
+- 测试工作区 `npx --yes @gangvy/claude-code-voc-intelligence@latest workspace --smoke` 通过。
+
+## 下一轮优化补充
+
+- `voc-issue-pool` 已支持跨轮问题变化:同一记忆范围下会对比上轮/本轮证据数,并标记新增、变多、变少、持平、本轮未再出现。
+- `voc-problem-deep-dive` 已支持动作权重记忆:用户反馈“有效/无效”后,会记录验证次数,后续优先推荐有效动作、降低无效动作。
+- 趋势情报输出后的推荐链路应优先进入问题池:先把证据整理成 VOC 问题池,判断先改哪个问题,再进入单点 VOC 深挖。
+
+## 工作坊交付型入口
+
+| 老板说法 | 推荐入口 | MCP 工具 | 交付物 |
+| --- | --- | --- | --- |
+| 帮我看这个品类最近顾客在关心什么 | 小红书/抖音趋势 skill | `voc_xiaohongshu_trend_run` / `voc_douyin_trend_run` | 行业用户洞察趋势清单 |
+| 帮我看顾客关心什么、先改哪里、下周发什么 | `voc-business-workflow` | `voc_business_workflow_run` | VOC 经营闭环报告 |
+| 我不知道竞品是谁,帮我看看别人怎么做 | `voc-competitor-map` | `voc_competitor_map_run` | 竞品图谱与错位机会报告 |
+| 顾客到底在吐槽什么,先改哪个 | `voc-issue-pool` | `voc_issue_pool_run` | VOC 问题清单与优先级 |
+| 这个问题老板怎么解决 | `voc-problem-deep-dive` | `voc_problem_deep_dive_run` | 单点问题 7 天行动计划 |
+| 下周账号发什么,帮我写口播 | `voc-content-plan` | `voc_content_plan_run` | 7 天内容选题与口播脚本 |
+| 选题 2 进入脚本共创 | `voc-speaking-script` | `voc_speaking_script_run` | 口播稿改稿与定稿记忆 |
+
+边界说明:
+
+- 口播/短视频脚本属于本技能包范围,但必须来自 VOC 用户问题、竞品评论差异、问题池 Top 问题或单点深挖动作。
+- 创始人故事、品牌自传、老板个人 IP 长文不属于 `claude-code-voc-intelligence` 主线。
+- 现场工作坊前台不要讲 MCP、schema、提示词工程;前台只讲“看市场、看竞品、看问题、找动作、做内容”。

+ 491 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/docs/context-budget-implementation-plan.md

@@ -0,0 +1,491 @@
+# Claude Code 上下文省 token 工作流落地方案
+
+## 背景问题
+
+截图里的失败不是单次工具报错,而是会话已经背了过多原始资料、读取记录和中间推理。Claude Code 报出:
+
+```text
+Input tokens exceed the configured limit of 272000 tokens.
+```
+
+这说明继续在同一会话里点“继续”,大概率还是会失败。技能包需要把“长任务上下文”从聊天窗口里搬到文件和工具里,让 Claude 每轮只读取必要的轻量状态,而不是反复读取所有原始图片、Excel、长 Markdown 和完整历史对话。
+
+## 落地目标
+
+给 `claude-code-voc-intelligence` 增加一套“省 token 长任务工作流”,用于真实采集任务文档、小红书样本整理、客户素材归档、批量图片/Excel/文本分析等场景。
+
+目标效果:
+
+- 用户可以把大量资料放到工作区,Claude 不会一次读完全部文件。
+- 每轮最多处理 1 个样本单元,例如 `XHS-001_1`。
+- 每个样本单元处理完后,必须写入中间摘要文件。
+- 后续会话只读取任务状态、中间摘要和下一个样本单元,不重复读取原始大文件。
+- 上下文不足时,技能包能主动生成交接摘要,并提示新会话如何继续。
+- 最终报告由多个中间摘要汇总而来,而不是由一个超长会话硬扛到底。
+
+## 核心设计
+
+采用“原始资料 -> 文件清单 -> 样本单元摘要 -> 总控任务状态 -> 最终报告”的流水线。
+
+```mermaid
+flowchart TD
+  A["原始资料目录"] --> B["context scan 文件清单"]
+  B --> C["sample units 样本单元索引"]
+  C --> D["每轮只处理 1 个样本单元"]
+  D --> E["unit summary 中间摘要"]
+  E --> F["task-state 当前进度"]
+  F --> G["handoff 新会话交接摘要"]
+  E --> H["final report 汇总文档"]
+```
+
+关键原则:
+
+- 聊天上下文只放“当前要判断的内容”。
+- 大文件只让脚本读取,Claude 只读脚本生成的轻量摘要。
+- 任务进度写入文件,不依赖会话记忆。
+- 新会话从 `task-state` 恢复,而不是从聊天历史恢复。
+
+## 需要新增的文件结构
+
+建议先落在 `claude-code-voc-intelligence` 技能包内:
+
+```text
+claude-code-voc-intelligence/
+  docs/
+    context-budget-implementation-plan.md
+  skills/xiaohongshu-trend-intelligence/references/
+    context-budget-workflow.md
+  mcp/src/features/context-budget/
+    file-manifest.js
+    sample-units.js
+    checkpoint.js
+    handoff.js
+  mcp/src/tools/
+    context-budget-run.js
+  scripts/
+    smoke-context-budget.js
+```
+
+安装到工作区后,运行时在客户项目里生成:
+
+```text
+.claude/task-state/
+  voc-context-state.md
+  voc-context-state.json
+  handoff.md
+
+outputs/voc-context/
+  file-manifest.json
+  sample-units.json
+  unit-summaries/
+    XHS-001_1.md
+    XHS-002_1.md
+  final/
+    真实采集任务文档.md
+```
+
+## 技术实现模块
+
+### 1. 文件清单扫描器
+
+作用:先建立资料目录的轻量索引,避免 Claude 直接递归读取全部内容。
+
+建议实现:
+
+```text
+mcp/src/features/context-budget/file-manifest.js
+```
+
+输入保持简单:
+
+```json
+{
+  "rootDir": "docs/真实采集任务文档",
+  "taskName": "小红书真实采集任务文档"
+}
+```
+
+输出内容:
+
+- 文件路径
+- 文件类型
+- 文件大小
+- 修改时间
+- 所属样本单元推断,例如 `XHS-001_1`
+- 是否疑似大文件
+- 是否建议脚本预处理
+
+不在第一步读取图片正文、Excel 全量内容或长 Markdown 全文。
+
+### 2. 样本单元识别器
+
+作用:把 `01-06`、`XHS-001_1`、`XHS-008_8` 这类目录识别成可独立处理的样本单元。
+
+建议规则:
+
+- 优先识别 `XHS-\d+(_\d+)?`
+- 其次识别一级/二级目录名
+- 允许用户在状态文件里手动修正单元关系
+- 每个单元包含:截图、笔记正文、评论、Excel 行、人工备注、校验材料
+
+输出:
+
+```text
+outputs/voc-context/sample-units.json
+```
+
+示例字段:
+
+```json
+{
+  "unitId": "XHS-001_1",
+  "status": "pending",
+  "files": [],
+  "summaryPath": "outputs/voc-context/unit-summaries/XHS-001_1.md",
+  "estimatedRisk": "large-image-set"
+}
+```
+
+### 3. 单元摘要生成器
+
+作用:每轮只处理一个样本单元,并把结果写成结构化摘要。
+
+建议实现:
+
+```text
+mcp/src/features/context-budget/sample-units.js
+```
+
+摘要模板:
+
+```markdown
+# XHS-001_1 样本摘要
+
+## 已读取资料
+
+## 账号/笔记信息
+
+## 小红书趋势关键词
+
+## 用户评论与真实顾虑
+
+## 可验证话题方向
+
+## 博主真实度/商业痕迹判断
+
+## 证据片段
+
+## 待复核问题
+
+## 不要重复读取的原始文件
+```
+
+约束:
+
+- 单元摘要控制在 800-1500 字。
+- 摘要必须引用文件名,但不要复制大段原文。
+- 图片只记录观察结论、截图编号和需要复核的位置。
+- Excel 只读取表头、关键列、与当前单元相关的行。
+
+### 4. 任务状态与检查点
+
+作用:让新会话知道任务做到哪里,不靠历史聊天。
+
+建议实现:
+
+```text
+mcp/src/features/context-budget/checkpoint.js
+```
+
+生成两个文件:
+
+```text
+.claude/task-state/voc-context-state.json
+.claude/task-state/voc-context-state.md
+```
+
+状态内容:
+
+- 当前目标
+- 已完成样本单元
+- 正在处理的样本单元
+- 待处理样本单元
+- 已生成摘要文件
+- 禁止重复读取的原始目录
+- 下一步只允许读取哪些文件
+- 汇总报告缺口
+
+`voc-context-state.md` 要给人看,`voc-context-state.json` 给工具读。
+
+### 5. 新会话交接摘要
+
+作用:上下文过大时,直接停止扩读,生成新会话启动提示。
+
+建议实现:
+
+```text
+mcp/src/features/context-budget/handoff.js
+```
+
+生成:
+
+```text
+.claude/task-state/handoff.md
+```
+
+内容模板:
+
+```markdown
+# 新会话交接摘要
+
+## 当前任务
+
+## 已完成
+
+## 不要重新读取
+
+## 新会话只读取
+
+1. .claude/task-state/voc-context-state.md
+2. outputs/voc-context/sample-units.json
+3. outputs/voc-context/unit-summaries/
+
+## 下一步
+
+只处理 XHS-002_1,完成后写入对应摘要并停止。
+
+## 可直接复制给 Claude Code 的 Prompt
+```
+
+## 需要新增的 MCP 工具
+
+建议在 `mcp/src/server.js` 增加三个工具。入参保持简单,避免让用户理解技术参数。
+
+### `voc_context_prepare`
+
+用途:初始化长任务,扫描目录,生成文件清单、样本单元和任务状态。
+
+输入:
+
+```json
+{
+  "rootDir": "docs/真实采集任务文档",
+  "taskName": "小红书真实采集任务文档"
+}
+```
+
+输出:
+
+- `assistantMessage`:告诉用户识别出多少样本单元,建议先处理哪个。
+- `data.statePath`
+- `data.sampleUnitsPath`
+- `nextActions`
+
+### `voc_context_next_unit`
+
+用途:读取任务状态,返回下一轮应该处理的单元和允许读取的文件清单。
+
+输入:
+
+```json
+{
+  "statePath": ".claude/task-state/voc-context-state.json"
+}
+```
+
+输出:
+
+- 下一单元 ID
+- 允许读取的文件
+- 禁止读取的文件
+- 摘要写入路径
+
+### `voc_context_checkpoint`
+
+用途:处理完一个样本单元后,写入摘要、更新任务状态、生成必要的 handoff。
+
+输入:
+
+```json
+{
+  "unitId": "XHS-001_1",
+  "summary": "本轮摘要正文",
+  "status": "completed"
+}
+```
+
+输出:
+
+- 更新后的进度
+- 下一个单元
+- 新会话启动提示
+
+## CLI 命令设计
+
+为了兼容 Claude Code 工具不可用或客户只会终端的情况,也建议在 `claude-voc` CLI 增加同款命令:
+
+```powershell
+claude-voc context prepare --root "docs/真实采集任务文档" --task "小红书真实采集任务文档"
+claude-voc context next
+claude-voc context checkpoint --unit XHS-001_1 --summary "outputs/voc-context/unit-summaries/XHS-001_1.md"
+claude-voc context handoff
+```
+
+客户不需要主动使用这些命令,但 Claude Code 可以通过 Bash 调用,作为稳定兜底。
+
+## Skill 文档需要加的规则
+
+在 `skills/xiaohongshu-trend-intelligence/SKILL.md` 增加“长任务省 token 协议”:
+
+```markdown
+## 长任务省 token 协议
+
+- 如果用户要求处理多个样本目录、批量图片、Excel 或长文档,不要一次读取全部资料。
+- 先调用/执行 context prepare,生成文件清单、样本单元和任务状态。
+- 每轮最多处理 1 个样本单元。
+- 每处理完一个样本单元,必须写入 unit summary,并更新 task-state。
+- 后续汇总只能优先读取 unit summary,不重复读取原始大文件。
+- 当上下文明显变大、用户说继续多次、或任务超过 3 个样本单元时,先生成 handoff,再建议开新会话。
+- 新会话只读取 handoff、task-state、sample-units 和必要的 unit summary。
+```
+
+## 面向用户的话术
+
+当用户给了大批资料时,Claude 应该这样说:
+
+```text
+资料比较多,我会按省上下文方式处理:先建立文件清单和样本单元索引,然后每轮只处理一个样本单元。每个单元处理完都会写入摘要,后续汇总只读摘要,不重复读取原始大文件。
+
+我先处理 XHS-001_1,处理完会停下来给你确认。
+```
+
+当上下文不足时:
+
+```text
+当前会话已经接近上下文上限。我已经把进度写入 .claude/task-state/handoff.md。
+
+建议新开一个 Claude Code 会话,并只让它读取 handoff、task-state 和已生成的 unit summary,不要重新读取所有原始图片和 Excel。
+```
+
+新会话启动 Prompt:
+
+```text
+继续处理小红书真实采集任务文档,但请控制上下文。
+
+先只读取:
+1. .claude/task-state/handoff.md
+2. .claude/task-state/voc-context-state.md
+3. outputs/voc-context/sample-units.json
+4. 已生成的 unit summary
+
+不要重新读取所有 XHS 原始图片、Excel、长文档。
+请先告诉我当前进度、缺什么、下一步只处理哪个样本单元。
+每次最多处理一个样本单元,处理完写入对应摘要文件后停止。
+```
+
+## 分阶段实施计划
+
+### P0:先用规则和文档止血
+
+改动:
+
+- 新增 `references/context-budget-workflow.md`
+- 在 `SKILL.md` 引用省 token 协议
+- 在 `customer-quickstart.md` 或 `demo-runbook.md` 补充大资料处理话术
+
+收益:不改代码也能显著降低 Claude 一次性读取全部文件的概率。
+
+### P1:实现本地文件清单和任务状态
+
+改动:
+
+- 新增 `context-budget-run.js`
+- 支持 `prepare`、`next`、`checkpoint`、`handoff`
+- 写入 `.claude/task-state` 和 `outputs/voc-context`
+
+收益:长任务可以跨会话续跑。
+
+### P2:接入 MCP 工具
+
+改动:
+
+- 在 `server.js` 注册 `voc_context_prepare`
+- 注册 `voc_context_next_unit`
+- 注册 `voc_context_checkpoint`
+- smoke 测试覆盖工具返回的 `assistantMessage`
+
+收益:Claude Code 插件里可以直接调用工具,不必靠手写 Bash。
+
+### P3:增加文件预处理能力
+
+按优先级实现:
+
+1. Markdown/TXT 分章节摘要。
+2. Excel 只提取 sheet 名、列名、前 N 行和命中当前样本 ID 的行。
+3. 图片只提取路径、大小、尺寸、所属样本,不直接 OCR 全图。
+4. 后续如需要,再接 OCR 或视觉模型,但必须按单元处理。
+
+如果引入依赖,建议:
+
+- Excel:`xlsx`
+- 图片尺寸:`image-size`
+
+第一版也可以先不加依赖,只做路径、大小、扩展名和目录归属。
+
+### P4:打包和发布
+
+改动:
+
+- `package.json` 增加 smoke 脚本
+- `scripts/smoke-package.js` 增加 context workflow 检查
+- `npm run claude-voc:npm-pack`
+- `npm run claude-voc:build`
+- 升级 npm 版本并发布
+
+## 验收标准
+
+功能验收:
+
+- 给一个包含 6 个样本单元、图片、Excel、长文档的目录,`prepare` 后能生成 `sample-units.json`。
+- Claude 第一轮只处理 1 个样本单元。
+- 处理完会生成 `unit-summaries/<unitId>.md`。
+- `task-state.md` 能清楚显示已完成、待处理和下一步。
+- 新会话只读 handoff 和摘要,也能继续下一单元。
+
+体验验收:
+
+- 不再出现“继续”后反复超 token。
+- 不让用户理解复杂参数。
+- 不把 JSON 调试信息直接甩给用户。
+- 不要求用户手动整理 01-06 到 XHS-001 的映射,工具能先自动推断,必要时再让用户确认。
+
+安全验收:
+
+- 不把 VOC token、npm token 写入状态文件、摘要文件或报告。
+- `.claude/task-state` 可以进入工作区,但任何 `.env.local`、`.npmrc` 不进入 git。
+
+## 推荐优先落地顺序
+
+建议先做 P0 + P1。
+
+原因:
+
+- 这两个阶段能直接解决截图里的核心问题。
+- 不需要马上引入 Excel/OCR 复杂依赖。
+- 不改变小红书采集接口入参。
+- 后续再把工具挂到 MCP,风险更小。
+
+第一轮实施后,真实工作流会变成:
+
+```text
+用户给大资料目录
+-> Claude 调 context prepare
+-> 生成样本单元
+-> Claude 只处理 XHS-001_1
+-> 写摘要和 checkpoint
+-> 用户确认
+-> 下一轮处理 XHS-002_1
+-> 最后汇总所有 unit summary
+```
+
+这套方案和当前小红书趋势情报官并不冲突。它是给“资料很多、任务很长、需要跨会话完成”的场景加一层上下文管理能力。

+ 43 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/docs/customer-quickstart.md

@@ -0,0 +1,43 @@
+# 客户快速开始
+
+这套技能包适合给业务用户直接看趋势,不是给技术人员看接口。
+
+## 你可以这样开始
+
+如果你想一次拿到经营闭环:
+
+```text
+帮我看一下[行业/门店]最近顾客在关心什么,并告诉我先改哪里、下周发什么。
+```
+
+它会输出:市场和用户声音、先改哪个问题、Top 问题怎么解决、7 天内容计划和第一条口播稿。
+
+如果你只想先看趋势:
+
+```text
+帮我做一份[行业]的小红书趋势情报。
+先用演示样例跑通流程,不要真实采集。
+在聊天里给我第一轮样本观察和待确认问题。
+```
+
+## 你会看到什么
+
+- 第一轮初步判断(待校准)
+- 样本观察
+- 机会假设
+- 用户顾虑
+- 证据样本
+- 证据质量门槛
+- 下一轮要确认的问题
+
+## 什么时候切真实采集
+
+当你已经确认:
+
+- 行业方向对了
+- 关键词对了
+- 你想看真实数据
+
+再切 live 模式。
+
+如果没有 token 或余额不足,系统会给充值链接,不会只丢一条冷冰冰的报错。

+ 43 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/docs/deliverables/competitor-map-report.md

@@ -0,0 +1,43 @@
+# 竞品图谱与错位机会报告模板
+
+## 适用场景
+
+用户想看竞品、同城同品类别人怎么做、为什么吸引顾客,或者用户还不知道竞品是谁。
+
+老板语言入口:
+
+- 帮我看看竞品都在做什么。
+- 我不知道竞品是谁,帮我先做一份竞品图谱。
+- 看看同城同品类别人为什么吸引顾客。
+
+## 输入字段
+
+- 品牌/门店。
+- 城市/商圈。
+- 行业/品类。
+- 价格带,可选。
+- 消费场景。
+- 竞品名单,可选。
+- 竞品评论/VOC 证据,可选。
+
+## 输出结构
+
+1. 你这轮能拿到什么。
+2. 业务口径。
+3. 竞品候选图谱。
+4. 每类竞品为什么要看。
+5. 用户可能被吸引的点。
+6. 用户可能吐槽的点。
+7. 我方错位机会。
+8. VOC 信号。
+9. 证据样本。
+10. 下一步:进入问题池或 7 天内容选题。
+
+## 验收标准
+
+- 用户没给竞品时,必须先生成候选维度。
+- 输出必须带 VOC 证据样本。
+- 不能只做 SWOT。
+- 必须给出错位竞争动作和内容角度。
+- 不覆盖创始人故事。
+

+ 45 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/docs/deliverables/seven-day-content-plan.md

@@ -0,0 +1,45 @@
+# 7 天内容选题与口播脚本模板
+
+## 适用场景
+
+用户想知道下周账号发什么,或想把 VOC 问题转成短视频选题、口播脚本和镜头建议。
+
+老板语言入口:
+
+- 下周账号发什么。
+- 根据这些顾客吐槽生成 7 天口播。
+- 把排队久、人均贵、怕踩雷变成视频脚本。
+
+## 输入字段
+
+- 品牌/门店。
+- 行业/品类。
+- 平台。
+- VOC 问题。
+- 趋势报告/问题池/单点深挖结果,可选。
+- 内容周期,默认 7 天。
+
+## 输出结构
+
+1. 你这轮能拿到什么。
+2. 业务口径。
+3. 7 天内容表。
+4. 每天对应的用户问题。
+5. 洞察判断。
+6. 选题标题。
+7. 前 3 秒钩子。
+8. 30-60 秒口播脚本。
+9. 镜头建议。
+10. 评论区引导。
+11. 验证指标。
+12. 拍摄提醒。
+13. 下一步建议。
+
+## 验收标准
+
+- 每条内容必须绑定一个 VOC 用户问题。
+- 口播要能被餐饮老板直接照着拍。
+- 不输出泛泛爆款标题。
+- 不覆盖创始人故事。
+- 发布后要引导把评论反馈放回 VOC 问题池。
+

+ 49 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/docs/deliverables/single-problem-action-plan.md

@@ -0,0 +1,49 @@
+# 单点问题 7 天行动计划模板
+
+## 适用场景
+
+用户已经知道一个具体问题,想知道老板应该怎么解决、今天先改什么、7 天怎么验证。
+
+老板语言入口:
+
+- 这个问题老板怎么解决。
+- 继续深挖排队/价格/服务/味道。
+- 这个差评背后到底是什么问题。
+- 这个动作有效/无效,后面怎么调整。
+
+## 输入字段
+
+- 单个问题。
+- 行业/品类。
+- 场景。
+- 目标用户。
+- 证据样本。
+- 偏好动作/屏蔽动作,可选。
+- 有效/无效反馈,可选。
+- 深挖记忆,可选。
+
+## 输出结构
+
+1. 一句话判断。
+2. 业务口径。
+3. 优先级判断。
+4. 用户原话证据。
+5. 表层问题 vs 深层问题。
+6. 老板视角影响。
+7. 可执行解决动作。
+8. 为什么优先推荐这些动作,记忆命中时显示。
+9. 今天就能做。
+10. 7 天内验证。
+11. 长期沉淀。
+12. 可转化内容选题。
+13. 7 天验证计划。
+14. 下轮验证指标。
+15. 仍需确认的问题。
+
+## 验收标准
+
+- 每次只深挖一个问题。
+- 必须给出老板今天能做的动作。
+- 必须给出 7 天验证指标。
+- 用户反馈有效/无效后,后续排序必须变化。
+

+ 41 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/docs/deliverables/trend-insight-report.md

@@ -0,0 +1,41 @@
+# 行业用户洞察趋势清单模板
+
+## 适用场景
+
+用户想知道某个行业、城市、品类或平台上,顾客最近在关心什么、吐槽什么、什么内容值得跟进。
+
+老板语言入口:
+
+- 帮我看这个品类最近顾客在关心什么。
+- 看看南昌餐饮最近有什么机会。
+- 跑一份小红书/抖音趋势情报。
+
+## 输入字段
+
+- 行业/品类。
+- 城市/区域,可选。
+- 目标用户。
+- 关键词。
+- 平台:小红书/抖音。
+- 采集模式:sample/live。
+- 历史偏好记忆,可选。
+
+## 输出结构
+
+1. 你这轮能拿到什么。
+2. 第一轮初步判断。
+3. 数据口径。
+4. 样本观察 Top 5。
+5. 用户顾虑/决策阻力。
+6. 证据样本。
+7. 可继续验证的选题方向。
+8. 待确认问题。
+9. 下一步:整理成 VOC 问题池。
+
+## 验收标准
+
+- 必须说明这是第一轮判断,不是最终结论。
+- 必须包含证据样本,不能只有抽象判断。
+- 必须给出下一步进入问题池的建议。
+- 不要求用户理解 MCP、profile、collectionMode。
+

+ 42 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/docs/deliverables/voc-issue-pool-report.md

@@ -0,0 +1,42 @@
+# VOC 问题清单与优先级模板
+
+## 适用场景
+
+用户想知道评论里到底在吐槽什么、哪些问题最影响生意、先改哪个。
+
+老板语言入口:
+
+- 顾客到底在吐槽什么。
+- 把这些评论整理成问题池。
+- 我这个店先改哪里。
+- 哪些问题最影响生意。
+
+## 输入字段
+
+- 品牌/门店。
+- 行业/品类。
+- 使用场景。
+- 目标用户。
+- 评论/证据文本。
+- 上一轮趋势报告,可选。
+- 问题状态更新,可选。
+- 问题池记忆,可选。
+
+## 输出结构
+
+1. 一句话判断。
+2. 业务口径。
+3. 问题优先级。
+4. 每个问题的状态、证据数、影响环节、建议动作。
+5. 问题变化:新增、变多、变少、持平、本轮未再出现。
+6. 本周建议推进。
+7. 下一轮怎么用。
+8. 问题池记忆文件。
+
+## 验收标准
+
+- 必须站在经营视角说明影响新客、复购、客单、口碑或现场效率。
+- 必须包含证据样本。
+- 连续使用时必须对比问题变化。
+- 必须引导进入单点深挖。
+

+ 31 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/docs/demo-runbook.md

@@ -0,0 +1,31 @@
+# 演示运行手册
+
+## 演示目标
+
+让第一次接触的客户快速明白:
+
+这不是在教技术,而是在帮他从小红书样本里看趋势、看顾虑、看选题。
+
+## 演示顺序
+
+1. 先跑 sample
+2. 给出第一轮样本观察
+3. 让客户回答校准问题
+4. 保存偏好
+5. 再决定要不要切 live
+
+## 演示口播
+
+```text
+现在先看演示样例,不做真实采集。
+我会先给你第一轮样本观察和待确认问题。
+你只要告诉我哪些方向保留、哪些方向不要、下一版更偏什么视角就行。
+```
+
+## 演示重点
+
+- 第一轮不是结论
+- 证据比结论更重要
+- 用户反馈会进入下一轮校准
+- 没有 token 时也能先把流程讲明白
+

+ 50 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/docs/live-manual-acceptance-checklist.md

@@ -0,0 +1,50 @@
+# VOC Live 采集手动验收清单
+
+这份清单用于发布前或课程现场前确认真实采集链路可用。自动 smoke 不默认跑 live,因为 live 依赖上游余额、token 状态和平台接口稳定性。
+
+## 验收前准备
+
+- 当前工作区存在 `.env.local`,并配置 `VOC_TOKEN=<服务方提供的 session token>`。
+- 不依赖 `~/.openclaw/voc-credentials.json`。
+- 如果使用全局配置,只允许使用 `~/.claude/voc-credentials.json`。
+- 上游余额充足,且充值入口可打开:
+  `https://app.fmode.cn/dev/apig-pay/?apigid=Vo3ROWEvDy&fun_id=HOkkX72PMF`
+
+## 小红书 live 最小验收
+
+```bash
+node ".claude/plugins/voc-intelligence/mcp/src/tools/xiaohongshu-trend-run.js" --collection-mode live --industry "本地生活餐饮" --keywords "南昌美食,南昌菜" --keyword-limit 1 --notes-per-keyword 1 --max-comment-pages 1 --output "outputs/live-acceptance-xhs" --result-prefix XHS_TREND_RESULT
+```
+
+验收标准:
+
+- `status` 为 `ok`,或在无 token/余额不足时返回友好充值提示。
+- 不出现原始 `403`、权限栈、接口响应体。
+- 报告里有证据样本、原帖链接、评论证据。
+- `files` 中包含 Markdown、JSON,若有图片则包含本地 `assets`。
+
+## 抖音 live 最小验收
+
+```bash
+node ".claude/plugins/voc-intelligence/mcp/src/tools/douyin-trend-run.js" --collection-mode live --industry "本地生活餐饮" --keywords "南昌美食,南昌菜" --keyword-limit 1 --videos-per-keyword 1 --max-comment-pages 1 --output "outputs/live-acceptance-douyin" --result-prefix DOUYIN_TREND_RESULT
+```
+
+验收标准:
+
+- `status` 为 `ok`,或在无 token/余额不足时返回友好充值提示。
+- 不出现原始 `403`、权限栈、接口响应体。
+- 报告里有视频证据、原视频链接、评论证据。
+- 输出内容仍是“第一轮初步判断 / 机会假设 / 待确认问题”,不能把首轮样本写成最终结论。
+
+## 单点 VOC 记忆验收
+
+```bash
+node ".claude/plugins/voc-intelligence/mcp/src/tools/voc-problem-deep-dive-run.js" --issue "排队" --project "样板门店A" --industry "本地生活餐饮" --scenario "门店经营" --audience "到店顾客" --feedback "这个店不想做等位饮品,更想先改评论区回复和预点单" --assistant-message-only
+```
+
+验收标准:
+
+- 输出有“优先级判断、老板视角影响、可执行解决动作、7 天验证计划、下轮验证指标”。
+- “等位饮品”只能出现在“已应用的使用记忆”,不能出现在“可执行解决动作”。
+- 默认记忆文件按项目/行业/场景/人群隔离,不应污染其它行业或其它客户。
+

+ 93 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/docs/npm-packaging-notes.md

@@ -0,0 +1,93 @@
+# Claude Code VOC 技能包 npm 打包经验
+
+## 目标
+
+同一个技能包同时支持两种交付:
+
+- 链接下载 zip:适合不会 npm 的客户,解压后执行 `node install.js`。
+- npm/npx 安装:适合教学、远程交付和批量升级。
+
+推荐命令:
+
+```bash
+npm install -g @gangvy/claude-code-voc-intelligence
+claude-voc install
+```
+
+免全局安装:
+
+```bash
+npx @gangvy/claude-code-voc-intelligence install
+```
+
+## 关键设计
+
+`npx` 下载的包可能在临时缓存里,不能直接让 Claude Code 长期加载这个临时目录。
+
+所以 `claude-voc install` 会把包复制到固定目录:
+
+```text
+~/.claude/plugins/voc-intelligence
+```
+
+然后在该目录安装运行依赖,并输出:
+
+```bash
+claude --plugin-dir "<安装目录>"
+```
+
+这样全局安装和 npx 安装的结果一致,后续也便于升级。
+
+## package.json 要点
+
+- npm 包名使用 scoped package:`@gangvy/claude-code-voc-intelligence`
+- CLI 入口:
+
+```json
+{
+  "bin": {
+    "claude-voc": "bin/claude-voc.js"
+  }
+}
+```
+
+- `files` 必须显式包含:
+  - `.claude-plugin/`
+  - `.mcp.json`
+  - `bin/`
+  - `skills/`
+  - `mcp/`
+  - `memory-templates/`
+  - `docs/`
+  - `install.js`
+  - `skill-package-manifest.json`
+
+## 验证命令
+
+根目录执行:
+
+```bash
+npm run claude-voc:npm-pack
+```
+
+这个命令会做三件事:
+
+1. 校验 npm package metadata 和 CLI 入口。
+2. 生成 `dist/npm/*.tgz`。
+3. 在临时目录里模拟 npm 安装,并执行 `claude-voc install --smoke`。
+
+发布命令:
+
+```bash
+npm run claude-voc:npm-publish
+```
+
+发布前需要完成 `npm login`,或在 CI/部署环境配置 npm token。
+
+## 注意点
+
+- zip 包和 npm 包共用同一套源码,但验证脚本分开。
+- npm 包不要发布 `node_modules/`、`memory/`、`outputs/`、`.env`。
+- `install.js` 不强依赖 `package-lock.json`,因为 npm publish 后 package-lock 不一定随包安装。
+- 用户侧不要暴露 profile、output、collectionMode 这类参数;安装完成后仍然用自然语言启动。
+

+ 86 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/docs/payment-package-links.md

@@ -0,0 +1,86 @@
+# VOC 技能套餐与充值入口说明
+
+## 适用场景
+
+本说明用于把 `apig-pay` 的课程套餐、Claude Code 技能包和 live 真实采集串成一个闭环:
+
+```text
+安装 VS Code Claude Code
+-> 安装 VOC Intelligence 技能包
+-> 购买/开通技能套餐
+-> 复制并配置 session token
+-> 运行小红书 live 真实采集
+```
+
+## 套餐入口
+
+公开套餐总入口:
+
+```text
+https://app.fmode.cn/dev/apig-pay/#/workshop
+```
+
+常用套餐直达:
+
+| 套餐 | 入口 | 说明 |
+|---|---|---|
+| 课程特惠体验包 | https://app.fmode.cn/dev/apig-pay/#/workshop/19-9 | 隐藏直达,适合课堂试用 |
+| 课程加量体验包 | https://app.fmode.cn/dev/apig-pay/#/workshop/29-9 | 隐藏直达,适合课上多跑几轮 |
+| 纯享练习包 | https://app.fmode.cn/dev/apig-pay/#/workshop/99 | 公开入口,适合自助练习 |
+| 现场实训包 | https://app.fmode.cn/dev/apig-pay/#/workshop/299 | 公开入口,适合线下课程 |
+| 情报官实战包 | https://app.fmode.cn/dev/apig-pay/#/workshop/499 | 隐藏直达,适合企业实训 |
+| 情报官团队包 | https://app.fmode.cn/dev/apig-pay/#/workshop/699 | 公开入口,适合团队共用和复盘 |
+
+## 单 APIG 补额度入口
+
+当前小红书 live 采集走 VOC 社媒数据中台:
+
+```text
+https://app.fmode.cn/dev/apig-pay/?apigid=Vo3ROWEvDy&fun_id=HOkkX72PMF
+```
+
+如果用户只是社媒数据中台余额不足,可以使用这个入口补额度。课程和工作坊场景优先引导用户买技能套餐,因为技能套餐会同时给社媒数据中台和电商数据中台分配额度。
+
+## 技能包安装入口
+
+推荐 VS Code Claude Code 工作区安装:
+
+```powershell
+npx @gangvy/claude-code-voc-intelligence@latest workspace --smoke
+```
+
+安装后会写入:
+
+```text
+.\.claude\plugins\voc-intelligence
+.\.claude\skills\xiaohongshu-trend-intelligence\SKILL.md
+.\.mcp.json
+```
+
+安装完成后重启当前 VS Code Claude Code 会话。
+
+## Token 配置口径
+
+支付页登录后会显示“复制 OpenClaw 授权命令”。学员复制后在目标工作区终端执行,把 session token 写入本机凭证文件。
+
+技能包会按以下顺序读取 token:
+
+```text
+工具入参 vocToken / xiaohongshuToken
+workspace .env.local
+VOC_TOKEN
+VOC_SOCIAL_TOKEN
+~/.claude/voc-credentials.json
+```
+
+注意:
+
+- 不要把 token 写入 Git 仓库。
+- 不要把 token 放进讲义截图、报告或聊天输出。
+- 真实采集失败时,优先区分“没有 token”“token 失效”“余额不足”三类问题。
+
+## 课堂讲师话术
+
+可以这样讲:
+
+> 技能包负责告诉 Claude Code 怎么完成小红书趋势情报任务;套餐包负责给真实采集提供数据中台额度。先用 sample 模式让大家看懂流程,再让需要真实采集的学员购买套餐、配置 token,最后切到 live 模式。

+ 207 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/install.js

@@ -0,0 +1,207 @@
+#!/usr/bin/env node
+const fs = require('fs');
+const path = require('path');
+const { spawnSync } = require('child_process');
+
+const ROOT = __dirname;
+const MIN_NODE_MAJOR = 18;
+const PACKAGE_NAMES = new Set([
+  'claude-code-voc-intelligence',
+  '@gangvy/claude-code-voc-intelligence'
+]);
+
+function parseArgs(argv) {
+  const opts = {
+    check: false,
+    smoke: false,
+    skipInstall: false,
+    help: false,
+    noNext: false
+  };
+  for (const token of argv) {
+    if (token === '--check') opts.check = true;
+    else if (token === '--smoke') opts.smoke = true;
+    else if (token === '--skip-install') opts.skipInstall = true;
+    else if (token === '--no-next') opts.noNext = true;
+    else if (token === '--help' || token === '-h') opts.help = true;
+  }
+  return opts;
+}
+
+function usage() {
+  return [
+    'Claude Code VOC Intelligence installer',
+    '',
+    'Usage:',
+    '  node install.js',
+    '  node install.js --smoke',
+    '  node install.js --check',
+    '',
+    'Options:',
+    '  --check          Check files and environment only',
+    '  --smoke          Run sample/preference/MCP smoke checks',
+    '  --skip-install   Skip npm install',
+    '  --no-next        Do not print next-step instructions',
+    '  --help, -h       Show help'
+  ].join('\n');
+}
+
+function run(command, args, options = {}) {
+  const useCmd = process.platform === 'win32' && command === 'npm';
+  const executable = useCmd ? 'cmd.exe' : command;
+  const finalArgs = useCmd ? ['/d', '/s', '/c', 'npm', ...args] : args;
+  const child = spawnSync(executable, finalArgs, {
+    cwd: options.cwd || ROOT,
+    encoding: 'utf8',
+    stdio: options.stdio || 'inherit',
+    maxBuffer: 1024 * 1024 * 100
+  });
+  if (child.status !== 0) {
+    const detail = child.error ? `: ${child.error.message}` : '';
+    throw new Error(`${command} ${args.join(' ')} failed with exit ${child.status}${detail}`);
+  }
+  return child;
+}
+
+function readJson(relativePath) {
+  return JSON.parse(fs.readFileSync(path.join(ROOT, relativePath), 'utf8'));
+}
+
+function writeMcpConfig(rootDir = ROOT) {
+  const mcpConfigPath = path.join(rootDir, '.mcp.json');
+  const serverPath = path.join(rootDir, 'mcp', 'src', 'server.js');
+  const mcpConfig = {
+    mcpServers: {
+      voc: {
+        command: 'node',
+        args: [serverPath],
+        cwd: rootDir
+      }
+    }
+  };
+  fs.writeFileSync(mcpConfigPath, `${JSON.stringify(mcpConfig, null, 2)}\n`, 'utf8');
+}
+
+function assertFile(relativePath) {
+  const absolute = path.join(ROOT, relativePath);
+  if (!fs.existsSync(absolute)) {
+    throw new Error(`Missing required file: ${relativePath}`);
+  }
+}
+
+function checkNodeVersion() {
+  const major = Number(process.versions.node.split('.')[0]);
+  if (!Number.isFinite(major) || major < MIN_NODE_MAJOR) {
+    throw new Error(`Node.js ${MIN_NODE_MAJOR}+ is required. Current version: ${process.version}`);
+  }
+}
+
+function checkFiles() {
+  [
+    'package.json',
+    '.claude-plugin/plugin.json',
+    '.mcp.json',
+    'skill-package-manifest.json',
+    'skills/xiaohongshu-trend-intelligence/SKILL.md',
+    'skills/douyin-trend-intelligence/SKILL.md',
+    'skills/voc-issue-pool/SKILL.md',
+    'skills/voc-problem-deep-dive/SKILL.md',
+    'skills/voc-content-plan/SKILL.md',
+    'skills/voc-speaking-script/SKILL.md',
+    'skills/voc-competitor-map/SKILL.md',
+    'skills/voc-business-workflow/SKILL.md',
+    'mcp/src/server.js'
+  ].forEach(assertFile);
+
+  const packageJson = readJson('package.json');
+  const pluginJson = readJson('.claude-plugin/plugin.json');
+  const mcpJson = readJson('.mcp.json');
+  if (!PACKAGE_NAMES.has(packageJson.name)) {
+    throw new Error('package.json name is invalid');
+  }
+  if (pluginJson.name !== 'voc-intelligence') {
+    throw new Error('.claude-plugin/plugin.json name is invalid');
+  }
+  if (!mcpJson.mcpServers || !(mcpJson.mcpServers.voc || mcpJson.mcpServers['voc-intelligence'])) {
+    throw new Error('.mcp.json does not configure the VOC MCP server');
+  }
+}
+
+function checkClaudeCommand() {
+  const command = process.platform === 'win32' ? 'where' : 'which';
+  const child = spawnSync(command, ['claude'], {
+    cwd: ROOT,
+    encoding: 'utf8',
+    stdio: 'pipe'
+  });
+  return child.status === 0;
+}
+
+function printNextSteps({ claudeAvailable }) {
+  const loadCommand = `claude --plugin-dir "${ROOT}"`;
+  console.log('');
+  console.log('Install check complete.');
+  console.log('');
+  console.log('Next step:');
+  console.log(`  1. Start Claude Code with: ${loadCommand}`);
+  console.log('  2. In Claude Code, say:');
+  console.log('     帮我看一下南昌餐饮最近顾客在关心什么,并告诉我先改哪里、下周发什么。');
+  console.log('  或者说:');
+  console.log('     帮我做一份家装全屋定制行业的小红书趋势情报。');
+  console.log('     先用演示样例跑通流程,不要真实采集。');
+  console.log('     在聊天里给我第一轮样本观察和待确认问题。');
+  console.log('  或者说:');
+  console.log('     帮我做一份家装全屋定制行业的抖音趋势情报。');
+  console.log('     先用演示样例跑通流程,不要真实采集。');
+  console.log('     在聊天里给我第一轮样本观察和待确认问题。');
+  console.log('');
+  if (!claudeAvailable) {
+    console.log('Tip: the current terminal did not detect the claude command.');
+    console.log('If Claude Code is installed elsewhere, run the command above in that terminal.');
+    console.log('');
+  }
+  console.log('Live collection requires a Xiaohongshu or Douyin/VOC token. Sample mode works without a token.');
+}
+
+function main() {
+  const opts = parseArgs(process.argv.slice(2));
+  if (opts.help) {
+    console.log(usage());
+    return;
+  }
+
+  console.log('');
+  console.log('Claude Code VOC Intelligence install check');
+  console.log('==========================================');
+  console.log('');
+
+  checkNodeVersion();
+  checkFiles();
+  writeMcpConfig(ROOT);
+  console.log(`Node.js: ${process.version}`);
+  console.log('Required files: OK');
+
+  if (!opts.check && !opts.skipInstall) {
+    console.log('');
+    console.log('Installing dependencies...');
+    run('npm', ['install', '--omit=dev', '--ignore-scripts']);
+  }
+
+  if (opts.smoke) {
+    console.log('');
+    console.log('Running sample/preference/MCP smoke checks...');
+    run(process.execPath, ['scripts/smoke-package.js']);
+  }
+
+  if (!opts.noNext) {
+    printNextSteps({ claudeAvailable: checkClaudeCommand() });
+  }
+}
+
+try {
+  main();
+} catch (error) {
+  console.error('');
+  console.error(`Install failed: ${error.message}`);
+  process.exit(1);
+}

+ 85 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/core/credentials.js

@@ -0,0 +1,85 @@
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+function readJsonMaybe(filePath) {
+  try {
+    if (!filePath || !fs.existsSync(filePath)) return {};
+    return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''));
+  } catch {
+    return {};
+  }
+}
+
+function firstNonEmpty(values) {
+  return values.find(value => typeof value === 'string' && value.trim()) || '';
+}
+
+function readEnvFileMaybe(filePath) {
+  try {
+    if (!filePath || !fs.existsSync(filePath)) return {};
+    const env = {};
+    const content = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
+    for (const rawLine of content.split(/\r?\n/)) {
+      const line = rawLine.trim();
+      if (!line || line.startsWith('#')) continue;
+      const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
+      if (!match) continue;
+      let value = match[2].trim();
+      if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
+        value = value.slice(1, -1);
+      }
+      env[match[1]] = value;
+    }
+    return env;
+  } catch {
+    return {};
+  }
+}
+
+function readWorkspaceEnvLocal() {
+  return readEnvFileMaybe(path.join(process.cwd(), '.env.local'));
+}
+
+function readVocToken(input = {}) {
+  const workspaceEnv = readWorkspaceEnvLocal();
+  const claudeCreds = readJsonMaybe(path.join(os.homedir(), '.claude', 'voc-credentials.json'));
+  return firstNonEmpty([
+    input.xiaohongshuToken,
+    input.douyinToken,
+    input.vocToken,
+    input['xiaohongshu-token'],
+    input['douyin-token'],
+    input['voc-token'],
+    input.token,
+    input.apiToken,
+    input['api-token'],
+    workspaceEnv.VOC_TOKEN,
+    workspaceEnv.VOC_SOCIAL_TOKEN,
+    workspaceEnv.XIAOHONGSHU_API_TOKEN,
+    workspaceEnv.DOUYIN_API_TOKEN,
+    workspaceEnv.DOUYIN_TOKEN,
+    process.env.VOC_TOKEN,
+    process.env.VOC_SOCIAL_TOKEN,
+    process.env.XIAOHONGSHU_API_TOKEN,
+    process.env.DOUYIN_API_TOKEN,
+    process.env.DOUYIN_TOKEN,
+    process.env.TIKHUB_API_TOKEN,
+    process.env.FMODE_XIAOHONGSHU_TOKEN,
+    process.env.FMODE_SOCIAL_TOKEN,
+    claudeCreds.vocToken,
+    claudeCreds.token,
+    claudeCreds.xiaohongshuToken,
+    claudeCreds.douyinToken,
+    claudeCreds.tikhubToken
+  ]);
+}
+
+function readXiaohongshuToken(input = {}) {
+  return readVocToken(input);
+}
+
+module.exports = {
+  readVocToken,
+  readXiaohongshuToken
+};

+ 106 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/core/evidence-quality.js

@@ -0,0 +1,106 @@
+function asArray(value) {
+  return Array.isArray(value) ? value : [];
+}
+
+function hasSourceUrl(item) {
+  return Boolean(item && (item.sourceUrl || item.webUrl || item.url));
+}
+
+function hasImage(item) {
+  return Boolean(
+    item &&
+    (item.localCoverPath ||
+      item.coverUrl ||
+      (Array.isArray(item.imageUrls) && item.imageUrls.length))
+  );
+}
+
+function hasText(item) {
+  return Boolean(
+    item &&
+    String(item.contentExcerpt || item.rawExcerpt || item.content || item.desc || item.title || '').trim()
+  );
+}
+
+function assessEvidenceQuality({ items, comments, itemLabel = '样本', collectionMode = 'sample' }) {
+  const rows = asArray(items);
+  const commentRows = asArray(comments);
+  const sourceUrlCount = rows.filter(hasSourceUrl).length;
+  const imageCount = rows.filter(hasImage).length;
+  const textCount = rows.filter(hasText).length;
+  const highLikeCommentCount = commentRows.filter(comment => Number(comment.likeCount || 0) > 0).length;
+
+  let score = 0;
+  if (rows.length >= 8) score += 2;
+  else if (rows.length >= 3) score += 1;
+  if (commentRows.length >= 50) score += 2;
+  else if (commentRows.length >= 10) score += 1;
+  if (sourceUrlCount >= Math.min(rows.length, 3)) score += 1;
+  if (imageCount >= Math.min(rows.length, 3)) score += 1;
+  if (highLikeCommentCount >= 3) score += 1;
+  if (textCount >= Math.min(rows.length, 3)) score += 1;
+
+  const grade = score >= 7 ? 'A' : score >= 4 ? 'B' : 'C';
+  const canMakeBusinessDecision = grade === 'A' || (grade === 'B' && collectionMode === 'live');
+  const recommendation = canMakeBusinessDecision
+    ? '可以作为第一轮经营判断依据,但仍建议结合门店/销售/私域反馈校准。'
+    : '只适合作为初步机会假设,不建议直接当最终经营结论。';
+
+  return {
+    grade,
+    itemLabel,
+    itemCount: rows.length,
+    commentCount: commentRows.length,
+    sourceUrlCount,
+    imageCount,
+    textCount,
+    highLikeCommentCount,
+    canMakeBusinessDecision,
+    recommendation
+  };
+}
+
+function buildEvidenceQualitySection(quality) {
+  return [
+    '## 证据质量门槛',
+    '',
+    `- 本轮证据质量:${quality.grade}`,
+    `- ${quality.itemLabel}数量:${quality.itemCount}`,
+    `- 评论数量:${quality.commentCount}`,
+    `- 含原文/原视频链接:${quality.sourceUrlCount}`,
+    `- 含图片/封面:${quality.imageCount}`,
+    `- 含文本摘录:${quality.textCount}`,
+    `- 含点赞评论:${quality.highLikeCommentCount}`,
+    `- 是否适合直接做经营判断:${quality.canMakeBusinessDecision ? '适合第一轮判断' : '仅适合初步假设'}`,
+    `- 使用建议:${quality.recommendation}`
+  ];
+}
+
+function buildNoSampleFallbackMessage({ platformLabel, profile, examples = [] }) {
+  const keywords = asArray(profile && profile.keywords).slice(0, 5).join('、') || '当前关键词';
+  const exampleLines = examples.length ? examples.map(item => `- ${item}`) : [
+    '- 换成更具体的品类词、场景词或城市词',
+    '- 从产品词扩展到用户真实说法',
+    '- 先用 sample 模式演示完整流程,但明确标注非真实采集'
+  ];
+
+  return [
+    `本次 live 模式没有采到可用于分析的${platformLabel}样本。`,
+    '',
+    '这不代表行业没有机会,更可能是关键词过窄、平台内容表达不一样,或本轮上游没有返回可用内容。',
+    '',
+    '建议按这三步继续:',
+    `1. 先保留当前方向:${keywords}`,
+    '2. 自动换一组更贴近用户说法的关键词再试一次',
+    '3. 如果仍然没有样本,降级到相关大词做第一轮机会假设,并明确标注“非真实采集/待验证”',
+    '',
+    '可尝试的关键词方向:',
+    ...exampleLines
+  ].join('\n');
+}
+
+module.exports = {
+  assessEvidenceQuality,
+  buildEvidenceQualitySection,
+  buildNoSampleFallbackMessage
+};

+ 31 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/core/memory-store.js

@@ -0,0 +1,31 @@
+const fs = require('fs');
+const path = require('path');
+
+function ensureDir(dirPath) {
+  fs.mkdirSync(dirPath, { recursive: true });
+}
+
+function readJson(filePath, fallback = {}) {
+  try {
+    if (!filePath || !fs.existsSync(filePath)) return fallback;
+    return JSON.parse(fs.readFileSync(filePath, 'utf8'));
+  } catch {
+    return fallback;
+  }
+}
+
+function writeJson(filePath, data) {
+  ensureDir(path.dirname(filePath));
+  fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
+}
+
+function defaultMemoryPath(name) {
+  return path.resolve('memory', name);
+}
+
+module.exports = {
+  readJson,
+  writeJson,
+  defaultMemoryPath
+};
+

+ 117 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/core/payment-links.js

@@ -0,0 +1,117 @@
+const VOC_SOCIAL_APIG_ID = 'Vo3ROWEvDy';
+const VOC_RECHARGE_FUN_ID = 'HOkkX72PMF';
+const VOC_RECHARGE_BASE_URL = 'https://app.fmode.cn/dev/apig-pay/';
+const PARSE_USERS_ME_URL = 'https://server.fmode.cn/parse/users/me';
+const DEFAULT_WORKSHOP_PACKAGE_SLUG = '99';
+const VOC_WORKSHOP_PACKAGE_SLUGS = {
+  trial199: '19-9',
+  trial299: '29-9',
+  selfPractice: '99',
+  onsiteTraining: '299',
+  teamDelivery: '699'
+};
+
+function buildVocRechargeUrl(options = {}) {
+  const url = new URL(VOC_RECHARGE_BASE_URL);
+  if (options.userId) {
+    url.searchParams.set('user', String(options.userId));
+  }
+  url.searchParams.set('apigid', VOC_SOCIAL_APIG_ID);
+  url.searchParams.set('fun_id', VOC_RECHARGE_FUN_ID);
+  return url.toString();
+}
+
+function buildVocWorkshopUrl(options = {}) {
+  const url = new URL(VOC_RECHARGE_BASE_URL);
+  if (options.userId) {
+    url.searchParams.set('user', String(options.userId));
+  }
+  const slug = options.slug ? String(options.slug) : '';
+  url.hash = slug ? `/workshop/${slug}` : '/workshop';
+  return url.toString();
+}
+
+async function resolveVocUserId(token) {
+  if (!token) return '';
+  try {
+    const response = await fetch(PARSE_USERS_ME_URL, {
+      method: 'GET',
+      headers: {
+        'X-Parse-Application-Id': 'ncloudmaster',
+        'X-Parse-Session-Token': token,
+        Accept: 'application/json'
+      }
+    });
+    if (!response.ok) return '';
+    const json = await response.json();
+    return json && json.objectId ? String(json.objectId) : '';
+  } catch {
+    return '';
+  }
+}
+
+async function buildVocRechargeInfo(options = {}) {
+  const userId = await resolveVocUserId(options.token);
+  const workshopUrl = buildVocWorkshopUrl({ userId });
+  const recommendedPackageUrl = buildVocWorkshopUrl({
+    userId,
+    slug: options.packageSlug || DEFAULT_WORKSHOP_PACKAGE_SLUG
+  });
+  return {
+    paymentUrl: buildVocRechargeUrl({ userId }),
+    fallbackPaymentUrl: buildVocRechargeUrl(),
+    workshopUrl,
+    recommendedPackageUrl,
+    packageUrls: {
+      trial199: buildVocWorkshopUrl({ userId, slug: VOC_WORKSHOP_PACKAGE_SLUGS.trial199 }),
+      trial299: buildVocWorkshopUrl({ userId, slug: VOC_WORKSHOP_PACKAGE_SLUGS.trial299 }),
+      selfPractice: buildVocWorkshopUrl({ userId, slug: VOC_WORKSHOP_PACKAGE_SLUGS.selfPractice }),
+      onsiteTraining: buildVocWorkshopUrl({ userId, slug: VOC_WORKSHOP_PACKAGE_SLUGS.onsiteTraining }),
+      teamDelivery: buildVocWorkshopUrl({ userId, slug: VOC_WORKSHOP_PACKAGE_SLUGS.teamDelivery })
+    },
+    apigId: VOC_SOCIAL_APIG_ID,
+    funId: VOC_RECHARGE_FUN_ID,
+    personalized: Boolean(userId)
+  };
+}
+
+function buildMissingTokenMessage(paymentUrl, recharge = {}, options = {}) {
+  const platformLabel = options.platformLabel || '小红书';
+  const packageUrl = recharge.recommendedPackageUrl || recharge.workshopUrl || paymentUrl;
+  return [
+    `还没有检测到 VOC-AI 数据服务 token,暂时不能进行真实 ${platformLabel} 采集。`,
+    '',
+    `请先打开充值/开通链接:${paymentUrl}`,
+    '',
+    `课程或工作坊用户也可以打开套餐入口:${packageUrl}`,
+    '',
+    '完成充值或开通后,把服务方提供的 session token 配置到当前工作区 `.env.local` 或 `~/.claude/voc-credentials.json`,再重新运行 live 采集。',
+    '',
+    '系统只会用 token 发起请求,不会在报告、日志或聊天输出里回显 token。'
+  ].join('\n');
+}
+
+function buildRechargeRequiredMessage(paymentUrl, recharge = {}, options = {}) {
+  const platformLabel = options.platformLabel || '小红书';
+  const packageUrl = recharge.recommendedPackageUrl || recharge.workshopUrl || paymentUrl;
+  return [
+    `VOC-AI 数据服务余额不足,暂时不能继续真实 ${platformLabel} 采集。`,
+    '',
+    `请先打开充值链接补充额度:${paymentUrl}`,
+    '',
+    `课程或工作坊用户也可以打开套餐入口:${packageUrl}`,
+    '',
+    `我已经隐藏了底层接口权限/余额报错,不会把真实 ${platformLabel} 接口 403 或权限信息直接展示给用户。`
+  ].join('\n');
+}
+
+module.exports = {
+  VOC_SOCIAL_APIG_ID,
+  VOC_RECHARGE_FUN_ID,
+  VOC_WORKSHOP_PACKAGE_SLUGS,
+  buildVocRechargeInfo,
+  buildVocRechargeUrl,
+  buildVocWorkshopUrl,
+  buildMissingTokenMessage,
+  buildRechargeRequiredMessage
+};

+ 31 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/core/result-envelope.js

@@ -0,0 +1,31 @@
+function okResult(payload = {}) {
+  return {
+    status: 'ok',
+    assistantMessage: payload.assistantMessage || '',
+    summary: payload.summary || {},
+    data: payload.data || {},
+    files: payload.files || [],
+    nextActions: payload.nextActions || [],
+    warnings: payload.warnings || [],
+    errors: payload.errors || []
+  };
+}
+
+function errorResult(message, payload = {}) {
+  return {
+    status: 'error',
+    assistantMessage: message,
+    summary: payload.summary || {},
+    data: payload.data || {},
+    files: payload.files || [],
+    nextActions: payload.nextActions || [],
+    warnings: payload.warnings || [],
+    errors: payload.errors || [{ message }]
+  };
+}
+
+module.exports = {
+  okResult,
+  errorResult
+};
+

+ 259 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/douyin-trend/live-collector.js

@@ -0,0 +1,259 @@
+const { DouyinApi } = require('../../providers/douyin-api');
+
+function normalizeCount(value) {
+  if (value === undefined || value === null || value === '') return 0;
+  if (typeof value === 'number') return Number.isFinite(value) ? value : 0;
+  const text = String(value).trim().toLowerCase();
+  const match = text.match(/([\d.]+)/);
+  if (!match) return 0;
+  const base = Number.parseFloat(match[1]);
+  if (!Number.isFinite(base)) return 0;
+  if (text.includes('w') || text.includes('万')) return Math.round(base * 10000);
+  if (text.includes('k') || text.includes('千')) return Math.round(base * 1000);
+  return Math.round(base);
+}
+
+function videoScore(video) {
+  return normalizeCount(video.likeCount) +
+    normalizeCount(video.commentCount) * 3 +
+    normalizeCount(video.shareCount) * 2 +
+    normalizeCount(video.playCount) * 0.02;
+}
+
+function pickFirstString(...values) {
+  return values
+    .flat()
+    .filter(value => typeof value === 'string')
+    .map(value => value.trim())
+    .find(Boolean) || '';
+}
+
+function nestedValue(object, path) {
+  return path.split('.').reduce((current, key) => (
+    current && typeof current === 'object' ? current[key] : undefined
+  ), object);
+}
+
+function collectUrls(value, result = []) {
+  if (!value) return result;
+  if (typeof value === 'string') {
+    if (/^https?:\/\//i.test(value) && !result.includes(value)) result.push(value);
+    return result;
+  }
+  if (Array.isArray(value)) {
+    value.forEach(item => collectUrls(item, result));
+    return result;
+  }
+  if (typeof value !== 'object') return result;
+
+  ['url', 'url_default', 'url_pre', 'original_url', 'large', 'middle', 'thumbnail', 'image_url', 'imageUrl']
+    .forEach(key => collectUrls(value[key], result));
+
+  ['url_list', 'urlList', 'info_list', 'infoList', 'images', 'image_list', 'imageList']
+    .forEach(key => collectUrls(value[key], result));
+
+  return result;
+}
+
+function extractImageUrls(video = {}, raw = {}) {
+  const result = [];
+  [
+    video.cover,
+    video.origin_cover,
+    video.dynamic_cover,
+    video.video && video.video.cover,
+    video.video && video.video.origin_cover,
+    video.video && video.video.dynamic_cover,
+    raw.cover,
+    raw.origin_cover,
+    raw.dynamic_cover,
+    raw.video && raw.video.cover,
+    raw.video && raw.video.origin_cover,
+    raw.video && raw.video.dynamic_cover
+  ].forEach(value => collectUrls(value, result));
+  return result;
+}
+
+function pickSourceUrl(video = {}, raw = {}) {
+  const id = video.aweme_id || video.id || raw.aweme_id || raw.id || '';
+  const candidates = [
+    nestedValue(video, 'share_info.link'),
+    nestedValue(raw, 'share_info.link'),
+    nestedValue(video, 'share_info.share_url'),
+    nestedValue(raw, 'share_info.share_url'),
+    video.share_url,
+    raw.share_url,
+    video.web_url,
+    raw.web_url,
+    video.url,
+    raw.url,
+    id ? `https://www.douyin.com/video/${id}` : ''
+  ].filter(Boolean);
+  return pickFirstString(candidates);
+}
+
+function sourceUrlVerified(url = '') {
+  return /douyin\.com|v\.douyin\.com|aweme/i.test(String(url || ''));
+}
+
+function normalizeVideo(raw = {}, keyword = '', detail) {
+  const video = detail && (detail.aweme_detail || detail.aweme_info || detail.id) ? (detail.aweme_detail || detail.aweme_info || detail) : raw;
+  const item = video.aweme_info || raw.aweme_info || video;
+  const author = item.author || raw.author || {};
+  const sourceUrl = pickSourceUrl(item, raw);
+  const imageUrls = extractImageUrls(item, raw);
+  const stats = item.statistics || raw.statistics || {};
+  const desc = item.desc || item.title || raw.desc || raw.title || '';
+  const id = item.aweme_id || item.id || raw.aweme_id || raw.id || '';
+  const tags = [];
+  (Array.isArray(item.cha_list) ? item.cha_list : []).forEach(tag => {
+    const text = tag?.cha_name || tag?.desc || tag?.title || tag?.name || '';
+    if (text) tags.push(String(text));
+  });
+  const textExtra = Array.isArray(item.text_extra) ? item.text_extra : [];
+  textExtra.forEach(extra => {
+    const text = extra?.hashtag_name || extra?.name || extra?.text || '';
+    if (text) tags.push(String(text));
+  });
+
+  return {
+    id,
+    videoId: id,
+    platform: 'douyin',
+    keyword,
+    title: desc,
+    desc,
+    content: desc,
+    contentExcerpt: String(desc || '').replace(/\s+/g, ' ').trim(),
+    rawExcerpt: String(desc || '').replace(/\s+/g, ' ').trim(),
+    author: author.nickname || author.name || '',
+    authorId: author.uid || author.id || '',
+    authorSecUid: author.sec_uid || author.secUid || '',
+    likeCount: normalizeCount(stats.digg_count || item.like_count || item.digg_count),
+    collectCount: normalizeCount(stats.collect_count || item.collect_count || item.favorite_count),
+    commentCount: normalizeCount(stats.comment_count || item.comment_count),
+    shareCount: normalizeCount(stats.share_count || item.share_count),
+    playCount: normalizeCount(stats.play_count || item.play_count),
+    timestamp: item.create_time || raw.create_time || '',
+    url: sourceUrl,
+    webUrl: sourceUrl,
+    shareUrl: sourceUrl,
+    sourceUrl,
+    sourceUrlVerified: sourceUrlVerified(sourceUrl),
+    fallbackUrl: id ? `https://www.douyin.com/video/${id}` : '',
+    coverUrl: imageUrls[0] || '',
+    imageUrls,
+    tags: [...new Set(tags)].filter(Boolean)
+  };
+}
+
+function normalizeComment(raw = {}, videoId, keyword) {
+  const user = raw.user_info || raw.user || {};
+  return {
+    id: raw.cid || raw.id || raw.comment_id || `${videoId}-${Math.random().toString(36).slice(2, 8)}`,
+    cid: raw.cid || raw.comment_id || raw.id || '',
+    noteId: videoId,
+    videoId,
+    platform: 'douyin',
+    keyword,
+    text: raw.text || raw.content || '',
+    likeCount: normalizeCount(raw.digg_count || raw.like_count || raw.likeCount),
+    author: user.nickname || user.name || '',
+    authorId: user.uid || user.user_id || user.id || '',
+    createTime: raw.create_time || '',
+    ipLocation: raw.ip_label || raw.ip_location || '',
+    replyCommentTotal: normalizeCount(raw.reply_comment_total || raw.reply_count || 0)
+  };
+}
+
+function extractSearchVideos(data = {}) {
+  const cards = data.business_data || data.cards || data.items || [];
+  if (!Array.isArray(cards)) return [];
+  return cards
+    .map(card => card?.data?.aweme_info || card?.data?.aweme_detail?.aweme_info || card?.data?.aweme_info || card?.aweme_info || card)
+    .filter(item => item && (item.aweme_id || item.id));
+}
+
+function extractComments(data = {}) {
+  const comments = data.comments || data.comment_list || [];
+  return Array.isArray(comments) ? comments : [];
+}
+
+async function collectLiveDataset({ profile, token, options = {} }) {
+  const api = new DouyinApi({ token, baseUrl: options.baseUrl });
+  const keywordLimit = Math.max(1, Number(options.keywordLimit || 1));
+  const videosPerKeyword = Math.max(1, Number(options.videosPerKeyword || options.notesPerKeyword || 1));
+  const maxCommentPages = Math.max(0, Number(options.maxCommentPages ?? 1));
+  const keywords = (profile.keywords || []).slice(0, keywordLimit);
+  const videos = [];
+  const comments = [];
+  const warnings = [];
+  const errors = [];
+
+  for (const keyword of keywords) {
+    try {
+      const search = await api.searchVideos({
+        keyword,
+        cursor: 0,
+        sortType: options.sortType || options.sort || '1',
+        publishTime: options.publishTime || options.publish_time || '7',
+        filterDuration: options.filterDuration || options.filter_duration || '0',
+        contentType: options.contentType || options.content_type || '1',
+        searchId: options.searchId || options.search_id || '',
+        backtrace: options.backtrace || ''
+      });
+      const rawVideos = extractSearchVideos(search.data)
+        .map(raw => normalizeVideo(raw, keyword))
+        .sort((a, b) => videoScore(b) - videoScore(a))
+        .slice(0, videosPerKeyword);
+
+      for (const video of rawVideos) {
+        videos.push(video);
+        let cursor = 0;
+        for (let page = 0; page < maxCommentPages; page++) {
+          try {
+            const commentResult = await api.getVideoComments({ awemeId: video.id, cursor, count: 20 });
+            extractComments(commentResult.data).forEach(rawComment => {
+              comments.push(normalizeComment(rawComment, video.id, keyword));
+            });
+            cursor = Number(commentResult.data.cursor || 0);
+            if (!commentResult.data.has_more || !cursor) break;
+          } catch (error) {
+            warnings.push({
+              stage: 'video_comments',
+              keyword,
+              videoId: video.id,
+              message: error.message,
+              httpStatus: error.httpStatus
+            });
+            break;
+          }
+        }
+      }
+    } catch (error) {
+      errors.push({
+        stage: 'search_videos',
+        keyword,
+        message: error.message,
+        httpStatus: error.httpStatus
+      });
+    }
+  }
+
+  return {
+    videos,
+    comments,
+    warnings,
+    errors
+  };
+}
+
+module.exports = {
+  collectLiveDataset,
+  normalizeVideo,
+  normalizeComment,
+  extractSearchVideos,
+  extractComments,
+  pickSourceUrl,
+  sourceUrlVerified
+};

+ 130 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/douyin-trend/preference-memory.js

@@ -0,0 +1,130 @@
+const path = require('path');
+const { readJson, writeJson, defaultMemoryPath } = require('../../core/memory-store');
+
+const DEFAULT_MEMORY_FILE = 'douyin-trend-memory.json';
+
+function cleanList(values) {
+  return [...new Set((values || [])
+    .flatMap(value => Array.isArray(value) ? value : String(value || '').split(/[,,、\n\r]+|以及|与|和/))
+    .map(value => String(value || '').trim())
+    .filter(Boolean))];
+}
+
+function appendNote(notes, note) {
+  const text = String(note || '').trim();
+  if (!text) return (notes || []).slice(-20);
+  const next = [...(notes || []).filter(Boolean), text];
+  return [...new Set(next)].slice(-20);
+}
+
+function defaultTrendMemory() {
+  return {
+    version: '0.3.10',
+    preferredDirections: [],
+    blockedDirections: [],
+    focusModes: [],
+    notes: [],
+    updatedAt: ''
+  };
+}
+
+function trendMemoryPath(input = {}) {
+  return path.resolve(input.memory || input.memoryPath || defaultMemoryPath(DEFAULT_MEMORY_FILE));
+}
+
+function readTrendMemory(memoryPath) {
+  return {
+    ...defaultTrendMemory(),
+    ...readJson(memoryPath, {})
+  };
+}
+
+function extractAfter(text, pattern) {
+  const match = String(text || '').match(pattern);
+  return match ? match[1] : '';
+}
+
+function inferPreferenceFromMessage(message) {
+  const text = String(message || '').trim();
+  const preferred = cleanList([
+    extractAfter(text, /(?:保留|多给|继续给|更喜欢|适合)(.+?)(?:。|;|;|,但|但是|不要|少给|降权|$)/)
+  ]);
+  const blocked = cleanList([
+    extractAfter(text, /(?:不要|少给|降权|过滤|不适合|避开)(.+?)(?:。|;|;|,|$)/)
+  ]);
+  const focus = cleanList([
+    extractAfter(text, /(?:更偏|侧重|多看|下一版更偏)(.+?)(?:。|;|;|,|$)/)
+  ]);
+  return { preferred, blocked, focus };
+}
+
+function updateTrendMemory(input = {}) {
+  const memoryPath = trendMemoryPath(input);
+  const memory = readTrendMemory(memoryPath);
+  const inferred = inferPreferenceFromMessage(input.message || input.feedback || '');
+  const preferredDirections = cleanList([
+    ...memory.preferredDirections,
+    ...inferred.preferred,
+    ...(input.preferredDirections || [])
+  ]);
+  const blockedDirections = cleanList([
+    ...memory.blockedDirections,
+    ...inferred.blocked,
+    ...(input.blockedDirections || [])
+  ]);
+  const focusModes = cleanList([
+    ...memory.focusModes,
+    ...inferred.focus,
+    ...(input.focusModes || [])
+  ]);
+  const note = String(input.message || input.feedback || '').trim();
+  const notes = appendNote(memory.notes, note);
+  const next = {
+    ...memory,
+    version: defaultTrendMemory().version,
+    preferredDirections,
+    blockedDirections,
+    focusModes,
+    notes,
+    updatedAt: new Date().toISOString()
+  };
+  writeJson(memoryPath, next);
+  return { memoryPath, memory: next };
+}
+
+function topicMatches(topic, terms) {
+  const text = `${topic.title || ''} ${topic.angle || ''} ${topic.evidence || ''}`;
+  const domainTerms = ['抖音', '视频', '评论', '口播', '开头', '脚本', '账号', '表达', '钩子', '转化', '选题', '流量'];
+  const candidates = cleanList(terms).flatMap(term => {
+    const trimmed = String(term || '').replace(/(方向|选题|内容|话术|场景|趋势)$/u, '');
+    return cleanList([
+      term,
+      trimmed,
+      ...domainTerms.filter(domainTerm => String(term || '').includes(domainTerm))
+    ]);
+  });
+  return candidates.some(term => text.includes(term));
+}
+
+function rankTopicsWithMemory(topics, memory) {
+  const preferred = cleanList(memory.preferredDirections);
+  const blocked = cleanList(memory.blockedDirections);
+  return [...topics]
+    .map((topic, originalIndex) => {
+      let score = topics.length - originalIndex;
+      if (topicMatches(topic, preferred)) score += 20;
+      if (topicMatches(topic, blocked)) score -= 50;
+      return { topic, score };
+    })
+    .sort((a, b) => b.score - a.score)
+    .map(item => item.topic);
+}
+
+module.exports = {
+  DEFAULT_MEMORY_FILE,
+  trendMemoryPath,
+  readTrendMemory,
+  updateTrendMemory,
+  rankTopicsWithMemory,
+  cleanList
+};

+ 318 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/douyin-trend/report.js

@@ -0,0 +1,318 @@
+const fs = require('fs');
+const path = require('path');
+const { rankTopicsWithMemory, cleanList } = require('./preference-memory');
+const { buildSignalAnalysis } = require('./signal-extractor');
+const {
+  firstTerm,
+  firstDistinctTerm,
+  findTopComment,
+  buildOpportunityTopics,
+  buildBusinessTalk
+} = require('../xiaohongshu-trend/topic-builder');
+const {
+  assessEvidenceQuality,
+  buildEvidenceQualitySection
+} = require('../../core/evidence-quality');
+
+function ensureDir(dirPath) {
+  fs.mkdirSync(dirPath, { recursive: true });
+}
+
+function scoreVideo(video) {
+  return Number(video.likeCount || 0) +
+    Number(video.commentCount || 0) * 3 +
+    Number(video.shareCount || 0) * 2 +
+    Number(video.playCount || 0) * 0.02;
+}
+
+function asList(value) {
+  if (!value) return [];
+  return Array.isArray(value) ? value.filter(Boolean).map(String) : [String(value)];
+}
+
+function formatCount(value) {
+  const count = Number(value || 0);
+  if (!Number.isFinite(count) || count <= 0) return '0';
+  if (count >= 10000) {
+    return `${(count / 10000).toFixed(1).replace(/\.0$/, '')}w`;
+  }
+  return String(Math.round(count));
+}
+
+function truncateText(value, limit = 120) {
+  const text = String(value || '').replace(/\s+/g, ' ').trim();
+  if (text.length <= limit) return text;
+  return `${text.slice(0, limit - 1)}...`;
+}
+
+function markdownUrl(value) {
+  const url = String(value || '').trim().replace(/[<>]/g, '');
+  return url ? `<${url}>` : '';
+}
+
+function memoryHasSignal(memory = {}) {
+  return Boolean(
+    cleanList(memory.preferredDirections).length ||
+    cleanList(memory.blockedDirections).length ||
+    cleanList(memory.focusModes).length
+  );
+}
+
+function buildMemorySection(memory = {}) {
+  if (!memoryHasSignal(memory)) return [];
+  const preferred = cleanList(memory.preferredDirections);
+  const blocked = cleanList(memory.blockedDirections);
+  const focus = cleanList(memory.focusModes);
+  return [
+    '## 已读取偏好',
+    '',
+    preferred.length ? `- 优先保留:${preferred.join('、')}` : '',
+    blocked.length ? `- 后续降权:${blocked.join('、')}` : '',
+    focus.length ? `- 本轮侧重:${focus.join('、')}` : ''
+  ].filter(Boolean);
+}
+
+function buildScopeSection({ profile, videos, comments, collectionMode, signals }) {
+  const audience = asList(profile.targetAudience).slice(0, 3).join('、') || '未标注';
+  const keywords = asList(profile.keywords).slice(0, 5).join('、') || '未标注';
+  const presetLabel = signals && signals.preset && signals.preset.label ? signals.preset.label : '';
+  return [
+    '## 数据口径',
+    '',
+    '- 平台:抖音',
+    `- 行业/品类:${profile.industry || presetLabel || '未标注'}`,
+    profile.businessType ? `- 业务类型:${profile.businessType}` : '',
+    `- 目标人群:${audience}`,
+    `- 关注关键词:${keywords}`,
+    `- 样本:${videos.length} 条视频 / ${comments.length} 条评论`,
+    presetLabel ? `- 行业模板:${presetLabel}` : '',
+    collectionMode === 'sample'
+      ? '- 说明:本次为演示样例,用于确认交付形式;接入 VOC 服务后可切换真实抖音采集。'
+      : ''
+  ].filter(Boolean);
+}
+
+function buildEvidenceReason(video, topComment) {
+  if (video.evidenceReason) return video.evidenceReason;
+  if (topComment && topComment.text) {
+    return '这条样本同时提供了视频表达和用户评论,可以用来校验趋势判断是否贴近真实顾虑。';
+  }
+  return '这条样本互动较高,适合用作本轮趋势判断的视频证据。';
+}
+
+function buildSourceLine(video) {
+  const url = video.sourceUrl || video.webUrl || video.url || '';
+  if (!url) return '   - 原视频:暂无可访问链接';
+  return `   - 原视频:[打开视频](${markdownUrl(url)})`;
+}
+
+function buildEvidenceSection(videos, comments) {
+  const rows = videos.slice(0, 3).map((video, index) => {
+    const topComment = findTopComment(comments, video.id);
+    const coverUrl = video.localCoverPath || video.coverUrl || (Array.isArray(video.imageUrls) ? video.imageUrls[0] : '');
+    const excerpt = truncateText(video.contentExcerpt || video.rawExcerpt || video.desc || video.content, 120);
+    const topCommentText = topComment ? truncateText(topComment.text, 120) : '';
+    return [
+      `${index + 1}. ${video.title || '未命名视频'}`,
+      `   - 作者:${video.author || '未标注'} | 互动:${formatCount(video.likeCount)}赞 / ${formatCount(video.commentCount)}评 / ${formatCount(video.shareCount)}转 / ${formatCount(video.playCount)}播`,
+      buildSourceLine(video),
+      `   - 为什么值得看:${buildEvidenceReason(video, topComment)}`,
+      coverUrl ? `   - 图片:\n\n![视频封面](${markdownUrl(coverUrl)})` : '   - 图片:暂无可展示封面,可点开视频查看。',
+      excerpt ? `   - 视频文案:${excerpt}` : '',
+      topCommentText ? `   - 高赞评论:${topCommentText}` : ''
+    ].filter(Boolean).join('\n');
+  });
+  return [
+    '## 证据样本',
+    '',
+    rows.length ? rows.join('\n') : '暂无可展示样本。'
+  ];
+}
+
+function buildConcernLines(decisionSignals, applicationLabel) {
+  if (!decisionSignals.length) {
+    return ['- 暂未出现稳定高频顾虑,建议先扩大关键词或评论样本。'];
+  }
+  return decisionSignals.slice(0, 5).map(item => (
+    `- ${item.term}:在样本中出现 ${item.count} 次,适合转成${applicationLabel || '业务验证或内容选题'}。`
+  ));
+}
+
+function buildObservationLines({ videos, comments, signals }) {
+  const preference = firstTerm(signals.preferenceSignals, '核心卖点');
+  const secondPreference = firstDistinctTerm(signals.preferenceSignals.slice(1), [preference], '');
+  const concern = firstDistinctTerm(signals.decisionSignals, [preference], '主要顾虑');
+  const secondConcern = firstDistinctTerm(signals.decisionSignals, [preference, concern], '');
+  const element = firstDistinctTerm(signals.elementSignals, [preference, concern], '具体元素');
+  const risk = firstDistinctTerm(signals.riskSignals, [preference, concern, element], '潜在风险');
+  const topVideo = videos[0] || {};
+  const topComment = topVideo.id ? findTopComment(comments, topVideo.id) : comments[0];
+  return [
+    `1. 样本里“${preference}”是一个明显入口,但它需要和“${concern}”一起看,不能直接等同于真实选择理由。`,
+    secondPreference
+      ? `2. “${secondPreference}”也在样本中出现,说明用户可能在比较不同场景、人群和使用条件。`
+      : '2. 暂时还没有第二个稳定偏好信号,建议下一轮补充更细的关键词或评论样本。',
+    secondConcern
+      ? `3. 用户顾虑里除了“${concern}”,还出现了“${secondConcern}”,这两个点需要用你的真实客户反馈来排优先级。`
+      : `3. 当前顾虑仍集中在“${concern}”,还不能判断是否足够普遍。`,
+    `4. “${element}”这类具体元素可以继续验证,因为它更容易连接到内容表达、成本、维护和转化承诺。`,
+    topComment && topComment.text
+      ? `5. 高赞评论提到“${truncateText(topComment.text, 80)}”,适合作为下一轮向客户/销售/社群核验的问题。`
+      : `5. 当前评论证据还不够强,下一轮需要补充更多真实评论原话来校准“${risk}”这类风险点。`
+  ];
+}
+
+function buildHypothesisLines({ signals }) {
+  const preference = firstTerm(signals.preferenceSignals, '核心卖点');
+  const concern = firstDistinctTerm(signals.decisionSignals, [preference], '主要顾虑');
+  const element = firstDistinctTerm(signals.elementSignals, [preference, concern], '具体元素');
+  return [
+    `1. ${preference} 可能不是单独成立的趋势,而是和“${concern}”一起影响用户选择。`,
+    `2. ${element} 更适合继续验证,因为它能连接到场景、成本、效果证据和落地方案。`,
+    '3. 第一轮样本更像是在提示“用户为什么犹豫”,下一轮需要用你的真实客户反馈校准。'
+  ];
+}
+
+function buildDynamicValidationQuestions({ videos, comments, signals, rankedTopics }) {
+  const preference = firstTerm(signals.preferenceSignals, '当前卖点');
+  const concern = firstDistinctTerm(signals.decisionSignals, [preference], '当前顾虑');
+  const element = firstDistinctTerm(signals.elementSignals, [preference, concern], '当前元素');
+  const risk = firstDistinctTerm(signals.riskSignals, [preference, concern, element], '风险点');
+  const topVideo = videos[0] || {};
+  const topComment = topVideo.id ? findTopComment(comments, topVideo.id) : comments[0];
+  const topTopic = rankedTopics[0] || {};
+  return [
+    `样本里“${preference}”和“${concern}”同时出现,你实际接触的用户是先被${preference}吸引,还是先担心${concern}?`,
+    topComment && topComment.text
+      ? `这条高赞评论“${truncateText(topComment.text, 80)}”像不像你销售、客服、社群或私域里真实听到的问题?如果不像,真实问题更接近什么?`
+      : '当前样本缺少高赞评论证据,你手上有没有最近用户反复问的一句话?',
+    `围绕“${element}”,你们现在有没有可证明的案例、价格/成本、效果证据或售后承诺?没有的话,这个方向先不要直接定稿。`,
+    topTopic.title
+      ? `如果只继续深挖一个方向,你更想验证“${topTopic.title}”,还是换成更贴近你业务的方向?`
+      : `如果只继续深挖一个方向,你更想验证“${risk}”,还是换成更贴近你业务的方向?`
+  ];
+}
+
+function buildOneLine({ profile, signals }) {
+  const industry = profile.industry || (signals.preset && signals.preset.label) || '当前行业';
+  const preference = firstTerm(signals.preferenceSignals, '核心卖点');
+  const concern = firstDistinctTerm(signals.decisionSignals, [preference], '主要顾虑');
+  const risk = firstDistinctTerm(signals.riskSignals, [preference, concern], '风险点');
+  return `本轮${industry}最值得关注的不是单一“${preference}”,而是“${preference}、${concern}、${risk}”一起进入用户决策。`;
+}
+
+function buildTrendReport({ profile, dataset, collectionMode, memory = {} }) {
+  const videos = [...(dataset.videos || [])].sort((a, b) => scoreVideo(b) - scoreVideo(a));
+  const comments = [...(dataset.comments || [])].sort((a, b) => Number(b.likeCount || 0) - Number(a.likeCount || 0));
+  const signals = buildSignalAnalysis({ profile, videos, comments });
+  const topTopics = buildOpportunityTopics({ profile, notes: videos, comments, signals });
+  const rankedTopics = rankTopicsWithMemory(topTopics, memory);
+  const evidenceQuality = assessEvidenceQuality({
+    items: videos,
+    comments,
+    itemLabel: '视频',
+    collectionMode
+  });
+  const validationQuestions = buildDynamicValidationQuestions({
+    videos,
+    comments,
+    signals,
+    rankedTopics
+  });
+  const salesTalk = buildBusinessTalk({ profile, signals });
+  const applicationLabel = signals.preset && signals.preset.applicationLabel;
+
+  const assistantMessage = [
+    `# 抖音趋势情报${collectionMode === 'sample' ? '(演示样例)' : ''}`,
+    '',
+    '## 第一轮初步判断(待校准)',
+    '',
+    `${buildOneLine({ profile, signals })} 这还不是最终结论,需要先结合你的真实客户反馈做一轮校准。`,
+    '',
+    ...buildScopeSection({ profile, videos, comments, collectionMode, signals }),
+    '',
+    ...buildEvidenceQualitySection(evidenceQuality),
+    '',
+    ...buildMemorySection(memory),
+    memoryHasSignal(memory) ? '' : '',
+    '## 视频观察 Top 5',
+    '',
+    ...buildObservationLines({ videos, comments, signals }),
+    '',
+    '## 本轮机会假设',
+    '',
+    ...buildHypothesisLines({ signals }),
+    '',
+    '## 用户顾虑/决策阻力',
+    '',
+    ...buildConcernLines(signals.decisionSignals, applicationLabel),
+    '',
+    ...buildEvidenceSection(videos, comments),
+    '',
+    '## 可继续验证的选题方向',
+    '',
+    ...rankedTopics.map((topic, index) => [
+      `${index + 1}. ${topic.title}`,
+      `   - 角度:${topic.angle}`,
+      `   - 证据:${topic.evidence}`,
+      topic.confidence ? `   - 置信度:${topic.confidence}` : ''
+    ].filter(Boolean).join('\n')),
+    '',
+    '## 暂可参考业务话术/内容表达(待确认)',
+    '',
+    ...salesTalk.map(item => `- ${item}`),
+    '',
+    '## 下一轮请先校准这些问题',
+    '',
+    ...validationQuestions.map((question, index) => `${index + 1}. ${question}`),
+    '',
+    '你可以直接按序号简单回答。回答后我会把本轮假设收敛成更贴近你真实业务的趋势判断、选题方向和待验证话术。'
+  ].filter(Boolean).join('\n');
+
+  return {
+    assistantMessage,
+    summary: {
+      workflowStage: 'initial_hypothesis_needs_calibration',
+      platform: 'douyin',
+      industryPreset: signals.preset && signals.preset.id,
+      videoCount: videos.length,
+      commentCount: comments.length,
+      topicCount: rankedTopics.length,
+      preferenceMemoryApplied: memoryHasSignal(memory),
+      topStyleSignals: signals.preferenceSignals.slice(0, 5),
+      topPreferenceSignals: signals.preferenceSignals.slice(0, 5),
+      topDecisionSignals: signals.decisionSignals.slice(0, 5),
+      topElementSignals: signals.elementSignals.slice(0, 5),
+      topRiskSignals: signals.riskSignals.slice(0, 5),
+      evidenceVideoCount: Math.min(videos.length, 3),
+      evidenceQuality,
+      validationQuestionCount: validationQuestions.length
+    },
+    data: {
+      profile,
+      industryPreset: signals.preset,
+      signals,
+      videos,
+      comments,
+      topTopics: rankedTopics,
+      validationQuestions,
+      preferenceMemory: memory,
+      salesTalk,
+      evidenceQuality
+    }
+  };
+}
+
+function writeReport(outputDir, report) {
+  ensureDir(outputDir);
+  const mdPath = path.join(outputDir, 'douyin-trend-report.md');
+  const jsonPath = path.join(outputDir, 'douyin-trend-report.json');
+  fs.writeFileSync(mdPath, report.assistantMessage, 'utf8');
+  fs.writeFileSync(jsonPath, JSON.stringify(report, null, 2), 'utf8');
+  return [mdPath, jsonPath];
+}
+
+module.exports = {
+  buildTrendReport,
+  writeReport
+};

+ 103 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/douyin-trend/sample-data.js

@@ -0,0 +1,103 @@
+const { resolveIndustryPreset, cleanList } = require('../xiaohongshu-trend/industry-presets');
+
+function sampleImage(label, accent = '#d8c4a4') {
+  const safeLabel = String(label || '抖音趋势样本').slice(0, 24);
+  const svg = [
+    '<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="760" viewBox="0 0 1200 760">',
+    '<rect width="1200" height="760" fill="#f5f0e7"/>',
+    `<rect x="88" y="76" width="1024" height="528" rx="28" fill="${accent}" opacity="0.3"/>`,
+    '<rect x="148" y="130" width="416" height="394" rx="24" fill="#ffffff" opacity="0.86"/>',
+    '<rect x="620" y="132" width="440" height="72" rx="16" fill="#ffffff" opacity="0.82"/>',
+    '<rect x="620" y="236" width="360" height="52" rx="12" fill="#ffffff" opacity="0.74"/>',
+    '<rect x="620" y="320" width="390" height="52" rx="12" fill="#ffffff" opacity="0.68"/>',
+    '<rect x="620" y="404" width="310" height="52" rx="12" fill="#ffffff" opacity="0.62"/>',
+    `<text x="120" y="668" font-size="44" font-family="Arial, sans-serif" fill="#4a3727">${safeLabel}</text>`,
+    '<text x="120" y="714" font-size="24" font-family="Arial, sans-serif" fill="#806853">Douyin sample evidence image</text>',
+    '</svg>'
+  ].join('');
+  return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
+}
+
+function metric(index, base) {
+  return Math.max(1, Math.round(base - index * base * 0.28));
+}
+
+function buildVideo({ item, preset, keyword, index }) {
+  const id = `dy-${preset.id}-${String(index + 1).padStart(3, '0')}`;
+  const coverUrl = sampleImage(item.imageLabel || item.title, ['#b7a17a', '#dfc7a2', '#a7b8aa'][index % 3]);
+  const sourceUrl = `https://www.douyin.com/video/${id}?from=sample&share_token=sample-token-${preset.id}-${index + 1}`;
+  return {
+    id,
+    videoId: id,
+    platform: 'douyin',
+    keyword,
+    title: item.title,
+    desc: item.content,
+    content: item.content,
+    contentExcerpt: item.content,
+    author: item.author || `${preset.label}观察员`,
+    authorId: `${id}-author`,
+    authorSecUid: `${id}-secuid`,
+    likeCount: metric(index, 128000),
+    commentCount: metric(index, 8600),
+    shareCount: metric(index, 5200),
+    playCount: metric(index, 1880000),
+    createTime: new Date(Date.UTC(2026, 4, 25, 8 + index, 0, 0)).toISOString(),
+    timestamp: Date.UTC(2026, 4, 25, 8 + index, 0, 0),
+    sourceUrl,
+    webUrl: sourceUrl,
+    shareUrl: sourceUrl,
+    sourceUrlVerified: true,
+    coverUrl,
+    imageUrls: [coverUrl],
+    tags: item.tags || [keyword, preset.label],
+    statistics: {
+      digg_count: metric(index, 128000),
+      comment_count: metric(index, 8600),
+      share_count: metric(index, 5200),
+      play_count: metric(index, 1880000)
+    }
+  };
+}
+
+function buildComments({ item, videoId, keyword, index }) {
+  const comments = cleanList(item.comments).length
+    ? cleanList(item.comments)
+    : [`我最关心的是${keyword}到底适不适合自己,不想只看种草结论。`];
+  return comments.map((text, commentIndex) => ({
+    id: `${videoId}-comment-${commentIndex + 1}`,
+    cid: `${videoId}-comment-${commentIndex + 1}`,
+    noteId: videoId,
+    videoId,
+    platform: 'douyin',
+    keyword,
+    text,
+    likeCount: Math.max(1, 401 - index * 88 - commentIndex * 65),
+    replyCommentTotal: commentIndex === 0 ? 3 : 0,
+    createTime: new Date(Date.UTC(2026, 4, 25, 9 + index, commentIndex * 6, 0)).toISOString(),
+    user: {
+      nickname: `评论用户${index + 1}-${commentIndex + 1}`,
+      uid: `${videoId}-user-${commentIndex + 1}`
+    }
+  }));
+}
+
+function buildSampleDataset(profile = {}) {
+  const preset = resolveIndustryPreset(profile);
+  const keywords = cleanList(profile.keywords).length ? cleanList(profile.keywords) : preset.defaultKeywords;
+  const videos = [];
+  const comments = [];
+
+  preset.sampleNotes.slice(0, 3).forEach((item, index) => {
+    const keyword = keywords[index] || keywords[0] || preset.label;
+    const video = buildVideo({ item, preset, keyword, index });
+    videos.push(video);
+    comments.push(...buildComments({ item, videoId: video.id, keyword, index }));
+  });
+
+  return { videos, comments };
+}
+
+module.exports = {
+  buildSampleDataset
+};

+ 79 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/douyin-trend/signal-extractor.js

@@ -0,0 +1,79 @@
+const { buildSignalTerms, unique } = require('../xiaohongshu-trend/industry-presets');
+
+function textOfItem(item = {}) {
+  return [
+    item.title,
+    item.desc,
+    item.content,
+    item.text,
+    item.contentExcerpt,
+    item.rawExcerpt,
+    item.author && item.author.nickname,
+    ...(Array.isArray(item.tags) ? item.tags : [])
+  ].filter(Boolean).join(' ');
+}
+
+function collectTerms(items, terms) {
+  return unique(terms)
+    .map(term => {
+      const evidence = items.filter(item => textOfItem(item).includes(term));
+      return {
+        term,
+        count: evidence.length,
+        evidenceIds: evidence.map(item => item.id || item.videoId || '').filter(Boolean).slice(0, 5)
+      };
+    })
+    .filter(item => item.count > 0)
+    .sort((a, b) => b.count - a.count || a.term.localeCompare(b.term));
+}
+
+function collectTagTerms(videos = []) {
+  const counter = new Map();
+  videos.forEach(video => {
+    (Array.isArray(video.tags) ? video.tags : []).forEach(tag => {
+      const key = String(tag || '').trim();
+      if (!key) return;
+      counter.set(key, (counter.get(key) || 0) + 1);
+    });
+  });
+  return [...counter.entries()]
+    .map(([term, count]) => ({ term, count, evidenceIds: [] }))
+    .sort((a, b) => b.count - a.count || a.term.localeCompare(b.term));
+}
+
+function mergeSignals(primary, fallback) {
+  const seen = new Set();
+  return [...primary, ...fallback]
+    .filter(item => {
+      if (!item || !item.term || seen.has(item.term)) return false;
+      seen.add(item.term);
+      return true;
+    });
+}
+
+function buildSignalAnalysis({ profile = {}, videos = [], comments = [] } = {}) {
+  const { preset, preferenceTerms, decisionTerms, elementTerms, riskTerms } = buildSignalTerms(profile);
+  const allItems = [...videos, ...comments];
+  const tags = collectTagTerms(videos);
+  const keywordSignals = collectTerms(allItems, profile.keywords || []);
+
+  const preferenceSignals = mergeSignals(collectTerms(allItems, preferenceTerms), keywordSignals);
+  const decisionSignals = mergeSignals(collectTerms(allItems, decisionTerms), tags);
+  const elementSignals = mergeSignals(collectTerms(allItems, elementTerms), tags);
+  const riskSignals = collectTerms(allItems, riskTerms);
+
+  return {
+    preset,
+    preferenceSignals,
+    decisionSignals,
+    elementSignals,
+    riskSignals,
+    tagSignals: tags,
+    keywordSignals
+  };
+}
+
+module.exports = {
+  buildSignalAnalysis,
+  collectTerms
+};

+ 1 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/douyin-trend/topic-builder.js

@@ -0,0 +1 @@
+module.exports = require('../xiaohongshu-trend/topic-builder');

+ 214 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/voc-business-workflow/business-workflow.js

@@ -0,0 +1,214 @@
+const path = require('path');
+const { runXiaohongshuTrend } = require('../../tools/xiaohongshu-trend-run');
+const { runDouyinTrend } = require('../../tools/douyin-trend-run');
+const { runVocIssuePool } = require('../../tools/voc-issue-pool-run');
+const { runVocProblemDeepDive } = require('../../tools/voc-problem-deep-dive-run');
+const { runVocContentPlan } = require('../../tools/voc-content-plan-run');
+const { runVocSpeakingScript } = require('../../tools/voc-speaking-script-run');
+
+function asList(value) {
+  if (!value) return [];
+  if (Array.isArray(value)) return value.map(String).map(item => item.trim()).filter(Boolean);
+  return String(value).split(/[,,、\n\r]+/).map(item => item.trim()).filter(Boolean);
+}
+
+function firstText(...values) {
+  for (const value of values) {
+    if (value && String(value).trim()) return String(value).trim();
+  }
+  return '';
+}
+
+function collectEvidence(trendResult) {
+  const comments = trendResult.data && Array.isArray(trendResult.data.comments)
+    ? trendResult.data.comments
+    : [];
+  const notes = trendResult.data && Array.isArray(trendResult.data.notes)
+    ? trendResult.data.notes
+    : [];
+  const videos = trendResult.data && Array.isArray(trendResult.data.videos)
+    ? trendResult.data.videos
+    : [];
+  const commentEvidence = comments
+    .slice(0, 12)
+    .map(comment => comment.text || comment.content)
+    .filter(Boolean);
+  const itemEvidence = [...notes, ...videos]
+    .slice(0, 5)
+    .map(item => firstText(item.title, item.desc, item.content, item.contentExcerpt))
+    .filter(Boolean);
+  return [...commentEvidence, ...itemEvidence].slice(0, 16);
+}
+
+function extractIssues(issuePoolResult, fallbackEvidence) {
+  const issues = issuePoolResult.data && Array.isArray(issuePoolResult.data.issues)
+    ? issuePoolResult.data.issues
+    : [];
+  const titles = issues.map(issue => issue.title || issue.issue || issue.name).filter(Boolean);
+  return titles.length ? titles : fallbackEvidence.slice(0, 3);
+}
+
+function compactSection(title, body, maxLines = 18) {
+  const lines = String(body || '').split('\n').filter(Boolean);
+  return [`## ${title}`, '', ...lines.slice(0, maxLines)].join('\n');
+}
+
+async function runBusinessWorkflow(input = {}) {
+  const platform = String(input.platform || input.sourcePlatform || 'douyin').toLowerCase().includes('xiao')
+    || String(input.platform || '').includes('小红书')
+    ? 'xiaohongshu'
+    : 'douyin';
+  const collectionMode = input.collectionMode || input['collection-mode'] || 'sample';
+  const project = firstText(input.project, input.brand, input.store, 'VOC经营闭环');
+  const industry = firstText(input.industry, input.category, '本地生活餐饮');
+  const brand = firstText(input.brand, input.store, project);
+  const scenario = firstText(input.scenario, input.scene, '经营诊断');
+  const audience = firstText(input.audience, input.targetAudience, '目标用户');
+  const keywords = asList(input.keywords).length ? asList(input.keywords) : asList(input.keyword);
+  const outputRoot = path.resolve(input.output || path.join('outputs', 'voc-business-workflow', new Date().toISOString().slice(0, 10)));
+
+  const trendInput = {
+    ...input,
+    project,
+    industry,
+    targetAudience: Array.isArray(input.targetAudience) ? input.targetAudience : [audience],
+    keywords: keywords.length ? keywords : undefined,
+    collectionMode,
+    output: path.join(outputRoot, platform === 'xiaohongshu' ? 'xiaohongshu-trend' : 'douyin-trend'),
+    keywordLimit: input.keywordLimit || input['keyword-limit'] || 2,
+    notesPerKeyword: input.notesPerKeyword || input['notes-per-keyword'] || 2,
+    videosPerKeyword: input.videosPerKeyword || input['videos-per-keyword'] || 2,
+    maxCommentPages: input.maxCommentPages || input['max-comment-pages'] || 1
+  };
+  const trendResult = platform === 'xiaohongshu'
+    ? await runXiaohongshuTrend(trendInput)
+    : await runDouyinTrend(trendInput);
+
+  if (trendResult.status !== 'ok') {
+    return {
+      ...trendResult,
+      assistantMessage: [
+        '# VOC经营闭环未完成',
+        '',
+        '第一步真实声音采集没有拿到可继续分析的样本,先按下面提示处理:',
+        '',
+        trendResult.assistantMessage || '',
+        '',
+        '处理完成后再说:继续做经营闭环。'
+      ].filter(Boolean).join('\n')
+    };
+  }
+
+  const evidence = collectEvidence(trendResult);
+  const issuePool = await runVocIssuePool({
+    project,
+    brand,
+    industry,
+    scenario,
+    audience,
+    evidence,
+    reportText: trendResult.assistantMessage
+  });
+  const issues = extractIssues(issuePool, evidence);
+  const topIssue = firstText(input.issue, input.problem, issues[0], '用户顾虑不清晰');
+  const deepDive = await runVocProblemDeepDive({
+    project,
+    brand,
+    industry,
+    scenario,
+    audience,
+    issue: topIssue,
+    evidence
+  });
+  const contentPlan = await runVocContentPlan({
+    project,
+    brand,
+    industry,
+    platform: platform === 'xiaohongshu' ? '小红书' : '抖音',
+    issues: issues.slice(0, 5),
+    reportText: deepDive.assistantMessage
+  });
+  const firstTopic = contentPlan.data && Array.isArray(contentPlan.data.plan)
+    ? contentPlan.data.plan[0]
+    : null;
+  const speakingScript = await runVocSpeakingScript({
+    project,
+    brand,
+    industry,
+    topic: firstText(input.topic, firstTopic && firstTopic.title, topIssue),
+    userIssue: firstText(firstTopic && firstTopic.userIssue, topIssue),
+    evidence: evidence.slice(0, 5),
+    feedback: input.feedback,
+    finalize: Boolean(input.finalize)
+  });
+
+  const files = [
+    ...(trendResult.files || []),
+    ...(issuePool.files || []),
+    ...(deepDive.files || []),
+    ...(contentPlan.files || []),
+    ...(speakingScript.files || [])
+  ];
+  const assistantMessage = [
+    '# VOC经营闭环(第一轮)',
+    '',
+    '你这轮能拿到:市场真实声音、问题优先级、一个单点解决动作、7天内容方向,以及一条可继续改稿的口播稿。',
+    '',
+    `- 平台:${platform === 'xiaohongshu' ? '小红书' : '抖音'}`,
+    `- 模式:${collectionMode}`,
+    `- 行业/品类:${industry}`,
+    `- 品牌/门店:${brand}`,
+    '',
+    compactSection('1. 市场和用户声音', trendResult.assistantMessage, 16),
+    '',
+    compactSection('2. 先改哪个问题', issuePool.assistantMessage, 18),
+    '',
+    compactSection('3. Top问题怎么解决', deepDive.assistantMessage, 18),
+    '',
+    compactSection('4. 下周账号发什么', contentPlan.assistantMessage, 18),
+    '',
+    compactSection('5. 第一条口播稿', speakingScript.assistantMessage, 18),
+    '',
+    '## 下一步建议',
+    '',
+    `1. 先确认 Top 问题“${topIssue}”是否真的最影响生意。`,
+    '2. 从 7 天内容计划里选 1 条进入改稿。',
+    '3. 7 天后反馈哪些动作有效/无效,让记忆继续优化。'
+  ].join('\n');
+
+  return {
+    status: 'ok',
+    assistantMessage,
+    summary: {
+      workflowStage: 'business_workflow_first_round',
+      platform,
+      collectionMode,
+      trendStatus: trendResult.status,
+      issueCount: issues.length,
+      topIssue,
+      contentTopicCount: contentPlan.summary && contentPlan.summary.topicCount,
+      speakingScriptStatus: speakingScript.status
+    },
+    data: {
+      trend: trendResult,
+      issuePool,
+      deepDive,
+      contentPlan,
+      speakingScript
+    },
+    files,
+    nextActions: [
+      `确认 Top 问题“${topIssue}”是否优先`,
+      '选择 7 天内容计划中的一条继续改成最终口播稿',
+      '执行 7 天后反馈有效/无效动作,让记忆继续收敛'
+    ],
+    warnings: [
+      collectionMode === 'sample' ? '本轮使用 sample 演示数据,不是真实采集。' : ''
+    ].filter(Boolean),
+    errors: []
+  };
+}
+
+module.exports = {
+  runBusinessWorkflow
+};

+ 198 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/voc-competitor-map/competitor-map.js

@@ -0,0 +1,198 @@
+const fs = require('fs');
+const path = require('path');
+
+const DEFAULT_COMPETITOR_TYPES = [
+  {
+    name: '同城同品类强势门店',
+    why: '抢同一批顾客的正餐/聚餐选择。',
+    praised: ['招牌菜记忆点强', '排队带来热闹感', '内容里菜品视觉冲击强'],
+    complained: ['排队久', '人均不透明', '第一次来不知道怎么点'],
+    opportunity: '不要只学热闹,要把“第一次来怎么点、几点来更舒服、哪些人适合来”讲清楚。'
+  },
+  {
+    name: '同价格带高口碑门店',
+    why: '用户会拿它来判断“值不值”。',
+    praised: ['套餐清晰', '服务稳定', '出品稳定'],
+    complained: ['贵', '菜品不够惊喜', '高峰体验下降'],
+    opportunity: '用价值解释和套餐路径降低第一次决策成本。'
+  },
+  {
+    name: '内容平台声量高的网红店',
+    why: '影响用户种草预期和探店选择。',
+    praised: ['画面好看', '话题性强', '适合拍照分享'],
+    complained: ['到店落差', '滤镜重', '不好吃或不稳定'],
+    opportunity: '用真实场景和避坑表达建立信任,不和对方拼滤镜。'
+  }
+];
+
+function splitList(value) {
+  if (!value) return [];
+  if (Array.isArray(value)) return value.flatMap(splitList);
+  return String(value).split(/[;;,,、\n\r]+/).map(item => item.trim()).filter(Boolean);
+}
+
+function readTextIfExists(filePath) {
+  if (!filePath) return '';
+  const absolute = path.resolve(filePath);
+  if (!fs.existsSync(absolute)) return '';
+  return fs.readFileSync(absolute, 'utf8').replace(/^\uFEFF/, '');
+}
+
+function collectEvidence(input = {}) {
+  const explicit = splitList(input.evidence || input.comments || input.voices || input.reportText);
+  if (explicit.length) return explicit.slice(0, 50);
+  const report = readTextIfExists(input.report || input.reportPath);
+  if (report) {
+    return report
+      .split(/\r?\n/)
+      .map(line => line.trim())
+      .filter(line => /评论|吐槽|担心|排队|贵|招牌|服务|好吃|不好吃|人均|套餐/.test(line))
+      .slice(0, 50);
+  }
+  return [
+    '排队是排队,但第一次来真的不知道点什么。',
+    '人均有点高,如果没有套餐说明会犹豫。',
+    '视频里很种草,到店以后最怕落差。',
+    '招牌菜好吃,但高峰期服务有点跟不上。',
+    '如果老板能讲清楚适合几个人吃,会更容易决定。'
+  ];
+}
+
+function inferSignals(evidence = []) {
+  const text = evidence.join('\n');
+  const signals = [];
+  if (/排队|等位|等待/.test(text)) signals.push({ term: '排队/等位', risk: '热度带来期待,也带来体验压力' });
+  if (/贵|人均|性价比|值不值|套餐/.test(text)) signals.push({ term: '价格/套餐', risk: '用户需要更清晰的价值解释和点单路径' });
+  if (/招牌|特色|好吃|不好吃|味道/.test(text)) signals.push({ term: '招牌菜/味道', risk: '招牌要从菜名变成可复述的口味记忆' });
+  if (/服务|没人理|态度|卫生|环境/.test(text)) signals.push({ term: '服务/现场体验', risk: '高峰体验会影响复购和差评' });
+  if (/视频|种草|网红|滤镜|落差/.test(text)) signals.push({ term: '内容预期/到店落差', risk: '内容表达需要更真实,主动讲适合谁不适合谁' });
+  return signals.length ? signals : [
+    { term: '用户决策不清晰', risk: '需要先补充竞品评论和用户真实反馈' }
+  ];
+}
+
+function buildCompetitorCandidates(input = {}) {
+  const explicit = splitList(input.competitors || input.competitorNames);
+  if (explicit.length) {
+    return explicit.map((name, index) => ({
+      name,
+      type: DEFAULT_COMPETITOR_TYPES[index % DEFAULT_COMPETITOR_TYPES.length].name,
+      why: '用户已明确提到或业务方指定,需要优先纳入比较。'
+    }));
+  }
+  const city = String(input.city || input.region || '本地').trim();
+  const category = String(input.category || input.industry || '餐饮').trim();
+  return DEFAULT_COMPETITOR_TYPES.map(item => ({
+    name: `${city}${category}${item.name}`,
+    type: item.name,
+    why: item.why
+  }));
+}
+
+function buildCompetitorMap(input = {}) {
+  const brand = String(input.project || input.brand || input.store || '你的品牌').trim();
+  const city = String(input.city || input.region || '本地').trim();
+  const category = String(input.category || input.industry || '餐饮').trim();
+  const priceBand = String(input.priceBand || input.price || '未指定').trim();
+  const scene = String(input.scenario || input.scene || '到店消费/聚餐/日常就餐').trim();
+  const evidence = collectEvidence(input);
+  const signals = inferSignals(evidence);
+  const candidates = buildCompetitorCandidates(input);
+  const competitorCards = candidates.map((candidate, index) => {
+    const type = DEFAULT_COMPETITOR_TYPES[index % DEFAULT_COMPETITOR_TYPES.length];
+    return {
+      ...candidate,
+      praised: type.praised,
+      complained: type.complained,
+      opportunity: type.opportunity
+    };
+  });
+  const opportunities = signals.map(signal => ({
+    signal: signal.term,
+    risk: signal.risk,
+    action: `${brand} 可以围绕“${signal.term}”做错位表达:讲清楚适合谁、怎么点、什么时候来、为什么值,而不是只说好吃或热闹。`,
+    contentAngle: `从用户关心的“${signal.term}”切入,拍一条老板解释型视频。`
+  }));
+
+  const assistantMessage = [
+    '# 竞品图谱与错位竞争机会报告',
+    '',
+    '## 你这轮能拿到什么',
+    '- 一份可用于工作坊现场讨论的竞品候选图谱。',
+    '- 一组基于 VOC 的被夸点、被吐槽点和错位机会。',
+    '- 下一步可以直接转成问题池、单点深挖或 7 天内容选题。',
+    '',
+    '## 业务口径',
+    `- 品牌/门店:${brand}`,
+    `- 城市/区域:${city}`,
+    `- 品类:${category}`,
+    `- 价格带:${priceBand}`,
+    `- 消费场景:${scene}`,
+    '',
+    '## 竞品候选图谱',
+    ...competitorCards.map((item, index) => [
+      '',
+      `### ${index + 1}. ${item.name}`,
+      `- 类型:${item.type}`,
+      `- 为什么要看:${item.why}`,
+      `- 用户可能被吸引的点:${item.praised.join('、')}`,
+      `- 用户可能吐槽的点:${item.complained.join('、')}`,
+      `- 我方错位机会:${item.opportunity}`
+    ].join('\n')),
+    '',
+    '## VOC 信号',
+    ...signals.map(item => `- ${item.term}:${item.risk}`),
+    '',
+    '## 错位竞争建议',
+    ...opportunities.map((item, index) => [
+      `${index + 1}. ${item.signal}`,
+      `   - 风险:${item.risk}`,
+      `   - 经营动作:${item.action}`,
+      `   - 内容角度:${item.contentAngle}`
+    ].join('\n')),
+    '',
+    '## 证据样本',
+    ...evidence.slice(0, 6).map((item, index) => `${index + 1}. ${item}`),
+    '',
+    '## 下一步建议',
+    '1. 从候选竞品里确认 2-3 家真实竞品。',
+    '2. 把竞品评论继续放进 VOC 问题池,比较谁被夸、谁被骂。',
+    '3. 选择一个错位机会,生成 7 天内容选题和口播脚本。'
+  ].join('\n');
+
+  return {
+    status: 'ok',
+    assistantMessage,
+    summary: {
+      brand,
+      city,
+      category,
+      competitorCount: competitorCards.length,
+      signalCount: signals.length,
+      evidenceCount: evidence.length
+    },
+    data: {
+      brand,
+      city,
+      category,
+      priceBand,
+      scene,
+      competitors: competitorCards,
+      signals,
+      opportunities,
+      evidence
+    },
+    files: [],
+    nextActions: [
+      '确认 2-3 家真实竞品',
+      '把竞品评论整理成 VOC 问题池',
+      '选择一个错位机会生成 7 天内容选题'
+    ],
+    warnings: [],
+    errors: []
+  };
+}
+
+module.exports = {
+  buildCompetitorMap
+};

+ 206 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/voc-content-plan/content-plan.js

@@ -0,0 +1,206 @@
+const fs = require('fs');
+const path = require('path');
+
+const DEFAULT_VOC_ISSUES = [
+  {
+    issue: '第一次来怕踩雷',
+    insight: '用户不是不想来,而是不知道怎么点才稳。',
+    title: '第一次来我们店,照这个顺序点不容易踩雷',
+    hook: '第一次来别一上来就乱点,这 3 个菜最稳。',
+    script: '第一次来我们店,如果你怕点多了浪费、点错了踩雷,可以先按这个顺序来。第一道点招牌菜,先判断口味是不是你喜欢的;第二道点一个下饭菜,适合两三个人一起分;第三道再点一个清爽解腻的。这样人均更稳,也更容易吃出这家店的特色。',
+    shots: ['菜单特写', '招牌菜出餐', '老板解释点单顺序', '顾客桌面组合'],
+    commentPrompt: '第一次来你最怕点错什么?评论区告诉我,我帮你搭配。',
+    metric: '评论里“怎么点/第一次来/套餐/人均”的提问是否增加'
+  },
+  {
+    issue: '觉得人均贵',
+    insight: '用户真正想知道的是贵在哪里、值不值、怎么点更划算。',
+    title: '为什么这顿饭人均不算低,但很多老客还会再来',
+    hook: '如果你只看人均,会觉得我们不便宜,但这几个地方才是成本。',
+    script: '很多顾客第一次看人均会犹豫,所以今天直接讲清楚。我们贵的地方不是装修噱头,而是这几类食材、这道招牌菜的处理时间,还有高峰期也要保持稳定出品。第一次来不建议乱点,可以先按两人/四人套餐吃,既能吃到招牌,也不会一下子超预算。',
+    shots: ['食材近景', '后厨处理过程', '招牌菜上桌', '套餐组合价格牌'],
+    commentPrompt: '想看两人不踩雷点单,还是四人聚餐点单?',
+    metric: '评论里“贵/值不值”下降,“套餐/怎么点”上升'
+  },
+  {
+    issue: '怕排队久',
+    insight: '排队问题可以转成预期管理和到店攻略。',
+    title: '不想排太久,建议你这几个时间来',
+    hook: '想吃但怕排队,先记住这 3 个时间点。',
+    script: '我们高峰期确实会排队,但不是任何时间都要等。一般来说,工作日早点来更舒服,周末建议避开正餐最高峰。如果你已经在路上,可以先看评论区置顶的等位提示,或者提前想好要点的招牌菜,到店后更快决定。',
+    shots: ['门口等位情况', '非高峰空桌', '等位提示牌', '招牌菜快剪'],
+    commentPrompt: '你更想知道排队时间,还是想先看点单攻略?',
+    metric: '评论里“几点去/要等多久”的咨询是否变成可引导问题'
+  },
+  {
+    issue: '不知道招牌菜是什么',
+    insight: '招牌菜要从“老板推荐”变成“不同人群怎么选”。',
+    title: '第一次来,老板不建议你把所有招牌都点一遍',
+    hook: '招牌菜不是越多越好,第一次来先点这几个就够。',
+    script: '很多人第一次来会问哪个最招牌。我的建议是不要一次点太多,先按口味选。如果你喜欢重口味,先点这一道;如果带孩子或者长辈,先点更稳的这一道;如果是朋友聚餐,再加一个适合分享的菜。招牌不是让你多花钱,而是帮你少踩雷。',
+    shots: ['三道菜并排', '不同人群标签', '老板手指菜单', '夹菜试吃'],
+    commentPrompt: '你是重口味、带家人,还是朋友聚餐?我给你推荐点法。',
+    metric: '评论里“招牌/推荐/带孩子/聚餐”的提问是否增加'
+  },
+  {
+    issue: '担心服务忙不过来',
+    insight: '服务焦虑要用流程透明和高峰期提醒来降低。',
+    title: '高峰期来吃饭,先知道这几个小提醒',
+    hook: '我们忙的时候,最怕顾客不知道该找谁。',
+    script: '高峰期服务压力确实更大,所以我们把几个常见问题提前说清楚。加菜、催菜、打包、开发票分别找哪里,桌上也会放提示。你来吃饭不用猜流程,遇到问题直接按提示找对应的人,我们也会持续优化高峰期响应。',
+    shots: ['桌面提示牌', '服务员动线', '加菜流程', '收银台/打包区'],
+    commentPrompt: '你最在意上菜速度、服务响应,还是环境卫生?',
+    metric: '评论里“没人理/服务慢”下降,“流程/提示”正向反馈增加'
+  },
+  {
+    issue: '内容看着种草,到店怕落差',
+    insight: '内容要降低夸张表达,主动讲适合谁、不适合谁。',
+    title: '这家店适合谁,不适合谁,老板直接说清楚',
+    hook: '先说缺点,再说适合什么人来。',
+    script: '我们不想只拍滤镜很好看的视频,所以今天直接说清楚。我们适合喜欢这种口味、愿意接受高峰等位、想吃本地特色的人;如果你只想安安静静坐很久,或者完全不能吃辣,可能要提前避开某些菜。讲清楚不适合谁,反而能减少到店落差。',
+    shots: ['真实门店环境', '无滤镜菜品', '高峰现场', '老板正面说明'],
+    commentPrompt: '你来之前最想先知道优点还是缺点?',
+    metric: '评论里“真实/避坑/适合谁”的互动是否增加'
+  },
+  {
+    issue: '不知道为什么值得专门来',
+    insight: '门店内容要把特色从“好吃”讲到“为什么值得来”。',
+    title: '如果你只来一次,建议把这顿饭吃明白',
+    hook: '不是所有菜都要点,但这一个理由值得你来一次。',
+    script: '很多店都说自己好吃,但顾客真正想知道为什么值得专门来。我们这家店最核心的不是菜名,而是这个做法、这个口味记忆和这类用餐场景。第一次来,你可以不用点满一桌,但一定要试这一道,因为它最能代表我们想做的味道。',
+    shots: ['核心菜品制作', '口味细节', '顾客夹菜反应', '门头和桌面氛围'],
+    commentPrompt: '你会为了哪种味道专门跑一趟?',
+    metric: '收藏、转发、评论里“想去/值得去/地址”的信号是否增加'
+  }
+];
+
+function splitList(value) {
+  if (!value) return [];
+  if (Array.isArray(value)) return value.flatMap(splitList);
+  return String(value).split(/[;;\n\r]+/).map(item => item.trim()).filter(Boolean);
+}
+
+function readTextIfExists(filePath) {
+  if (!filePath) return '';
+  const absolute = path.resolve(filePath);
+  if (!fs.existsSync(absolute)) return '';
+  return fs.readFileSync(absolute, 'utf8').replace(/^\uFEFF/, '');
+}
+
+function collectIssues(input = {}) {
+  const explicit = splitList(input.issues || input.problems || input.vocIssues || input.evidence || input.comments);
+  if (explicit.length) return explicit.slice(0, 12);
+  const text = String(input.reportText || input.evidenceText || '').trim() || readTextIfExists(input.report || input.reportPath);
+  const lines = splitList(text).filter(line => /排队|贵|踩雷|服务|招牌|套餐|人均|不好吃|落差|等位|怎么点/.test(line));
+  return lines.length ? lines.slice(0, 12) : DEFAULT_VOC_ISSUES.map(item => item.issue);
+}
+
+function inferTemplate(issue, index) {
+  const text = String(issue || '');
+  const matched = DEFAULT_VOC_ISSUES.find(item =>
+    text.includes(item.issue) ||
+    text.includes(item.issue.replace('第一次来', '')) ||
+    item.issue.includes(text) ||
+    (/排队|等位|等待/.test(text) && item.issue.includes('排队')) ||
+    (/贵|人均|性价比|值不值/.test(text) && item.issue.includes('人均')) ||
+    (/踩雷|怎么点|点单/.test(text) && item.issue.includes('踩雷')) ||
+    (/招牌|推荐菜|特色菜/.test(text) && item.issue.includes('招牌')) ||
+    (/服务|没人理|态度/.test(text) && item.issue.includes('服务')) ||
+    (/落差|网红|滤镜|种草/.test(text) && item.issue.includes('落差'))
+  );
+  return matched || DEFAULT_VOC_ISSUES[index % DEFAULT_VOC_ISSUES.length];
+}
+
+function buildContentPlan(input = {}) {
+  const brand = String(input.project || input.brand || input.store || '你的门店').trim();
+  const industry = String(input.industry || input.category || '本地生活餐饮').trim();
+  const platform = String(input.platform || '抖音/小红书').trim();
+  const days = Math.max(1, Math.min(Number(input.days || 7), 15));
+  const issues = collectIssues(input);
+  const plan = Array.from({ length: days }, (_, index) => {
+    const sourceIssue = issues[index % issues.length];
+    const template = inferTemplate(sourceIssue, index);
+    return {
+      day: index + 1,
+      userIssue: sourceIssue || template.issue,
+      insight: template.insight,
+      title: template.title,
+      hook: template.hook,
+      script: template.script.replace(/我们店/g, brand === '你的门店' ? '我们店' : brand),
+      shots: template.shots,
+      commentPrompt: template.commentPrompt,
+      validationMetric: template.metric,
+      platform
+    };
+  });
+
+  const assistantMessage = [
+    '# 7 天 VOC 内容选题与口播脚本',
+    '',
+    '## 你这轮能拿到什么',
+    `- 一份面向 ${brand} 的 7 天内容选题表。`,
+    '- 每条内容都绑定一个真实用户问题,不做空泛爆款标题。',
+    '- 每条都包含前 3 秒钩子、口播脚本、镜头建议、评论区引导和验证指标。',
+    '',
+    '## 业务口径',
+    `- 品牌/门店:${brand}`,
+    `- 行业/品类:${industry}`,
+    `- 发布平台:${platform}`,
+    `- 内容周期:${days} 天`,
+    '',
+    '## 7 天内容表',
+    ...plan.map(item => [
+      '',
+      `### Day ${item.day}:${item.title}`,
+      `- 回应的用户问题:${item.userIssue}`,
+      `- 洞察判断:${item.insight}`,
+      `- 前 3 秒钩子:${item.hook}`,
+      `- 口播脚本:${item.script}`,
+      `- 镜头建议:${item.shots.join(' -> ')}`,
+      `- 评论区引导:${item.commentPrompt}`,
+      `- 验证指标:${item.validationMetric}`
+    ].join('\n')),
+    '',
+    '## 拍摄提醒',
+    '- 先拍真实门店、真实菜品、真实服务流程,不要只拍滤镜氛围。',
+    '- 每条内容只解决一个用户问题,避免一条视频讲太多。',
+    '- 发完后把评论里的新问题继续放回 VOC 问题池,下一轮再优化。',
+    '',
+    '## 下一步建议',
+    '1. 先选 2 条最容易拍的内容今天开拍。',
+    '2. 发布 7 天后,把评论反馈告诉我,我会判断哪些选题有效、哪些要降权。',
+    '3. 如果已经有趋势报告或问题池,可以把 Top 3 问题传进来,我会重排内容顺序。'
+  ].join('\n');
+
+  return {
+    status: 'ok',
+    assistantMessage,
+    summary: {
+      brand,
+      industry,
+      platform,
+      days,
+      topicCount: plan.length,
+      sourceIssueCount: issues.length
+    },
+    data: {
+      brand,
+      industry,
+      platform,
+      issues,
+      plan
+    },
+    files: [],
+    nextActions: [
+      '选择 2 条最容易拍的内容先执行',
+      '发布后把评论反馈放回 VOC 问题池',
+      '7 天后反馈有效/无效,我会更新内容方向'
+    ],
+    warnings: [],
+    errors: []
+  };
+}
+
+module.exports = {
+  buildContentPlan
+};

+ 391 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/voc-issue-pool/issue-pool.js

@@ -0,0 +1,391 @@
+const fs = require('fs');
+const path = require('path');
+const { readJson, writeJson, defaultMemoryPath } = require('../../core/memory-store');
+
+const DEFAULT_MEMORY_FILE = 'voc-issue-pool-memory.json';
+const DEFAULT_MEMORY_DIR = 'voc-issue-pool';
+
+const ISSUE_CATEGORIES = [
+  {
+    id: 'waiting',
+    title: '排队/等待',
+    terms: ['排队', '等位', '等待', '叫号', '翻台', '等太久'],
+    severity: 'high',
+    impact: ['新客转化', '现场效率', '口碑预期'],
+    action: '先统一评论区和门口等位预期,再做 7 天翻台/预点单验证。'
+  },
+  {
+    id: 'price',
+    title: '价格/性价比',
+    terms: ['贵', '价格', '人均', '性价比', '不值', '划算', '预算', '增项'],
+    severity: 'high',
+    impact: ['新客转化', '客单结构', '信任'],
+    action: '把价格解释成点单路径、价值证据和避坑说明,先降低第一次决策成本。'
+  },
+  {
+    id: 'taste_quality',
+    title: '味道/出品稳定',
+    terms: ['不好吃', '味道', '出品', '不稳定', '翻车', '口味', '辣', '咸', '淡'],
+    severity: 'high',
+    impact: ['复购', '差评风险', '推荐'],
+    action: '先盯核心单品 SOP 和高峰期出品稳定,避免内容预期高于门店交付。'
+  },
+  {
+    id: 'service',
+    title: '服务/体验',
+    terms: ['服务', '态度', '没人理', '员工', '老板', '体验', '环境', '卫生'],
+    severity: 'medium',
+    impact: ['复购', '带人到店', '现场效率'],
+    action: '把服务问题拆成高峰岗位、响应话术和现场流程,不只归因员工态度。'
+  },
+  {
+    id: 'product_function',
+    title: '产品/功能交付',
+    terms: ['不好用', '收纳', '动线', '板材', '环保', '售后', '柜门', '五金', '灯带'],
+    severity: 'high',
+    impact: ['成交信任', '售后风险', '复购转介绍'],
+    action: '把效果图卖点转成使用风险说明、材料证据和售后承诺。'
+  },
+  {
+    id: 'content_expectation',
+    title: '内容预期/到店落差',
+    terms: ['网红', '视频', '图片', '滤镜', '种草', '实际', '没有想象', '落差'],
+    severity: 'medium',
+    impact: ['内容信任', '到店转化', '差评风险'],
+    action: '降低夸张表达,改成真实场景、适合谁、不适合谁和避坑提醒。'
+  }
+];
+
+function splitList(value) {
+  if (!value) return [];
+  if (Array.isArray(value)) return value.flatMap(splitList);
+  return String(value).split(/[,,、\n\r]+/).map(item => item.trim()).filter(Boolean);
+}
+
+function cleanList(values) {
+  return [...new Set(splitList(values))].filter(Boolean);
+}
+
+function slugify(value) {
+  const raw = String(value || '').trim().toLowerCase();
+  if (!raw) return '';
+  return raw
+    .replace(/[^\p{L}\p{N}]+/gu, '-')
+    .replace(/^-+|-+$/g, '')
+    .slice(0, 80) || '';
+}
+
+function memoryScope(input = {}) {
+  return {
+    project: String(input.project || input.brand || input.store || '').trim(),
+    industry: String(input.industry || input.category || '').trim(),
+    scenario: String(input.scenario || input.scene || input.businessType || '').trim(),
+    audience: String(input.audience || input.targetAudience || '').trim()
+  };
+}
+
+function memoryScopeKey(input = {}) {
+  const scope = memoryScope(input);
+  const parts = [scope.project, scope.industry, scope.scenario, scope.audience]
+    .map(slugify)
+    .filter(Boolean);
+  return parts.length ? parts.join('__') : 'default';
+}
+
+function memoryPath(input = {}) {
+  if (input.memory || input.memoryPath) {
+    return path.resolve(input.memory || input.memoryPath);
+  }
+  const key = memoryScopeKey(input);
+  if (key && key !== 'default') {
+    return path.resolve('memory', DEFAULT_MEMORY_DIR, `${key}.json`);
+  }
+  return path.resolve(defaultMemoryPath(DEFAULT_MEMORY_FILE));
+}
+
+function defaultMemory(input = {}) {
+  return {
+    version: '0.3.10',
+    scopeKey: memoryScopeKey(input),
+    scope: memoryScope(input),
+    issues: {},
+    snapshots: [],
+    notes: [],
+    updatedAt: ''
+  };
+}
+
+function readMemory(input = {}) {
+  return {
+    ...defaultMemory(input),
+    ...readJson(memoryPath(input), {})
+  };
+}
+
+function readTextIfExists(filePath) {
+  if (!filePath) return '';
+  const absolute = path.resolve(filePath);
+  if (!fs.existsSync(absolute)) return '';
+  return fs.readFileSync(absolute, 'utf8').replace(/^\uFEFF/, '');
+}
+
+function collectEvidence(input = {}) {
+  const rawExplicit = input.evidence || input.comments || input.voices || input.issues || input.issueTexts;
+  const explicit = Array.isArray(rawExplicit)
+    ? rawExplicit.map(item => String(item).trim()).filter(Boolean)
+    : String(rawExplicit || '')
+      .split(/[;\n\r]+/)
+      .map(item => item.trim())
+      .filter(Boolean);
+  if (explicit.length) return explicit.slice(0, 80);
+  const text = String(input.evidenceText || input.reportText || '').trim() || readTextIfExists(input.report || input.reportPath);
+  if (!text) {
+    return [
+      '想吃但怕排队,到了现场才发现等位时间不确定。',
+      '排了很久以后,如果菜品普通就会觉得不值。',
+      '人均有点高,第一次来不知道怎么点不踩雷。',
+      '视频看着很种草,实际到店体验没有想象中稳定。',
+      '服务忙起来没人理,问问题不知道找谁。'
+    ];
+  }
+  return text
+    .split(/\r?\n/)
+    .map(line => line.replace(/^[-*\d.]+\s*/, '').trim())
+    .filter(line => line.length >= 6)
+    .slice(0, 80);
+}
+
+function matchCategory(text) {
+  return ISSUE_CATEGORIES.find(category => category.terms.some(term => text.includes(term))) || {
+    id: 'other',
+    title: '其它待归类问题',
+    terms: [],
+    severity: 'medium',
+    impact: ['转化', '复购', '口碑'],
+    action: '先补充更多评论证据,再判断它属于产品、服务、价格还是内容预期问题。'
+  };
+}
+
+function priorityScore(issue) {
+  const severityScore = issue.severity === 'high' ? 3 : issue.severity === 'medium' ? 2 : 1;
+  const countScore = Math.min(issue.count, 5);
+  const unresolvedScore = issue.status === '已解决' ? -2 : issue.status === '验证中' ? 0 : 1;
+  return severityScore * 10 + countScore + unresolvedScore;
+}
+
+function mergeStatusFromInput(issues, input = {}) {
+  const statusUpdates = input.statusUpdates || {};
+  const resolved = cleanList(input.resolvedIssues);
+  const validating = cleanList(input.validatingIssues);
+  const blocked = cleanList(input.blockedIssues);
+
+  for (const issue of issues) {
+    if (statusUpdates[issue.title] || statusUpdates[issue.id]) {
+      issue.status = String(statusUpdates[issue.title] || statusUpdates[issue.id]);
+    }
+    if (resolved.some(item => issue.title.includes(item) || issue.id === item)) issue.status = '已解决';
+    if (validating.some(item => issue.title.includes(item) || issue.id === item)) issue.status = '验证中';
+    if (blocked.some(item => issue.title.includes(item) || issue.id === item)) issue.status = '暂缓';
+  }
+}
+
+function buildIssues(input = {}, memory = {}) {
+  const evidence = collectEvidence(input);
+  const grouped = new Map();
+  for (const line of evidence) {
+    const category = matchCategory(line);
+    if (!grouped.has(category.id)) {
+      const previous = memory.issues?.[category.id] || {};
+      grouped.set(category.id, {
+        id: category.id,
+        title: category.title,
+        status: previous.status || '待验证',
+        severity: category.severity,
+        count: 0,
+        evidence: [],
+        impact: category.impact,
+        action: category.action,
+        lastSeenAt: ''
+      });
+    }
+    const issue = grouped.get(category.id);
+    issue.count += 1;
+    if (issue.evidence.length < 3) issue.evidence.push(line);
+  }
+  for (const [id, previous] of Object.entries(memory.issues || {})) {
+    if (!grouped.has(id) && previous.count > 0 && previous.status !== '已解决') {
+      grouped.set(id, {
+        ...previous,
+        count: 0,
+        previousCount: previous.count || 0,
+        delta: -(previous.count || 0),
+        trend: 'resolved',
+        evidence: [],
+        status: previous.status || '待验证'
+      });
+    }
+  }
+  const issues = [...grouped.values()].map(issue => {
+    const previous = memory.issues?.[issue.id] || {};
+    const previousCount = Number.isFinite(Number(issue.previousCount))
+      ? Number(issue.previousCount)
+      : Number(previous.count || 0);
+    const delta = issue.count - previousCount;
+    let trend = 'stable';
+    if (!previousCount && issue.count > 0) trend = 'new';
+    else if (issue.count === 0 && previousCount > 0) trend = 'resolved';
+    else if (delta > 0) trend = 'up';
+    else if (delta < 0) trend = 'down';
+    return {
+      ...issue,
+      previousCount,
+      delta,
+      trend
+    };
+  });
+  mergeStatusFromInput(issues, input);
+  return issues
+    .map(issue => ({ ...issue, priorityScore: priorityScore(issue) }))
+    .sort((a, b) => b.priorityScore - a.priorityScore);
+}
+
+function writeMemory(input = {}, issues = []) {
+  const current = readMemory(input);
+  const nextIssues = { ...(current.issues || {}) };
+  const now = new Date().toISOString();
+  for (const issue of issues) {
+    nextIssues[issue.id] = {
+      ...(nextIssues[issue.id] || {}),
+      ...issue,
+      totalCount: (nextIssues[issue.id]?.totalCount || 0) + issue.count,
+      lastSeenAt: now
+    };
+  }
+  const snapshot = {
+    at: now,
+    evidenceCount: issues.reduce((sum, issue) => sum + issue.count, 0),
+    issues: issues.map(issue => ({
+      id: issue.id,
+      title: issue.title,
+      count: issue.count,
+      previousCount: issue.previousCount || 0,
+      delta: issue.delta || 0,
+      trend: issue.trend || 'stable',
+      status: issue.status
+    }))
+  };
+  const feedback = String(input.feedback || input.message || '').trim();
+  const next = {
+    ...current,
+    version: '0.3.10',
+    scopeKey: memoryScopeKey(input),
+    scope: memoryScope(input),
+    issues: nextIssues,
+    snapshots: [...(current.snapshots || []), snapshot].slice(-12),
+    notes: feedback ? [...(current.notes || []), feedback].slice(-40) : (current.notes || []),
+    updatedAt: now
+  };
+  const filePath = memoryPath(input);
+  writeJson(filePath, next);
+  return { memoryPath: filePath, memory: next };
+}
+
+function formatIssue(issue, index) {
+  return [
+    `### ${index + 1}. ${issue.title}`,
+    `- 状态:${issue.status}`,
+    `- 优先级:${issue.priorityScore >= 33 ? '高' : issue.priorityScore >= 22 ? '中' : '低'}`,
+    `- 本轮证据数:${issue.count}`,
+    `- 影响环节:${issue.impact.join('、')}`,
+    `- 建议动作:${issue.action}`,
+    '- 证据样本:',
+    ...issue.evidence.map(item => `  - ${item}`),
+    `- 下一步:如果要深挖,直接说“继续深挖「${issue.title}」这个问题”。`
+  ].join('\n');
+}
+
+function formatTrendLabel(trend) {
+  return {
+    new: '新增',
+    up: '变多',
+    down: '变少',
+    stable: '持平',
+    resolved: '本轮未再出现'
+  }[trend] || '持平';
+}
+
+function formatIssueChange(issue) {
+  const deltaText = issue.delta > 0 ? `+${issue.delta}` : String(issue.delta || 0);
+  return `- ${issue.title}:${formatTrendLabel(issue.trend)}(上轮 ${issue.previousCount || 0} / 本轮 ${issue.count} / 变化 ${deltaText})`;
+}
+
+function buildIssuePool(input = {}) {
+  const memory = readMemory(input);
+  const issues = buildIssues(input, memory);
+  const scope = memoryScope(input);
+  const memoryUpdate = writeMemory(input, issues);
+  const topIssue = issues[0];
+  const assistantMessage = [
+    '# VOC 问题池',
+    '',
+    '## 一句话判断',
+    topIssue
+      ? `本轮最值得优先处理的是“${topIssue.title}”,它同时影响${topIssue.impact.join('、')}。`
+      : '本轮证据还不足,需要先补充更多真实评论或趋势报告。',
+    '',
+    '## 业务口径',
+    `- 项目/品牌:${scope.project || '未指定'}`,
+    `- 行业/品类:${scope.industry || '通用行业'}`,
+    `- 使用场景:${scope.scenario || '经营/内容/转化'}`,
+    `- 目标用户:${scope.audience || '目标用户'}`,
+    `- 问题数:${issues.length}`,
+    '',
+    '## 问题优先级',
+    ...issues.map(formatIssue),
+    '',
+    '## 问题变化',
+    ...(issues.length ? issues.map(formatIssueChange) : ['- 本轮证据不足,还不能做跨轮变化判断。']),
+    '',
+    '## 建议本周推进',
+    ...(issues.slice(0, 3).map((issue, index) => `${index + 1}. 先处理“${issue.title}”:${issue.action}`)),
+    '',
+    '## 下轮怎么用',
+    '- 想继续深入某个问题:说“继续深挖「问题名」”。',
+    '- 已经验证过动作:告诉我“这个有效/无效”,我会更新问题池状态和记忆。',
+    '- 下次再采集:把新评论或报告继续传入问题池,对比问题是否变多或变少。',
+    '',
+    '## 已保存',
+    `- 问题池记忆:${memoryUpdate.memoryPath}`
+  ].join('\n');
+
+  return {
+    status: 'ok',
+    assistantMessage,
+    summary: {
+      issueCount: issues.length,
+      topIssue: topIssue?.title || '',
+      memoryPath: memoryUpdate.memoryPath,
+      scopeKey: memoryScopeKey(input)
+    },
+    data: {
+      scope,
+      issues,
+      memory: memoryUpdate.memory
+    },
+    files: [memoryUpdate.memoryPath],
+    nextActions: [
+      topIssue ? `继续深挖「${topIssue.title}」` : '补充更多评论证据',
+      '更新已验证/已解决的问题状态',
+      '下一轮采集后继续更新问题池'
+    ],
+    warnings: [],
+    errors: []
+  };
+}
+
+module.exports = {
+  DEFAULT_MEMORY_FILE,
+  memoryPath,
+  readMemory,
+  buildIssuePool
+};

+ 662 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/voc-problem-deep-dive/deep-dive.js

@@ -0,0 +1,662 @@
+const fs = require('fs');
+const path = require('path');
+const { readJson, writeJson, defaultMemoryPath } = require('../../core/memory-store');
+
+const DEFAULT_MEMORY_FILE = 'voc-problem-deep-dive-memory.json';
+const DEFAULT_MEMORY_DIR = 'voc-problem-deep-dive';
+
+const ACTION_TERMS = [
+  '评论区回复',
+  '预点单',
+  '等位饮品',
+  '等位提示牌',
+  '门口提示牌',
+  '推荐先点',
+  '招牌菜',
+  '套餐',
+  '人均',
+  '点单路径',
+  'SOP',
+  '话术',
+  '员工培训',
+  '收台',
+  '翻台',
+  '试吃',
+  '内容表达',
+  '复盘',
+  '优惠券',
+  '会员',
+  '预约'
+];
+
+const ISSUE_PRESETS = [
+  {
+    match: /排队|等位|等待|翻台|叫号/,
+    issue: '排队',
+    priority: 'high',
+    severity: '高',
+    effort: '中',
+    impactTargets: ['新客转化', '复购', '内容口碑', '现场效率'],
+    judgment: '排队不是单纯的服务瑕疵,它同时暴露了预期管理、翻台效率和等位体验三个问题。',
+    surface: '用户表面在说“排队久”。',
+    deeper: '深层问题是等待时间不透明、等待过程没有被照顾、排队抬高了用户对出品的预期。',
+    voices: [
+      '想吃但怕排队,到了现场才发现等位时间不确定。',
+      '排了很久以后,如果菜品没有超出预期,就会觉得不值。',
+      '排队可以接受,但希望知道大概要等多久、值不值得等。'
+    ],
+    actionsToday: [
+      '统一评论区和门口话术:告诉用户高峰大概要等多久、几点来更舒服。',
+      '在门口放一个简单等位提示牌:当前桌数、预计等待区间、推荐先点的招牌菜。'
+    ],
+    actions7Days: [
+      '测试预点单或排队先点招牌菜,让用户等待时已经进入消费流程。',
+      '连续记录 7 天点单、出餐、结账、收台时间,找出翻台最长卡点。'
+    ],
+    actionsLongTerm: [
+      '把等位体验设计成品牌体验的一部分,比如小份试吃、等位饮品、等位菜单。',
+      '把内容表达从“天天排队”升级为“怎么排更值、什么时候来体验最好”。'
+    ],
+    content: [
+      '别只说南昌烧烤排队,告诉用户几点来不用等太久。',
+      '这家店为什么排队还值得等?只讲 3 个超过预期的点。',
+      '老板视角:排队不是炫耀,是一道必须被管理的用户体验。'
+    ],
+    metrics: [
+      '评论里“排队久/等太久/不值”出现次数是否下降。',
+      '评论里“几点去/要等多久/能不能预约”是否变成可引导咨询。',
+      '高峰期门口放弃率、翻台时长、预点单比例是否改善。'
+    ]
+  },
+  {
+    match: /贵|价格|人均|性价比|不值|划算/,
+    issue: '价格/性价比',
+    priority: 'high',
+    severity: '高',
+    effort: '低',
+    impactTargets: ['新客转化', '客单结构', '评论信任'],
+    judgment: '价格争议通常不是用户单纯嫌贵,而是用户没有感知到“贵在哪里值得”。',
+    surface: '用户表面在说“贵、不值、超预算”。',
+    deeper: '深层问题是套餐路径不清楚、招牌菜价值没有被解释、用户不知道怎么点最稳。',
+    voices: [
+      '味道可以,但这个人均会让我犹豫要不要再来。',
+      '如果是请客可以,日常吃就有点贵。',
+      '套餐看起来便宜,但实际点下来超预算。'
+    ],
+    actionsToday: [
+      '做一个 2 人尝鲜点单路径,写清楚人均、分量和适合场景。',
+      '把招牌菜旁边加一句价值解释:贵在哪里、适合谁、怎么吃更值。'
+    ],
+    actions7Days: [
+      '测试 2 人/4 人/6 人套餐,观察用户是否从质疑价格变成询问套餐。',
+      '整理评论区高频价格问题,统一回复人均区间和不踩雷点法。'
+    ],
+    actionsLongTerm: [
+      '建立稳定的新客尝鲜套餐,用低决策成本承接第一次到店。',
+      '把价格表达从“便宜”改成“怎么吃得值”。'
+    ],
+    content: [
+      '第一次来南昌烧烤,照这个 2 人套餐点不容易踩雷。',
+      '这道招牌菜到底贵在哪?老板只讲真实成本和口味差异。',
+      '人均多少能吃舒服?别让顾客到店才算账。'
+    ],
+    metrics: [
+      '评论里“贵/不值/踩雷套餐”是否下降。',
+      '首次套餐销量和招牌菜点单率是否上升。',
+      '用户是否开始在评论里询问套餐而不是直接质疑价格。'
+    ]
+  },
+  {
+    match: /不好吃|味道|出品|稳定|咸|辣|淡|翻车/,
+    issue: '味道/出品稳定',
+    priority: 'high',
+    severity: '高',
+    effort: '中',
+    impactTargets: ['复购', '转介绍', '差评风险'],
+    judgment: '味道问题最伤复购,老板要先判断是单品问题、批次问题,还是用户预期被内容抬高。',
+    surface: '用户表面在说“不好吃、不稳定、没有视频里那么惊艳”。',
+    deeper: '深层问题是招牌菜标准化不足,内容预期和门店交付没有对齐。',
+    voices: [
+      '刷到视频很想吃,但到店感觉没有说得那么惊艳。',
+      '有时候好吃,有时候味道不稳定。',
+      '辣度、咸淡和火候不稳定,会影响整桌体验。'
+    ],
+    actionsToday: [
+      '先盯 3 个核心单品,记录咸淡、温度、出餐时间和退换反馈。',
+      '把内容里的夸张表达降下来,改成具体口味描述。'
+    ],
+    actions7Days: [
+      '连续 7 天复盘招牌菜差评,区分食材、火候、等待、预期四类原因。',
+      '高峰期暂时减少不稳定 SKU,把出品能力集中到招牌菜。'
+    ],
+    actionsLongTerm: [
+      '建立核心单品 SOP 和抽检机制。',
+      '把真实口味标签固定下来,让内容、菜单和服务员表达一致。'
+    ],
+    content: [
+      '别只说好吃,讲清楚这家烧烤到底是哪种口味。',
+      '老板复盘:一条差评背后的出品稳定问题。',
+      '招牌菜为什么要少而稳?比菜单越多越重要。'
+    ],
+    metrics: [
+      '评论里“没有想象中好吃/不稳定/翻车”是否下降。',
+      '招牌菜复点率和退换反馈是否改善。',
+      '用户是否开始用具体口味词夸菜,而不是只说网红。'
+    ]
+  },
+  {
+    match: /服务|态度|老板|员工|体验|环境|卫生/,
+    issue: '服务/体验',
+    priority: 'medium',
+    severity: '中',
+    effort: '低',
+    impactTargets: ['复购', '带人到店', '现场效率'],
+    judgment: '服务体验问题不是一句“培训员工”能解决,而是高峰压力、流程缺口和话术不统一共同造成的。',
+    surface: '用户表面在说“没人理、态度差、环境一般”。',
+    deeper: '深层问题是高峰期岗位分工不清楚,用户遇到等待、催菜、投诉时没有稳定回应。',
+    voices: [
+      '东西还可以,但服务体验让我下次犹豫。',
+      '忙的时候没人理,问题不知道问谁。',
+      '环境和卫生会影响我愿不愿意带朋友来。'
+    ],
+    actionsToday: [
+      '设置高峰期固定响应人,专门处理等位、催菜、加单和投诉。',
+      '统一 5 句高频回应话术,避免员工临场发挥。'
+    ],
+    actions7Days: [
+      '每天复盘 3 条现场问题,记录发生时段、岗位、处理结果。',
+      '把收台、明档、卫生细节做成用户看得见的信任信号。'
+    ],
+    actionsLongTerm: [
+      '建立高峰期服务动线和岗位表。',
+      '把服务问题从“员工态度”升级为“流程管理”。'
+    ],
+    content: [
+      '一家烧烤店真正拉开差距的,不只是味道,还有高峰期体验。',
+      '老板每天复盘 3 个服务问题,比喊口号更有用。',
+      '为什么有些小店环境一般,顾客还是愿意反复来?'
+    ],
+    metrics: [
+      '评论里“没人理/态度差/环境差/卫生”是否下降。',
+      '高峰催菜次数、投诉次数、收台时长是否下降。',
+      '带朋友/家庭聚餐相关正向评论是否增加。'
+    ]
+  }
+];
+
+function splitList(value) {
+  if (!value) return [];
+  if (Array.isArray(value)) return value.flatMap(splitList);
+  return String(value).split(/[,,、\n\r]+/).map(item => item.trim()).filter(Boolean);
+}
+
+function cleanList(values) {
+  return [...new Set(splitList(values))].filter(Boolean);
+}
+
+function slugify(value) {
+  const raw = String(value || '').trim().toLowerCase();
+  if (!raw) return '';
+  const slug = raw
+    .replace(/[^\p{L}\p{N}]+/gu, '-')
+    .replace(/^-+|-+$/g, '')
+    .slice(0, 80);
+  return slug || '';
+}
+
+function memoryScope(input = {}) {
+  return {
+    project: String(input.project || input.brand || input.store || '').trim(),
+    industry: String(input.industry || input.category || '').trim(),
+    scenario: String(input.scenario || input.scene || input.businessType || '').trim(),
+    audience: String(input.audience || input.targetAudience || '').trim()
+  };
+}
+
+function memoryScopeKey(input = {}) {
+  const scope = memoryScope(input);
+  const parts = [scope.project, scope.industry, scope.scenario, scope.audience]
+    .map(slugify)
+    .filter(Boolean);
+  return parts.length ? parts.join('__') : 'default';
+}
+
+function memoryPath(input = {}) {
+  if (input.memory || input.memoryPath) {
+    return path.resolve(input.memory || input.memoryPath);
+  }
+  const key = memoryScopeKey(input);
+  if (key && key !== 'default') {
+    return path.resolve('memory', DEFAULT_MEMORY_DIR, `${key}.json`);
+  }
+  return path.resolve(defaultMemoryPath(DEFAULT_MEMORY_FILE));
+}
+
+function defaultMemory() {
+  return {
+    version: '0.3.10',
+    scopeKey: '',
+    scope: {},
+    preferredActions: [],
+    blockedActions: [],
+    preferredContentAngles: [],
+    knownIssues: [],
+    validatedActions: [],
+    rejectedActions: [],
+    actionStats: {},
+    notes: [],
+    lastIssue: '',
+    updatedAt: ''
+  };
+}
+
+function readMemory(input = {}) {
+  const scope = memoryScope(input);
+  const scopeKey = memoryScopeKey(input);
+  return {
+    ...defaultMemory(),
+    scopeKey,
+    scope,
+    ...readJson(memoryPath(input), {})
+  };
+}
+
+function appendUnique(existing = [], values = [], limit = 30) {
+  return [...new Set([...(existing || []), ...cleanList(values)])].slice(-limit);
+}
+
+function findActionTerms(text) {
+  const source = String(text || '');
+  if (!source) return [];
+  return ACTION_TERMS.filter(term => source.includes(term));
+}
+
+function extractTermsNearSignals(text, signals = []) {
+  const source = String(text || '');
+  if (!source) return [];
+  const found = [];
+  for (const term of ACTION_TERMS) {
+    const index = source.indexOf(term);
+    if (index < 0) continue;
+    const nearby = source.slice(index, index + term.length + 20).split(/[,,。;;!!\n\r]/)[0];
+    if (signals.some(signal => nearby.includes(signal))) found.push(term);
+  }
+  return found;
+}
+
+function keepValidatedTerms(text, terms = []) {
+  const source = String(text || '');
+  return cleanList(terms).filter(term => {
+    const index = source.indexOf(term);
+    if (index < 0) return true;
+    const nearby = source.slice(index, index + term.length + 20).split(/[,,。;;!!\n\r]/)[0];
+    return !nearby.includes('无效');
+  });
+}
+
+function extractTermsAfterSignals(text, signals = [], stopSignals = []) {
+  const source = String(text || '');
+  const found = [];
+  for (const signal of signals) {
+    const index = source.indexOf(signal);
+    if (index < 0) continue;
+    if (signal === '有效' && source[index - 1] === '无') continue;
+    let tail = source.slice(index + signal.length, index + signal.length + 48);
+    const stopIndexes = stopSignals
+      .map(stop => tail.indexOf(stop))
+      .filter(stopIndex => stopIndex >= 0);
+    if (stopIndexes.length) {
+      tail = tail.slice(0, Math.min(...stopIndexes));
+    }
+    found.push(...findActionTerms(tail));
+  }
+  return found;
+}
+
+function parseFeedbackMemory(input = {}) {
+  const feedback = String(input.feedback || input.message || '').trim();
+  if (!feedback) {
+    return {
+      preferredActions: [],
+      blockedActions: [],
+      validatedActions: [],
+      rejectedActions: [],
+      preferredContentAngles: []
+    };
+  }
+  const blockedSignals = ['不想做', '不要做', '不适合', '别做', '不能做', '先不要', '别用', '排除'];
+  const preferredSignals = ['更想', '优先', '先改', '先做', '偏好', '希望', '重点做', '可以做'];
+  const validatedSignals = ['有效', '有用', '验证通过', '跑通', '能转化', '效果好'];
+  const rejectedSignals = ['无效', '没用', '效果不好', '不转化', '验证失败'];
+  const contentSignals = ['内容', '选题', '口播', '话术', '小红书', '抖音'];
+  return {
+    preferredActions: extractTermsAfterSignals(feedback, preferredSignals, blockedSignals),
+    blockedActions: extractTermsAfterSignals(feedback, blockedSignals, preferredSignals),
+    validatedActions: keepValidatedTerms(feedback, [
+      extractTermsAfterSignals(feedback, validatedSignals),
+      extractTermsNearSignals(feedback, validatedSignals)
+    ]),
+    rejectedActions: cleanList([
+      extractTermsAfterSignals(feedback, rejectedSignals),
+      extractTermsNearSignals(feedback, rejectedSignals)
+    ]),
+    preferredContentAngles: contentSignals.some(signal => feedback.includes(signal)) ? findActionTerms(feedback) : []
+  };
+}
+
+function bumpActionStats(currentStats = {}, actions = [], field = '') {
+  const next = { ...(currentStats || {}) };
+  for (const action of cleanList(actions)) {
+    if (!action) continue;
+    next[action] = {
+      validatedCount: Number(next[action]?.validatedCount || 0),
+      rejectedCount: Number(next[action]?.rejectedCount || 0),
+      lastUpdatedAt: next[action]?.lastUpdatedAt || ''
+    };
+    next[action][field] += 1;
+    next[action].lastUpdatedAt = new Date().toISOString();
+  }
+  return next;
+}
+
+function updateMemory(input = {}, result = {}) {
+  const filePath = memoryPath(input);
+  const current = readMemory(input);
+  const feedback = String(input.feedback || input.message || '').trim();
+  const parsed = parseFeedbackMemory(input);
+  let actionStats = current.actionStats || {};
+  actionStats = bumpActionStats(actionStats, [input.validatedActions, parsed.validatedActions], 'validatedCount');
+  actionStats = bumpActionStats(actionStats, [input.rejectedActions, parsed.rejectedActions], 'rejectedCount');
+  const next = {
+    ...current,
+    version: '0.3.10',
+    scopeKey: memoryScopeKey(input),
+    scope: memoryScope(input),
+    preferredActions: appendUnique(current.preferredActions, [input.preferredActions, parsed.preferredActions]),
+    blockedActions: appendUnique(current.blockedActions, [input.blockedActions, parsed.blockedActions]),
+    preferredContentAngles: appendUnique(current.preferredContentAngles, [input.preferredContentAngles, parsed.preferredContentAngles]),
+    knownIssues: appendUnique(current.knownIssues, [result.summary?.issue]),
+    validatedActions: appendUnique(current.validatedActions, [input.validatedActions, parsed.validatedActions]),
+    rejectedActions: appendUnique(current.rejectedActions, [input.rejectedActions, parsed.rejectedActions]),
+    actionStats,
+    notes: appendUnique(current.notes, feedback ? [feedback] : [], 40),
+    lastIssue: result.summary?.issue || current.lastIssue,
+    updatedAt: new Date().toISOString()
+  };
+  writeJson(filePath, next);
+  return { memoryPath: filePath, memory: next };
+}
+
+function readTextIfExists(filePath) {
+  if (!filePath) return '';
+  const absolute = path.resolve(filePath);
+  if (!fs.existsSync(absolute)) return '';
+  return fs.readFileSync(absolute, 'utf8').replace(/^\uFEFF/, '');
+}
+
+function inferPreset(issue, evidenceText = '') {
+  const text = `${issue || ''}\n${evidenceText || ''}`;
+  return ISSUE_PRESETS.find(preset => preset.match.test(text)) || {
+    issue: issue || '待深挖问题',
+    priority: 'medium',
+    severity: '中',
+    effort: '中',
+    impactTargets: ['新客转化', '复购', '口碑'],
+    judgment: '这个问题需要先从用户真实声音里拆清楚:用户表面在吐槽什么,背后真正影响决策的是什么。',
+    surface: '用户已经表达了犹豫或不满。',
+    deeper: '深层问题可能是产品交付、现场体验和内容预期没有对齐。',
+    voices: [
+      '用户已经表达了犹豫或不满,但还需要继续归类证据。',
+      '这类反馈通常同时包含产品、体验和预期管理问题。'
+    ],
+    actionsToday: ['先收集 10 条相关评论,按产品、服务、价格、场景四类归因。'],
+    actions7Days: ['选择一个最小动作做 7 天验证,看负面表达是否下降。'],
+    actionsLongTerm: ['把高频问题沉淀成门店 SOP、内容话术和复盘指标。'],
+    content: ['从一条真实评论看门店怎么改。'],
+    metrics: ['相关负面词出现次数是否下降。']
+  };
+}
+
+function pickEvidence(input = {}) {
+  const explicit = cleanList(input.evidence || input.evidenceText || input.comments || input.voices);
+  if (explicit.length) return explicit.slice(0, 6);
+  const reportText = readTextIfExists(input.report || input.reportPath);
+  if (!reportText) return [];
+  return reportText
+    .split(/\r?\n/)
+    .map(line => line.trim())
+    .filter(line => /高赞评论|评论|吐槽|担心|怕|不满|问题|顾虑/.test(line))
+    .slice(0, 6);
+}
+
+function filterBlocked(actions, memory) {
+  const blocked = cleanList(memory.blockedActions);
+  if (!blocked.length) return actions;
+  return actions.filter(action => !blocked.some(term => action.includes(term)));
+}
+
+function prioritizePreferred(actions, memory) {
+  const preferred = cleanList(memory.preferredActions);
+  const validated = cleanList(memory.validatedActions);
+  const rejected = cleanList(memory.rejectedActions);
+  const stats = memory.actionStats || {};
+  if (!preferred.length && !validated.length && !rejected.length && !Object.keys(stats).length) return actions;
+  return [...actions].sort((a, b) => {
+    const scoreA = actionMemoryScore(a, { preferred, validated, rejected, stats });
+    const scoreB = actionMemoryScore(b, { preferred, validated, rejected, stats });
+    return scoreB - scoreA;
+  });
+}
+
+function actionMemoryScore(action, { preferred = [], validated = [], rejected = [], stats = {} } = {}) {
+  let score = 0;
+  for (const term of preferred) {
+    if (action.includes(term)) score += 20;
+  }
+  for (const term of validated) {
+    if (action.includes(term)) score += 30;
+  }
+  for (const term of rejected) {
+    if (action.includes(term)) score -= 25;
+  }
+  for (const [term, item] of Object.entries(stats || {})) {
+    if (!action.includes(term)) continue;
+    score += Number(item.validatedCount || 0) * 12;
+    score -= Number(item.rejectedCount || 0) * 10;
+  }
+  return score;
+}
+
+function actionMemoryReasonLines(memory) {
+  const lines = [];
+  const stats = memory.actionStats || {};
+  const validatedStats = Object.entries(stats)
+    .filter(([, item]) => Number(item.validatedCount || 0) > 0)
+    .sort((a, b) => Number(b[1].validatedCount || 0) - Number(a[1].validatedCount || 0))
+    .slice(0, 3);
+  const rejectedStats = Object.entries(stats)
+    .filter(([, item]) => Number(item.rejectedCount || 0) > 0)
+    .sort((a, b) => Number(b[1].rejectedCount || 0) - Number(a[1].rejectedCount || 0))
+    .slice(0, 3);
+  if (memory.preferredActions?.length) lines.push(`- 优先贴合你之前偏好的动作:${memory.preferredActions.slice(-5).join('、')}`);
+  if (validatedStats.length) {
+    lines.push(`- 已验证有效的动作会被提前:${validatedStats.map(([term, item]) => `${term} ${item.validatedCount} 次`).join('、')}`);
+  }
+  if (rejectedStats.length) {
+    lines.push(`- 已验证无效的动作会被降权:${rejectedStats.map(([term, item]) => `${term} ${item.rejectedCount} 次`).join('、')}`);
+  }
+  if (memory.blockedActions?.length) lines.push(`- 已明确不想做的动作不会进入可执行建议:${memory.blockedActions.slice(-5).join('、')}`);
+  return lines;
+}
+
+function memoryLines(memory) {
+  const lines = [];
+  if (memory.knownIssues?.length) lines.push(`- 已多次关注的问题:${memory.knownIssues.slice(-5).join('、')}`);
+  if (memory.preferredActions?.length) lines.push(`- 用户偏好的动作:${memory.preferredActions.slice(-5).join('、')}`);
+  if (memory.blockedActions?.length) lines.push(`- 用户不想采用的动作:${memory.blockedActions.slice(-5).join('、')}`);
+  if (memory.validatedActions?.length) lines.push(`- 已验证有效动作:${memory.validatedActions.slice(-5).join('、')}`);
+  if (memory.rejectedActions?.length) lines.push(`- 已验证无效动作:${memory.rejectedActions.slice(-5).join('、')}`);
+  const stats = Object.entries(memory.actionStats || {}).slice(-5);
+  if (stats.length) {
+    lines.push(`- 动作验证次数:${stats.map(([term, item]) => `${term} 有效${item.validatedCount || 0}/无效${item.rejectedCount || 0}`).join('、')}`);
+  }
+  return lines;
+}
+
+function buildDeepDive(input = {}) {
+  const currentMemory = readMemory(input);
+  const parsedFeedback = parseFeedbackMemory(input);
+  const effectiveMemory = {
+    ...currentMemory,
+    preferredActions: appendUnique(currentMemory.preferredActions, [input.preferredActions, parsedFeedback.preferredActions]),
+    blockedActions: appendUnique(currentMemory.blockedActions, [input.blockedActions, parsedFeedback.blockedActions]),
+    preferredContentAngles: appendUnique(currentMemory.preferredContentAngles, [input.preferredContentAngles, parsedFeedback.preferredContentAngles]),
+    validatedActions: appendUnique(currentMemory.validatedActions, [input.validatedActions, parsedFeedback.validatedActions]),
+    rejectedActions: appendUnique(currentMemory.rejectedActions, [input.rejectedActions, parsedFeedback.rejectedActions]),
+    actionStats: bumpActionStats(
+      bumpActionStats(currentMemory.actionStats || {}, [input.validatedActions, parsedFeedback.validatedActions], 'validatedCount'),
+      [input.rejectedActions, parsedFeedback.rejectedActions],
+      'rejectedCount'
+    )
+  };
+  const issue = String(input.issue || input.problem || input.topic || currentMemory.lastIssue || '').trim();
+  const industry = String(input.industry || input.category || '通用行业').trim();
+  const scenario = String(input.scenario || input.scene || input.businessType || '门店/品牌/服务商').trim();
+  const audience = String(input.audience || input.targetAudience || '目标用户').trim();
+  const evidence = pickEvidence(input);
+  const preset = inferPreset(issue, evidence.join('\n'));
+  const finalIssue = issue || preset.issue;
+  const voices = evidence.length ? evidence : preset.voices;
+
+  const todayActions = prioritizePreferred(filterBlocked(preset.actionsToday, effectiveMemory), effectiveMemory);
+  const weekActions = prioritizePreferred(filterBlocked(preset.actions7Days, effectiveMemory), effectiveMemory);
+  const longTermActions = prioritizePreferred(filterBlocked(preset.actionsLongTerm, effectiveMemory), effectiveMemory);
+  const actionReasons = actionMemoryReasonLines(effectiveMemory);
+  const memoryApplied = Boolean(
+    effectiveMemory.preferredActions.length ||
+    effectiveMemory.blockedActions.length ||
+    effectiveMemory.validatedActions.length ||
+    effectiveMemory.rejectedActions.length ||
+    Object.keys(effectiveMemory.actionStats || {}).length ||
+    currentMemory.knownIssues.length
+  );
+
+  const summary = {
+    issue: finalIssue,
+    industry,
+    scenario,
+    audience,
+    priority: preset.priority,
+    severity: preset.severity,
+    effort: preset.effort,
+    impactTargets: preset.impactTargets,
+    evidenceCount: voices.length,
+    memoryApplied,
+    memoryScopeKey: memoryScopeKey(input),
+    parsedFeedback
+  };
+
+  const output = {
+    status: 'ok',
+    assistantMessage: [
+      `# 单点 VOC 深挖:${finalIssue}`,
+      '',
+      '## 一句话判断',
+      `${preset.judgment}`,
+      '',
+      '## 业务口径',
+      `- 行业/品类:${industry}`,
+      `- 使用场景:${scenario}`,
+      `- 目标用户:${audience}`,
+      '',
+      '## 优先级判断',
+      `- 严重度:${preset.severity}`,
+      `- 执行难度:${preset.effort}`,
+      `- 优先级:${preset.priority === 'high' ? '高,建议本周处理' : '中,建议进入 7 天验证'}`,
+      `- 主要影响:${preset.impactTargets.join('、')}`,
+      '',
+      '## 用户原话证据',
+      ...voices.map((voice, index) => `${index + 1}. ${voice}`),
+      '',
+      '## 表层问题 vs 深层问题',
+      `- 表层问题:${preset.surface}`,
+      `- 深层问题:${preset.deeper}`,
+      '',
+      '## 老板视角影响',
+      ...preset.impactTargets.map(item => `- ${item}`),
+      '',
+      '## 可执行解决动作',
+      ...(actionReasons.length ? ['### 为什么优先推荐这些动作', ...actionReasons, ''] : []),
+      '### 今天就能做',
+      ...todayActions.map((item, index) => `${index + 1}. ${item}`),
+      '',
+      '### 7 天内验证',
+      ...weekActions.map((item, index) => `${index + 1}. ${item}`),
+      '',
+      '### 长期沉淀',
+      ...longTermActions.map((item, index) => `${index + 1}. ${item}`),
+      '',
+      '## 可转化内容选题',
+      ...preset.content.map((item, index) => `${index + 1}. ${item}`),
+      '',
+      '## 7 天验证计划',
+      '1. 第 1 天:确定一个门店动作和一个内容动作,不要同时改太多。',
+      '2. 第 2-4 天:记录相关评论、现场反馈和员工反馈。',
+      '3. 第 5-7 天:复查负面词是否下降,咨询型评论是否增加。',
+      '',
+      '## 下轮验证指标',
+      ...preset.metrics.map(item => `- ${item}`),
+      ...(memoryApplied ? ['', '## 已应用的使用记忆', ...memoryLines(effectiveMemory)] : []),
+      '',
+      '## 仍需确认的问题',
+      `1. 这个“${finalIssue}”主要发生在新客、老客,还是高峰期用户身上?`,
+      '2. 你们现在有没有对应的现场记录或员工反馈?',
+      '3. 如果只先改一个动作,老板更愿意改产品、服务、价格表达,还是内容表达?'
+    ].join('\n'),
+    summary,
+    data: {
+      issue: finalIssue,
+      industry,
+      scenario,
+      audience,
+      evidence: voices,
+      priority: preset.priority,
+      severity: preset.severity,
+      effort: preset.effort,
+      impactTargets: preset.impactTargets,
+      surfaceProblem: preset.surface,
+      deepProblem: preset.deeper,
+      actions: {
+        today: todayActions,
+        sevenDays: weekActions,
+        longTerm: longTermActions
+      },
+      contentIdeas: preset.content,
+      validationMetrics: preset.metrics,
+      memoryApplied
+    },
+    files: [],
+    nextActions: [
+      `先确认“${finalIssue}”主要影响新客还是复购`,
+      '选择一个门店动作和一个内容动作做 7 天验证',
+      '下一轮把验证结果告诉我,我会更新记忆并调整建议'
+    ],
+    warnings: [],
+    errors: []
+  };
+
+  const memoryUpdate = updateMemory(input, output);
+  output.summary.memoryApplied = memoryApplied;
+  output.summary.memoryPath = memoryUpdate.memoryPath;
+  output.data.memory = memoryUpdate.memory;
+  output.files = [memoryUpdate.memoryPath];
+  return output;
+}
+
+module.exports = {
+  DEFAULT_MEMORY_FILE,
+  memoryPath,
+  readMemory,
+  updateMemory,
+  buildDeepDive
+};

+ 293 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/voc-speaking-script/speaking-script.js

@@ -0,0 +1,293 @@
+const fs = require('fs');
+const path = require('path');
+
+function splitList(value) {
+  if (!value) return [];
+  if (Array.isArray(value)) return value.flatMap(splitList);
+  return String(value)
+    .split(/[;\n\r,,、]+/)
+    .map(item => item.trim())
+    .filter(Boolean);
+}
+
+function readJsonMaybe(filePath) {
+  try {
+    if (!filePath || !fs.existsSync(filePath)) return {};
+    return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''));
+  } catch {
+    return {};
+  }
+}
+
+function writeJson(filePath, data) {
+  fs.mkdirSync(path.dirname(filePath), { recursive: true });
+  fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
+}
+
+function defaultMemoryPath(input = {}) {
+  return path.resolve(
+    input.memory || input.memoryPath || path.join('memory', 'voc-speaking-script-memory.json')
+  );
+}
+
+function inferTopic(input = {}) {
+  const topic = String(input.topic || input.title || input.selectedTopic || input.userIssue || input.issue || '').trim();
+  if (topic) return topic;
+  const issues = splitList(input.issues || input.problems || input.vocIssues);
+  return issues[0] || '把一个真实顾客顾虑讲清楚';
+}
+
+function parseFeedback(feedback = '') {
+  const text = String(feedback || '').trim();
+  const preferred = [];
+  const blocked = [];
+  const styleHints = [];
+
+  if (/老板视角|经营者|创始人视角/.test(text)) preferred.push('老板视角');
+  if (/更狠|更犀利|更直接|开头.*狠/.test(text)) preferred.push('强开头');
+  if (/具体场景|少讲概念|别讲概念|多给案例|真实场景/.test(text)) preferred.push('具体场景');
+  if (/转化|私信|到店|成交|咨询/.test(text)) preferred.push('转化导向');
+  if (/温和|真诚|克制|别太硬/.test(text)) preferred.push('真诚克制');
+
+  if (/不要.*工具|别.*工具|不讲工具|少讲工具/.test(text)) blocked.push('工具清单式表达');
+  if (/不要.*概念|少讲概念|别.*概念/.test(text)) blocked.push('抽象概念');
+  if (/太泛|空泛|大词/.test(text)) blocked.push('空泛表达');
+  if (/不适合|不要|别再|降权/.test(text)) styleHints.push('本轮有降权反馈,需要避免重复推荐相同表达');
+
+  return { preferred, blocked, styleHints, raw: text };
+}
+
+function mergeUnique(...lists) {
+  return [...new Set(lists.flat().filter(Boolean))];
+}
+
+function buildEvidence(input = {}) {
+  return splitList(input.evidence || input.comments || input.voices || input.vocEvidence || input.evidenceText)
+    .slice(0, 8);
+}
+
+function buildScriptDraft({ brand, industry, topic, userIssue, evidence, preferred, blocked, feedback }) {
+  const isBoss = preferred.includes('老板视角');
+  const strong = preferred.includes('强开头');
+  const concrete = preferred.includes('具体场景');
+  const conversion = preferred.includes('转化导向');
+  const sincere = preferred.includes('真诚克制');
+  const avoidConcept = blocked.includes('抽象概念') || blocked.includes('空泛表达');
+
+  const hook = strong
+    ? `先说一句实话:${userIssue} 这个问题不讲清楚,再会拍视频也留不住人。`
+    : `很多人看到 ${brand} 会先问一个问题:${userIssue}。`;
+
+  const opening = isBoss
+    ? `我是 ${brand} 的经营者,今天不讲漂亮话,直接把这个问题拆开说。`
+    : `今天这条内容只回答一个真实顾客问题,不绕弯。`;
+
+  const evidenceLine = evidence.length
+    ? `我们看到的顾客原话里,有人提到:“${evidence[0]}”。这说明用户不是没兴趣,而是在做决定前还缺一个确定答案。`
+    : `这一版先按 VOC 问题做结构推演,证据还不够满,正式发布前最好再补 3-5 条真实评论或私域反馈。`;
+
+  const middle = concrete
+    ? `放到真实场景里,就是顾客准备来之前,会担心自己会不会踩雷、值不值得、到店后能不能马上做决定。所以这条视频要讲清楚:适合谁、不适合谁、第一次怎么选。`
+    : `所以我们不只说“我们很好”,而是要把用户最担心的条件讲清楚:适合谁、解决什么问题、怎么判断值不值。`;
+
+  const proof = avoidConcept
+    ? `具体做法是:给一个真实案例,讲一个选择标准,再给一个可以立刻照做的小动作。`
+    : `这里的关键不是堆卖点,而是把能力翻译成用户听得懂的证明。`;
+
+  const close = conversion
+    ? `如果你也有这个顾虑,可以在评论区留一句“我担心的是哪一点”,我按你的情况给你一个更具体的建议。`
+    : `你来之前先把这个问题想清楚,选择会更稳,也更不容易被单一卖点带偏。`;
+
+  const tone = sincere ? '语气要真诚克制,少夸自己,多解释判断依据。' : '语气要直接、清楚、有老板判断。';
+
+  return {
+    hook,
+    script60: [hook, opening, evidenceLine, middle, proof, close].join('\n'),
+    script30: [hook, evidenceLine, proof, close].join('\n'),
+    teleprompter: [hook, opening, evidenceLine, middle, proof, close]
+      .join('\n\n')
+      .replace(/。/g, '。\n'),
+    titles: [
+      `别急着问好不好,先看 ${userIssue}`,
+      `${industry} 顾客真正担心的不是表面卖点`,
+      `把 ${topic} 讲清楚,用户才敢做决定`
+    ],
+    shots: [
+      '开头正面对镜,直接抛出顾客顾虑',
+      '切真实场景或证据截图,不用过度包装',
+      '展示一个具体案例或操作细节',
+      '结尾回到评论区问题,引导用户补充自己的情况'
+    ],
+    commentPrompt: `你最担心的是 ${userIssue},还是另一个问题?评论区告诉我。`,
+    qualityNotes: [tone]
+  };
+}
+
+function updateMemory(memory, input, draft, feedbackSignals, finalized) {
+  const now = new Date().toISOString();
+  const next = {
+    version: memory.version || '0.1.0',
+    preferredStyles: mergeUnique(memory.preferredStyles || [], feedbackSignals.preferred),
+    blockedStyles: mergeUnique(memory.blockedStyles || [], feedbackSignals.blocked),
+    notes: [...(memory.notes || [])],
+    finalizedScripts: [...(memory.finalizedScripts || [])],
+    updatedAt: now
+  };
+
+  if (feedbackSignals.raw) {
+    next.notes.push({
+      at: now,
+      feedback: feedbackSignals.raw,
+      preferred: feedbackSignals.preferred,
+      blocked: feedbackSignals.blocked
+    });
+  }
+
+  if (finalized) {
+    next.finalizedScripts.push({
+      at: now,
+      brand: input.brand || input.project || input.store || '',
+      topic: inferTopic(input),
+      script60: draft.script60,
+      script30: draft.script30,
+      titles: draft.titles
+    });
+  }
+
+  return next;
+}
+
+function buildQualityGate({ evidence, input }) {
+  const warnings = [];
+  if (!evidence.length) warnings.push('缺少真实用户原话,这版只能作为结构推演稿。');
+  if (!input.topic && !input.title && !input.userIssue && !input.issue) warnings.push('缺少明确选题,建议从 7 天内容计划里选择一条再共创。');
+  const level = warnings.length >= 2 ? 'fail' : warnings.length ? 'warn' : 'pass';
+  return { level, warnings };
+}
+
+function buildVocSpeakingScript(input = {}) {
+  const brand = String(input.brand || input.project || input.store || '你的品牌/门店').trim();
+  const industry = String(input.industry || input.category || '你的行业').trim();
+  const topic = inferTopic(input);
+  const userIssue = String(input.userIssue || input.issue || input.problem || topic).trim();
+  const evidence = buildEvidence(input);
+  const feedbackText = String(input.feedback || input.message || '').trim();
+  const memoryPath = defaultMemoryPath(input);
+  const memory = readJsonMaybe(memoryPath);
+  const feedbackSignals = parseFeedback(feedbackText);
+  const preferred = mergeUnique(memory.preferredStyles || [], feedbackSignals.preferred, splitList(input.preferredStyles));
+  const blocked = mergeUnique(memory.blockedStyles || [], feedbackSignals.blocked, splitList(input.blockedStyles));
+  const finalized = input.finalize === true || input.finalize === 'true' || /定稿|确认这版/.test(feedbackText);
+  const draft = buildScriptDraft({ brand, industry, topic, userIssue, evidence, preferred, blocked, feedback: feedbackText });
+  const qualityGate = buildQualityGate({ evidence, input });
+  const nextMemory = updateMemory(memory, input, draft, feedbackSignals, finalized);
+  writeJson(memoryPath, nextMemory);
+
+  const outputDir = input.output ? path.resolve(input.output) : '';
+  const files = [];
+  const assistantMessage = [
+    '# VOC 口播脚本共创',
+    '',
+    '## 你这轮能拿到什么',
+    '- 一条从 VOC 问题出发的 60 秒口播稿。',
+    '- 一条 30 秒压缩版。',
+    '- 一版提词器文本、标题备选、镜头建议和评论区引导。',
+    '- 本轮反馈会写入脚本记忆,下次会优先沿用有效风格、避开不适合方向。',
+    '',
+    '## 业务口径',
+    `- 品牌/门店:${brand}`,
+    `- 行业/品类:${industry}`,
+    `- 选题:${topic}`,
+    `- 回应的用户问题:${userIssue}`,
+    '',
+    '## 证据与质量门槛',
+    `- 质量状态:${qualityGate.level}`,
+    evidence.length ? evidence.map((item, index) => `- 证据 ${index + 1}:${item}`).join('\n') : '- 暂无用户原话证据,建议补充评论/私域反馈后再定稿。',
+    ...qualityGate.warnings.map(item => `- 风险提醒:${item}`),
+    '',
+    '## 历史偏好已应用',
+    preferred.length ? `- 优先风格:${preferred.join('、')}` : '- 暂无历史优先风格。',
+    blocked.length ? `- 避免方向:${blocked.join('、')}` : '- 暂无历史禁区。',
+    '',
+    '## 60 秒口播稿',
+    draft.script60,
+    '',
+    '## 30 秒压缩版',
+    draft.script30,
+    '',
+    '## 提词器版',
+    draft.teleprompter,
+    '',
+    '## 标题备选',
+    ...draft.titles.map((item, index) => `${index + 1}. ${item}`),
+    '',
+    '## 镜头建议',
+    ...draft.shots.map((item, index) => `${index + 1}. ${item}`),
+    '',
+    '## 评论区引导',
+    draft.commentPrompt,
+    '',
+    '## 下一步',
+    finalized
+      ? '- 已按定稿处理,并写入脚本记忆。下一轮生成会参考这次的风格和禁区。'
+      : '- 你可以回复“开头更狠一点”“少讲概念,多给场景”“定稿”,我会继续改稿或写入记忆。'
+  ].join('\n');
+
+  if (outputDir) {
+    fs.mkdirSync(outputDir, { recursive: true });
+    const mdPath = path.join(outputDir, 'voc-speaking-script.md');
+    const jsonPath = path.join(outputDir, 'voc-speaking-script.json');
+    fs.writeFileSync(mdPath, assistantMessage, 'utf8');
+    writeJson(jsonPath, {
+      brand,
+      industry,
+      topic,
+      userIssue,
+      evidence,
+      preferred,
+      blocked,
+      qualityGate,
+      draft,
+      memoryPath
+    });
+    files.push(mdPath, jsonPath);
+  }
+
+  return {
+    status: 'ok',
+    assistantMessage,
+    summary: {
+      brand,
+      industry,
+      topic,
+      userIssue,
+      evidenceCount: evidence.length,
+      qualityGate: qualityGate.level,
+      finalized,
+      memoryPath
+    },
+    data: {
+      brand,
+      industry,
+      topic,
+      userIssue,
+      evidence,
+      preferred,
+      blocked,
+      qualityGate,
+      draft,
+      memory: nextMemory
+    },
+    files,
+    nextActions: finalized
+      ? ['用这版拍摄', '发布后把评论反馈放回 VOC 问题池', '下一轮沿用脚本记忆继续优化']
+      : ['回复修改要求继续改稿', '确认后回复“定稿”写入记忆', '发布后把有效/无效反馈告诉我'],
+    warnings: qualityGate.warnings,
+    errors: []
+  };
+}
+
+module.exports = {
+  buildVocSpeakingScript,
+  parseFeedback
+};

+ 156 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/xiaohongshu-trend/assets.js

@@ -0,0 +1,156 @@
+const fs = require('fs');
+const path = require('path');
+
+const DEFAULT_TIMEOUT_MS = 8000;
+const DEFAULT_LIMIT = 10;
+
+function ensureDir(dirPath) {
+  fs.mkdirSync(dirPath, { recursive: true });
+}
+
+function safeName(value, fallback = 'asset') {
+  const cleaned = String(value || '')
+    .trim()
+    .replace(/[^a-zA-Z0-9_-]+/g, '-')
+    .replace(/^-+|-+$/g, '')
+    .slice(0, 80);
+  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 body = match[2] || '';
+  return {
+    contentType,
+    buffer: isBase64 ? 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',
+        'User-Agent': 'claude-code-voc-intelligence/asset-cache'
+      }
+    });
+    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;
+  }
+  return {
+    contentType: fetched.contentType,
+    buffer: fetched.buffer
+  };
+}
+
+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
+};

+ 335 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/xiaohongshu-trend/industry-presets.js

@@ -0,0 +1,335 @@
+function cleanList(value) {
+  if (!value) return [];
+  const items = Array.isArray(value) ? value : [value];
+  return items
+    .flatMap(item => String(item || '').split(/[,,、\n\r]+/))
+    .map(item => item.trim())
+    .filter(Boolean);
+}
+
+function unique(items) {
+  return [...new Set(cleanList(items))];
+}
+
+function textOfProfile(profile = {}) {
+  return [
+    profile.industry,
+    profile.businessType,
+    ...(cleanList(profile.keywords)),
+    ...(cleanList(profile.trendQuestions)),
+    ...(cleanList(profile.mustTrackSignals))
+  ].join(' ');
+}
+
+const PRESETS = [
+  {
+    id: 'home-custom',
+    label: '家装全屋定制',
+    aliases: ['家装', '装修', '全屋定制', '橱柜', '衣柜', '软装', '收纳'],
+    defaultKeywords: ['全屋定制', '装修避坑', '小户型收纳', '衣柜设计', '厨房橱柜'],
+    preferenceSignals: ['奶油风', '原木', '弧形', '无主灯', '低饱和', '显大', '耐看'],
+    decisionSignals: ['预算', '增项', '环保', '板材', '不好打理', '落灰', '翻车', '动线', '收纳'],
+    elementSignals: ['开放格', '柜门', '灯带', '抽屉', '五金', '餐边柜', '玄关', '衣柜'],
+    riskSignals: ['显旧', '压抑', '落灰', '增项', '甲醛', '尺寸翻车'],
+    applicationLabel: '门店追问或内容选题',
+    talkTarget: '客户',
+    sampleNotes: [
+      {
+        title: '全屋定制最容易翻车的不是价格,是柜子做完不好住',
+        content: '很多女生装修前只看奶油风效果图,入住后才发现开放格落灰、柜门不好打理、收纳不够。全屋定制要先想生活动线,再看风格。',
+        author: '住进理想家',
+        imageLabel: '全屋定制翻车避坑',
+        tags: ['全屋定制', '装修避坑', '收纳'],
+        comments: [
+          '我就是开放格翻车,擦灰擦到怀疑人生,现在只想做能关起来的柜子。',
+          '预算最怕增项,前期说得很好,后面五金、灯带、抽屉全加钱。'
+        ]
+      },
+      {
+        title: '奶油风下半年还会流行吗?关键看材质和灯光',
+        content: '奶油风不是越白越好看,真正高级的是低饱和颜色、木纹、弧形和无主灯配合。评论更关心耐看、好打理、显大。',
+        author: '软装设计阿晴',
+        imageLabel: '奶油风耐看与打理',
+        tags: ['奶油风', '装修风格', '设计元素'],
+        comments: [
+          '奶油风好看是好看,就怕两年后显旧,而且有孩子真的怕不好打理。',
+          '我更想知道敏感鼻炎家庭怎么选板材,环保比风格重要。'
+        ]
+      },
+      {
+        title: '小户型别再硬塞满墙柜,真的会显压抑',
+        content: '小户型收纳不是柜子越多越好,要留出视觉呼吸感。评论区很多人问玄关、餐边柜、卧室衣柜怎么做才不显堵。',
+        author: '小家改造研究所',
+        imageLabel: '小户型收纳动线',
+        tags: ['小户型', '收纳', '柜体设计'],
+        comments: ['小户型真的不能只看收纳图,住进去以后动线堵才是最崩溃的。']
+      }
+    ]
+  },
+  {
+    id: 'pet-food',
+    label: '宠物食品',
+    aliases: ['宠物', '猫粮', '狗粮', '宠物食品', '宠物零食', '养猫', '养狗'],
+    defaultKeywords: ['猫粮推荐', '狗粮避坑', '宠物零食', '肠胃敏感猫粮', '冻干主粮'],
+    preferenceSignals: ['高蛋白', '无谷', '冻干', '鲜肉', '低敏', '适口性', '小包装'],
+    decisionSignals: ['成分', '价格', '软便', '泪痕', '过敏', '复购', '试吃装', '品牌口碑'],
+    elementSignals: ['主粮', '罐头', '冻干', '零食', '益生菌', '配料表', '颗粒大小'],
+    riskSignals: ['拉肚子', '不吃', '诱食剂', '虚标', '过敏', '油腻'],
+    applicationLabel: '产品卖点或种草选题',
+    talkTarget: '养宠用户',
+    sampleNotes: [
+      {
+        title: '新手选猫粮别只看蛋白高,先看肠胃能不能适应',
+        content: '很多新手一上来就买高蛋白猫粮,但评论里更关心软便、适口性和配料表。肠胃敏感猫要先小包装试吃,再看复购。',
+        author: '养猫配方研究员',
+        imageLabel: '猫粮配方与软便',
+        tags: ['猫粮推荐', '配料表', '肠胃敏感'],
+        comments: ['我家猫换粮最怕软便,贵不贵反而是第二位。', '希望品牌能直接出试吃装,不然一大袋不吃太浪费。']
+      },
+      {
+        title: '宠物零食越来越卷,铲屎官最怕的其实是诱食剂',
+        content: '冻干、肉条、罐头都在讲高肉含量,但高赞评论反复问有没有诱食剂、盐分高不高、会不会挑食。',
+        author: '狗狗零食测评',
+        imageLabel: '宠物零食避坑',
+        tags: ['宠物零食', '诱食剂', '测评'],
+        comments: ['我不怕零食贵,就怕吃完主粮不吃了。', '能不能把盐分和添加剂讲清楚,比拍得好看重要。']
+      },
+      {
+        title: '狗粮避坑:别只看进口国产,先看狗狗便便和毛发状态',
+        content: '不少用户评论说换粮后便便、泪痕、掉毛变化更能说明问题。真实喂养反馈比单纯参数更容易打动人。',
+        author: '养狗日记本',
+        imageLabel: '狗粮真实反馈',
+        tags: ['狗粮避坑', '复购', '喂养反馈'],
+        comments: ['我更想看连续喂一个月的反馈,不想只看开箱。']
+      }
+    ]
+  },
+  {
+    id: 'beauty-skincare',
+    label: '美妆护肤',
+    aliases: ['美妆', '护肤', '彩妆', '精华', '面霜', '防晒', '敏感肌'],
+    defaultKeywords: ['敏感肌护肤', '防晒推荐', '粉底液测评', '抗老精华', '油皮护肤'],
+    preferenceSignals: ['抗老', '修护', '提亮', '控油', '持妆', '轻薄', '温和'],
+    decisionSignals: ['成分', '肤质', '刺激', '闷痘', '价格', '功效', '妆感', '复购'],
+    elementSignals: ['精华', '面霜', '防晒', '粉底液', '散粉', '屏障', '配方'],
+    riskSignals: ['烂脸', '搓泥', '假白', '拔干', '过敏', '暗沉'],
+    applicationLabel: '功效表达或种草选题',
+    talkTarget: '目标消费者',
+    sampleNotes: [
+      {
+        title: '敏感肌别盲目追猛药,先看修护和刺激风险',
+        content: '评论区很多人不是不想抗老,而是怕泛红、刺痛和屏障受损。温和修护正在变成敏感肌护肤的第一决策点。',
+        author: '成分党小禾',
+        imageLabel: '敏感肌修护',
+        tags: ['敏感肌', '修护', '抗老'],
+        comments: ['我最怕用了第二天泛红,功效可以慢一点。', '能不能按肤质讲,不要所有人都推同一瓶。']
+      },
+      {
+        title: '防晒推荐别只讲 SPF,油皮更在意闷不闷痘',
+        content: '防晒内容里,清爽、成膜、泛白、搓泥是评论高频词。用户想知道的是通勤场景能不能长期用。',
+        author: '油皮防晒实验室',
+        imageLabel: '防晒肤感测评',
+        tags: ['防晒', '油皮', '肤感'],
+        comments: ['防晒指数都差不多,我更关心会不会闷痘和搓泥。']
+      },
+      {
+        title: '粉底液测评开始从遮瑕转向真实持妆',
+        content: '用户不只看上脸瞬间,更关心四小时后暗沉、卡粉和出油状态。真实带妆记录比棚拍更有说服力。',
+        author: '底妆记录员',
+        imageLabel: '粉底液持妆',
+        tags: ['粉底液', '持妆', '暗沉'],
+        comments: ['求别只开美颜,我想看下午真实斑驳情况。']
+      }
+    ]
+  },
+  {
+    id: 'baby-maternal',
+    label: '母婴用品',
+    aliases: ['母婴', '婴儿', '宝宝', '纸尿裤', '奶粉', '儿童用品', '孕妈'],
+    defaultKeywords: ['纸尿裤测评', '宝宝辅食', '婴儿湿巾', '奶粉选择', '新手妈妈'],
+    preferenceSignals: ['温和', '安全', '透气', '省心', '便携', '无添加', '分龄'],
+    decisionSignals: ['安全', '过敏', '红屁屁', '价格', '成分', '吸收', '口碑', '售后'],
+    elementSignals: ['纸尿裤', '湿巾', '奶瓶', '辅食', '奶粉', '推车', '洗护'],
+    riskSignals: ['红屁屁', '漏尿', '过敏', '呛奶', '添加剂', '踩雷'],
+    applicationLabel: '妈妈决策内容或产品表达',
+    talkTarget: '家长',
+    sampleNotes: [
+      {
+        title: '纸尿裤测评别只讲薄,妈妈更怕红屁屁和漏尿',
+        content: '高赞评论集中在夜用漏尿、红屁屁、尺码偏小。真实使用场景比单纯参数更影响购买。',
+        author: '新手妈妈测评',
+        imageLabel: '纸尿裤夜用测评',
+        tags: ['纸尿裤', '红屁屁', '漏尿'],
+        comments: ['我最怕半夜漏尿,薄不薄真的排第二。', '能不能按宝宝体重讲尺码,老买错。']
+      },
+      {
+        title: '宝宝辅食火的不是花样,是省心和不过敏',
+        content: '用户会收藏辅食清单,但评论更关心过敏、添加剂和制作时间。省心但安全是下一轮核心机会。',
+        author: '辅食日记',
+        imageLabel: '宝宝辅食安全',
+        tags: ['辅食', '过敏', '无添加'],
+        comments: ['我想要简单一点的,别每天像做大餐。']
+      },
+      {
+        title: '婴儿湿巾到底怎么选?成分表比香味重要',
+        content: '很多家长不再只看抽数和价格,而是看成分、厚度和会不会刺激皮肤。',
+        author: '母婴用品避坑',
+        imageLabel: '婴儿湿巾成分',
+        tags: ['湿巾', '成分', '温和'],
+        comments: ['便宜没用,宝宝一红就不敢用了。']
+      }
+    ]
+  },
+  {
+    id: 'education-training',
+    label: '教育培训',
+    aliases: ['教育', '培训', '课程', '教培', '升学', '考研', '英语', '职业培训'],
+    defaultKeywords: ['课程避坑', '英语启蒙', '考研规划', '职业技能培训', '学习方法'],
+    preferenceSignals: ['提分', '陪跑', '规划', '试听', '体系化', '实战', '反馈'],
+    decisionSignals: ['价格', '师资', '效果', '时间', '试听', '退费', '作业反馈', '适合程度'],
+    elementSignals: ['课程', '老师', '班型', '资料', '题库', '社群', '作业'],
+    riskSignals: ['没效果', '退费难', '跟不上', '销售焦虑', '虚假承诺'],
+    applicationLabel: '课程转化或内容选题',
+    talkTarget: '学习者/家长',
+    sampleNotes: [
+      {
+        title: '报课前别先问价格,先看孩子能不能跟上节奏',
+        content: '评论里很多家长担心不是贵,而是课程节奏太快、作业反馈不及时、试听和正课差距大。',
+        author: '学习规划老师',
+        imageLabel: '课程节奏与反馈',
+        tags: ['课程避坑', '试听', '作业反馈'],
+        comments: ['试听课很好,正课没人管才是最崩溃的。', '我更想知道跟不上有没有补救机制。']
+      },
+      {
+        title: '英语启蒙内容变了:家长不只要资源,要可执行路径',
+        content: '收藏高的内容通常有清单,但评论里反复问每天怎么安排、多久能看到变化。',
+        author: '英语启蒙路线图',
+        imageLabel: '英语启蒙路径',
+        tags: ['英语启蒙', '学习路径', '家长陪跑'],
+        comments: ['资源太多了,我缺的是每天照着做的计划。']
+      },
+      {
+        title: '职业技能培训最该讲清楚的是学完能做什么',
+        content: '用户关心案例、作品、就业和真实项目,不再满足于只看课程大纲。',
+        author: '职业技能观察',
+        imageLabel: '技能培训结果',
+        tags: ['职业培训', '就业', '实战'],
+        comments: ['能不能看往期学员作品,比老师头衔更重要。']
+      }
+    ]
+  },
+  {
+    id: 'local-dining',
+    label: '本地生活餐饮',
+    aliases: ['餐饮', '咖啡', '本地生活', '探店', '火锅', '烘焙', '小吃', '酒馆'],
+    defaultKeywords: ['本地探店', '餐厅推荐', '咖啡店打卡', '火锅避坑', '团购套餐'],
+    preferenceSignals: ['性价比', '氛围感', '出片', '服务', '口味稳定', '排队', '套餐'],
+    decisionSignals: ['价格', '口味', '分量', '环境', '排队', '服务', '停车', '复购'],
+    elementSignals: ['套餐', '招牌菜', '咖啡', '甜品', '包间', '团购', '位置'],
+    riskSignals: ['踩雷', '照骗', '排队久', '服务差', '分量少', '不值'],
+    applicationLabel: '探店内容或到店转化',
+    talkTarget: '到店用户',
+    sampleNotes: [
+      {
+        title: '探店内容别只拍氛围,评论区最关心值不值',
+        content: '用户会被氛围图吸引,但评论里反复问人均、分量、排队和团购能不能用。',
+        author: '城市吃喝指南',
+        imageLabel: '探店性价比',
+        tags: ['本地探店', '性价比', '团购'],
+        comments: ['图很好看,我就想知道两个人吃完到底多少钱。', '排队一小时就劝退了。']
+      },
+      {
+        title: '咖啡店打卡从出片转向长期复购',
+        content: '评论里除了装修,还在问豆子、甜品、座位和工作日体验。复购理由比一次性打卡更重要。',
+        author: '咖啡店观察',
+        imageLabel: '咖啡店复购',
+        tags: ['咖啡', '氛围', '复购'],
+        comments: ['能坐下来办公吗?如果只能拍照就不会再去了。']
+      },
+      {
+        title: '火锅团购套餐最容易翻车的是隐藏限制',
+        content: '用户对团购套餐很敏感,关注锅底、菜量、节假日能不能用和服务态度。',
+        author: '团购避坑局',
+        imageLabel: '火锅团购避坑',
+        tags: ['火锅', '团购', '套餐'],
+        comments: ['套餐看着便宜,结果锅底和小料另算就不香了。']
+      }
+    ]
+  }
+];
+
+function buildFallbackPreset(profile = {}) {
+  const industry = profile.industry || '通用行业';
+  const keywords = unique(profile.keywords).length ? unique(profile.keywords) : ['用户需求', '产品体验', '消费顾虑'];
+  const mustTrack = unique(profile.mustTrackSignals);
+  return {
+    id: 'generic',
+    label: industry,
+    aliases: [],
+    defaultKeywords: keywords,
+    preferenceSignals: unique([...mustTrack, ...keywords, '口碑', '体验', '效果', '品质', '服务', '场景']),
+    decisionSignals: unique([...mustTrack, '价格', '效果', '安全', '服务', '口碑', '复购', '踩坑', '性价比']),
+    elementSignals: unique([...keywords, '产品', '服务', '方案', '套餐', '功能', '案例', '体验']),
+    riskSignals: unique(['踩坑', '不值', '没效果', '售后', '虚假宣传', '价格不透明']),
+    applicationLabel: '业务验证或内容选题',
+    talkTarget: '目标用户',
+    sampleNotes: buildGenericSampleNotes(industry, keywords)
+  };
+}
+
+function buildGenericSampleNotes(industry, keywords) {
+  const [first, second, third] = keywords;
+  return [
+    {
+      title: `${first}最近为什么被反复讨论?`,
+      content: `${industry}用户不只看表面卖点,更关心真实体验、价格透明和长期使用是否稳定。评论里常见的问题是有没有对比、会不会踩坑、适不适合自己。`,
+      author: `${industry}观察员`,
+      imageLabel: `${first}趋势观察`,
+      tags: [first, '趋势观察', '用户顾虑'],
+      comments: ['我最想看真实使用反馈,别只给种草图。', '价格和后续成本讲清楚,比单纯说好更有用。']
+    },
+    {
+      title: `别只讲${second || first},用户更想知道适不适合自己`,
+      content: `高互动内容往往会把场景、人群和风险点讲清楚。用户不是没有兴趣,而是不确定这个选择是否适合自己的预算、时间和使用场景。`,
+      author: `${industry}内容研究所`,
+      imageLabel: `${second || first}场景验证`,
+      tags: [second || first, '场景', '避坑'],
+      comments: ['有没有不同人群的对比?我怕买了不适合。']
+    },
+    {
+      title: `${third || first}内容要从卖点走向证据`,
+      content: `用户更愿意相信可验证证据,比如真实案例、评论反馈、前后对比和使用周期,而不是单纯的结论式推荐。`,
+      author: `${industry}证据样本库`,
+      imageLabel: `${third || first}证据样本`,
+      tags: [third || first, '案例', '口碑'],
+      comments: ['能不能给具体案例?只说推荐我没法判断。']
+    }
+  ];
+}
+
+function resolveIndustryPreset(profile = {}) {
+  const haystack = textOfProfile(profile).toLowerCase();
+  const matched = PRESETS.find(preset => preset.aliases.some(alias => haystack.includes(alias.toLowerCase())));
+  return matched || buildFallbackPreset(profile);
+}
+
+function buildSignalTerms(profile = {}) {
+  const preset = resolveIndustryPreset(profile);
+  const mustTrack = unique(profile.mustTrackSignals);
+  const keywords = unique(profile.keywords).length ? unique(profile.keywords) : preset.defaultKeywords;
+  return {
+    preset,
+    preferenceTerms: unique([...mustTrack, ...preset.preferenceSignals, ...keywords.slice(0, 3)]),
+    decisionTerms: unique([...mustTrack, ...preset.decisionSignals]),
+    elementTerms: unique([...preset.elementSignals, ...keywords]),
+    riskTerms: unique([...preset.riskSignals, '避坑', '翻车', '顾虑'])
+  };
+}
+
+module.exports = {
+  PRESETS,
+  cleanList,
+  unique,
+  resolveIndustryPreset,
+  buildSignalTerms
+};

+ 294 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/xiaohongshu-trend/live-collector.js

@@ -0,0 +1,294 @@
+const { XiaohongshuApi } = require('../../providers/xiaohongshu-api');
+
+function normalizeCount(value) {
+  if (value === undefined || value === null || value === '') return 0;
+  if (typeof value === 'number') return Number.isFinite(value) ? value : 0;
+  const text = String(value).trim().toLowerCase();
+  const match = text.match(/([\d.]+)/);
+  if (!match) return 0;
+  const base = Number.parseFloat(match[1]);
+  if (!Number.isFinite(base)) return 0;
+  if (text.includes('w') || text.includes('万')) return Math.round(base * 10000);
+  if (text.includes('k') || text.includes('千')) return Math.round(base * 1000);
+  return Math.round(base);
+}
+
+function noteScore(note) {
+  return normalizeCount(note.likeCount) +
+    normalizeCount(note.collectCount) * 1.5 +
+    normalizeCount(note.commentCount) * 3 +
+    normalizeCount(note.shareCount) * 2;
+}
+
+function pickTags(note) {
+  const tags = note.tag_info || note.tagList || note.tags || [];
+  if (!Array.isArray(tags)) return [];
+  return tags
+    .map(tag => tag.name || tag.tag_name || tag.title || tag)
+    .filter(Boolean)
+    .map(String);
+}
+
+function pickFirstString(...values) {
+  return values
+    .flat()
+    .filter(value => typeof value === 'string')
+    .map(value => value.trim())
+    .find(Boolean) || '';
+}
+
+function nestedValue(object, path) {
+  return path.split('.').reduce((current, key) => (
+    current && typeof current === 'object' ? current[key] : undefined
+  ), object);
+}
+
+function hasXsecToken(url) {
+  return /[?&]xsec_token=/i.test(String(url || ''));
+}
+
+function pickWebUrl(note = {}, raw = {}) {
+  const candidates = [
+    'mini_program_info.webpage_url',
+    'note_card.mini_program_info.webpage_url',
+    'share_info.link',
+    'note_card.share_info.link',
+    'webpage_url',
+    'webUrl',
+    'url'
+  ].flatMap(field => [nestedValue(note, field), nestedValue(raw, field)]);
+  const withXsecToken = candidates.find(hasXsecToken);
+  return withXsecToken || pickFirstString(candidates);
+}
+
+function looksLikeImageUrl(value) {
+  return /^https?:\/\//i.test(String(value || ''));
+}
+
+function collectImageUrlsFromValue(value, result = []) {
+  if (!value) return result;
+  if (typeof value === 'string') {
+    if (looksLikeImageUrl(value) && !result.includes(value)) result.push(value);
+    return result;
+  }
+  if (Array.isArray(value)) {
+    value.forEach(item => collectImageUrlsFromValue(item, result));
+    return result;
+  }
+  if (typeof value !== 'object') return result;
+
+  [
+    'url',
+    'url_default',
+    'url_pre',
+    'original_url',
+    'original',
+    'large',
+    'large_url',
+    'middle',
+    'middle_url',
+    'thumbnail',
+    'image_url',
+    'imageUrl'
+  ].forEach(key => collectImageUrlsFromValue(value[key], result));
+
+  [
+    'url_list',
+    'urlList',
+    'info_list',
+    'infoList',
+    'images',
+    'image_list',
+    'imageList'
+  ].forEach(key => collectImageUrlsFromValue(value[key], result));
+
+  return result;
+}
+
+function extractImageUrls(note = {}, raw = {}) {
+  const result = [];
+  [
+    note.cover,
+    raw.cover,
+    note.note_card && note.note_card.cover,
+    raw.note_card && raw.note_card.cover,
+    note.image_list,
+    raw.image_list,
+    note.images_list,
+    raw.images_list,
+    note.imageList,
+    raw.imageList,
+    note.note_card && note.note_card.image_list,
+    raw.note_card && raw.note_card.image_list
+  ].forEach(value => collectImageUrlsFromValue(value, result));
+  return result;
+}
+
+function contentExcerpt(text, limit = 120) {
+  const value = String(text || '').replace(/\s+/g, ' ').trim();
+  if (value.length <= limit) return value;
+  return `${value.slice(0, limit - 1)}...`;
+}
+
+function normalizeNote(raw, keyword, detail) {
+  const note = detail && (detail.id || detail.note_id) ? detail : raw;
+  const id = note.id || note.note_id || raw.id || raw.note_id;
+  const user = note.user || raw.user || note.user_info || raw.user_info || {};
+  const accessibleUrl = pickWebUrl(note, raw);
+  const fallbackUrl = id ? `https://www.xiaohongshu.com/discovery/item/${id}` : '';
+  const imageUrls = extractImageUrls(note, raw);
+  const content = note.desc || raw.desc || note.content || raw.content || '';
+  return {
+    id,
+    platform: 'xiaohongshu',
+    keyword,
+    title: note.title || raw.title || '',
+    content,
+    contentExcerpt: contentExcerpt(content),
+    rawExcerpt: contentExcerpt(content),
+    author: user.nickname || user.name || '',
+    authorId: user.userid || user.user_id || '',
+    type: note.type || raw.type || '',
+    likeCount: normalizeCount(note.liked_count ?? raw.liked_count ?? note.likeCount ?? raw.likeCount),
+    collectCount: normalizeCount(note.collected_count ?? raw.collected_count ?? note.collectCount ?? raw.collectCount),
+    commentCount: normalizeCount(note.comments_count ?? raw.comments_count ?? note.commentCount ?? raw.commentCount),
+    shareCount: normalizeCount(note.shared_count ?? raw.shared_count ?? note.shareCount ?? raw.shareCount),
+    timestamp: note.timestamp || raw.timestamp || '',
+    url: accessibleUrl || fallbackUrl,
+    webUrl: accessibleUrl,
+    shareUrl: pickFirstString(
+      nestedValue(note, 'share_info.link'),
+      nestedValue(raw, 'share_info.link'),
+      nestedValue(note, 'note_card.share_info.link'),
+      nestedValue(raw, 'note_card.share_info.link')
+    ),
+    sourceUrl: accessibleUrl || fallbackUrl,
+    sourceUrlVerified: hasXsecToken(accessibleUrl),
+    fallbackUrl,
+    coverUrl: imageUrls[0] || '',
+    imageUrls,
+    tags: pickTags(note).length ? pickTags(note) : pickTags(raw)
+  };
+}
+
+function normalizeComment(raw, noteId, keyword) {
+  const user = raw.user_info || raw.user || {};
+  return {
+    id: raw.id || raw.comment_id || `${noteId}-${Math.random().toString(36).slice(2, 8)}`,
+    noteId,
+    platform: 'xiaohongshu',
+    keyword,
+    text: raw.content || raw.text || '',
+    likeCount: normalizeCount(raw.like_count || raw.liked_count || raw.likeCount),
+    author: user.nickname || user.name || '',
+    authorId: user.user_id || user.userid || '',
+    createTime: raw.create_time || '',
+    ipLocation: raw.ip_location || '',
+    subCommentCount: normalizeCount(raw.sub_comment_count || raw.subCommentCount)
+  };
+}
+
+function extractSearchNotes(data) {
+  const items = data.items || data.note_list || data.notes || [];
+  if (!Array.isArray(items)) return [];
+  return items
+    .map(item => item.note || item)
+    .filter(note => note && (note.id || note.note_id));
+}
+
+function extractDetailNote(data) {
+  const list = data.note_list || data.notes || [];
+  return Array.isArray(list) && list[0] ? list[0] : data.note || data;
+}
+
+function extractComments(data) {
+  const comments = data.comments || data.comment_list || [];
+  return Array.isArray(comments) ? comments : [];
+}
+
+async function collectLiveDataset({ profile, token, options = {} }) {
+  const api = new XiaohongshuApi({ token, baseUrl: options.baseUrl });
+  const keywordLimit = Math.max(1, Number(options.keywordLimit || 1));
+  const notesPerKeyword = Math.max(1, Number(options.notesPerKeyword || 1));
+  const maxCommentPages = Math.max(0, Number(options.maxCommentPages ?? 1));
+  const keywords = (profile.keywords || []).slice(0, keywordLimit);
+  const notes = [];
+  const comments = [];
+  const warnings = [];
+  const errors = [];
+
+  for (const keyword of keywords) {
+    try {
+      const search = await api.searchNotes({
+        keyword,
+        page: 1,
+        sort: options.sort || 'popularity_descending',
+        noteType: options.noteType || '_0'
+      });
+      const rawNotes = extractSearchNotes(search.data)
+        .map(raw => normalizeNote(raw, keyword))
+        .sort((a, b) => noteScore(b) - noteScore(a))
+        .slice(0, notesPerKeyword);
+
+      for (const rawNote of rawNotes) {
+        let note = rawNote;
+        try {
+          const detail = await api.getNoteDetail({ noteId: rawNote.id });
+          note = normalizeNote(rawNote, keyword, extractDetailNote(detail.data));
+        } catch (error) {
+          warnings.push({
+            stage: 'note_detail',
+            keyword,
+            noteId: rawNote.id,
+            message: error.message,
+            httpStatus: error.httpStatus
+          });
+        }
+        notes.push(note);
+
+        let cursor = '';
+        for (let page = 0; page < maxCommentPages; page++) {
+          try {
+            const commentResult = await api.getNoteComments({ noteId: note.id, cursor });
+            extractComments(commentResult.data).forEach(rawComment => {
+              comments.push(normalizeComment(rawComment, note.id, keyword));
+            });
+            cursor = commentResult.data.cursor || '';
+            if (!commentResult.data.has_more || !cursor) break;
+          } catch (error) {
+            warnings.push({
+              stage: 'note_comments',
+              keyword,
+              noteId: note.id,
+              message: error.message,
+              httpStatus: error.httpStatus
+            });
+            break;
+          }
+        }
+      }
+    } catch (error) {
+      errors.push({
+        stage: 'search_notes',
+        keyword,
+        message: error.message,
+        httpStatus: error.httpStatus
+      });
+    }
+  }
+
+  return {
+    notes,
+    comments,
+    warnings,
+    errors
+  };
+}
+
+module.exports = {
+  collectLiveDataset,
+  normalizeNote,
+  pickWebUrl,
+  extractImageUrls,
+  hasXsecToken
+};

+ 130 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/xiaohongshu-trend/preference-memory.js

@@ -0,0 +1,130 @@
+const path = require('path');
+const { readJson, writeJson, defaultMemoryPath } = require('../../core/memory-store');
+
+const DEFAULT_MEMORY_FILE = 'xiaohongshu-trend-memory.json';
+
+function cleanList(values) {
+  return [...new Set((values || [])
+    .flatMap(value => Array.isArray(value) ? value : String(value || '').split(/[,,、\n\r]+|以及|与|和/))
+    .map(value => String(value || '').trim())
+    .filter(Boolean))];
+}
+
+function appendNote(notes, note) {
+  const text = String(note || '').trim();
+  if (!text) return (notes || []).slice(-20);
+  const next = [...(notes || []).filter(Boolean), text];
+  return [...new Set(next)].slice(-20);
+}
+
+function defaultTrendMemory() {
+  return {
+  version: '0.1.14',
+    preferredDirections: [],
+    blockedDirections: [],
+    focusModes: [],
+    notes: [],
+    updatedAt: ''
+  };
+}
+
+function trendMemoryPath(input = {}) {
+  return path.resolve(input.memory || input.memoryPath || defaultMemoryPath(DEFAULT_MEMORY_FILE));
+}
+
+function readTrendMemory(memoryPath) {
+  return {
+    ...defaultTrendMemory(),
+    ...readJson(memoryPath, {})
+  };
+}
+
+function extractAfter(text, pattern) {
+  const match = String(text || '').match(pattern);
+  return match ? match[1] : '';
+}
+
+function inferPreferenceFromMessage(message) {
+  const text = String(message || '').trim();
+  const preferred = cleanList([
+    extractAfter(text, /(?:保留|多给|继续给|更喜欢|适合)(.+?)(?:。|;|;|,但|但是|不要|少给|降权|$)/)
+  ]);
+  const blocked = cleanList([
+    extractAfter(text, /(?:不要|少给|降权|过滤|不适合|避开)(.+?)(?:。|;|;|,|$)/)
+  ]);
+  const focus = cleanList([
+    extractAfter(text, /(?:更偏|侧重|多看|下一版更偏)(.+?)(?:。|;|;|,|$)/)
+  ]);
+  return { preferred, blocked, focus };
+}
+
+function updateTrendMemory(input = {}) {
+  const memoryPath = trendMemoryPath(input);
+  const memory = readTrendMemory(memoryPath);
+  const inferred = inferPreferenceFromMessage(input.message || input.feedback || '');
+  const preferredDirections = cleanList([
+    ...memory.preferredDirections,
+    ...inferred.preferred,
+    ...(input.preferredDirections || [])
+  ]);
+  const blockedDirections = cleanList([
+    ...memory.blockedDirections,
+    ...inferred.blocked,
+    ...(input.blockedDirections || [])
+  ]);
+  const focusModes = cleanList([
+    ...memory.focusModes,
+    ...inferred.focus,
+    ...(input.focusModes || [])
+  ]);
+  const note = String(input.message || input.feedback || '').trim();
+  const notes = appendNote(memory.notes, note);
+  const next = {
+    ...memory,
+    version: defaultTrendMemory().version,
+    preferredDirections,
+    blockedDirections,
+    focusModes,
+    notes,
+    updatedAt: new Date().toISOString()
+  };
+  writeJson(memoryPath, next);
+  return { memoryPath, memory: next };
+}
+
+function topicMatches(topic, terms) {
+  const text = `${topic.title || ''} ${topic.angle || ''} ${topic.evidence || ''}`;
+  const domainTerms = ['奶油风', '全屋定制', '翻车', '小户型', '收纳', '预算', '环保', '板材', '门店', '转化', '设计元素', '风格'];
+  const candidates = cleanList(terms).flatMap(term => {
+    const trimmed = String(term || '').replace(/(方向|选题|内容|话术|场景|趋势)$/u, '');
+    return cleanList([
+      term,
+      trimmed,
+      ...domainTerms.filter(domainTerm => String(term || '').includes(domainTerm))
+    ]);
+  });
+  return candidates.some(term => text.includes(term));
+}
+
+function rankTopicsWithMemory(topics, memory) {
+  const preferred = cleanList(memory.preferredDirections);
+  const blocked = cleanList(memory.blockedDirections);
+  return [...topics]
+    .map((topic, originalIndex) => {
+      let score = topics.length - originalIndex;
+      if (topicMatches(topic, preferred)) score += 20;
+      if (topicMatches(topic, blocked)) score -= 50;
+      return { topic, score };
+    })
+    .sort((a, b) => b.score - a.score)
+    .map(item => item.topic);
+}
+
+module.exports = {
+  DEFAULT_MEMORY_FILE,
+  trendMemoryPath,
+  readTrendMemory,
+  updateTrendMemory,
+  rankTopicsWithMemory,
+  cleanList
+};

+ 317 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/xiaohongshu-trend/report.js

@@ -0,0 +1,317 @@
+const fs = require('fs');
+const path = require('path');
+const { rankTopicsWithMemory, cleanList } = require('./preference-memory');
+const { buildSignalAnalysis } = require('./signal-extractor');
+const {
+  firstTerm,
+  firstDistinctTerm,
+  findTopComment,
+  buildOpportunityTopics,
+  buildBusinessTalk
+} = require('./topic-builder');
+const {
+  assessEvidenceQuality,
+  buildEvidenceQualitySection
+} = require('../../core/evidence-quality');
+
+function ensureDir(dirPath) {
+  fs.mkdirSync(dirPath, { recursive: true });
+}
+
+function scoreNote(note) {
+  return Number(note.likeCount || 0) +
+    Number(note.collectCount || 0) * 1.5 +
+    Number(note.commentCount || 0) * 3 +
+    Number(note.shareCount || 0) * 2;
+}
+
+function asList(value) {
+  if (!value) return [];
+  return Array.isArray(value) ? value.filter(Boolean).map(String) : [String(value)];
+}
+
+function formatCount(value) {
+  const count = Number(value || 0);
+  if (!Number.isFinite(count) || count <= 0) return '0';
+  if (count >= 10000) {
+    return `${(count / 10000).toFixed(1).replace(/\.0$/, '')}w`;
+  }
+  return String(Math.round(count));
+}
+
+function truncateText(value, limit = 120) {
+  const text = String(value || '').replace(/\s+/g, ' ').trim();
+  if (text.length <= limit) return text;
+  return `${text.slice(0, limit - 1)}...`;
+}
+
+function markdownUrl(value) {
+  const url = String(value || '').trim().replace(/[<>]/g, '');
+  return url ? `<${url}>` : '';
+}
+
+function memoryHasSignal(memory = {}) {
+  return Boolean(
+    cleanList(memory.preferredDirections).length ||
+    cleanList(memory.blockedDirections).length ||
+    cleanList(memory.focusModes).length
+  );
+}
+
+function buildMemorySection(memory = {}) {
+  if (!memoryHasSignal(memory)) return [];
+  const preferred = cleanList(memory.preferredDirections);
+  const blocked = cleanList(memory.blockedDirections);
+  const focus = cleanList(memory.focusModes);
+  return [
+    '## 已读取偏好',
+    '',
+    preferred.length ? `- 优先保留:${preferred.join('、')}` : '',
+    blocked.length ? `- 后续降权:${blocked.join('、')}` : '',
+    focus.length ? `- 本轮侧重:${focus.join('、')}` : ''
+  ].filter(Boolean);
+}
+
+function buildScopeSection({ profile, notes, comments, collectionMode, signals }) {
+  const audience = asList(profile.targetAudience).slice(0, 3).join('、') || '未标注';
+  const keywords = asList(profile.keywords).slice(0, 5).join('、') || '未标注';
+  const presetLabel = signals && signals.preset && signals.preset.label ? signals.preset.label : '';
+  return [
+    '## 数据口径',
+    '',
+    `- 行业/品类:${profile.industry || presetLabel || '未标注'}`,
+    profile.businessType ? `- 业务类型:${profile.businessType}` : '',
+    `- 目标人群:${audience}`,
+    `- 关注关键词:${keywords}`,
+    `- 样本:${notes.length} 条笔记 / ${comments.length} 条评论`,
+    presetLabel ? `- 行业模板:${presetLabel}` : '',
+    collectionMode === 'sample'
+      ? '- 说明:本次为演示样例,用于确认交付形式;接入 VOC 服务后可切换真实小红书采集。'
+      : ''
+  ].filter(Boolean);
+}
+
+function buildEvidenceReason(note, topComment) {
+  if (note.evidenceReason) return note.evidenceReason;
+  if (topComment && topComment.text) {
+    return '这条样本同时提供了原帖表达和用户评论,可以用来校验趋势判断是否贴近真实顾虑。';
+  }
+  return '这条样本互动较高,适合用作本轮趋势判断的原帖证据。';
+}
+
+function buildSourceLine(note) {
+  const url = note.sourceUrl || note.webUrl || note.url || '';
+  if (!url) return '   - 原帖:暂无可访问链接';
+  const label = note.sourceUrlVerified === false ? '打开原帖(链接待核验)' : '打开原帖';
+  return `   - 原帖:[${label}](${markdownUrl(url)})`;
+}
+
+function buildEvidenceSection(notes, comments) {
+  const rows = notes.slice(0, 3).map((note, index) => {
+    const topComment = findTopComment(comments, note.id);
+    const coverUrl = note.localCoverPath || note.coverUrl || (Array.isArray(note.imageUrls) ? note.imageUrls[0] : '');
+    const excerpt = truncateText(note.contentExcerpt || note.rawExcerpt || note.content, 120);
+    const topCommentText = topComment ? truncateText(topComment.text, 120) : '';
+    return [
+      `${index + 1}. ${note.title || '未命名笔记'}`,
+      `   - 作者:${note.author || '未标注'} | 互动:${formatCount(note.likeCount)}赞 / ${formatCount(note.collectCount)}藏 / ${formatCount(note.commentCount)}评`,
+      buildSourceLine(note),
+      `   - 为什么值得看:${buildEvidenceReason(note, topComment)}`,
+      coverUrl ? `   - 图片:\n\n![原帖首图](${markdownUrl(coverUrl)})` : '   - 图片:暂无可展示首图,可点开原帖查看。',
+      excerpt ? `   - 原文摘录:${excerpt}` : '',
+      topCommentText ? `   - 高赞评论:${topCommentText}` : ''
+    ].filter(Boolean).join('\n');
+  });
+  return [
+    '## 证据样本',
+    '',
+    rows.length ? rows.join('\n') : '暂无可展示样本。'
+  ];
+}
+
+function buildConcernLines(decisionSignals, applicationLabel) {
+  if (!decisionSignals.length) {
+    return ['- 暂未出现稳定高频顾虑,建议先扩大关键词或评论样本。'];
+  }
+  return decisionSignals.slice(0, 5).map(item => (
+    `- ${item.term}:在样本中出现 ${item.count} 次,适合转成${applicationLabel || '业务验证或内容选题'}。`
+  ));
+}
+
+function buildObservationLines({ notes, comments, signals }) {
+  const preference = firstTerm(signals.preferenceSignals, '核心卖点');
+  const secondPreference = firstDistinctTerm(signals.preferenceSignals.slice(1), [preference], '');
+  const concern = firstDistinctTerm(signals.decisionSignals, [preference], '主要顾虑');
+  const secondConcern = firstDistinctTerm(signals.decisionSignals, [preference, concern], '');
+  const element = firstDistinctTerm(signals.elementSignals, [preference, concern], '具体元素');
+  const risk = firstDistinctTerm(signals.riskSignals, [preference, concern, element], '潜在风险');
+  const topNote = notes[0] || {};
+  const topComment = topNote.id ? findTopComment(comments, topNote.id) : comments[0];
+  return [
+    `1. 样本里“${preference}”是一个明显入口,但它需要和“${concern}”一起看,不能直接等同于真实购买/选择理由。`,
+    secondPreference
+      ? `2. “${secondPreference}”也在样本中出现,说明用户可能在比较不同场景、人群和使用条件。`
+      : '2. 暂时还没有第二个稳定偏好信号,建议下一轮补充更细的关键词或评论样本。',
+    secondConcern
+      ? `3. 用户顾虑里除了“${concern}”,还出现了“${secondConcern}”,这两个点需要用你的真实客户反馈来排优先级。`
+      : `3. 当前顾虑仍集中在“${concern}”,还不能判断是否足够普遍。`,
+    `4. “${element}”这类具体元素可以继续验证,因为它更容易连接到方案、价格、维护和售后承诺。`,
+    topComment && topComment.text
+      ? `5. 高赞评论提到“${truncateText(topComment.text, 80)}”,适合作为下一轮向客户/销售/社群核验的问题。`
+      : `5. 当前评论证据还不够强,下一轮需要补充更多真实客户原话来校准“${risk}”这类风险点。`
+  ];
+}
+
+function buildHypothesisLines({ signals }) {
+  const preference = firstTerm(signals.preferenceSignals, '核心卖点');
+  const concern = firstDistinctTerm(signals.decisionSignals, [preference], '主要顾虑');
+  const element = firstDistinctTerm(signals.elementSignals, [preference, concern], '具体元素');
+  return [
+    `1. ${preference} 可能不是单独成立的趋势,而是和“${concern}”一起影响用户选择。`,
+    `2. ${element} 更适合继续验证,因为它能连接到场景、成本、效果证据和落地方案。`,
+    '3. 第一轮样本更像是在提示“用户为什么犹豫”,下一轮需要用你的真实客户反馈校准。'
+  ];
+}
+
+function buildDynamicValidationQuestions({ notes, comments, signals, rankedTopics }) {
+  const preference = firstTerm(signals.preferenceSignals, '当前卖点');
+  const concern = firstDistinctTerm(signals.decisionSignals, [preference], '当前顾虑');
+  const element = firstDistinctTerm(signals.elementSignals, [preference, concern], '当前元素');
+  const risk = firstDistinctTerm(signals.riskSignals, [preference, concern, element], '风险点');
+  const topNote = notes[0] || {};
+  const topComment = topNote.id ? findTopComment(comments, topNote.id) : comments[0];
+  const topTopic = rankedTopics[0] || {};
+  return [
+    `样本里“${preference}”和“${concern}”同时出现,你实际接触的用户是先被${preference}吸引,还是先担心${concern}?`,
+    topComment && topComment.text
+      ? `这条高赞评论“${truncateText(topComment.text, 80)}”像不像你销售、客服、社群或私域里真实听到的问题?如果不像,真实问题更接近什么?`
+      : '当前样本缺少高赞评论证据,你手上有没有最近客户反复问的一句话?',
+    `围绕“${element}”,你们现在有没有可证明的案例、价格/成本、效果证据或售后承诺?没有的话,这个方向先不要直接定稿。`,
+    topTopic.title
+      ? `如果只继续深挖一个方向,你更想验证“${topTopic.title}”,还是换成更贴近你业务的方向?`
+      : `如果只继续深挖一个方向,你更想验证“${risk}”,还是换成更贴近你业务的方向?`
+  ];
+}
+
+function buildOneLine({ profile, signals }) {
+  const industry = profile.industry || (signals.preset && signals.preset.label) || '当前行业';
+  const preference = firstTerm(signals.preferenceSignals, '核心卖点');
+  const concern = firstDistinctTerm(signals.decisionSignals, [preference], '主要顾虑');
+  const risk = firstDistinctTerm(signals.riskSignals, [preference, concern], '风险点');
+  return `本轮${industry}最值得关注的不是单一“${preference}”,而是“${preference}、${concern}、${risk}”一起进入用户决策。`;
+}
+
+function buildTrendReport({ profile, dataset, collectionMode, memory = {} }) {
+  const notes = [...(dataset.notes || [])].sort((a, b) => scoreNote(b) - scoreNote(a));
+  const comments = [...(dataset.comments || [])].sort((a, b) => Number(b.likeCount || 0) - Number(a.likeCount || 0));
+  const signals = buildSignalAnalysis({ profile, notes, comments });
+  const topTopics = buildOpportunityTopics({ profile, notes, comments, signals });
+  const rankedTopics = rankTopicsWithMemory(topTopics, memory);
+  const evidenceQuality = assessEvidenceQuality({
+    items: notes,
+    comments,
+    itemLabel: '笔记',
+    collectionMode
+  });
+  const validationQuestions = buildDynamicValidationQuestions({
+    notes,
+    comments,
+    signals,
+    rankedTopics
+  });
+  const salesTalk = buildBusinessTalk({ profile, signals });
+  const applicationLabel = signals.preset && signals.preset.applicationLabel;
+
+  const assistantMessage = [
+    `# 小红书趋势情报${collectionMode === 'sample' ? '(演示样例)' : ''}`,
+    '',
+    '## 第一轮初步判断(待校准)',
+    '',
+    `${buildOneLine({ profile, signals })} 这还不是最终结论,需要先结合你的真实客户反馈做一轮校准。`,
+    '',
+    ...buildScopeSection({ profile, notes, comments, collectionMode, signals }),
+    '',
+    ...buildEvidenceQualitySection(evidenceQuality),
+    '',
+    ...buildMemorySection(memory),
+    memoryHasSignal(memory) ? '' : '',
+    '## 样本观察 Top 5',
+    '',
+    ...buildObservationLines({ notes, comments, signals }),
+    '',
+    '## 本轮机会假设',
+    '',
+    ...buildHypothesisLines({ signals }),
+    '',
+    '## 用户顾虑/决策阻力',
+    '',
+    ...buildConcernLines(signals.decisionSignals, applicationLabel),
+    '',
+    ...buildEvidenceSection(notes, comments),
+    '',
+    '## 可继续验证的选题方向',
+    '',
+    ...rankedTopics.map((topic, index) => [
+      `${index + 1}. ${topic.title}`,
+      `   - 角度:${topic.angle}`,
+      `   - 证据:${topic.evidence}`,
+      topic.confidence ? `   - 置信度:${topic.confidence}` : ''
+    ].filter(Boolean).join('\n')),
+    '',
+    '## 暂可参考业务话术/内容表达(待确认)',
+    '',
+    ...salesTalk.map(item => `- ${item}`),
+    '',
+    '## 下一轮请先校准这些问题',
+    '',
+    ...validationQuestions.map((question, index) => `${index + 1}. ${question}`),
+    '',
+    '你可以直接按序号简单回答。回答后我会把本轮假设收敛成更贴近你真实业务的趋势判断、选题方向和待验证话术。'
+  ].filter(Boolean).join('\n');
+
+  return {
+    assistantMessage,
+    summary: {
+      workflowStage: 'initial_hypothesis_needs_calibration',
+      industryPreset: signals.preset && signals.preset.id,
+      noteCount: notes.length,
+      commentCount: comments.length,
+      topicCount: rankedTopics.length,
+      preferenceMemoryApplied: memoryHasSignal(memory),
+      topStyleSignals: signals.preferenceSignals.slice(0, 5),
+      topPreferenceSignals: signals.preferenceSignals.slice(0, 5),
+      topDecisionSignals: signals.decisionSignals.slice(0, 5),
+      topElementSignals: signals.elementSignals.slice(0, 5),
+      topRiskSignals: signals.riskSignals.slice(0, 5),
+      evidenceNoteCount: Math.min(notes.length, 3),
+      evidenceQuality,
+      validationQuestionCount: validationQuestions.length
+    },
+    data: {
+      profile,
+      industryPreset: signals.preset,
+      signals,
+      notes,
+      comments,
+      topTopics: rankedTopics,
+      validationQuestions,
+      preferenceMemory: memory,
+      salesTalk,
+      evidenceQuality
+    }
+  };
+}
+
+function writeReport(outputDir, report) {
+  ensureDir(outputDir);
+  const mdPath = path.join(outputDir, 'xiaohongshu-trend-report.md');
+  const jsonPath = path.join(outputDir, 'xiaohongshu-trend-report.json');
+  fs.writeFileSync(mdPath, report.assistantMessage, 'utf8');
+  fs.writeFileSync(jsonPath, JSON.stringify(report, null, 2), 'utf8');
+  return [mdPath, jsonPath];
+}
+
+module.exports = {
+  buildTrendReport,
+  writeReport
+};

+ 79 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/xiaohongshu-trend/sample-data.js

@@ -0,0 +1,79 @@
+const { resolveIndustryPreset, cleanList } = require('./industry-presets');
+
+function sampleImage(label, accent = '#d7b98e') {
+  const safeLabel = String(label || '行业趋势样本').slice(0, 24);
+  const svg = [
+    '<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="760" viewBox="0 0 1200 760">',
+    '<rect width="1200" height="760" fill="#f7f2ea"/>',
+    `<rect x="88" y="80" width="1024" height="520" rx="24" fill="${accent}" opacity="0.32"/>`,
+    '<rect x="144" y="132" width="420" height="392" rx="18" fill="#ffffff" opacity="0.86"/>',
+    '<rect x="620" y="132" width="436" height="72" rx="14" fill="#ffffff" opacity="0.82"/>',
+    '<rect x="620" y="236" width="360" height="52" rx="12" fill="#ffffff" opacity="0.74"/>',
+    '<rect x="620" y="320" width="390" height="52" rx="12" fill="#ffffff" opacity="0.68"/>',
+    '<rect x="620" y="404" width="300" height="52" rx="12" fill="#ffffff" opacity="0.62"/>',
+    `<text x="120" y="668" font-size="44" font-family="Arial, sans-serif" fill="#4b3b2b">${safeLabel}</text>`,
+    '<text x="120" y="714" font-size="24" font-family="Arial, sans-serif" fill="#806f5d">Xiaohongshu sample evidence image</text>',
+    '</svg>'
+  ].join('');
+  return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
+}
+
+function metric(index, base) {
+  return Math.max(1, Math.round(base - index * base * 0.28));
+}
+
+function buildNote({ item, preset, keyword, index }) {
+  const id = `xhs-${preset.id}-${String(index + 1).padStart(3, '0')}`;
+  const coverUrl = sampleImage(item.imageLabel || item.title, ['#b7a17a', '#dfc7a2', '#a7b8aa'][index % 3]);
+  return {
+    id,
+    keyword,
+    title: item.title,
+    content: item.content,
+    author: item.author || `${preset.label}观察员`,
+    likeCount: metric(index, 6920),
+    collectCount: metric(index, 4100),
+    commentCount: metric(index, 612),
+    shareCount: metric(index, 488),
+    sourceUrl: `https://www.xiaohongshu.com/discovery/item/${id}?xsec_source=app_share&xsec_token=sample-token-${preset.id}-${index + 1}`,
+    webUrl: `https://www.xiaohongshu.com/discovery/item/${id}?xsec_source=app_share&xsec_token=sample-token-${preset.id}-${index + 1}`,
+    sourceUrlVerified: true,
+    coverUrl,
+    imageUrls: [coverUrl],
+    contentExcerpt: item.content,
+    tags: item.tags || [keyword, preset.label]
+  };
+}
+
+function buildComments({ item, noteId, keyword, index }) {
+  const comments = cleanList(item.comments).length
+    ? cleanList(item.comments)
+    : [`我最关心的是${keyword}到底适不适合自己,不想只看种草结论。`];
+  return comments.map((text, commentIndex) => ({
+    id: `${noteId}-comment-${commentIndex + 1}`,
+    noteId,
+    keyword,
+    text,
+    likeCount: Math.max(1, 401 - index * 88 - commentIndex * 65)
+  }));
+}
+
+function buildSampleDataset(profile = {}) {
+  const preset = resolveIndustryPreset(profile);
+  const keywords = cleanList(profile.keywords).length ? cleanList(profile.keywords) : preset.defaultKeywords;
+  const notes = [];
+  const comments = [];
+
+  preset.sampleNotes.slice(0, 3).forEach((item, index) => {
+    const keyword = keywords[index] || keywords[0] || preset.label;
+    const note = buildNote({ item, preset, keyword, index });
+    notes.push(note);
+    comments.push(...buildComments({ item, noteId: note.id, keyword, index }));
+  });
+
+  return { notes, comments };
+}
+
+module.exports = {
+  buildSampleDataset
+};

+ 77 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/xiaohongshu-trend/signal-extractor.js

@@ -0,0 +1,77 @@
+const { buildSignalTerms, unique } = require('./industry-presets');
+
+function textOfItem(item = {}) {
+  return [
+    item.title,
+    item.content,
+    item.text,
+    item.contentExcerpt,
+    item.rawExcerpt,
+    ...(Array.isArray(item.tags) ? item.tags : [])
+  ].filter(Boolean).join(' ');
+}
+
+function collectTerms(items, terms) {
+  return unique(terms)
+    .map(term => {
+      const evidence = items.filter(item => textOfItem(item).includes(term));
+      return {
+        term,
+        count: evidence.length,
+        evidenceIds: evidence.map(item => item.id || item.noteId || '').filter(Boolean).slice(0, 5)
+      };
+    })
+    .filter(item => item.count > 0)
+    .sort((a, b) => b.count - a.count || a.term.localeCompare(b.term));
+}
+
+function collectTagTerms(notes = []) {
+  const counter = new Map();
+  notes.forEach(note => {
+    (Array.isArray(note.tags) ? note.tags : []).forEach(tag => {
+      const key = String(tag || '').trim();
+      if (!key) return;
+      counter.set(key, (counter.get(key) || 0) + 1);
+    });
+  });
+  return [...counter.entries()]
+    .map(([term, count]) => ({ term, count, evidenceIds: [] }))
+    .sort((a, b) => b.count - a.count || a.term.localeCompare(b.term));
+}
+
+function mergeSignals(primary, fallback) {
+  const seen = new Set();
+  return [...primary, ...fallback]
+    .filter(item => {
+      if (!item || !item.term || seen.has(item.term)) return false;
+      seen.add(item.term);
+      return true;
+    });
+}
+
+function buildSignalAnalysis({ profile = {}, notes = [], comments = [] } = {}) {
+  const { preset, preferenceTerms, decisionTerms, elementTerms, riskTerms } = buildSignalTerms(profile);
+  const allItems = [...notes, ...comments];
+  const tags = collectTagTerms(notes);
+  const keywordSignals = collectTerms(allItems, profile.keywords || []);
+
+  const preferenceSignals = mergeSignals(collectTerms(allItems, preferenceTerms), keywordSignals);
+  const decisionSignals = mergeSignals(collectTerms(allItems, decisionTerms), tags);
+  const elementSignals = mergeSignals(collectTerms(allItems, elementTerms), tags);
+  const riskSignals = collectTerms(allItems, riskTerms);
+
+  return {
+    preset,
+    preferenceSignals,
+    decisionSignals,
+    elementSignals,
+    riskSignals,
+    tagSignals: tags,
+    keywordSignals
+  };
+}
+
+module.exports = {
+  buildSignalAnalysis,
+  collectTerms
+};

+ 84 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/features/xiaohongshu-trend/topic-builder.js

@@ -0,0 +1,84 @@
+function firstTerm(items, fallback = '') {
+  return items && items[0] && items[0].term ? items[0].term : fallback;
+}
+
+function firstDistinctTerm(items, excluded = [], fallback = '') {
+  const blocked = new Set([].concat(excluded).filter(Boolean).map(String));
+  const match = (items || []).find(item => item && item.term && !blocked.has(item.term));
+  return match ? match.term : fallback;
+}
+
+function findTopComment(comments, noteId) {
+  return comments
+    .filter(comment => comment.noteId === noteId)
+    .sort((a, b) => Number(b.likeCount || 0) - Number(a.likeCount || 0))[0];
+}
+
+function truncateText(value, limit = 80) {
+  const text = String(value || '').replace(/\s+/g, ' ').trim();
+  if (text.length <= limit) return text;
+  return `${text.slice(0, limit - 1)}...`;
+}
+
+function buildOpportunityTopics({ profile = {}, notes = [], comments = [], signals = {}, memoryRanker } = {}) {
+  const preset = signals.preset || {};
+  const industry = profile.industry || preset.label || '当前行业';
+  const preference = firstTerm(signals.preferenceSignals, firstTerm(signals.keywordSignals, '核心卖点'));
+  const concern = firstDistinctTerm(signals.decisionSignals, [preference], firstTerm(signals.riskSignals, '主要顾虑'));
+  const element = firstDistinctTerm(signals.elementSignals, [preference, concern], firstTerm(signals.keywordSignals, '具体元素'));
+  const risk = firstDistinctTerm(signals.riskSignals, [preference, concern, element], concern);
+  const topNote = notes[0] || {};
+  const topComment = topNote.id ? findTopComment(comments, topNote.id) : comments[0];
+  const commentEvidence = topComment && topComment.text ? truncateText(topComment.text, 70) : `${concern} 在样本中反复出现`;
+
+  const topics = [
+    {
+      title: `别只讲${preference},要讲清楚用户担心的${concern}`,
+      angle: '把显性卖点转成决策阻力的解释',
+      evidence: commentEvidence,
+      relatedNotes: topNote.id ? [topNote.id] : [],
+      confidence: topComment ? 'medium' : 'low'
+    },
+    {
+      title: `${industry}内容要从“好不好”走向“适不适合我”`,
+      angle: '用人群、场景和使用条件降低用户判断成本',
+      evidence: `${element}、${concern} 等信号共同出现,说明用户需要更具体的选择依据`,
+      relatedNotes: notes.slice(0, 2).map(note => note.id).filter(Boolean),
+      confidence: notes.length >= 2 ? 'medium' : 'low'
+    },
+    {
+      title: `围绕${element}做一轮真实案例验证`,
+      angle: '用评论原话和案例证据验证内容/产品机会',
+      evidence: risk ? `样本里出现 ${risk} 这类风险词,适合继续核验` : '当前样本还需要补充更多评论原话',
+      relatedNotes: notes.slice(0, 3).map(note => note.id).filter(Boolean),
+      confidence: signals.riskSignals && signals.riskSignals.length ? 'medium' : 'low'
+    }
+  ];
+
+  return typeof memoryRanker === 'function' ? memoryRanker(topics) : topics;
+}
+
+function buildBusinessTalk({ profile = {}, signals = {} } = {}) {
+  const preset = signals.preset || {};
+  const target = preset.talkTarget || '目标用户';
+  const application = preset.applicationLabel || '业务验证或内容选题';
+  const preference = firstTerm(signals.preferenceSignals, '核心卖点');
+  const concern = firstDistinctTerm(signals.decisionSignals, [preference], '主要顾虑');
+  const element = firstDistinctTerm(signals.elementSignals, [preference, concern], '具体元素');
+  const risk = firstDistinctTerm(signals.riskSignals, [preference, concern, element], '风险点');
+  const businessType = profile.businessType || '业务团队';
+
+  return [
+    `面对${target}沟通时,不要只说“${preference}好”,先追问:你最担心${concern},还是${risk}?`,
+    `${businessType}可以把“${element}”拆成真实案例、价格/成本、适用人群和售后承诺四个部分来讲。`,
+    `下一轮${application}建议优先确认“${concern}”是否真的是成交/转化前的最大阻力。`
+  ];
+}
+
+module.exports = {
+  firstTerm,
+  firstDistinctTerm,
+  findTopComment,
+  buildOpportunityTopics,
+  buildBusinessTalk
+};

+ 140 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/providers/douyin-api.js

@@ -0,0 +1,140 @@
+const DOUYIN_API_ROOT = 'https://server.fmode.cn/api/voc-social/douyin';
+const DOUYIN_APP_BASE_URL = `${DOUYIN_API_ROOT}/app`;
+const DOUYIN_SEARCH_PATH = 'search/fetch_general_search_v2';
+const DOUYIN_VIDEO_COMMENTS_PATH = 'app/v3/fetch_video_comments';
+const DOUYIN_VIDEO_REPLIES_PATH = 'app/v3/fetch_video_comment_replies';
+
+function buildEndpointUrl(baseUrl, endpointPath) {
+  return `${String(baseUrl || '').replace(/\/$/, '')}/${String(endpointPath || '').replace(/^\//, '')}`;
+}
+
+function buildUrl(baseUrl, path, query = {}) {
+  const url = new URL(`${baseUrl.replace(/\/$/, '')}/${path.replace(/^\//, '')}`);
+  Object.entries(query).forEach(([key, value]) => {
+    if (value !== undefined && value !== null && value !== '') {
+      url.searchParams.set(key, String(value));
+    }
+  });
+  return url.toString();
+}
+
+function unwrapData(json) {
+  if (!json || typeof json !== 'object') return {};
+  return json.data?.data || json.data || json;
+}
+
+function pickBusinessStatus(json) {
+  const topCode = Number(json?.code);
+  if (Number.isFinite(topCode) && topCode !== 0 && topCode !== 200) {
+    return topCode;
+  }
+  const nestedCode = Number(json?.data?.status_code);
+  if (Number.isFinite(nestedCode) && nestedCode !== 0 && nestedCode !== 200) {
+    return nestedCode;
+  }
+  return 0;
+}
+
+function redactSecret(value) {
+  return String(value || '')
+    .replace(/Bearer\s+[^"'\s,}]+/gi, 'Bearer [REDACTED]')
+    .replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, 'sk-[REDACTED]')
+    .replace(/\br:[A-Za-z0-9]{4,}\b/g, 'r:[REDACTED]')
+    .replace(/("Authorization"\s*:\s*")([^"]+)(")/gi, '$1[REDACTED]$3')
+    .replace(/("api[_-]?token"\s*:\s*")([^"]+)(")/gi, '$1[REDACTED]$3');
+}
+
+function isSuccessCode(json) {
+  const topCode = Number(json?.code);
+  if (Number.isFinite(topCode) && ![0, 200].includes(topCode)) {
+    return false;
+  }
+  const statusCode = Number(json?.data?.status_code);
+  if (Number.isFinite(statusCode) && ![0, 200].includes(statusCode)) {
+    return false;
+  }
+  return true;
+}
+
+async function requestJson(url, token, { method = 'GET', body } = {}) {
+  const response = await fetch(url, {
+    method,
+    headers: {
+      Authorization: `Bearer ${token}`,
+      Accept: 'application/json',
+      ...(method === 'POST' ? { 'Content-Type': 'application/json' } : {})
+    },
+    body: body ? JSON.stringify(body) : undefined
+  });
+  const text = await response.text();
+  let json;
+  try {
+    json = JSON.parse(text);
+  } catch {
+    json = undefined;
+  }
+  if (!response.ok || (json && !isSuccessCode(json))) {
+    const message = json?.message || json?.msg || json?.data?.msg || json?.data?.message || text || `HTTP ${response.status}`;
+    const error = new Error(redactSecret(message));
+    error.httpStatus = pickBusinessStatus(json) || response.status || 500;
+    error.responseBody = redactSecret(text);
+    throw error;
+  }
+  return { json, data: unwrapData(json), rawText: text };
+}
+
+class DouyinApi {
+  constructor(options = {}) {
+    this.baseUrl = options.baseUrl || process.env.DOUYIN_API_BASE_URL || process.env.VOC_DOUYIN_API_BASE_URL || DOUYIN_API_ROOT;
+    this.token = options.token;
+  }
+
+  async searchVideos({
+    keyword,
+    cursor = 0,
+    sortType = '0',
+    publishTime = '0',
+    filterDuration = '0',
+    contentType = '0',
+    searchId = '',
+    backtrace = ''
+  }) {
+    const url = buildEndpointUrl(this.baseUrl, DOUYIN_SEARCH_PATH);
+    return requestJson(url, this.token, {
+      method: 'POST',
+      body: {
+        keyword,
+        cursor,
+        sort_type: sortType,
+        publish_time: publishTime,
+        filter_duration: filterDuration,
+        content_type: contentType,
+        search_id: searchId,
+        backtrace
+      }
+    });
+  }
+
+  async getVideoComments({ awemeId, cursor = 0, count = 20 }) {
+    const url = new URL(buildEndpointUrl(this.baseUrl, DOUYIN_VIDEO_COMMENTS_PATH));
+    url.searchParams.set('aweme_id', String(awemeId));
+    url.searchParams.set('cursor', String(cursor));
+    url.searchParams.set('count', String(count));
+    return requestJson(url, this.token);
+  }
+
+  async getCommentReplies({ itemId, commentId, cursor = 0, count = 20 }) {
+    const url = new URL(buildEndpointUrl(this.baseUrl, DOUYIN_VIDEO_REPLIES_PATH));
+    url.searchParams.set('item_id', String(itemId));
+    url.searchParams.set('comment_id', String(commentId));
+    url.searchParams.set('cursor', String(cursor));
+    url.searchParams.set('count', String(count));
+    return requestJson(url, this.token);
+  }
+}
+
+module.exports = {
+  DOUYIN_API_ROOT,
+  DOUYIN_APP_BASE_URL,
+  DouyinApi
+};

+ 102 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/providers/xiaohongshu-api.js

@@ -0,0 +1,102 @@
+const VOC_SOCIAL_API_ROOT = 'https://server.fmode.cn/api/voc-social';
+const XIAOHONGSHU_APP_BASE_URL = `${VOC_SOCIAL_API_ROOT}/xiaohongshu/app`;
+
+function buildUrl(baseUrl, path, query = {}) {
+  const url = new URL(`${baseUrl.replace(/\/$/, '')}/${path.replace(/^\//, '')}`);
+  Object.entries(query).forEach(([key, value]) => {
+    if (value !== undefined && value !== null && value !== '') {
+      url.searchParams.set(key, String(value));
+    }
+  });
+  return url.toString();
+}
+
+function unwrapData(json) {
+  if (!json || typeof json !== 'object') return {};
+  return json.data?.data || json.data || json;
+}
+
+function pickBusinessStatus(json) {
+  const topCode = Number(json?.code);
+  if (Number.isFinite(topCode) && topCode !== 0 && topCode !== 200) {
+    return topCode;
+  }
+  const nestedCode = Number(json?.data?.status_code);
+  if (Number.isFinite(nestedCode) && nestedCode !== 0 && nestedCode !== 200) {
+    return nestedCode;
+  }
+  return 0;
+}
+
+function redactSecret(value) {
+  return String(value || '')
+    .replace(/Bearer\s+[^"'\s,}]+/gi, 'Bearer [REDACTED]')
+    .replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, 'sk-[REDACTED]')
+    .replace(/\br:[A-Za-z0-9]{4,}\b/g, 'r:[REDACTED]')
+    .replace(/("Authorization"\s*:\s*")([^"]+)(")/gi, '$1[REDACTED]$3')
+    .replace(/("api[_-]?token"\s*:\s*")([^"]+)(")/gi, '$1[REDACTED]$3');
+}
+
+async function requestJson(url, token) {
+  const response = await fetch(url, {
+    method: 'GET',
+    headers: {
+      Authorization: `Bearer ${token}`,
+      Accept: 'application/json'
+    }
+  });
+  const text = await response.text();
+  let json;
+  try {
+    json = JSON.parse(text);
+  } catch {
+    json = undefined;
+  }
+  const businessStatus = pickBusinessStatus(json);
+  if (!response.ok || businessStatus) {
+    const message = json?.message || json?.msg || json?.data?.msg || text || `HTTP ${response.status}`;
+    const error = new Error(redactSecret(message));
+    error.httpStatus = businessStatus || response.status || 500;
+    error.responseBody = redactSecret(text);
+    throw error;
+  }
+  return { json, data: unwrapData(json), rawText: text };
+}
+
+class XiaohongshuApi {
+  constructor(options = {}) {
+    this.baseUrl = options.baseUrl || process.env.XIAOHONGSHU_API_BASE_URL || XIAOHONGSHU_APP_BASE_URL;
+    this.token = options.token;
+  }
+
+  async searchNotes({ keyword, page = 1, sort = 'popularity_descending', noteType = '_0' }) {
+    const url = buildUrl(this.baseUrl, 'search_notes', {
+      keyword,
+      page,
+      sort,
+      note_type: noteType
+    });
+    return requestJson(url, this.token);
+  }
+
+  async getNoteDetail({ noteId }) {
+    const url = buildUrl(this.baseUrl, 'get_note_info', {
+      note_id: noteId
+    });
+    return requestJson(url, this.token);
+  }
+
+  async getNoteComments({ noteId, cursor = '' }) {
+    const url = buildUrl(this.baseUrl, 'get_note_comments', {
+      note_id: noteId,
+      cursor
+    });
+    return requestJson(url, this.token);
+  }
+}
+
+module.exports = {
+  VOC_SOCIAL_API_ROOT,
+  XIAOHONGSHU_APP_BASE_URL,
+  XiaohongshuApi
+};

+ 585 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/server.js

@@ -0,0 +1,585 @@
+#!/usr/bin/env node
+const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
+const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
+const { z } = require('zod');
+const { readXiaohongshuToken, readVocToken } = require('./core/credentials');
+const { runXiaohongshuTrend } = require('./tools/xiaohongshu-trend-run');
+const { updateXiaohongshuPreference } = require('./tools/xiaohongshu-preference-update');
+const { runDouyinTrend } = require('./tools/douyin-trend-run');
+const { updateDouyinPreference } = require('./tools/douyin-preference-update');
+const { runVocProblemDeepDive } = require('./tools/voc-problem-deep-dive-run');
+const { runVocIssuePool } = require('./tools/voc-issue-pool-run');
+const { runVocContentPlan } = require('./tools/voc-content-plan-run');
+const { runVocCompetitorMap } = require('./tools/voc-competitor-map-run');
+const { runVocSpeakingScript } = require('./tools/voc-speaking-script-run');
+const { runBusinessWorkflow } = require('./tools/voc-business-workflow-run');
+const { buildVocRechargeInfo, buildMissingTokenMessage } = require('./core/payment-links');
+
+function asToolResult(result) {
+  return {
+    content: [
+      {
+        type: 'text',
+        text: result.assistantMessage || JSON.stringify(result, null, 2)
+      }
+    ],
+    structuredContent: result,
+    isError: result.status !== 'ok'
+  };
+}
+
+function createServer() {
+  const server = new McpServer({
+    name: 'voc-intelligence',
+    version: '0.3.10'
+  });
+
+  server.registerTool(
+    'voc_business_workflow_run',
+    {
+      title: 'Run VOC Business Workflow',
+      description: [
+        'Run the full VOC business workflow from market voices to issue pool, top issue deep dive, 7-day content plan, and one speaking script.',
+        'Use when the user asks to see what customers care about, decide what to fix first, and know what to post next week in one end-to-end workflow.'
+      ].join(' '),
+      inputSchema: {
+        project: z.string().optional(),
+        brand: z.string().optional(),
+        store: z.string().optional(),
+        industry: z.string().optional(),
+        category: z.string().optional(),
+        platform: z.enum(['douyin', 'xiaohongshu', '抖音', '小红书']).optional(),
+        collectionMode: z.enum(['sample', 'live']).optional(),
+        scenario: z.string().optional(),
+        scene: z.string().optional(),
+        audience: z.string().optional(),
+        targetAudience: z.string().optional(),
+        keywords: z.array(z.string()).optional(),
+        keyword: z.string().optional(),
+        issue: z.string().optional(),
+        problem: z.string().optional(),
+        topic: z.string().optional(),
+        feedback: z.string().optional(),
+        finalize: z.boolean().optional(),
+        output: z.string().optional(),
+        keywordLimit: z.number().int().min(1).max(10).optional(),
+        notesPerKeyword: z.number().int().min(1).max(10).optional(),
+        videosPerKeyword: z.number().int().min(1).max(10).optional(),
+        maxCommentPages: z.number().int().min(0).max(5).optional(),
+        xiaohongshuToken: z.string().optional(),
+        douyinToken: z.string().optional(),
+        vocToken: z.string().optional()
+      },
+      outputSchema: {
+        status: z.string(),
+        assistantMessage: z.string(),
+        summary: z.record(z.any()).optional(),
+        data: z.record(z.any()).optional(),
+        files: z.array(z.string()).optional(),
+        nextActions: z.array(z.string()).optional(),
+        warnings: z.array(z.any()).optional(),
+        errors: z.array(z.any()).optional()
+      }
+    },
+    async input => asToolResult(await runBusinessWorkflow(input))
+  );
+
+  server.registerTool(
+    'voc_speaking_script_run',
+    {
+      title: 'Run VOC Speaking Script Co-Creation',
+      description: [
+        'Turn one VOC-backed topic into a co-created speaking script.',
+        'Use after a content plan when the user selects a topic, asks to enter script co-creation, revise a speaking script, finalize a draft, or save script preferences.'
+      ].join(' '),
+      inputSchema: {
+        project: z.string().optional(),
+        brand: z.string().optional(),
+        store: z.string().optional(),
+        industry: z.string().optional(),
+        category: z.string().optional(),
+        topic: z.string().optional(),
+        title: z.string().optional(),
+        selectedTopic: z.string().optional(),
+        userIssue: z.string().optional(),
+        issue: z.string().optional(),
+        problem: z.string().optional(),
+        issues: z.array(z.string()).optional(),
+        problems: z.array(z.string()).optional(),
+        vocIssues: z.array(z.string()).optional(),
+        evidence: z.array(z.string()).optional(),
+        comments: z.array(z.string()).optional(),
+        voices: z.array(z.string()).optional(),
+        vocEvidence: z.array(z.string()).optional(),
+        evidenceText: z.string().optional(),
+        feedback: z.string().optional(),
+        message: z.string().optional(),
+        preferredStyles: z.array(z.string()).optional(),
+        blockedStyles: z.array(z.string()).optional(),
+        finalize: z.boolean().optional(),
+        memory: z.string().optional(),
+        memoryPath: z.string().optional(),
+        output: z.string().optional()
+      },
+      outputSchema: {
+        status: z.string(),
+        assistantMessage: z.string(),
+        summary: z.record(z.any()).optional(),
+        data: z.record(z.any()).optional(),
+        files: z.array(z.string()).optional(),
+        nextActions: z.array(z.string()).optional(),
+        warnings: z.array(z.any()).optional(),
+        errors: z.array(z.any()).optional()
+      }
+    },
+    async input => asToolResult(await runVocSpeakingScript(input))
+  );
+
+  server.registerTool(
+    'voc_content_plan_run',
+    {
+      title: 'Run VOC Content Plan',
+      description: [
+        'Turn VOC issues, comments, trend reports, or issue-pool findings into a 7-day content plan and speaking scripts.',
+        'Use when the user asks what to post next week, wants口播脚本, video topics, account content ideas, or wants to turn customer problems into marketing content.'
+      ].join(' '),
+      inputSchema: {
+        project: z.string().optional(),
+        brand: z.string().optional(),
+        store: z.string().optional(),
+        industry: z.string().optional(),
+        category: z.string().optional(),
+        platform: z.string().optional(),
+        days: z.number().int().min(1).max(15).optional(),
+        issues: z.array(z.string()).optional(),
+        problems: z.array(z.string()).optional(),
+        vocIssues: z.array(z.string()).optional(),
+        evidence: z.array(z.string()).optional(),
+        comments: z.array(z.string()).optional(),
+        evidenceText: z.string().optional(),
+        reportText: z.string().optional(),
+        report: z.string().optional(),
+        reportPath: z.string().optional()
+      },
+      outputSchema: {
+        status: z.string(),
+        assistantMessage: z.string(),
+        summary: z.record(z.any()).optional(),
+        data: z.record(z.any()).optional(),
+        files: z.array(z.string()).optional(),
+        nextActions: z.array(z.string()).optional(),
+        warnings: z.array(z.any()).optional(),
+        errors: z.array(z.any()).optional()
+      }
+    },
+    async input => asToolResult(await runVocContentPlan(input))
+  );
+
+  server.registerTool(
+    'voc_competitor_map_run',
+    {
+      title: 'Run VOC Competitor Map',
+      description: [
+        'Build a competitor map and differentiated opportunity report from category, city, competitors, and VOC evidence.',
+        'Use when the user asks to look at competitors, competitor analysis, who else is doing well, or how the brand should compete differently.'
+      ].join(' '),
+      inputSchema: {
+        project: z.string().optional(),
+        brand: z.string().optional(),
+        store: z.string().optional(),
+        industry: z.string().optional(),
+        category: z.string().optional(),
+        city: z.string().optional(),
+        region: z.string().optional(),
+        priceBand: z.string().optional(),
+        price: z.string().optional(),
+        scenario: z.string().optional(),
+        scene: z.string().optional(),
+        competitors: z.array(z.string()).optional(),
+        competitorNames: z.array(z.string()).optional(),
+        evidence: z.array(z.string()).optional(),
+        comments: z.array(z.string()).optional(),
+        voices: z.array(z.string()).optional(),
+        reportText: z.string().optional(),
+        report: z.string().optional(),
+        reportPath: z.string().optional()
+      },
+      outputSchema: {
+        status: z.string(),
+        assistantMessage: z.string(),
+        summary: z.record(z.any()).optional(),
+        data: z.record(z.any()).optional(),
+        files: z.array(z.string()).optional(),
+        nextActions: z.array(z.string()).optional(),
+        warnings: z.array(z.any()).optional(),
+        errors: z.array(z.any()).optional()
+      }
+    },
+    async input => asToolResult(await runVocCompetitorMap(input))
+  );
+
+  server.registerTool(
+    'voc_issue_pool_run',
+    {
+      title: 'Run VOC Issue Pool',
+      description: [
+        'Turn user comments, report text, or VOC snippets into a prioritized issue pool.',
+        'Use when the user asks which real problems matter most, asks to manage VOC issues, or wants a problem list before deep-diving.'
+      ].join(' '),
+      inputSchema: {
+        project: z.string().optional().describe('Optional project, brand, account, or store name used to isolate issue-pool memory.'),
+        brand: z.string().optional(),
+        store: z.string().optional(),
+        industry: z.string().optional(),
+        category: z.string().optional(),
+        scenario: z.string().optional(),
+        scene: z.string().optional(),
+        businessType: z.string().optional(),
+        audience: z.string().optional(),
+        targetAudience: z.string().optional(),
+        evidence: z.array(z.string()).optional().describe('User comments or VOC snippets.'),
+        comments: z.array(z.string()).optional().describe('User comment snippets.'),
+        voices: z.array(z.string()).optional().describe('User voice snippets.'),
+        issues: z.array(z.string()).optional().describe('Known issue snippets.'),
+        issueTexts: z.array(z.string()).optional().describe('Known issue snippets.'),
+        evidenceText: z.string().optional().describe('Raw comments or evidence text.'),
+        reportText: z.string().optional().describe('Previous report markdown text.'),
+        report: z.string().optional().describe('Optional previous report markdown path.'),
+        reportPath: z.string().optional().describe('Optional previous report markdown path.'),
+        memory: z.string().optional().describe('Optional issue-pool memory JSON path.'),
+        memoryPath: z.string().optional().describe('Optional issue-pool memory JSON path.'),
+        feedback: z.string().optional().describe('User feedback or status notes.'),
+        message: z.string().optional().describe('User feedback or status notes.'),
+        resolvedIssues: z.array(z.string()).optional().describe('Issue names or ids already resolved.'),
+        validatingIssues: z.array(z.string()).optional().describe('Issue names or ids currently validating.'),
+        blockedIssues: z.array(z.string()).optional().describe('Issue names or ids to pause.'),
+        statusUpdates: z.record(z.string()).optional().describe('Map of issue title/id to status.')
+      },
+      outputSchema: {
+        status: z.string(),
+        assistantMessage: z.string(),
+        summary: z.record(z.any()).optional(),
+        data: z.record(z.any()).optional(),
+        files: z.array(z.string()).optional(),
+        nextActions: z.array(z.string()).optional(),
+        warnings: z.array(z.any()).optional(),
+        errors: z.array(z.any()).optional()
+      }
+    },
+    async input => {
+      const result = await runVocIssuePool(input);
+      return asToolResult(result);
+    }
+  );
+
+  server.registerTool(
+    'voc_problem_deep_dive_run',
+    {
+      title: 'Run VOC Problem Deep Dive',
+      description: [
+        'Deep-dive a single VOC problem from a boss/operator perspective.',
+        'Use when the user says continue digging, what should the boss do, how to solve this problem, or turn this VOC into actions.'
+      ].join(' '),
+      inputSchema: {
+        issue: z.string().optional().describe('Single VOC issue to deep dive, e.g. 排队, 贵, 不好吃, 服务差.'),
+        problem: z.string().optional().describe('Alias for issue.'),
+        topic: z.string().optional().describe('Alias for issue.'),
+        project: z.string().optional().describe('Optional project, brand, or account name used to isolate deep-dive memory.'),
+        brand: z.string().optional().describe('Optional brand name used to isolate deep-dive memory.'),
+        store: z.string().optional().describe('Optional store name used to isolate deep-dive memory.'),
+        industry: z.string().optional(),
+        category: z.string().optional(),
+        scenario: z.string().optional(),
+        scene: z.string().optional(),
+        businessType: z.string().optional(),
+        audience: z.string().optional(),
+        targetAudience: z.string().optional(),
+        evidence: z.array(z.string()).optional().describe('Optional user comments or VOC snippets.'),
+        evidenceText: z.string().optional().describe('Optional text with comments or evidence.'),
+        comments: z.array(z.string()).optional().describe('Optional comment snippets.'),
+        voices: z.array(z.string()).optional().describe('Optional user voice snippets.'),
+        report: z.string().optional().describe('Optional previous report markdown path.'),
+        reportPath: z.string().optional().describe('Optional previous report markdown path.'),
+        memory: z.string().optional().describe('Optional deep-dive memory JSON path.'),
+        memoryPath: z.string().optional().describe('Optional deep-dive memory JSON path.'),
+        feedback: z.string().optional().describe('User feedback from previous iteration.'),
+        message: z.string().optional().describe('User feedback from previous iteration.'),
+        preferredActions: z.array(z.string()).optional().describe('Actions the user prefers.'),
+        blockedActions: z.array(z.string()).optional().describe('Actions the user does not want.'),
+        preferredContentAngles: z.array(z.string()).optional().describe('Content angles the user prefers.'),
+        validatedActions: z.array(z.string()).optional().describe('Actions proven useful in practice.'),
+        rejectedActions: z.array(z.string()).optional().describe('Actions proven ineffective in practice.')
+      },
+      outputSchema: {
+        status: z.string(),
+        assistantMessage: z.string(),
+        summary: z.record(z.any()).optional(),
+        data: z.record(z.any()).optional(),
+        files: z.array(z.string()).optional(),
+        nextActions: z.array(z.string()).optional(),
+        warnings: z.array(z.any()).optional(),
+        errors: z.array(z.any()).optional()
+      }
+    },
+    async input => {
+      const result = await runVocProblemDeepDive(input);
+      return asToolResult(result);
+    }
+  );
+
+  server.registerTool(
+    'voc_xiaohongshu_token_check',
+    {
+      title: 'Check Xiaohongshu Token',
+      description: 'Check whether a Xiaohongshu/TikHub API token is configured for live collection.',
+      inputSchema: {
+        xiaohongshuToken: z.string().optional().describe('Optional request-scoped collection token. It is never echoed back.'),
+        vocToken: z.string().optional().describe('Optional VOC social token, same convention as douyin-speaking-daily. It is never echoed back.')
+      },
+      outputSchema: {
+        status: z.string(),
+        assistantMessage: z.string(),
+        configured: z.boolean(),
+        data: z.record(z.any()).optional()
+      }
+    },
+    async input => {
+      const token = readXiaohongshuToken(input);
+      const recharge = token ? null : await buildVocRechargeInfo();
+      const result = {
+        status: token ? 'ok' : 'needs_token',
+        configured: Boolean(token),
+        data: recharge ? { recharge } : {},
+        assistantMessage: token
+          ? '小红书 live 采集 Token 已配置,可以尝试小规模真实采集。'
+          : buildMissingTokenMessage(recharge.paymentUrl)
+      };
+      return asToolResult(result);
+    }
+  );
+
+  server.registerTool(
+    'voc_douyin_token_check',
+    {
+      title: 'Check Douyin Token',
+      description: 'Check whether a Douyin/VOC API token is configured for live collection.',
+      inputSchema: {
+        douyinToken: z.string().optional().describe('Optional request-scoped Douyin token. It is never echoed back.'),
+        vocToken: z.string().optional().describe('Optional VOC social token, same convention as xiaohongshu trend.'),
+        xiaohongshuToken: z.string().optional().describe('Optional platform token alias, never echoed back.')
+      },
+      outputSchema: {
+        status: z.string(),
+        assistantMessage: z.string(),
+        configured: z.boolean(),
+        data: z.record(z.any()).optional()
+      }
+    },
+    async input => {
+      const token = readVocToken(input);
+      const recharge = token ? null : await buildVocRechargeInfo();
+      const result = {
+        status: token ? 'ok' : 'needs_token',
+        configured: Boolean(token),
+        data: recharge ? { recharge } : {},
+        assistantMessage: token
+          ? '抖音 live 采集 Token 已配置,可以开始小规模真实采集。'
+          : buildMissingTokenMessage(recharge.paymentUrl, recharge, { platformLabel: '抖音' })
+      };
+      return asToolResult(result);
+    }
+  );
+
+  server.registerTool(
+    'voc_xiaohongshu_trend_run',
+    {
+      title: 'Run Xiaohongshu Trend Intelligence',
+      description: [
+        'Generate first-round Xiaohongshu trend observations and validation questions from an industry profile.',
+        'P0 supports sample mode for demos and course recording.',
+        'Returns assistantMessage as the user-facing body; first-round output is a hypothesis that needs user calibration.'
+      ].join(' '),
+      inputSchema: {
+        collectionMode: z.enum(['sample', 'live']).optional().describe('Use sample for P0 demo. Live will be enabled after VOC provider integration.'),
+        profile: z.string().optional().describe('Optional profile JSON path.'),
+        output: z.string().optional().describe('Optional output directory.'),
+        memory: z.string().optional().describe('Optional preference memory JSON path.'),
+        memoryPath: z.string().optional().describe('Optional preference memory JSON path.'),
+        project: z.string().optional(),
+        industry: z.string().optional(),
+        businessType: z.string().optional(),
+        targetAudience: z.array(z.string()).optional(),
+        keywords: z.array(z.string()).optional(),
+        trendQuestions: z.array(z.string()).optional(),
+        mustTrackSignals: z.array(z.string()).optional(),
+        keywordLimit: z.number().int().min(1).max(10).optional(),
+        notesPerKeyword: z.number().int().min(1).max(10).optional(),
+        maxCommentPages: z.number().int().min(0).max(5).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.'),
+        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.')
+      },
+      outputSchema: {
+        status: z.string(),
+        assistantMessage: z.string(),
+        summary: z.record(z.any()).optional(),
+        data: z.record(z.any()).optional(),
+        files: z.array(z.string()).optional(),
+        nextActions: z.array(z.string()).optional(),
+        warnings: z.array(z.string()).optional(),
+        errors: z.array(z.any()).optional()
+      }
+    },
+    async input => {
+      const result = await runXiaohongshuTrend({
+        ...input,
+        collectionMode: input.collectionMode || 'sample'
+      });
+      return asToolResult(result);
+    }
+  );
+
+  server.registerTool(
+    'voc_douyin_trend_run',
+    {
+      title: 'Run Douyin Trend Intelligence',
+      description: [
+        'Generate first-round Douyin trend observations and validation questions from an industry profile.',
+        'P0 supports sample mode for demos and course recording.',
+        'Returns assistantMessage as the user-facing body; first-round output is a hypothesis that needs user calibration.'
+      ].join(' '),
+      inputSchema: {
+        collectionMode: z.enum(['sample', 'live']).optional().describe('Use sample for P0 demo. Live will be enabled after VOC provider integration.'),
+        profile: z.string().optional().describe('Optional profile JSON path.'),
+        output: z.string().optional().describe('Optional output directory.'),
+        memory: z.string().optional().describe('Optional preference memory JSON path.'),
+        memoryPath: z.string().optional().describe('Optional preference memory JSON path.'),
+        project: z.string().optional(),
+        industry: z.string().optional(),
+        businessType: z.string().optional(),
+        targetAudience: z.array(z.string()).optional(),
+        keywords: z.array(z.string()).optional(),
+        trendQuestions: z.array(z.string()).optional(),
+        mustTrackSignals: z.array(z.string()).optional(),
+        keywordLimit: z.number().int().min(1).max(10).optional(),
+        videosPerKeyword: z.number().int().min(1).max(10).optional(),
+        maxCommentPages: z.number().int().min(0).max(5).optional(),
+        sortType: z.enum(['0', '1', '2']).optional(),
+        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.'),
+        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.')
+      },
+      outputSchema: {
+        status: z.string(),
+        assistantMessage: z.string(),
+        summary: z.record(z.any()).optional(),
+        data: z.record(z.any()).optional(),
+        files: z.array(z.string()).optional(),
+        nextActions: z.array(z.string()).optional(),
+        warnings: z.array(z.any()).optional(),
+        errors: z.array(z.any()).optional()
+      }
+    },
+    async input => {
+      const result = await runDouyinTrend({
+        ...input,
+        collectionMode: input.collectionMode || 'sample'
+      });
+      return asToolResult(result);
+    }
+  );
+
+  server.registerTool(
+    'voc_xiaohongshu_preference_update',
+    {
+      title: 'Update Xiaohongshu Trend Preference',
+      description: [
+        'Save user feedback for the next Xiaohongshu trend refinement.',
+        'Use after the user answers validation questions or says what to keep, block, downgrade, or focus on next time.'
+      ].join(' '),
+      inputSchema: {
+        message: z.string().optional().describe('Natural-language user feedback.'),
+        feedback: z.string().optional().describe('Natural-language user feedback.'),
+        memory: z.string().optional().describe('Optional preference memory JSON path.'),
+        memoryPath: z.string().optional().describe('Optional preference memory JSON path.'),
+        preferredDirections: z.array(z.string()).optional(),
+        blockedDirections: z.array(z.string()).optional(),
+        focusModes: z.array(z.string()).optional()
+      },
+      outputSchema: {
+        status: z.string(),
+        assistantMessage: z.string(),
+        summary: z.record(z.any()).optional(),
+        data: z.record(z.any()).optional(),
+        files: z.array(z.string()).optional(),
+        nextActions: z.array(z.string()).optional(),
+        warnings: z.array(z.string()).optional(),
+        errors: z.array(z.any()).optional()
+      }
+    },
+    async input => {
+      const result = await updateXiaohongshuPreference(input);
+      return asToolResult(result);
+    }
+  );
+
+  server.registerTool(
+    'voc_douyin_preference_update',
+    {
+      title: 'Update Douyin Trend Preference',
+      description: [
+        'Save user feedback for the next Douyin trend refinement.',
+        'Use after the user answers validation questions or says what to keep, block, downgrade, or focus on next time.'
+      ].join(' '),
+      inputSchema: {
+        message: z.string().optional().describe('Natural-language user feedback.'),
+        feedback: z.string().optional().describe('Natural-language user feedback.'),
+        memory: z.string().optional().describe('Optional preference memory JSON path.'),
+        memoryPath: z.string().optional().describe('Optional preference memory JSON path.'),
+        preferredDirections: z.array(z.string()).optional(),
+        blockedDirections: z.array(z.string()).optional(),
+        focusModes: z.array(z.string()).optional()
+      },
+      outputSchema: {
+        status: z.string(),
+        assistantMessage: z.string(),
+        summary: z.record(z.any()).optional(),
+        data: z.record(z.any()).optional(),
+        files: z.array(z.string()).optional(),
+        nextActions: z.array(z.string()).optional(),
+        warnings: z.array(z.any()).optional(),
+        errors: z.array(z.any()).optional()
+      }
+    },
+    async input => {
+      const result = await updateDouyinPreference(input);
+      return asToolResult(result);
+    }
+  );
+
+  return server;
+}
+
+async function main() {
+  const server = createServer();
+  const transport = new StdioServerTransport();
+  await server.connect(transport);
+}
+
+if (require.main === module) {
+  main().catch(error => {
+    console.error(error);
+    process.exit(1);
+  });
+}
+
+module.exports = {
+  createServer
+};

+ 70 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/tools/douyin-preference-update.js

@@ -0,0 +1,70 @@
+#!/usr/bin/env node
+const path = require('path');
+const { okResult } = require('../core/result-envelope');
+const { updateTrendMemory } = require('../features/douyin-trend/preference-memory');
+
+function parseArgs(argv) {
+  const args = {};
+  const setArg = (key, value) => {
+    args[key] = value;
+    if (key.includes('-')) {
+      args[key.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())] = value;
+    }
+  };
+  for (let i = 0; i < argv.length; i++) {
+    const token = argv[i];
+    if (!token.startsWith('--')) continue;
+    const eq = token.indexOf('=');
+    if (eq >= 0) {
+      setArg(token.slice(2, eq), token.slice(eq + 1));
+      continue;
+    }
+    const key = token.slice(2);
+    const next = argv[i + 1];
+    if (next && !next.startsWith('--')) {
+      setArg(key, next);
+      i++;
+    } else {
+      setArg(key, true);
+    }
+  }
+  return args;
+}
+
+async function updateDouyinPreference(input = {}) {
+  const result = updateTrendMemory(input);
+  return okResult({
+    status: 'ok',
+    assistantMessage: [
+      '# 抖音偏好记忆已更新',
+      '',
+      '已把你这次的反馈写入偏好记忆,下一轮抖音趋势情报会自动参考。',
+      '',
+      `memory: ${path.relative(process.cwd(), result.memoryPath)}`
+    ].join('\n'),
+    summary: {
+      memoryPath: result.memoryPath,
+      preferredDirections: result.memory.preferredDirections,
+      blockedDirections: result.memory.blockedDirections,
+      focusModes: result.memory.focusModes
+    },
+    data: result
+  });
+}
+
+async function main() {
+  const args = parseArgs(process.argv.slice(2));
+  const result = await updateDouyinPreference(args);
+  console.log(JSON.stringify(result, null, 2));
+}
+
+if (require.main === module) {
+  main().catch(error => {
+    console.error(JSON.stringify({ status: 'error', message: error.message }, null, 2));
+    process.exit(1);
+  });
+}
+
+module.exports = {
+  updateDouyinPreference
+};

+ 354 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/tools/douyin-trend-run.js

@@ -0,0 +1,354 @@
+#!/usr/bin/env node
+const fs = require('fs');
+const path = require('path');
+const { okResult, errorResult } = require('../core/result-envelope');
+const { readVocToken } = 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 { buildTrendReport, writeReport } = require('../features/douyin-trend/report');
+const { trendMemoryPath, readTrendMemory } = require('../features/douyin-trend/preference-memory');
+const { buildNoSampleFallbackMessage } = require('../core/evidence-quality');
+const {
+  buildVocRechargeInfo,
+  buildMissingTokenMessage,
+  buildRechargeRequiredMessage
+} = require('../core/payment-links');
+
+function parseArgs(argv) {
+  const args = {};
+  const setArg = (key, value) => {
+    args[key] = value;
+    if (key.includes('-')) {
+      args[key.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())] = value;
+    }
+  };
+  for (let i = 0; i < argv.length; i++) {
+    const token = argv[i];
+    if (!token.startsWith('--')) continue;
+    const eq = token.indexOf('=');
+    if (eq >= 0) {
+      setArg(token.slice(2, eq), token.slice(eq + 1));
+      continue;
+    }
+    const key = token.slice(2);
+    const next = argv[i + 1];
+    if (next && !next.startsWith('--')) {
+      setArg(key, next);
+      i++;
+    } else {
+      setArg(key, true);
+    }
+  }
+  return args;
+}
+
+function splitList(value) {
+  if (!value) return [];
+  if (Array.isArray(value)) return value.map(String).map(item => item.trim()).filter(Boolean);
+  return String(value).split(/[,,、\n\r]+/).map(item => item.trim()).filter(Boolean);
+}
+
+function defaultProfile() {
+  return {
+    project: '抖音趋势情报官',
+    industry: '通用行业',
+    businessType: '品牌方/门店/服务商/内容账号',
+    targetAudience: ['目标用户'],
+    keywords: ['行业关键词', '用户痛点', '产品体验', '价格', '口碑'],
+    trendQuestions: [
+      '用户最近在关注什么表达和卖点?',
+      '哪些顾虑影响点击、停留或评论?',
+      '什么内容开头最容易带来转化?'
+    ],
+    mustTrackSignals: ['价格', '口碑', '效果', '安全', '服务', '体验']
+  };
+}
+
+function readJsonIfExists(filePath) {
+  if (!filePath) return {};
+  const absolute = path.resolve(filePath);
+  if (!fs.existsSync(absolute)) return {};
+  return JSON.parse(fs.readFileSync(absolute, 'utf8'));
+}
+
+function findAuthError(errors = []) {
+  return errors.find(error => {
+    const message = String(error && error.message || '');
+    return Number(error && error.httpStatus) === 401 ||
+      /Invalid API token|无效的API令牌|invalid token/i.test(message);
+  });
+}
+
+function findGatewayError(errors = []) {
+  return errors.find(error => {
+    const status = Number(error && error.httpStatus);
+    const message = String(error && error.message || '');
+    return status === 402 ||
+      status === 403 ||
+      /社交平台接口请求错误|没有开通|余额不足|无权限|权限或余额|permission|quota|payment|insufficient|balance/i.test(message);
+  });
+}
+
+function loadProfile(args) {
+  const fromFile = readJsonIfExists(args.profile);
+  const defaults = defaultProfile();
+  return {
+    ...fromFile,
+    project: args.project || fromFile.project || defaults.project,
+    industry: args.industry || fromFile.industry || defaults.industry,
+    businessType: args.businessType || args['business-type'] || fromFile.businessType || defaults.businessType,
+    targetAudience: splitList(args.targetAudience || args['target-audience']).length
+      ? splitList(args.targetAudience || args['target-audience'])
+      : (fromFile.targetAudience || defaults.targetAudience),
+    keywords: splitList(args.keywords).length
+      ? splitList(args.keywords)
+      : (fromFile.keywords || defaults.keywords),
+    trendQuestions: splitList(args.trendQuestions || args['trend-questions']).length
+      ? splitList(args.trendQuestions || args['trend-questions'])
+      : (fromFile.trendQuestions || defaults.trendQuestions),
+    mustTrackSignals: splitList(args.mustTrackSignals || args['must-track-signals']).length
+      ? splitList(args.mustTrackSignals || args['must-track-signals'])
+      : (fromFile.mustTrackSignals || defaults.mustTrackSignals)
+  };
+}
+
+async function runDouyinTrend(input = {}) {
+  const collectionMode = input.collectionMode || input['collection-mode'] || 'sample';
+  const outputDir = path.resolve(input.output || path.join('outputs', 'claude-code-douyin-trend', new Date().toISOString().slice(0, 10)));
+  const profile = loadProfile(input);
+  const memoryPath = trendMemoryPath(input);
+  const memory = readTrendMemory(memoryPath);
+  const warnings = [];
+  const errors = [];
+  const assetFiles = [];
+  let dataset;
+
+  if (collectionMode === 'sample') {
+    dataset = buildSampleDataset(profile);
+    warnings.push('P0 阶段使用 sample 演示数据,真实采集需要接入 VOC 服务。');
+  } else if (collectionMode === 'live') {
+    const token = readVocToken(input);
+    if (!token) {
+      const recharge = await buildVocRechargeInfo();
+      return {
+        status: 'needs_token',
+        assistantMessage: buildMissingTokenMessage(recharge.paymentUrl, recharge, { platformLabel: '抖音' }),
+        summary: { requestedCollectionMode: collectionMode },
+        data: {
+          requestedCollectionMode: collectionMode,
+          recharge
+        },
+        files: [],
+        nextActions: [
+          `打开充值/开通链接:${recharge.paymentUrl}`,
+          '充值完成后配置 session token,再重新运行 live 模式',
+          '如果只是录制教学,可以先用 sample 模式演示完整流程'
+        ],
+        warnings: [],
+        errors: []
+      };
+    }
+
+    const liveDataset = await collectLiveDataset({
+      profile,
+      token,
+      options: {
+        keywordLimit: input.keywordLimit || input['keyword-limit'],
+        videosPerKeyword: input.videosPerKeyword || input['videos-per-keyword'],
+        maxCommentPages: input.maxCommentPages || input['max-comment-pages'],
+        sortType: input.sortType || input.sort || input['sort-type'],
+        publishTime: input.publishTime || input['publish-time'],
+        filterDuration: input.filterDuration || input['filter-duration'],
+        contentType: input.contentType || input['content-type'],
+        baseUrl: input.baseUrl || input['base-url'] || process.env.DOUYIN_API_BASE_URL || process.env.VOC_DOUYIN_API_BASE_URL
+      }
+    });
+    dataset = {
+      videos: liveDataset.videos,
+      comments: liveDataset.comments
+    };
+    warnings.push(...liveDataset.warnings);
+    errors.push(...liveDataset.errors);
+
+    const collectionIssue = findGatewayError([...errors, ...warnings]);
+    if (collectionIssue) {
+      const recharge = await buildVocRechargeInfo({ token });
+      return {
+        status: 'needs_recharge',
+        assistantMessage: buildRechargeRequiredMessage(recharge.paymentUrl, recharge, { platformLabel: '抖音' }),
+        summary: {
+          videoCount: dataset.videos.length,
+          commentCount: dataset.comments.length,
+          failedStage: collectionIssue.stage || '',
+          failedKeyword: collectionIssue.keyword || ''
+        },
+        data: {
+          profile,
+          recharge
+        },
+        files: [],
+        nextActions: [
+          `打开充值链接:${recharge.paymentUrl}`,
+          '支付完成后重新运行本次抖音 live 采集',
+          '如果需要先给客户演示,可以切换 sample 模式继续'
+        ],
+        warnings: [],
+        errors: []
+      };
+    }
+
+    if (!dataset.videos.length) {
+      const authError = findAuthError(errors);
+      if (authError) {
+        const recharge = await buildVocRechargeInfo({ token });
+        return {
+          status: 'needs_valid_token',
+          assistantMessage: [
+            '当前 VOC-AI 数据服务 token 没有通过校验,暂时不能进行真实抖音采集。',
+            '',
+            '请确认已经完成 VOC-AI 数据服务开通/充值,并重新配置服务方提供的 session token。',
+            '',
+            `充值/开通链接:${recharge.paymentUrl}`
+          ].join('\n'),
+          summary: {
+            videoCount: 0,
+            commentCount: dataset.comments.length,
+            errorCount: errors.length,
+            failedStage: authError.stage || 'search_videos',
+            failedKeyword: authError.keyword || ''
+          },
+          data: { profile, recharge },
+          files: [],
+          nextActions: [
+            `打开充值/开通链接:${recharge.paymentUrl}`,
+            '重新配置有效 session token 后重试 live 模式',
+            '需要录教学视频时,可先用 sample 模式演示完整多轮校准流程'
+          ],
+          warnings: [],
+          errors: []
+        };
+      }
+
+      const gatewayError = findGatewayError(errors);
+      if (gatewayError) {
+        const recharge = await buildVocRechargeInfo({ token });
+        return {
+          status: 'needs_recharge',
+          assistantMessage: buildRechargeRequiredMessage(recharge.paymentUrl, recharge, { platformLabel: '抖音' }),
+          summary: {
+            videoCount: 0,
+            commentCount: dataset.comments.length,
+            failedStage: gatewayError.stage || 'search_videos',
+            failedKeyword: gatewayError.keyword || ''
+          },
+          data: { profile, recharge },
+          files: [],
+          nextActions: [
+            `打开充值链接:${recharge.paymentUrl}`,
+            '支付完成后重新运行本次抖音 live 采集',
+            '如果需要先给客户演示,可以切换 sample 模式继续'
+          ],
+          warnings: [],
+          errors: []
+        };
+      }
+
+      return {
+        status: 'no_live_samples',
+        assistantMessage: buildNoSampleFallbackMessage({
+          platformLabel: '抖音',
+          profile,
+          examples: [
+            '把行业词换成视频表达词,例如“第一次来怎么点”“老板推荐”“避坑”',
+            '加入城市/商圈/品类,例如“南昌 烧烤 第一次来”“南昌菜 排队”',
+            '从窄服务词扩展到用户情绪词,例如“父母礼物、家庭故事、人生感悟”'
+          ]
+        }),
+        summary: {
+          videoCount: 0,
+          commentCount: dataset.comments.length,
+          errorCount: errors.length
+        },
+        data: { profile },
+        files: [],
+        nextActions: [
+          '换一组更像视频标题/评论表达的关键词后重试 live 模式',
+          '扩大到相关大词做第一轮真实样本尝试',
+          '如果用于教学或远程演示,先切换 sample 模式,并明确标注非真实采集'
+        ],
+        warnings,
+        errors
+      };
+    }
+  } else {
+    return errorResult(`Unsupported collectionMode: ${collectionMode}`);
+  }
+
+  const cacheAssetsEnabled = input.cacheAssets !== false &&
+    input['cache-assets'] !== false &&
+    String(input.cacheAssets || input['cache-assets'] || '').toLowerCase() !== 'false';
+  if (cacheAssetsEnabled) {
+    const assetResult = await cacheEvidenceAssets({
+      notes: dataset.videos,
+      outputDir,
+      limit: input.assetLimit ?? input['asset-limit'] ?? 10
+    });
+    warnings.push(...assetResult.warnings);
+    assetFiles.push(...assetResult.files);
+  }
+
+  const report = buildTrendReport({ profile, dataset, collectionMode, memory });
+  const files = writeReport(outputDir, report);
+  const validationQuestions = report.data.validationQuestions || [];
+  const nextActions = validationQuestions.length
+    ? [
+      `先回答校准问题 1:${validationQuestions[0]}`,
+      validationQuestions[1] ? `再确认问题 2:${validationQuestions[1]}` : '',
+      '把本轮证据整理成 VOC 问题池,判断先改哪个问题。',
+      '也可以直接补充一条你最近听到的真实用户反馈,我会据此收敛下一轮判断。'
+    ].filter(Boolean)
+    : [
+      '先补充一条你最近听到的真实用户反馈',
+      '把本轮证据整理成 VOC 问题池,判断先改哪个问题。',
+      '再基于用户反馈收敛下一轮趋势判断'
+    ];
+
+  return okResult({
+    assistantMessage: report.assistantMessage,
+    summary: report.summary,
+    data: {
+      ...report.data,
+      memoryPath
+    },
+    files: [...files, ...assetFiles],
+    nextActions,
+    warnings,
+    errors
+  });
+}
+
+async function main() {
+  const args = parseArgs(process.argv.slice(2));
+  const result = await runDouyinTrend(args);
+  if (args.assistantMessageOnly || args['assistant-message-only']) {
+    console.log(result.assistantMessage || JSON.stringify(result, null, 2));
+  } else {
+    console.log(JSON.stringify(result, null, 2));
+  }
+  if (!args.assistantMessageOnly && !args['assistant-message-only'] && (args.resultPrefix || args['result-prefix'])) {
+    const prefix = args.resultPrefix || args['result-prefix'];
+    console.log(`${prefix}=${JSON.stringify(result)}`);
+  }
+}
+
+if (require.main === module) {
+  main().catch(error => {
+    console.error(JSON.stringify({ status: 'error', message: error.message }, null, 2));
+    process.exit(1);
+  });
+}
+
+module.exports = {
+  runDouyinTrend
+};

+ 52 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/tools/voc-business-workflow-run.js

@@ -0,0 +1,52 @@
+#!/usr/bin/env node
+const { runBusinessWorkflow } = require('../features/voc-business-workflow/business-workflow');
+
+function parseArgs(argv) {
+  const args = {};
+  const setArg = (key, value) => {
+    args[key] = value;
+    if (key.includes('-')) {
+      args[key.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())] = value;
+    }
+  };
+  for (let i = 0; i < argv.length; i++) {
+    const token = argv[i];
+    if (!token.startsWith('--')) continue;
+    const eq = token.indexOf('=');
+    if (eq >= 0) {
+      setArg(token.slice(2, eq), token.slice(eq + 1));
+      continue;
+    }
+    const key = token.slice(2);
+    const next = argv[i + 1];
+    if (next && !next.startsWith('--')) {
+      setArg(key, next);
+      i++;
+    } else {
+      setArg(key, true);
+    }
+  }
+  return args;
+}
+
+async function main() {
+  const args = parseArgs(process.argv.slice(2));
+  const result = await runBusinessWorkflow(args);
+  if (args.assistantMessageOnly || args['assistant-message-only']) {
+    console.log(result.assistantMessage || JSON.stringify(result, null, 2));
+  } else {
+    console.log(JSON.stringify(result, null, 2));
+  }
+  process.exitCode = result.status === 'ok' ? 0 : 1;
+}
+
+if (require.main === module) {
+  main().catch(error => {
+    console.error(JSON.stringify({ status: 'error', message: error.message }, null, 2));
+    process.exit(1);
+  });
+}
+
+module.exports = {
+  runBusinessWorkflow
+};

+ 59 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/tools/voc-competitor-map-run.js

@@ -0,0 +1,59 @@
+#!/usr/bin/env node
+const { buildCompetitorMap } = require('../features/voc-competitor-map/competitor-map');
+
+function parseArgs(argv) {
+  const args = {};
+  const setArg = (key, value) => {
+    args[key] = value;
+    if (key.includes('-')) {
+      args[key.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())] = value;
+    }
+  };
+  for (let i = 0; i < argv.length; i++) {
+    const token = argv[i];
+    if (!token.startsWith('--')) continue;
+    const eq = token.indexOf('=');
+    if (eq >= 0) {
+      setArg(token.slice(2, eq), token.slice(eq + 1));
+      continue;
+    }
+    const key = token.slice(2);
+    const next = argv[i + 1];
+    if (next && !next.startsWith('--')) {
+      setArg(key, next);
+      i++;
+    } else {
+      setArg(key, true);
+    }
+  }
+  return args;
+}
+
+async function runVocCompetitorMap(input = {}) {
+  return buildCompetitorMap(input);
+}
+
+async function main() {
+  const args = parseArgs(process.argv.slice(2));
+  const result = await runVocCompetitorMap(args);
+  if (args.assistantMessageOnly || args['assistant-message-only']) {
+    console.log(result.assistantMessage || JSON.stringify(result, null, 2));
+  } else {
+    console.log(JSON.stringify(result, null, 2));
+  }
+  if (!args.assistantMessageOnly && !args['assistant-message-only'] && (args.resultPrefix || args['result-prefix'])) {
+    const prefix = args.resultPrefix || args['result-prefix'];
+    console.log(`${prefix}=${JSON.stringify(result)}`);
+  }
+}
+
+if (require.main === module) {
+  main().catch(error => {
+    console.error(JSON.stringify({ status: 'error', message: error.message }, null, 2));
+    process.exit(1);
+  });
+}
+
+module.exports = {
+  runVocCompetitorMap
+};

+ 59 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/tools/voc-content-plan-run.js

@@ -0,0 +1,59 @@
+#!/usr/bin/env node
+const { buildContentPlan } = require('../features/voc-content-plan/content-plan');
+
+function parseArgs(argv) {
+  const args = {};
+  const setArg = (key, value) => {
+    args[key] = value;
+    if (key.includes('-')) {
+      args[key.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())] = value;
+    }
+  };
+  for (let i = 0; i < argv.length; i++) {
+    const token = argv[i];
+    if (!token.startsWith('--')) continue;
+    const eq = token.indexOf('=');
+    if (eq >= 0) {
+      setArg(token.slice(2, eq), token.slice(eq + 1));
+      continue;
+    }
+    const key = token.slice(2);
+    const next = argv[i + 1];
+    if (next && !next.startsWith('--')) {
+      setArg(key, next);
+      i++;
+    } else {
+      setArg(key, true);
+    }
+  }
+  return args;
+}
+
+async function runVocContentPlan(input = {}) {
+  return buildContentPlan(input);
+}
+
+async function main() {
+  const args = parseArgs(process.argv.slice(2));
+  const result = await runVocContentPlan(args);
+  if (args.assistantMessageOnly || args['assistant-message-only']) {
+    console.log(result.assistantMessage || JSON.stringify(result, null, 2));
+  } else {
+    console.log(JSON.stringify(result, null, 2));
+  }
+  if (!args.assistantMessageOnly && !args['assistant-message-only'] && (args.resultPrefix || args['result-prefix'])) {
+    const prefix = args.resultPrefix || args['result-prefix'];
+    console.log(`${prefix}=${JSON.stringify(result)}`);
+  }
+}
+
+if (require.main === module) {
+  main().catch(error => {
+    console.error(JSON.stringify({ status: 'error', message: error.message }, null, 2));
+    process.exit(1);
+  });
+}
+
+module.exports = {
+  runVocContentPlan
+};

+ 59 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/tools/voc-issue-pool-run.js

@@ -0,0 +1,59 @@
+#!/usr/bin/env node
+const { buildIssuePool } = require('../features/voc-issue-pool/issue-pool');
+
+function parseArgs(argv) {
+  const args = {};
+  const setArg = (key, value) => {
+    args[key] = value;
+    if (key.includes('-')) {
+      args[key.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())] = value;
+    }
+  };
+  for (let i = 0; i < argv.length; i++) {
+    const token = argv[i];
+    if (!token.startsWith('--')) continue;
+    const eq = token.indexOf('=');
+    if (eq >= 0) {
+      setArg(token.slice(2, eq), token.slice(eq + 1));
+      continue;
+    }
+    const key = token.slice(2);
+    const next = argv[i + 1];
+    if (next && !next.startsWith('--')) {
+      setArg(key, next);
+      i++;
+    } else {
+      setArg(key, true);
+    }
+  }
+  return args;
+}
+
+async function runVocIssuePool(input = {}) {
+  return buildIssuePool(input);
+}
+
+async function main() {
+  const args = parseArgs(process.argv.slice(2));
+  const result = await runVocIssuePool(args);
+  if (args.assistantMessageOnly || args['assistant-message-only']) {
+    console.log(result.assistantMessage || JSON.stringify(result, null, 2));
+  } else {
+    console.log(JSON.stringify(result, null, 2));
+  }
+  if (!args.assistantMessageOnly && !args['assistant-message-only'] && (args.resultPrefix || args['result-prefix'])) {
+    const prefix = args.resultPrefix || args['result-prefix'];
+    console.log(`${prefix}=${JSON.stringify(result)}`);
+  }
+}
+
+if (require.main === module) {
+  main().catch(error => {
+    console.error(JSON.stringify({ status: 'error', message: error.message }, null, 2));
+    process.exit(1);
+  });
+}
+
+module.exports = {
+  runVocIssuePool
+};

+ 59 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/tools/voc-problem-deep-dive-run.js

@@ -0,0 +1,59 @@
+#!/usr/bin/env node
+const { buildDeepDive } = require('../features/voc-problem-deep-dive/deep-dive');
+
+function parseArgs(argv) {
+  const args = {};
+  const setArg = (key, value) => {
+    args[key] = value;
+    if (key.includes('-')) {
+      args[key.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())] = value;
+    }
+  };
+  for (let i = 0; i < argv.length; i++) {
+    const token = argv[i];
+    if (!token.startsWith('--')) continue;
+    const eq = token.indexOf('=');
+    if (eq >= 0) {
+      setArg(token.slice(2, eq), token.slice(eq + 1));
+      continue;
+    }
+    const key = token.slice(2);
+    const next = argv[i + 1];
+    if (next && !next.startsWith('--')) {
+      setArg(key, next);
+      i++;
+    } else {
+      setArg(key, true);
+    }
+  }
+  return args;
+}
+
+async function runVocProblemDeepDive(input = {}) {
+  return buildDeepDive(input);
+}
+
+async function main() {
+  const args = parseArgs(process.argv.slice(2));
+  const result = await runVocProblemDeepDive(args);
+  if (args.assistantMessageOnly || args['assistant-message-only']) {
+    console.log(result.assistantMessage || JSON.stringify(result, null, 2));
+  } else {
+    console.log(JSON.stringify(result, null, 2));
+  }
+  if (!args.assistantMessageOnly && !args['assistant-message-only'] && (args.resultPrefix || args['result-prefix'])) {
+    const prefix = args.resultPrefix || args['result-prefix'];
+    console.log(`${prefix}=${JSON.stringify(result)}`);
+  }
+}
+
+if (require.main === module) {
+  main().catch(error => {
+    console.error(JSON.stringify({ status: 'error', message: error.message }, null, 2));
+    process.exit(1);
+  });
+}
+
+module.exports = {
+  runVocProblemDeepDive
+};

+ 59 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/tools/voc-speaking-script-run.js

@@ -0,0 +1,59 @@
+#!/usr/bin/env node
+const { buildVocSpeakingScript } = require('../features/voc-speaking-script/speaking-script');
+
+function parseArgs(argv) {
+  const args = {};
+  const setArg = (key, value) => {
+    args[key] = value;
+    if (key.includes('-')) {
+      args[key.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())] = value;
+    }
+  };
+  for (let i = 0; i < argv.length; i++) {
+    const token = argv[i];
+    if (!token.startsWith('--')) continue;
+    const eq = token.indexOf('=');
+    if (eq >= 0) {
+      setArg(token.slice(2, eq), token.slice(eq + 1));
+      continue;
+    }
+    const key = token.slice(2);
+    const next = argv[i + 1];
+    if (next && !next.startsWith('--')) {
+      setArg(key, next);
+      i++;
+    } else {
+      setArg(key, true);
+    }
+  }
+  return args;
+}
+
+async function runVocSpeakingScript(input = {}) {
+  return buildVocSpeakingScript(input);
+}
+
+async function main() {
+  const args = parseArgs(process.argv.slice(2));
+  const result = await runVocSpeakingScript(args);
+  if (args.assistantMessageOnly || args['assistant-message-only']) {
+    console.log(result.assistantMessage || JSON.stringify(result, null, 2));
+  } else {
+    console.log(JSON.stringify(result, null, 2));
+  }
+  if (!args.assistantMessageOnly && !args['assistant-message-only'] && (args.resultPrefix || args['result-prefix'])) {
+    const prefix = args.resultPrefix || args['result-prefix'];
+    console.log(`${prefix}=${JSON.stringify(result)}`);
+  }
+}
+
+if (require.main === module) {
+  main().catch(error => {
+    console.error(JSON.stringify({ status: 'error', message: error.message }, null, 2));
+    process.exit(1);
+  });
+}
+
+module.exports = {
+  runVocSpeakingScript
+};

+ 85 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/tools/xiaohongshu-preference-update.js

@@ -0,0 +1,85 @@
+#!/usr/bin/env node
+const { okResult, errorResult } = require('../core/result-envelope');
+const { updateTrendMemory } = require('../features/xiaohongshu-trend/preference-memory');
+
+function parseArgs(argv) {
+  const args = {};
+  for (let i = 0; i < argv.length; i++) {
+    const token = argv[i];
+    if (!token.startsWith('--')) continue;
+    const eq = token.indexOf('=');
+    if (eq >= 0) {
+      args[token.slice(2, eq)] = token.slice(eq + 1);
+      continue;
+    }
+    const key = token.slice(2);
+    const next = argv[i + 1];
+    if (next && !next.startsWith('--')) {
+      args[key] = next;
+      i++;
+    } else {
+      args[key] = true;
+    }
+  }
+  return args;
+}
+
+function splitList(value) {
+  if (!value) return [];
+  if (Array.isArray(value)) return value.map(String).map(item => item.trim()).filter(Boolean);
+  return String(value).split(/[,,、\n\r]+/).map(item => item.trim()).filter(Boolean);
+}
+
+async function updateXiaohongshuPreference(input = {}) {
+  const { memoryPath, memory } = updateTrendMemory({
+    ...input,
+    preferredDirections: splitList(input.preferredDirections || input['preferred-directions']),
+    blockedDirections: splitList(input.blockedDirections || input['blocked-directions']),
+    focusModes: splitList(input.focusModes || input['focus-modes'])
+  });
+
+  return okResult({
+    assistantMessage: [
+      '已保存本次校准反馈。',
+      '',
+      memory.preferredDirections.length ? `优先保留:${memory.preferredDirections.join('、')}` : '',
+      memory.blockedDirections.length ? `后续降权:${memory.blockedDirections.join('、')}` : '',
+      memory.focusModes.length ? `下一轮侧重:${memory.focusModes.join('、')}` : '',
+      '',
+      '我会把这次回答作为校准输入,不把第一轮样本直接当最终结论。下一轮会按这些反馈重新排序样本、收敛机会假设,并把话术标成待验证版本。'
+    ].filter(Boolean).join('\n'),
+    summary: {
+      preferredCount: memory.preferredDirections.length,
+      blockedCount: memory.blockedDirections.length,
+      focusCount: memory.focusModes.length
+    },
+    data: { memory },
+    files: [memoryPath],
+    nextActions: ['基于已保存反馈生成下一轮收敛判断', '继续补充一条真实客户原话或一个不准确的样本点']
+  });
+}
+
+async function main() {
+  const args = parseArgs(process.argv.slice(2));
+  try {
+    const result = await updateXiaohongshuPreference(args);
+    console.log(JSON.stringify(result, null, 2));
+    if (args.resultPrefix || args['result-prefix']) {
+      const prefix = args.resultPrefix || args['result-prefix'];
+      console.log(`${prefix}=${JSON.stringify(result)}`);
+    }
+    process.exit(0);
+  } catch (error) {
+    const result = errorResult(error.message || String(error));
+    console.log(JSON.stringify(result, null, 2));
+    process.exit(1);
+  }
+}
+
+if (require.main === module) {
+  main();
+}
+
+module.exports = {
+  updateXiaohongshuPreference
+};

+ 356 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/mcp/src/tools/xiaohongshu-trend-run.js

@@ -0,0 +1,356 @@
+#!/usr/bin/env node
+const fs = require('fs');
+const path = require('path');
+const { okResult, errorResult } = require('../core/result-envelope');
+const { readXiaohongshuToken } = 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 { buildTrendReport, writeReport } = require('../features/xiaohongshu-trend/report');
+const { trendMemoryPath, readTrendMemory } = require('../features/xiaohongshu-trend/preference-memory');
+const { buildNoSampleFallbackMessage } = require('../core/evidence-quality');
+const {
+  buildVocRechargeInfo,
+  buildMissingTokenMessage,
+  buildRechargeRequiredMessage
+} = require('../core/payment-links');
+
+function parseArgs(argv) {
+  const args = {};
+  const setArg = (key, value) => {
+    args[key] = value;
+    if (key.includes('-')) {
+      args[key.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())] = value;
+    }
+  };
+  for (let i = 0; i < argv.length; i++) {
+    const token = argv[i];
+    if (!token.startsWith('--')) continue;
+    const eq = token.indexOf('=');
+    if (eq >= 0) {
+      setArg(token.slice(2, eq), token.slice(eq + 1));
+      continue;
+    }
+    const key = token.slice(2);
+    const next = argv[i + 1];
+    if (next && !next.startsWith('--')) {
+      setArg(key, next);
+      i++;
+    } else {
+      setArg(key, true);
+    }
+  }
+  return args;
+}
+
+function splitList(value) {
+  if (!value) return [];
+  if (Array.isArray(value)) return value.map(String).map(item => item.trim()).filter(Boolean);
+  return String(value).split(/[,,、\n\r]+/).map(item => item.trim()).filter(Boolean);
+}
+
+function defaultProfile() {
+  return {
+    project: '行业趋势情报官',
+    industry: '通用行业',
+    businessType: '品牌方/门店/服务商/内容账号',
+    targetAudience: ['目标用户'],
+    keywords: ['行业关键词', '用户痛点', '产品体验', '价格', '口碑'],
+    trendQuestions: [
+      '用户最近在关注什么卖点?',
+      '哪些顾虑影响选择或购买?',
+      '什么内容表达最容易带来转化?'
+    ],
+    mustTrackSignals: ['价格', '口碑', '效果', '安全', '服务', '体验']
+  };
+}
+
+function readJsonIfExists(filePath) {
+  if (!filePath) return {};
+  const absolute = path.resolve(filePath);
+  if (!fs.existsSync(absolute)) return {};
+  return JSON.parse(fs.readFileSync(absolute, 'utf8'));
+}
+
+function findAuthError(errors = []) {
+  return errors.find(error => {
+    const message = String(error && error.message || '');
+    return Number(error && error.httpStatus) === 401 ||
+      /Invalid API token|无效的API令牌|invalid token/i.test(message);
+  });
+}
+
+function findGatewayError(errors = []) {
+  return errors.find(error => {
+    const status = Number(error && error.httpStatus);
+    const message = String(error && error.message || '');
+    return status === 402 ||
+      status === 403 ||
+      /社交平台接口请求错误|没有开通|余额不足|无权限|权限或余额|permission|quota|payment|insufficient|balance/i.test(message);
+  });
+}
+
+function loadProfile(args) {
+  const fromFile = readJsonIfExists(args.profile);
+  const defaults = defaultProfile();
+  return {
+    ...fromFile,
+    project: args.project || fromFile.project || defaults.project,
+    industry: args.industry || fromFile.industry || defaults.industry,
+    businessType: args.businessType || args['business-type'] || fromFile.businessType || defaults.businessType,
+    targetAudience: splitList(args.targetAudience || args['target-audience']).length
+      ? splitList(args.targetAudience || args['target-audience'])
+      : (fromFile.targetAudience || defaults.targetAudience),
+    keywords: splitList(args.keywords).length
+      ? splitList(args.keywords)
+      : (fromFile.keywords || defaults.keywords),
+    trendQuestions: splitList(args.trendQuestions || args['trend-questions']).length
+      ? splitList(args.trendQuestions || args['trend-questions'])
+      : (fromFile.trendQuestions || defaults.trendQuestions),
+    mustTrackSignals: splitList(args.mustTrackSignals || args['must-track-signals']).length
+      ? splitList(args.mustTrackSignals || args['must-track-signals'])
+      : (fromFile.mustTrackSignals || defaults.mustTrackSignals)
+  };
+}
+
+async function runXiaohongshuTrend(input = {}) {
+  const collectionMode = input.collectionMode || input['collection-mode'] || 'sample';
+  const outputDir = path.resolve(input.output || path.join('outputs', 'claude-code-xiaohongshu-trend', new Date().toISOString().slice(0, 10)));
+  const profile = loadProfile(input);
+  const memoryPath = trendMemoryPath(input);
+  const memory = readTrendMemory(memoryPath);
+  const warnings = [];
+  const errors = [];
+  const assetFiles = [];
+  let dataset;
+
+  if (collectionMode === 'sample') {
+    dataset = buildSampleDataset(profile);
+    warnings.push('P0 阶段使用 sample 演示数据,真实采集需要接入 VOC 服务。');
+  } else if (collectionMode === 'live') {
+    const token = readXiaohongshuToken(input);
+    if (!token) {
+      const recharge = await buildVocRechargeInfo();
+      return {
+        status: 'needs_token',
+        assistantMessage: buildMissingTokenMessage(recharge.paymentUrl, recharge),
+        summary: { requestedCollectionMode: collectionMode },
+        data: {
+          requestedCollectionMode: collectionMode,
+          recharge
+        },
+        files: [],
+        nextActions: [
+          `打开充值/开通链接:${recharge.paymentUrl}`,
+          '充值完成后配置 session token,再重新运行 live 模式',
+          '如果只是录制教学,可以先用 sample 模式演示完整流程'
+        ],
+        warnings: [],
+        errors: []
+      };
+    }
+
+    const liveDataset = await collectLiveDataset({
+      profile,
+      token,
+      options: {
+        keywordLimit: input.keywordLimit || input['keyword-limit'],
+        notesPerKeyword: input.notesPerKeyword || input['notes-per-keyword'],
+        maxCommentPages: input.maxCommentPages || input['max-comment-pages'],
+        sort: input.sort,
+        noteType: input.noteType || input['note-type'],
+        baseUrl: input.baseUrl || input['base-url'] || process.env.XIAOHONGSHU_API_BASE_URL
+      }
+    });
+    dataset = {
+      notes: liveDataset.notes,
+      comments: liveDataset.comments
+    };
+    warnings.push(...liveDataset.warnings);
+    errors.push(...liveDataset.errors);
+
+    const collectionIssue = findGatewayError([...errors, ...warnings]);
+    if (collectionIssue) {
+      const recharge = await buildVocRechargeInfo({ token });
+      return {
+        status: 'needs_recharge',
+        assistantMessage: buildRechargeRequiredMessage(recharge.paymentUrl, recharge),
+        summary: {
+          noteCount: dataset.notes.length,
+          commentCount: dataset.comments.length,
+          failedStage: collectionIssue.stage || '',
+          failedKeyword: collectionIssue.keyword || ''
+        },
+        data: {
+          profile,
+          recharge
+        },
+        files: [],
+        nextActions: [
+          `打开充值链接:${recharge.paymentUrl}`,
+          '支付完成后重新运行本次小红书 live 采集',
+          '如果需要先给客户演示,可以切换 sample 模式继续'
+        ],
+        warnings: [],
+        errors: []
+      };
+    }
+
+    if (!dataset.notes.length) {
+      const authError = findAuthError(errors);
+      if (authError) {
+        const recharge = await buildVocRechargeInfo({ token });
+        return {
+          status: 'needs_valid_token',
+          assistantMessage: [
+            '当前 VOC-AI 数据服务 token 没有通过校验,暂时不能进行真实小红书采集。',
+            '',
+            '请确认已经完成 VOC-AI 数据服务开通/充值,并重新配置服务方提供的 session token。',
+            '',
+            `充值/开通链接:${recharge.paymentUrl}`
+          ].join('\n'),
+          summary: {
+            noteCount: 0,
+            commentCount: dataset.comments.length,
+            errorCount: errors.length,
+            failedStage: authError.stage || 'search_notes',
+            failedKeyword: authError.keyword || ''
+          },
+          data: { profile, recharge },
+          files: [],
+          nextActions: [
+            `打开充值/开通链接:${recharge.paymentUrl}`,
+            '重新配置有效 session token 后重试 live 模式',
+            '需要录教学视频时,可先用 sample 模式演示完整多轮校准流程'
+          ],
+          warnings: [],
+          errors: []
+        };
+      }
+
+      const gatewayError = findGatewayError(errors);
+      if (gatewayError) {
+        const recharge = await buildVocRechargeInfo({ token });
+        return {
+          status: 'needs_recharge',
+          assistantMessage: buildRechargeRequiredMessage(recharge.paymentUrl, recharge),
+          summary: {
+            noteCount: 0,
+            commentCount: dataset.comments.length,
+            failedStage: gatewayError.stage || 'search_notes',
+            failedKeyword: gatewayError.keyword || ''
+          },
+          data: { profile, recharge },
+          files: [],
+          nextActions: [
+            `打开充值链接:${recharge.paymentUrl}`,
+            '支付完成后重新运行本次小红书 live 采集',
+            '如果需要先给客户演示,可以切换 sample 模式继续'
+          ],
+          warnings: [],
+          errors: []
+        };
+      }
+
+      return {
+        status: 'no_live_samples',
+        assistantMessage: buildNoSampleFallbackMessage({
+          platformLabel: '小红书',
+          profile,
+          examples: [
+            '把品牌/服务词换成用户场景词,例如“第一次装修怕踩坑”“父母礼物”“家庭故事”',
+            '加入城市/品类/人群,例如“南昌 拌粉 第一次来”“小户型 衣柜 避坑”',
+            '用相关大词做降级验证,例如从“老人自传”扩展到“父母礼物、家庭故事、人生故事”'
+          ]
+        }),
+        summary: {
+          noteCount: 0,
+          commentCount: dataset.comments.length,
+          errorCount: errors.length
+        },
+        data: { profile },
+        files: [],
+        nextActions: [
+          '换一组更贴近用户说法的关键词后重试 live 模式',
+          '扩大到相关大词做第一轮真实样本尝试',
+          '如果用于教学或远程演示,先切换 sample 模式,并明确标注非真实采集'
+        ],
+        warnings,
+        errors
+      };
+    }
+  } else {
+    return errorResult(`Unsupported collectionMode: ${collectionMode}`);
+  }
+
+  const cacheAssetsEnabled = input.cacheAssets !== false &&
+    input['cache-assets'] !== false &&
+    String(input.cacheAssets || input['cache-assets'] || '').toLowerCase() !== 'false';
+  if (cacheAssetsEnabled) {
+    const assetResult = await cacheEvidenceAssets({
+      notes: dataset.notes,
+      outputDir,
+      limit: input.assetLimit ?? input['asset-limit'] ?? 10
+    });
+    warnings.push(...assetResult.warnings);
+    assetFiles.push(...assetResult.files);
+  }
+
+  const report = buildTrendReport({ profile, dataset, collectionMode, memory });
+  const files = writeReport(outputDir, report);
+  const validationQuestions = report.data.validationQuestions || [];
+  const nextActions = validationQuestions.length
+    ? [
+      `先回答校准问题 1:${validationQuestions[0]}`,
+      validationQuestions[1] ? `再确认问题 2:${validationQuestions[1]}` : '',
+      '把本轮证据整理成 VOC 问题池,判断先改哪个问题。',
+      '也可以直接补充一条你最近听到的真实客户反馈,我会据此收敛下一轮判断。'
+    ].filter(Boolean)
+    : [
+      '先补充一条你最近听到的真实客户反馈',
+      '把本轮证据整理成 VOC 问题池,判断先改哪个问题。',
+      '再基于客户反馈收敛下一轮趋势判断'
+    ];
+
+  return okResult({
+    assistantMessage: report.assistantMessage,
+    summary: report.summary,
+    data: {
+      ...report.data,
+      memoryPath
+    },
+    files: [...files, ...assetFiles],
+    nextActions,
+    warnings,
+    errors
+  });
+}
+
+async function main() {
+  const args = parseArgs(process.argv.slice(2));
+  try {
+    const result = await runXiaohongshuTrend(args);
+    if (args.assistantMessageOnly || args['assistant-message-only']) {
+      console.log(result.assistantMessage || JSON.stringify(result, null, 2));
+    } else {
+      console.log(JSON.stringify(result, null, 2));
+    }
+    if (!args.assistantMessageOnly && !args['assistant-message-only'] && (args.resultPrefix || args['result-prefix'])) {
+      const prefix = args.resultPrefix || args['result-prefix'];
+      console.log(`${prefix}=${JSON.stringify(result)}`);
+    }
+    process.exitCode = result.status === 'ok' ? 0 : 1;
+  } catch (error) {
+    const result = errorResult(error.message || String(error));
+    console.log(JSON.stringify(result, null, 2));
+    process.exitCode = 1;
+  }
+}
+
+if (require.main === module) {
+  main();
+}
+
+module.exports = {
+  runXiaohongshuTrend
+};

+ 33 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/memory-templates/douyin-trend-profile.json

@@ -0,0 +1,33 @@
+{
+  "project": "抖音趋势情报官",
+  "industry": "通用行业",
+  "businessType": "品牌方/门店/服务商/内容账号",
+  "targetAudience": [
+    "目标用户"
+  ],
+  "platforms": [
+    "douyin"
+  ],
+  "keywords": [
+    "行业关键词",
+    "用户痛点",
+    "产品体验",
+    "价格",
+    "口碑"
+  ],
+  "trendQuestions": [
+    "用户最近在关注什么表达和卖点?",
+    "哪些顾虑影响点击、停留或评论?",
+    "什么内容开头最容易带来转化?"
+  ],
+  "mustTrackSignals": [
+    "价格",
+    "口碑",
+    "效果",
+    "安全",
+    "服务",
+    "体验"
+  ],
+  "blockedDirections": [],
+  "updatedAt": ""
+}

+ 33 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/memory-templates/xiaohongshu-trend-profile.json

@@ -0,0 +1,33 @@
+{
+  "project": "行业趋势情报官",
+  "industry": "通用行业",
+  "businessType": "品牌方/门店/服务商/内容账号",
+  "targetAudience": [
+    "目标用户"
+  ],
+  "platforms": [
+    "xiaohongshu"
+  ],
+  "keywords": [
+    "行业关键词",
+    "用户痛点",
+    "产品体验",
+    "价格",
+    "口碑"
+  ],
+  "trendQuestions": [
+    "用户最近在关注什么卖点?",
+    "哪些顾虑影响选择或购买?",
+    "什么内容表达最容易带来转化?"
+  ],
+  "mustTrackSignals": [
+    "价格",
+    "口碑",
+    "效果",
+    "安全",
+    "服务",
+    "体验"
+  ],
+  "blockedDirections": [],
+  "updatedAt": ""
+}

+ 1159 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/package-lock.json

@@ -0,0 +1,1159 @@
+{
+  "name": "@gangvy/claude-code-voc-intelligence",
+  "version": "0.3.10",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "@gangvy/claude-code-voc-intelligence",
+      "version": "0.3.10",
+      "dependencies": {
+        "@modelcontextprotocol/sdk": "^1.29.0",
+        "zod": "^4.4.3"
+      },
+      "bin": {
+        "claude-voc": "bin/claude-voc.js"
+      }
+    },
+    "node_modules/@hono/node-server": {
+      "version": "1.19.14",
+      "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
+      "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18.14.1"
+      },
+      "peerDependencies": {
+        "hono": "^4"
+      }
+    },
+    "node_modules/@modelcontextprotocol/sdk": {
+      "version": "1.29.0",
+      "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
+      "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@hono/node-server": "^1.19.9",
+        "ajv": "^8.17.1",
+        "ajv-formats": "^3.0.1",
+        "content-type": "^1.0.5",
+        "cors": "^2.8.5",
+        "cross-spawn": "^7.0.5",
+        "eventsource": "^3.0.2",
+        "eventsource-parser": "^3.0.0",
+        "express": "^5.2.1",
+        "express-rate-limit": "^8.2.1",
+        "hono": "^4.11.4",
+        "jose": "^6.1.3",
+        "json-schema-typed": "^8.0.2",
+        "pkce-challenge": "^5.0.0",
+        "raw-body": "^3.0.0",
+        "zod": "^3.25 || ^4.0",
+        "zod-to-json-schema": "^3.25.1"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "@cfworker/json-schema": "^4.1.1",
+        "zod": "^3.25 || ^4.0"
+      },
+      "peerDependenciesMeta": {
+        "@cfworker/json-schema": {
+          "optional": true
+        },
+        "zod": {
+          "optional": false
+        }
+      }
+    },
+    "node_modules/accepts": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+      "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "^3.0.0",
+        "negotiator": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/ajv": {
+      "version": "8.20.0",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+      "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+      "license": "MIT",
+      "dependencies": {
+        "fast-deep-equal": "^3.1.3",
+        "fast-uri": "^3.0.1",
+        "json-schema-traverse": "^1.0.0",
+        "require-from-string": "^2.0.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
+    "node_modules/ajv-formats": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+      "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+      "license": "MIT",
+      "dependencies": {
+        "ajv": "^8.0.0"
+      },
+      "peerDependencies": {
+        "ajv": "^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "ajv": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/body-parser": {
+      "version": "2.2.2",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
+      "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "^3.1.2",
+        "content-type": "^1.0.5",
+        "debug": "^4.4.3",
+        "http-errors": "^2.0.0",
+        "iconv-lite": "^0.7.0",
+        "on-finished": "^2.4.1",
+        "qs": "^6.14.1",
+        "raw-body": "^3.0.1",
+        "type-is": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/content-disposition": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
+      "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+      "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.6.0"
+      }
+    },
+    "node_modules/cors": {
+      "version": "2.8.6",
+      "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
+      "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
+      "license": "MIT",
+      "dependencies": {
+        "object-assign": "^4",
+        "vary": "^1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/cross-spawn": {
+      "version": "7.0.6",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+      "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+      "license": "MIT",
+      "dependencies": {
+        "path-key": "^3.1.0",
+        "shebang-command": "^2.0.0",
+        "which": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "license": "MIT"
+    },
+    "node_modules/encodeurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/eventsource": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
+      "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
+      "license": "MIT",
+      "dependencies": {
+        "eventsource-parser": "^3.0.1"
+      },
+      "engines": {
+        "node": ">=18.0.0"
+      }
+    },
+    "node_modules/eventsource-parser": {
+      "version": "3.0.8",
+      "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz",
+      "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18.0.0"
+      }
+    },
+    "node_modules/express": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+      "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "^2.0.0",
+        "body-parser": "^2.2.1",
+        "content-disposition": "^1.0.0",
+        "content-type": "^1.0.5",
+        "cookie": "^0.7.1",
+        "cookie-signature": "^1.2.1",
+        "debug": "^4.4.0",
+        "depd": "^2.0.0",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "etag": "^1.8.1",
+        "finalhandler": "^2.1.0",
+        "fresh": "^2.0.0",
+        "http-errors": "^2.0.0",
+        "merge-descriptors": "^2.0.0",
+        "mime-types": "^3.0.0",
+        "on-finished": "^2.4.1",
+        "once": "^1.4.0",
+        "parseurl": "^1.3.3",
+        "proxy-addr": "^2.0.7",
+        "qs": "^6.14.0",
+        "range-parser": "^1.2.1",
+        "router": "^2.2.0",
+        "send": "^1.1.0",
+        "serve-static": "^2.2.0",
+        "statuses": "^2.0.1",
+        "type-is": "^2.0.1",
+        "vary": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/express-rate-limit": {
+      "version": "8.5.2",
+      "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
+      "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
+      "license": "MIT",
+      "dependencies": {
+        "ip-address": "^10.2.0"
+      },
+      "engines": {
+        "node": ">= 16"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/express-rate-limit"
+      },
+      "peerDependencies": {
+        "express": ">= 4.11"
+      }
+    },
+    "node_modules/fast-deep-equal": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+      "license": "MIT"
+    },
+    "node_modules/fast-uri": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
+      "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ],
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/finalhandler": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+      "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.0",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "on-finished": "^2.4.1",
+        "parseurl": "^1.3.3",
+        "statuses": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 18.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+      "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+      "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/hono": {
+      "version": "4.12.21",
+      "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.21.tgz",
+      "integrity": "sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=16.9.0"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~2.0.0",
+        "inherits": "~2.0.4",
+        "setprototypeof": "~1.2.0",
+        "statuses": "~2.0.2",
+        "toidentifier": "~1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+      "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/ip-address": {
+      "version": "10.2.0",
+      "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
+      "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 12"
+      }
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/is-promise": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+      "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+      "license": "MIT"
+    },
+    "node_modules/isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+      "license": "ISC"
+    },
+    "node_modules/jose": {
+      "version": "6.2.3",
+      "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
+      "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/panva"
+      }
+    },
+    "node_modules/json-schema-traverse": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+      "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+      "license": "MIT"
+    },
+    "node_modules/json-schema-typed": {
+      "version": "8.0.2",
+      "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
+      "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
+      "license": "BSD-2-Clause"
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+      "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+      "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.54.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+      "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+      "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "^1.54.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/negotiator": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+      "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "license": "MIT",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+      "license": "ISC",
+      "dependencies": {
+        "wrappy": "1"
+      }
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-key": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "8.4.2",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+      "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/pkce-challenge": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
+      "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+      "license": "MIT",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.15.2",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
+      "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "side-channel": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/range-parser": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+      "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.7.0",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/require-from-string": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/router": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+      "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.0",
+        "depd": "^2.0.0",
+        "is-promise": "^4.0.0",
+        "parseurl": "^1.3.3",
+        "path-to-regexp": "^8.0.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      }
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/send": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+      "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.3",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "etag": "^1.8.1",
+        "fresh": "^2.0.0",
+        "http-errors": "^2.0.1",
+        "mime-types": "^3.0.2",
+        "ms": "^2.1.3",
+        "on-finished": "^2.4.1",
+        "range-parser": "^1.2.1",
+        "statuses": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/serve-static": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+      "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "parseurl": "^1.3.3",
+        "send": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "license": "ISC"
+    },
+    "node_modules/shebang-command": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+      "license": "MIT",
+      "dependencies": {
+        "shebang-regex": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/shebang-regex": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+      "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3",
+        "side-channel-list": "^1.0.0",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+      "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/statuses": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/type-is": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
+      "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
+      "license": "MIT",
+      "dependencies": {
+        "content-type": "^2.0.0",
+        "media-typer": "^1.1.0",
+        "mime-types": "^3.0.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/type-is/node_modules/content-type": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+      "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/which": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+      "license": "ISC",
+      "dependencies": {
+        "isexe": "^2.0.0"
+      },
+      "bin": {
+        "node-which": "bin/node-which"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+      "license": "ISC"
+    },
+    "node_modules/zod": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+      "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/colinhacks"
+      }
+    },
+    "node_modules/zod-to-json-schema": {
+      "version": "3.25.2",
+      "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
+      "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
+      "license": "ISC",
+      "peerDependencies": {
+        "zod": "^3.25.28 || ^4"
+      }
+    }
+  }
+}

+ 65 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/package.json

@@ -0,0 +1,65 @@
+{
+  "name": "@gangvy/claude-code-voc-intelligence",
+  "version": "0.3.10",
+  "description": "Claude Code VOC intelligence skills for Xiaohongshu and Douyin trend reports, live social VOC collection, single-issue deep dives, boss/operator action plans, and multi-turn memory.",
+  "type": "commonjs",
+  "bin": {
+    "claude-voc": "bin/claude-voc.js"
+  },
+  "scripts": {
+    "install:check": "node install.js --check",
+    "install:smoke": "node install.js --smoke",
+    "smoke:package": "node scripts/smoke-package.js",
+    "smoke:cross-industry": "node scripts/smoke-cross-industry.js",
+    "xhs:sample": "node mcp/src/tools/xiaohongshu-trend-run.js --collection-mode sample --profile memory-templates/xiaohongshu-trend-profile.json --output ../outputs/claude-code-xhs-sample --result-prefix XHS_TREND_RESULT",
+    "xhs:preference-smoke": "node mcp/src/tools/xiaohongshu-preference-update.js --message \"保留奶油风和全屋定制翻车方向,不要纯风格美图,下一个版本更偏门店转化话术。\" --memory ../outputs/claude-code-xhs-memory-smoke/xiaohongshu-trend-memory.json --result-prefix XHS_PREF_RESULT",
+    "xhs:sample-with-memory": "node mcp/src/tools/xiaohongshu-trend-run.js --collection-mode sample --profile memory-templates/xiaohongshu-trend-profile.json --memory ../outputs/claude-code-xhs-memory-smoke/xiaohongshu-trend-memory.json --output ../outputs/claude-code-xhs-memory-smoke/report --result-prefix XHS_TREND_RESULT",
+    "xhs:live": "node mcp/src/tools/xiaohongshu-trend-run.js --collection-mode live --profile memory-templates/xiaohongshu-trend-profile.json --output ../outputs/claude-code-xhs-live --keyword-limit 1 --notes-per-keyword 1 --max-comment-pages 1 --result-prefix XHS_TREND_RESULT",
+    "dy:sample": "node mcp/src/tools/douyin-trend-run.js --collection-mode sample --profile memory-templates/douyin-trend-profile.json --output ../outputs/claude-code-douyin-sample --result-prefix DOUYIN_TREND_RESULT",
+    "dy:preference-smoke": "node mcp/src/tools/douyin-preference-update.js --message \"保留口播开头和评论区问题,不要只做纯素材剪辑,下一个版本更偏门店转化话术。\" --memory ../outputs/claude-code-douyin-memory-smoke/douyin-trend-memory.json --result-prefix DOUYIN_PREF_RESULT",
+    "dy:sample-with-memory": "node mcp/src/tools/douyin-trend-run.js --collection-mode sample --profile memory-templates/douyin-trend-profile.json --memory ../outputs/claude-code-douyin-memory-smoke/douyin-trend-memory.json --output ../outputs/claude-code-douyin-memory-smoke/report --result-prefix DOUYIN_TREND_RESULT",
+    "dy:live": "node mcp/src/tools/douyin-trend-run.js --collection-mode live --profile memory-templates/douyin-trend-profile.json --output ../outputs/claude-code-douyin-live --keyword-limit 1 --videos-per-keyword 1 --max-comment-pages 1 --result-prefix DOUYIN_TREND_RESULT",
+    "voc:issue-pool": "node mcp/src/tools/voc-issue-pool-run.js --project 样板门店A --industry 本地生活餐饮 --scenario 门店经营 --audience 到店顾客 --evidence \"排队久,但是不知道值不值得等。;人均有点高,第一次来不知道怎么点不踩雷。;服务忙起来没人理。\" --assistant-message-only",
+    "voc:deep-dive": "node mcp/src/tools/voc-problem-deep-dive-run.js --issue 排队 --industry 本地生活餐饮 --scenario 门店经营 --audience 到店顾客 --assistant-message-only",
+    "voc:content-plan": "node mcp/src/tools/voc-content-plan-run.js --brand 样板门店A --industry 本地生活餐饮 --issues \"第一次来怕踩雷;觉得人均贵;怕排队久\" --assistant-message-only",
+    "voc:speaking-script": "node mcp/src/tools/voc-speaking-script-run.js --brand 样板门店A --industry 本地生活餐饮 --topic \"第一次来怎么点不踩雷\" --user-issue \"第一次来怕点错\" --evidence \"第一次来不知道怎么点,怕踩雷\" --assistant-message-only",
+    "voc:competitor-map": "node mcp/src/tools/voc-competitor-map-run.js --brand 样板门店A --city 南昌 --category 餐饮 --assistant-message-only",
+    "voc:business-workflow": "node mcp/src/tools/voc-business-workflow-run.js --brand 样板门店A --industry 本地生活餐饮 --platform douyin --collection-mode sample --keywords \"南昌菜,第一次来怎么点,排队\" --assistant-message-only",
+    "mcp:start": "node mcp/src/server.js",
+    "mcp:smoke": "node scripts/smoke-mcp.js"
+  },
+  "files": [
+    ".claude-plugin/",
+    ".mcp.json",
+    "bin/",
+    "docs/",
+    "install.js",
+    "mcp/",
+    "memory-templates/",
+    "package.json",
+    "README.md",
+    "scripts/",
+    "skill-package-manifest.json",
+    "skills/"
+  ],
+  "publishConfig": {
+    "access": "public"
+  },
+  "keywords": [
+    "claude-code",
+    "voc",
+    "voice-of-customer",
+    "xiaohongshu",
+    "douyin",
+    "trend-intelligence",
+    "customer-insights",
+    "mcp",
+    "openclaw",
+    "social-listening"
+  ],
+  "homepage": "https://www.npmjs.com/package/@gangvy/claude-code-voc-intelligence",
+  "dependencies": {
+    "@modelcontextprotocol/sdk": "^1.29.0",
+    "zod": "^4.4.3"
+  }
+}

+ 126 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/scripts/smoke-cross-industry.js

@@ -0,0 +1,126 @@
+#!/usr/bin/env node
+const fs = require('fs');
+const path = require('path');
+const { runXiaohongshuTrend } = require('../mcp/src/tools/xiaohongshu-trend-run');
+
+const LEAK_TERMS = [
+  '家装',
+  '全屋定制',
+  '奶油风',
+  '开放格',
+  '厨房橱柜',
+  '衣柜',
+  '小户型'
+];
+
+const CASES = [
+  {
+    name: 'pet-food',
+    profile: {
+      project: '行业趋势情报官',
+      industry: '宠物食品',
+      businessType: '品牌方/电商/门店',
+      targetAudience: ['养宠用户', '第一次养猫养狗的新手'],
+      keywords: ['猫粮', '狗粮', '配料表', '试吃', '复购'],
+      trendQuestions: ['用户最近在意哪些成分和适口性?', '什么话术最容易带来复购?', '哪些风险点会影响下单?'],
+      mustTrackSignals: ['成分', '适口性', '复购', '价格', '口碑']
+    }
+  },
+  {
+    name: 'beauty-skincare',
+    profile: {
+      project: '行业趋势情报官',
+      industry: '美妆护肤',
+      businessType: '品牌方/测评账号/门店',
+      targetAudience: ['护肤消费者', '敏感肌用户'],
+      keywords: ['面霜', '防晒', '粉底液', '成分党', '测评'],
+      trendQuestions: ['哪些护肤成分被反复讨论?', '用户最担心哪些翻车点?', '什么测评角度更容易转化?'],
+      mustTrackSignals: ['成分', '肤感', '持妆', '价格', '复购']
+    }
+  },
+  {
+    name: 'baby-maternal',
+    profile: {
+      project: '行业趋势情报官',
+      industry: '母婴用品',
+      businessType: '品牌方/门店/内容账号',
+      targetAudience: ['宝妈', '新生儿家庭'],
+      keywords: ['纸尿裤', '辅食', '奶瓶', '婴儿湿巾', '新手妈妈'],
+      trendQuestions: ['家长更在意什么安全和使用细节?', '哪些场景会触发购买?', '什么内容最能降低顾虑?'],
+      mustTrackSignals: ['安全', '舒适', '分龄', '口碑', '售后']
+    }
+  },
+  {
+    name: 'education-training',
+    profile: {
+      project: '行业趋势情报官',
+      industry: '教育培训',
+      businessType: '机构/老师/内容账号',
+      targetAudience: ['学生家长', '学习者'],
+      keywords: ['课程', '试听', '提分', '学习方法', '规划'],
+      trendQuestions: ['家长更担心哪些效果问题?', '什么样的课程表达更容易被信任?', '用户最在意的决策阻力是什么?'],
+      mustTrackSignals: ['效果', '师资', '时间', '价格', '退费']
+    }
+  },
+  {
+    name: 'local-dining',
+    profile: {
+      project: '行业趋势情报官',
+      industry: '本地生活餐饮',
+      businessType: '门店/团购/探店账号',
+      targetAudience: ['到店消费者', '团购用户'],
+      keywords: ['探店', '套餐', '人均', '排队', '氛围'],
+      trendQuestions: ['用户更在意口味还是性价比?', '什么门店体验会影响复购?', '哪些负面点最容易被评论放大?'],
+      mustTrackSignals: ['口味', '服务', '排队', '环境', '复购']
+    }
+  }
+];
+
+async function smokeCrossIndustry({ root = path.resolve(__dirname, '..') } = {}) {
+  const outputRoot = path.join(root, 'outputs', 'claude-code-cross-industry-smoke');
+  fs.mkdirSync(outputRoot, { recursive: true });
+  const results = [];
+
+  for (const item of CASES) {
+    const result = await runXiaohongshuTrend({
+      collectionMode: 'sample',
+      output: path.join(outputRoot, item.name),
+      cacheAssets: false,
+      ...item.profile
+    });
+    if (result.status !== 'ok') {
+      throw new Error(`${item.name} sample failed: ${result.status}`);
+    }
+    const body = String(result.assistantMessage || '');
+    const leakHits = LEAK_TERMS.filter(term => body.includes(term));
+    if (leakHits.length) {
+      throw new Error(`${item.name} leaked house-specific terms: ${leakHits.join(', ')}`);
+    }
+    results.push({
+      industry: item.profile.industry,
+      preset: result.summary && result.summary.industryPreset,
+      preview: body.split('\n').slice(0, 5).join('\n')
+    });
+  }
+
+  return {
+    status: 'ok',
+    cases: results
+  };
+}
+
+async function main() {
+  const result = await smokeCrossIndustry();
+  console.log(JSON.stringify(result, null, 2));
+}
+
+if (require.main === module) {
+  main().catch(error => {
+    console.error(JSON.stringify({ status: 'error', message: error.message }, null, 2));
+    process.exit(1);
+  });
+}
+
+module.exports = {
+  smokeCrossIndustry
+};

+ 272 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/scripts/smoke-mcp.js

@@ -0,0 +1,272 @@
+#!/usr/bin/env node
+const path = require('path');
+const { Client } = require('@modelcontextprotocol/sdk/client/index.js');
+const { StdioClientTransport } = require('@modelcontextprotocol/sdk/client/stdio.js');
+const { version } = require('../package.json');
+
+async function main() {
+  const cwd = path.resolve(__dirname, '..');
+  const client = new Client({
+    name: 'voc-intelligence-smoke',
+    version
+  });
+
+  const transport = new StdioClientTransport({
+    command: process.execPath,
+    args: ['mcp/src/server.js'],
+    cwd,
+    stderr: 'pipe'
+  });
+
+  const stderr = transport.stderr;
+  if (stderr) {
+    stderr.on('data', chunk => {
+      process.stderr.write(chunk);
+    });
+  }
+
+  await client.connect(transport);
+  try {
+    const tools = await client.listTools();
+    const toolNames = tools.tools.map(tool => tool.name);
+    const trendTool = tools.tools.find(tool => tool.name === 'voc_xiaohongshu_trend_run');
+    const trendSchema = JSON.stringify(trendTool?.inputSchema || {});
+    if (!trendTool || !trendSchema.includes('xiaohongshuToken') || !trendSchema.includes('vocToken')) {
+      throw new Error('Expected voc_xiaohongshu_trend_run to accept simple request token inputs xiaohongshuToken and vocToken');
+    }
+    if (!toolNames.includes('voc_xiaohongshu_trend_run')) {
+      throw new Error(`Expected tool voc_xiaohongshu_trend_run, got: ${toolNames.join(', ')}`);
+    }
+    if (!toolNames.includes('voc_xiaohongshu_preference_update')) {
+      throw new Error(`Expected tool voc_xiaohongshu_preference_update, got: ${toolNames.join(', ')}`);
+    }
+    if (!toolNames.includes('voc_problem_deep_dive_run')) {
+      throw new Error(`Expected tool voc_problem_deep_dive_run, got: ${toolNames.join(', ')}`);
+    }
+    if (!toolNames.includes('voc_issue_pool_run')) {
+      throw new Error(`Expected tool voc_issue_pool_run, got: ${toolNames.join(', ')}`);
+    }
+    if (!toolNames.includes('voc_content_plan_run')) {
+      throw new Error(`Expected tool voc_content_plan_run, got: ${toolNames.join(', ')}`);
+    }
+    if (!toolNames.includes('voc_speaking_script_run')) {
+      throw new Error(`Expected tool voc_speaking_script_run, got: ${toolNames.join(', ')}`);
+    }
+    if (!toolNames.includes('voc_competitor_map_run')) {
+      throw new Error(`Expected tool voc_competitor_map_run, got: ${toolNames.join(', ')}`);
+    }
+    if (!toolNames.includes('voc_business_workflow_run')) {
+      throw new Error(`Expected tool voc_business_workflow_run, got: ${toolNames.join(', ')}`);
+    }
+    const issuePoolTool = tools.tools.find(tool => tool.name === 'voc_issue_pool_run');
+    const issuePoolSchema = JSON.stringify(issuePoolTool?.inputSchema || {});
+    if (!issuePoolSchema.includes('evidence') || !issuePoolSchema.includes('resolvedIssues')) {
+      throw new Error('Expected voc_issue_pool_run to expose evidence and status inputs');
+    }
+    const deepDiveTool = tools.tools.find(tool => tool.name === 'voc_problem_deep_dive_run');
+    const deepDiveSchema = JSON.stringify(deepDiveTool?.inputSchema || {});
+    if (!deepDiveSchema.includes('project') || !deepDiveSchema.includes('feedback') || !deepDiveSchema.includes('blockedActions')) {
+      throw new Error('Expected voc_problem_deep_dive_run to expose scoped memory and feedback inputs');
+    }
+
+    const preferenceResult = await client.callTool({
+      name: 'voc_xiaohongshu_preference_update',
+      arguments: {
+        message: '保留奶油风和全屋定制翻车方向,不要纯风格美图,下一个版本更偏门店转化话术。',
+        memory: '../outputs/claude-code-xhs-mcp-smoke/xiaohongshu-trend-memory.json'
+      }
+    });
+
+    const preferenceStructured = preferenceResult.structuredContent || {};
+    if (preferenceStructured.status !== 'ok') {
+      throw new Error(`Expected ok preference result, got: ${JSON.stringify(preferenceStructured)}`);
+    }
+
+    const sentinelToken = 'SMOKE_REQUEST_TOKEN_VALUE';
+    const tokenCheckResult = await client.callTool({
+      name: 'voc_xiaohongshu_token_check',
+      arguments: {
+        vocToken: sentinelToken
+      }
+    });
+    const tokenStructured = tokenCheckResult.structuredContent || {};
+    const tokenText = JSON.stringify(tokenStructured);
+    if (tokenStructured.status !== 'ok' || tokenStructured.configured !== true) {
+      throw new Error(`Expected request-scoped token to be accepted, got: ${tokenText}`);
+    }
+    if (tokenText.includes(sentinelToken)) {
+      throw new Error('Request-scoped token leaked in MCP token check result');
+    }
+
+    const result = await client.callTool({
+      name: 'voc_xiaohongshu_trend_run',
+      arguments: {
+        collectionMode: 'sample',
+        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'
+      }
+    });
+
+    const structured = result.structuredContent || {};
+    if (structured.status !== 'ok') {
+      throw new Error(`Expected ok result, got: ${JSON.stringify(structured)}`);
+    }
+
+    const assistantMessage = String(structured.assistantMessage || '');
+    const evidenceFields = ['## 证据样本', '原帖', '原文摘录', '高赞评论'];
+    const missingEvidenceFields = evidenceFields.filter(field => !assistantMessage.includes(field));
+    if (missingEvidenceFields.length) {
+      throw new Error(`Expected evidence card fields in sample report, missing: ${missingEvidenceFields.join(', ')}`);
+    }
+    if (/xiaohongshu\.com\/explore\//.test(assistantMessage)) {
+      throw new Error('Sample report should not contain /explore/ Xiaohongshu links');
+    }
+    const assetFiles = (structured.files || []).filter(file => /[\\/]assets[\\/]/.test(file));
+    if (!assetFiles.length) {
+      throw new Error('Expected cached asset files in sample report result');
+    }
+
+    const issuePoolResult = await client.callTool({
+      name: 'voc_issue_pool_run',
+      arguments: {
+        project: 'MCP样板门店',
+        industry: '本地生活餐饮',
+        scenario: '门店经营',
+        audience: '到店顾客',
+        evidence: [
+          '排队久,但是不知道值不值得等。',
+          '人均有点高,第一次来不知道怎么点不踩雷。',
+          '服务忙起来没人理。'
+        ]
+      }
+    });
+    const issuePoolStructured = issuePoolResult.structuredContent || {};
+    if (issuePoolStructured.status !== 'ok' ||
+      !String(issuePoolStructured.assistantMessage || '').includes('## 问题优先级') ||
+      !issuePoolStructured.data?.issues?.length) {
+      throw new Error(`Expected issue pool result, got: ${JSON.stringify(issuePoolStructured)}`);
+    }
+
+    const deepDiveResult = await client.callTool({
+      name: 'voc_problem_deep_dive_run',
+      arguments: {
+        issue: '排队',
+        project: 'MCP样板门店',
+        industry: '本地生活餐饮',
+        scenario: '门店经营',
+        audience: '到店顾客',
+        evidence: ['排队久,但是不知道值不值得等。'],
+        feedback: '老板不想做等位饮品,更想先改评论区回复和预点单'
+      }
+    });
+    const deepDiveStructured = deepDiveResult.structuredContent || {};
+    const deepDiveMessage = String(deepDiveStructured.assistantMessage || '');
+    if (deepDiveStructured.status !== 'ok' ||
+      !deepDiveMessage.includes('## 可执行解决动作') ||
+      !deepDiveMessage.includes('## 优先级判断') ||
+      !deepDiveStructured.summary?.memoryPath) {
+      throw new Error(`Expected boss-oriented deep dive result, got: ${JSON.stringify(deepDiveStructured)}`);
+    }
+    if (!deepDiveStructured.data?.memory?.blockedActions?.includes('等位饮品') ||
+      !deepDiveStructured.data?.memory?.preferredActions?.includes('预点单')) {
+      throw new Error('Expected deep dive MCP call to parse feedback into scoped memory');
+    }
+
+    const contentPlanResult = await client.callTool({
+      name: 'voc_content_plan_run',
+      arguments: {
+        brand: 'MCP样板门店',
+        industry: '本地生活餐饮',
+        issues: ['第一次来怕踩雷', '觉得人均贵', '怕排队久']
+      }
+    });
+    const contentPlanStructured = contentPlanResult.structuredContent || {};
+    const contentPlanMessage = String(contentPlanStructured.assistantMessage || '');
+    if (contentPlanStructured.status !== 'ok' ||
+      !contentPlanMessage.includes('7 天 VOC 内容选题') ||
+      !contentPlanMessage.includes('前 3 秒钩子') ||
+      !contentPlanStructured.data?.plan?.length) {
+      throw new Error(`Expected VOC content plan result, got: ${JSON.stringify(contentPlanStructured)}`);
+    }
+
+    const speakingScriptResult = await client.callTool({
+      name: 'voc_speaking_script_run',
+      arguments: {
+        brand: 'MCP样板门店',
+        industry: '本地生活餐饮',
+        topic: '第一次来怎么点不踩雷',
+        userIssue: '第一次来怕点错',
+        evidence: ['第一次来不知道怎么点,怕踩雷'],
+        feedback: '开头更狠一点,老板视角,少讲概念,多给具体场景',
+        memory: '../outputs/claude-code-xhs-mcp-smoke/voc-speaking-script-memory.json',
+        finalize: true
+      }
+    });
+    const speakingScriptStructured = speakingScriptResult.structuredContent || {};
+    const speakingScriptMessage = String(speakingScriptStructured.assistantMessage || '');
+    if (speakingScriptStructured.status !== 'ok' ||
+      !speakingScriptMessage.includes('VOC 口播脚本共创') ||
+      !speakingScriptMessage.includes('60 秒口播稿') ||
+      !speakingScriptMessage.includes('30 秒压缩版') ||
+      !speakingScriptStructured.data?.memory?.finalizedScripts?.length ||
+      !speakingScriptStructured.data?.memory?.preferredStyles?.includes('老板视角')) {
+      throw new Error(`Expected VOC speaking script result, got: ${JSON.stringify(speakingScriptStructured)}`);
+    }
+
+    const competitorMapResult = await client.callTool({
+      name: 'voc_competitor_map_run',
+      arguments: {
+        brand: 'MCP样板门店',
+        city: '南昌',
+        category: '餐饮'
+      }
+    });
+    const competitorMapStructured = competitorMapResult.structuredContent || {};
+    const competitorMapMessage = String(competitorMapStructured.assistantMessage || '');
+    if (competitorMapStructured.status !== 'ok' ||
+      !competitorMapMessage.includes('竞品图谱') ||
+      !competitorMapMessage.includes('错位竞争') ||
+      !competitorMapStructured.data?.competitors?.length) {
+      throw new Error(`Expected VOC competitor map result, got: ${JSON.stringify(competitorMapStructured)}`);
+    }
+
+    const businessWorkflowResult = await client.callTool({
+      name: 'voc_business_workflow_run',
+      arguments: {
+        brand: 'MCP样板门店',
+        industry: '本地生活餐饮',
+        platform: 'douyin',
+        collectionMode: 'sample',
+        keywords: ['南昌菜', '第一次来怎么点', '排队']
+      }
+    });
+    const businessWorkflowStructured = businessWorkflowResult.structuredContent || {};
+    const businessWorkflowMessage = String(businessWorkflowStructured.assistantMessage || '');
+    if (businessWorkflowStructured.status !== 'ok' ||
+      !businessWorkflowMessage.includes('VOC经营闭环') ||
+      businessWorkflowStructured.summary?.workflowStage !== 'business_workflow_first_round') {
+      throw new Error(`Expected VOC business workflow result, got: ${JSON.stringify(businessWorkflowStructured)}`);
+    }
+
+    console.log(JSON.stringify({
+      status: 'ok',
+      tools: toolNames,
+      assistantMessagePreview: assistantMessage.slice(0, 120),
+      deepDivePreview: deepDiveMessage.slice(0, 120),
+      contentPlanPreview: contentPlanMessage.slice(0, 120),
+      speakingScriptPreview: speakingScriptMessage.slice(0, 120),
+      competitorMapPreview: competitorMapMessage.slice(0, 120),
+      businessWorkflowPreview: businessWorkflowMessage.slice(0, 120),
+      preferenceMemoryApplied: Boolean(structured.summary && structured.summary.preferenceMemoryApplied),
+      files: structured.files || []
+    }, null, 2));
+  } finally {
+    await client.close();
+  }
+}
+
+main().catch(error => {
+  console.error(error);
+  process.exit(1);
+});

+ 739 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/scripts/smoke-package.js

@@ -0,0 +1,739 @@
+#!/usr/bin/env node
+const path = require('path');
+const os = require('os');
+const fs = require('fs');
+const { runXiaohongshuTrend } = require('../mcp/src/tools/xiaohongshu-trend-run');
+const { updateXiaohongshuPreference } = require('../mcp/src/tools/xiaohongshu-preference-update');
+const { normalizeNote } = require('../mcp/src/features/xiaohongshu-trend/live-collector');
+const { XiaohongshuApi } = require('../mcp/src/providers/xiaohongshu-api');
+const { runDouyinTrend } = require('../mcp/src/tools/douyin-trend-run');
+const { updateDouyinPreference } = require('../mcp/src/tools/douyin-preference-update');
+const { runVocIssuePool } = require('../mcp/src/tools/voc-issue-pool-run');
+const { runVocProblemDeepDive } = require('../mcp/src/tools/voc-problem-deep-dive-run');
+const { runVocContentPlan } = require('../mcp/src/tools/voc-content-plan-run');
+const { runVocSpeakingScript } = require('../mcp/src/tools/voc-speaking-script-run');
+const { runVocCompetitorMap } = require('../mcp/src/tools/voc-competitor-map-run');
+const { runBusinessWorkflow } = require('../mcp/src/tools/voc-business-workflow-run');
+const { normalizeVideo } = require('../mcp/src/features/douyin-trend/live-collector');
+const { DouyinApi } = require('../mcp/src/providers/douyin-api');
+const { readVocToken } = require('../mcp/src/core/credentials');
+const { smokeCrossIndustry } = require('./smoke-cross-industry');
+const { Client } = require('@modelcontextprotocol/sdk/client/index.js');
+const { StdioClientTransport } = require('@modelcontextprotocol/sdk/client/stdio.js');
+const { version } = require('../package.json');
+
+const ROOT = path.resolve(__dirname, '..');
+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');
+
+async function smokeMcpTools() {
+  const client = new Client({
+    name: 'voc-intelligence-package-smoke',
+    version
+  });
+  const transport = new StdioClientTransport({
+    command: process.execPath,
+    args: ['mcp/src/server.js'],
+    cwd: ROOT,
+    stderr: 'pipe'
+  });
+  await client.connect(transport);
+  try {
+    const tools = await client.listTools();
+    const toolNames = tools.tools.map(tool => tool.name);
+
+    const xhsTrendTool = tools.tools.find(tool => tool.name === 'voc_xiaohongshu_trend_run');
+    const xhsTrendSchema = JSON.stringify(xhsTrendTool?.inputSchema || {});
+    if (!xhsTrendTool || !xhsTrendSchema.includes('xiaohongshuToken') || !xhsTrendSchema.includes('vocToken')) {
+      throw new Error('Expected voc_xiaohongshu_trend_run to accept simple request token inputs xiaohongshuToken and vocToken');
+    }
+
+    const douyinTrendTool = tools.tools.find(tool => tool.name === 'voc_douyin_trend_run');
+    const douyinTrendSchema = JSON.stringify(douyinTrendTool?.inputSchema || {});
+    if (!douyinTrendTool || !douyinTrendSchema.includes('douyinToken') || !douyinTrendSchema.includes('vocToken')) {
+      throw new Error('Expected voc_douyin_trend_run to accept simple request token inputs douyinToken and vocToken');
+    }
+
+    const deepDiveTool = tools.tools.find(tool => tool.name === 'voc_problem_deep_dive_run');
+    const deepDiveSchema = JSON.stringify(deepDiveTool?.inputSchema || {});
+    if (!deepDiveTool || !deepDiveSchema.includes('issue') || !deepDiveSchema.includes('evidence')) {
+      throw new Error('Expected voc_problem_deep_dive_run to accept issue and evidence inputs');
+    }
+
+    const issuePoolTool = tools.tools.find(tool => tool.name === 'voc_issue_pool_run');
+    const issuePoolSchema = JSON.stringify(issuePoolTool?.inputSchema || {});
+    if (!issuePoolTool || !issuePoolSchema.includes('evidence') || !issuePoolSchema.includes('resolvedIssues')) {
+      throw new Error('Expected voc_issue_pool_run to accept evidence and status inputs');
+    }
+
+    return toolNames;
+  } finally {
+    await client.close();
+  }
+}
+
+async function smokeNoTokenRechargePrompt() {
+  const originalCwd = process.cwd();
+  const originalEnv = {
+    USERPROFILE: process.env.USERPROFILE,
+    HOME: process.env.HOME,
+    VOC_TOKEN: process.env.VOC_TOKEN,
+    VOC_SOCIAL_TOKEN: process.env.VOC_SOCIAL_TOKEN,
+    XIAOHONGSHU_API_TOKEN: process.env.XIAOHONGSHU_API_TOKEN,
+    TIKHUB_API_TOKEN: process.env.TIKHUB_API_TOKEN,
+    FMODE_XIAOHONGSHU_TOKEN: process.env.FMODE_XIAOHONGSHU_TOKEN,
+    FMODE_SOCIAL_TOKEN: process.env.FMODE_SOCIAL_TOKEN,
+    DOUYIN_API_TOKEN: process.env.DOUYIN_API_TOKEN,
+    DOUYIN_TOKEN: process.env.DOUYIN_TOKEN,
+    VOC_DOUYIN_TOKEN: process.env.VOC_DOUYIN_TOKEN
+  };
+  const tempHome = path.join(os.tmpdir(), 'claude-voc-no-token-smoke');
+  const tempWorkspace = path.join(os.tmpdir(), 'claude-voc-no-token-workspace-smoke');
+  fs.mkdirSync(tempHome, { recursive: true });
+  fs.mkdirSync(tempWorkspace, { recursive: true });
+  try {
+    process.chdir(tempWorkspace);
+    process.env.USERPROFILE = tempHome;
+    process.env.HOME = tempHome;
+    [
+      'VOC_TOKEN',
+      'VOC_SOCIAL_TOKEN',
+      'XIAOHONGSHU_API_TOKEN',
+      'TIKHUB_API_TOKEN',
+      'FMODE_XIAOHONGSHU_TOKEN',
+      'FMODE_SOCIAL_TOKEN',
+      'DOUYIN_API_TOKEN',
+      'DOUYIN_TOKEN',
+      'VOC_DOUYIN_TOKEN'
+    ].forEach(key => delete process.env[key]);
+    const result = await runXiaohongshuTrend({
+      collectionMode: 'live',
+      keywords: '全屋定制',
+      output: path.join(OUTPUT_ROOT, 'no-token')
+    });
+    const body = String(result.assistantMessage || '');
+    const hasPaymentUrl = /apig-pay/.test(body) && /Vo3ROWEvDy/.test(body);
+    const hasEarlyPaymentUrl = /apig-pay/.test(body.slice(0, 180));
+    const hasNextActionUrl = (result.nextActions || []).some(action => /apig-pay/.test(String(action)));
+    if (result.status !== 'needs_token' || !hasPaymentUrl || !hasEarlyPaymentUrl || !hasNextActionUrl || (result.errors || []).length) {
+      throw new Error('no-token live path did not return a clean recharge prompt');
+    }
+  } finally {
+    Object.entries(originalEnv).forEach(([key, value]) => {
+      if (value === undefined) {
+        delete process.env[key];
+      } else {
+        process.env[key] = value;
+      }
+    });
+    process.chdir(originalCwd);
+  }
+}
+
+async function smokeDouyinNoTokenRechargePrompt() {
+  const originalCwd = process.cwd();
+  const originalEnv = {
+    USERPROFILE: process.env.USERPROFILE,
+    HOME: process.env.HOME,
+    VOC_TOKEN: process.env.VOC_TOKEN,
+    VOC_SOCIAL_TOKEN: process.env.VOC_SOCIAL_TOKEN,
+    XIAOHONGSHU_API_TOKEN: process.env.XIAOHONGSHU_API_TOKEN,
+    TIKHUB_API_TOKEN: process.env.TIKHUB_API_TOKEN,
+    FMODE_XIAOHONGSHU_TOKEN: process.env.FMODE_XIAOHONGSHU_TOKEN,
+    FMODE_SOCIAL_TOKEN: process.env.FMODE_SOCIAL_TOKEN,
+    DOUYIN_API_TOKEN: process.env.DOUYIN_API_TOKEN,
+    DOUYIN_TOKEN: process.env.DOUYIN_TOKEN,
+    VOC_DOUYIN_TOKEN: process.env.VOC_DOUYIN_TOKEN
+  };
+  const tempHome = path.join(os.tmpdir(), 'claude-voc-douyin-no-token-smoke');
+  const tempWorkspace = path.join(os.tmpdir(), 'claude-voc-douyin-no-token-workspace-smoke');
+  fs.mkdirSync(tempHome, { recursive: true });
+  fs.mkdirSync(tempWorkspace, { recursive: true });
+  try {
+    process.chdir(tempWorkspace);
+    process.env.USERPROFILE = tempHome;
+    process.env.HOME = tempHome;
+    [
+      'VOC_TOKEN',
+      'VOC_SOCIAL_TOKEN',
+      'XIAOHONGSHU_API_TOKEN',
+      'TIKHUB_API_TOKEN',
+      'FMODE_XIAOHONGSHU_TOKEN',
+      'FMODE_SOCIAL_TOKEN',
+      'DOUYIN_API_TOKEN',
+      'DOUYIN_TOKEN',
+      'VOC_DOUYIN_TOKEN'
+    ].forEach(key => delete process.env[key]);
+    const result = await runDouyinTrend({
+      collectionMode: 'live',
+      keywords: '全屋定制',
+      output: path.join(OUTPUT_ROOT, 'douyin-no-token')
+    });
+    const body = String(result.assistantMessage || '');
+    const hasPaymentUrl = /apig-pay/.test(body) && /Vo3ROWEvDy/.test(body);
+    const hasEarlyPaymentUrl = /apig-pay/.test(body.slice(0, 180));
+    const hasNextActionUrl = (result.nextActions || []).some(action => /apig-pay/.test(String(action)));
+    if (result.status !== 'needs_token' || !hasPaymentUrl || !hasEarlyPaymentUrl || !hasNextActionUrl || (result.errors || []).length) {
+      throw new Error('douyin no-token live path did not return a clean recharge prompt');
+    }
+  } finally {
+    Object.entries(originalEnv).forEach(([key, value]) => {
+      if (value === undefined) {
+        delete process.env[key];
+      } else {
+        process.env[key] = value;
+      }
+    });
+    process.chdir(originalCwd);
+  }
+}
+
+function smokeWorkspaceEnvLocalToken() {
+  const originalCwd = process.cwd();
+  const originalEnv = {
+    USERPROFILE: process.env.USERPROFILE,
+    HOME: process.env.HOME,
+    VOC_TOKEN: process.env.VOC_TOKEN,
+    VOC_SOCIAL_TOKEN: process.env.VOC_SOCIAL_TOKEN,
+    XIAOHONGSHU_API_TOKEN: process.env.XIAOHONGSHU_API_TOKEN,
+    TIKHUB_API_TOKEN: process.env.TIKHUB_API_TOKEN,
+    FMODE_XIAOHONGSHU_TOKEN: process.env.FMODE_XIAOHONGSHU_TOKEN,
+    FMODE_SOCIAL_TOKEN: process.env.FMODE_SOCIAL_TOKEN,
+    DOUYIN_API_TOKEN: process.env.DOUYIN_API_TOKEN,
+    DOUYIN_TOKEN: process.env.DOUYIN_TOKEN,
+    VOC_DOUYIN_TOKEN: process.env.VOC_DOUYIN_TOKEN
+  };
+  const tempHome = path.join(os.tmpdir(), 'claude-voc-env-local-home-smoke');
+  const tempWorkspace = path.join(os.tmpdir(), 'claude-voc-env-local-workspace-smoke');
+  fs.mkdirSync(tempHome, { recursive: true });
+  fs.mkdirSync(tempWorkspace, { recursive: true });
+  try {
+    process.chdir(tempWorkspace);
+    process.env.USERPROFILE = tempHome;
+    process.env.HOME = tempHome;
+    [
+      'VOC_TOKEN',
+      'VOC_SOCIAL_TOKEN',
+      'XIAOHONGSHU_API_TOKEN',
+      'TIKHUB_API_TOKEN',
+      'FMODE_XIAOHONGSHU_TOKEN',
+      'FMODE_SOCIAL_TOKEN',
+      'DOUYIN_API_TOKEN',
+      'DOUYIN_TOKEN',
+      'VOC_DOUYIN_TOKEN'
+    ].forEach(key => delete process.env[key]);
+    fs.writeFileSync(path.join(tempWorkspace, '.env.local'), 'VOC_TOKEN=workspace-smoke-token\n');
+    const token = readVocToken({});
+    if (token !== 'workspace-smoke-token') {
+      throw new Error('workspace .env.local token was not used');
+    }
+  } finally {
+    Object.entries(originalEnv).forEach(([key, value]) => {
+      if (value === undefined) {
+        delete process.env[key];
+      } else {
+        process.env[key] = value;
+      }
+    });
+    process.chdir(originalCwd);
+  }
+}
+
+function smokeAccessibleUrlNormalization() {
+  const note = normalizeNote({
+    id: '69f72f860000000035038a78',
+    title: '自制多功能收纳抽屉课题',
+    desc: '把柜内空间重新分区,适合小户型收纳。',
+    user: { nickname: '小熊居家' },
+    liked_count: '16.3w',
+    collected_count: '15.9w',
+    comments_count: '1.8k',
+    mini_program_info: {
+      webpage_url: 'https://www.xiaohongshu.com/discovery/item/69f72f860000000035038a78?xsec_source=app_share&xsec_token=sample-xsec-token'
+    },
+    share_info: {
+      link: 'https://www.xiaohongshu.com/discovery/item/69f72f860000000035038a78?xsec_source=app_share&xsec_token=sample-share-token'
+    },
+    image_list: [
+      { url: 'https://img.example.com/xhs-cover.jpg' }
+    ]
+  }, '全屋定制');
+  if (!note.sourceUrlVerified || !/xsec_token=sample-xsec-token/.test(note.sourceUrl || '')) {
+    throw new Error(`expected xsec_token sourceUrl, got ${note.sourceUrl}`);
+  }
+  if (/\/explore\//.test(note.sourceUrl || '') || /\/explore\//.test(note.url || '')) {
+    throw new Error('normalizeNote should not use /explore/ as the primary source URL');
+  }
+  if (note.coverUrl !== 'https://img.example.com/xhs-cover.jpg') {
+    throw new Error(`expected coverUrl from image_list, got ${note.coverUrl}`);
+  }
+}
+
+function smokeDouyinUrlNormalization() {
+  const video = normalizeVideo({
+    aweme_id: '7448118827402972455',
+    desc: '全屋定制视频开头怎么讲更容易留人?',
+    author: { nickname: '抖音家装观察员', uid: 'u001', sec_uid: 'sec001' },
+    statistics: {
+      digg_count: '1.2w',
+      comment_count: '860',
+      share_count: '520',
+      play_count: '23.5w'
+    },
+    video: {
+      cover: { url_list: ['https://img.example.com/dy-cover.jpg'] }
+    },
+    share_info: {
+      link: 'https://www.douyin.com/video/7448118827402972455?from=sample'
+    }
+  }, '全屋定制');
+  if (!video.sourceUrlVerified || !/douyin\.com\/video\//.test(video.sourceUrl || '')) {
+    throw new Error(`expected douyin sourceUrl, got ${video.sourceUrl}`);
+  }
+  if (video.coverUrl !== 'https://img.example.com/dy-cover.jpg') {
+    throw new Error(`expected coverUrl from video.cover, got ${video.coverUrl}`);
+  }
+}
+
+function smokeEvidenceCardReport(result) {
+  const body = String(result.assistantMessage || '');
+  const required = ['## 证据样本', '原帖', '原文摘录', '高赞评论'];
+  const missing = required.filter(item => !body.includes(item));
+  if (missing.length) {
+    throw new Error(`sample report missing evidence card fields: ${missing.join(', ')}`);
+  }
+  if (/xiaohongshu\.com\/explore\//.test(body)) {
+    throw new Error('sample report should not contain /explore/ Xiaohongshu links');
+  }
+  if (!body.includes('## 证据质量门槛') || !result.summary?.evidenceQuality?.grade) {
+    throw new Error('sample report did not include evidence quality gate');
+  }
+  const assetFiles = (result.files || []).filter(file => /[\\/]assets[\\/]/.test(file));
+  if (!assetFiles.length) {
+    throw new Error('sample report did not return cached asset files');
+  }
+  const missingAsset = assetFiles.find(file => !fs.existsSync(file));
+  if (missingAsset) {
+    throw new Error(`cached asset file missing: ${missingAsset}`);
+  }
+}
+
+function smokeDouyinEvidenceCardReport(result) {
+  const body = String(result.assistantMessage || '');
+  const required = ['## 证据样本', '原视频', '视频文案', '高赞评论'];
+  const missing = required.filter(item => !body.includes(item));
+  if (missing.length) {
+    throw new Error(`douyin sample report missing evidence card fields: ${missing.join(', ')}`);
+  }
+  if (!/douyin\.com\/video\//.test(body)) {
+    throw new Error('douyin sample report should contain a Douyin video link');
+  }
+  if (!body.includes('## 证据质量门槛') || !result.summary?.evidenceQuality?.grade) {
+    throw new Error('douyin sample report did not include evidence quality gate');
+  }
+  const assetFiles = (result.files || []).filter(file => /[\\/]assets[\\/]/.test(file));
+  if (!assetFiles.length) {
+    throw new Error('douyin sample report did not return cached asset files');
+  }
+  const missingAsset = assetFiles.find(file => !fs.existsSync(file));
+  if (missingAsset) {
+    throw new Error(`cached douyin asset file missing: ${missingAsset}`);
+  }
+}
+
+function sectionBetween(text, startTitle, endTitle) {
+  const body = String(text || '');
+  const start = body.indexOf(startTitle);
+  if (start < 0) return '';
+  const end = body.indexOf(endTitle, start + startTitle.length);
+  return end >= 0 ? body.slice(start, end) : body.slice(start);
+}
+
+function smokeDeepDiveMemoryBehavior(result, blockedTerm) {
+  const body = String(result.assistantMessage || '');
+  const actionSection = sectionBetween(
+    body,
+    '### 今天就能做',
+    '## \u53ef\u8f6c\u5316\u5185\u5bb9\u9009\u9898'
+  );
+  const memorySection = sectionBetween(
+    body,
+    '## \u5df2\u5e94\u7528\u7684\u4f7f\u7528\u8bb0\u5fc6',
+    '## \u4ecd\u9700\u786e\u8ba4\u7684\u95ee\u9898'
+  );
+  if (!actionSection) {
+    throw new Error('VOC problem deep dive action section was not found');
+  }
+  if (actionSection.includes(blockedTerm)) {
+    throw new Error('VOC problem deep dive leaked a blocked action into executable actions');
+  }
+  if (!memorySection.includes(blockedTerm)) {
+    throw new Error('VOC problem deep dive did not show blocked action in applied memory');
+  }
+}
+
+async function smokeBusinessStatusErrors() {
+  const originalFetch = global.fetch;
+  const makeBusinessErrorFetch = () => async () => ({
+    ok: true,
+    status: 200,
+    text: async () => JSON.stringify({
+      code: 403,
+      message: 'insufficient balance'
+    })
+  });
+
+  try {
+    global.fetch = makeBusinessErrorFetch();
+    await new XiaohongshuApi({
+      token: 'r:smoke-token',
+      baseUrl: 'https://example.test/xiaohongshu/app'
+    }).searchNotes({ keyword: 'smoke' });
+    throw new Error('xiaohongshu business status 403 did not throw');
+  } catch (error) {
+    if (error.message === 'xiaohongshu business status 403 did not throw') {
+      throw error;
+    }
+    if (Number(error.httpStatus) !== 403) {
+      throw new Error(`expected xiaohongshu business httpStatus 403, got ${error.httpStatus}`);
+    }
+  } finally {
+    global.fetch = originalFetch;
+  }
+
+  try {
+    global.fetch = makeBusinessErrorFetch();
+    await new DouyinApi({
+      token: 'r:smoke-token',
+      baseUrl: 'https://example.test/douyin'
+    }).searchVideos({ keyword: 'smoke' });
+    throw new Error('douyin business status 403 did not throw');
+  } catch (error) {
+    if (error.message === 'douyin business status 403 did not throw') {
+      throw error;
+    }
+    if (Number(error.httpStatus) !== 403) {
+      throw new Error(`expected douyin business httpStatus 403, got ${error.httpStatus}`);
+    }
+  } finally {
+    global.fetch = originalFetch;
+  }
+}
+
+async function main() {
+  const sample = await runXiaohongshuTrend({
+    collectionMode: 'sample',
+    profile: path.join(ROOT, 'memory-templates', 'xiaohongshu-trend-profile.json'),
+    output: path.join(OUTPUT_ROOT, 'sample')
+  });
+  if (sample.status !== 'ok') {
+    throw new Error(`sample failed: ${sample.status}`);
+  }
+  smokeEvidenceCardReport(sample);
+  smokeAccessibleUrlNormalization();
+
+  const douyinSample = await runDouyinTrend({
+    collectionMode: 'sample',
+    profile: path.join(ROOT, 'memory-templates', 'douyin-trend-profile.json'),
+    output: path.join(OUTPUT_ROOT, 'douyin-sample')
+  });
+  if (douyinSample.status !== 'ok') {
+    throw new Error(`douyin sample failed: ${douyinSample.status}`);
+  }
+  smokeDouyinEvidenceCardReport(douyinSample);
+  smokeDouyinUrlNormalization();
+
+  const preference = await updateXiaohongshuPreference({
+    message: '保留奶油风和全屋定制翻车方向,不要纯风格美图,下一个版本更偏门店转化话术。',
+    memory: MEMORY_PATH
+  });
+  if (preference.status !== 'ok') {
+    throw new Error(`preference failed: ${preference.status}`);
+  }
+
+  const withMemory = await runXiaohongshuTrend({
+    collectionMode: 'sample',
+    profile: path.join(ROOT, 'memory-templates', 'xiaohongshu-trend-profile.json'),
+    memory: MEMORY_PATH,
+    output: path.join(OUTPUT_ROOT, 'with-memory')
+  });
+  if (withMemory.status !== 'ok' || !withMemory.summary.preferenceMemoryApplied) {
+    throw new Error('sample-with-memory did not apply preference memory');
+  }
+
+  const douyinPreference = await updateDouyinPreference({
+    message: '保留口播开头和评论区问题,不要只做纯素材剪辑,下一个版本更偏门店转化话术。',
+    memory: DOUYIN_MEMORY_PATH
+  });
+  if (douyinPreference.status !== 'ok') {
+    throw new Error(`douyin preference failed: ${douyinPreference.status}`);
+  }
+
+  const douyinWithMemory = await runDouyinTrend({
+    collectionMode: 'sample',
+    profile: path.join(ROOT, 'memory-templates', 'douyin-trend-profile.json'),
+    memory: DOUYIN_MEMORY_PATH,
+    output: path.join(OUTPUT_ROOT, 'douyin-with-memory')
+  });
+  if (douyinWithMemory.status !== 'ok' || !douyinWithMemory.summary.preferenceMemoryApplied) {
+    throw new Error('douyin sample-with-memory did not apply preference memory');
+  }
+
+  const deepDive = await runVocProblemDeepDive({
+    issue: '排队',
+    industry: '本地生活餐饮',
+    scenario: '门店经营',
+    audience: '到店顾客',
+    memory: path.join(OUTPUT_ROOT, 'voc-problem-memory.json'),
+    evidence: [
+      '想吃就得排队。',
+      '排了很久以后,如果菜品普通就会觉得不值。'
+    ]
+  });
+  const deepDiveBody = String(deepDive.assistantMessage || '');
+  if (deepDive.status !== 'ok' ||
+    !deepDiveBody.includes('## 老板视角影响') ||
+    !deepDiveBody.includes('## 可执行解决动作') ||
+    !deepDiveBody.includes('## 下轮验证指标')) {
+    throw new Error('VOC problem deep dive did not return boss-oriented action output');
+  }
+  if (!deepDiveBody.includes('## 优先级判断') || !deepDiveBody.includes('## 7 天验证计划')) {
+    throw new Error('VOC problem deep dive did not return priority and validation plan');
+  }
+  if (!deepDive.summary.memoryPath || !(deepDive.files || []).includes(deepDive.summary.memoryPath)) {
+    throw new Error('VOC problem deep dive did not persist memory');
+  }
+  const deepDiveWithMemory = await runVocProblemDeepDive({
+    issue: '排队',
+    industry: '本地生活餐饮',
+    scenario: '门店经营',
+    audience: '到店顾客',
+    memory: deepDive.summary.memoryPath,
+    feedback: '老板不想做等位饮品,更想先改评论区回复和预点单',
+    blockedActions: ['等位饮品'],
+    preferredActions: ['评论区回复', '预点单']
+  });
+  const memoryBody = String(deepDiveWithMemory.assistantMessage || '');
+  if (!deepDiveWithMemory.summary.memoryApplied || !memoryBody.includes('## 已应用的使用记忆')) {
+    throw new Error('VOC problem deep dive did not apply iterative memory');
+  }
+  smokeDeepDiveMemoryBehavior(deepDiveWithMemory, '等位饮品');
+
+  const issuePool = await runVocIssuePool({
+    project: '样板门店A',
+    industry: '本地生活餐饮',
+    scenario: '门店经营',
+    audience: '到店顾客',
+    evidence: [
+      '排队久,但是不知道值不值得等。',
+      '等位时间不透明,到了现场才知道要等多久。',
+      '人均有点高,第一次来不知道怎么点不踩雷。',
+      '服务忙起来没人理。',
+      '视频很种草,实际到店体验有落差。'
+    ]
+  });
+  const issuePoolBody = String(issuePool.assistantMessage || '');
+  if (issuePool.status !== 'ok' ||
+    !issuePoolBody.includes('# VOC 问题池') ||
+    !issuePoolBody.includes('## 问题优先级') ||
+    !issuePool.summary.memoryPath ||
+    !issuePool.data?.issues?.length) {
+    throw new Error('VOC issue pool did not return prioritized issue output');
+  }
+  if (!issuePool.data.issues.some(issue => issue.title === '排队/等待')) {
+    throw new Error('VOC issue pool did not classify waiting issue');
+  }
+  const issuePoolUpdated = await runVocIssuePool({
+    project: '样板门店A',
+    industry: '本地生活餐饮',
+    scenario: '门店经营',
+    audience: '到店顾客',
+    memory: issuePool.summary.memoryPath,
+    evidence: ['排队问题已经开始做预点单验证。'],
+    validatingIssues: ['排队/等待']
+  });
+  if (!issuePoolUpdated.data?.issues?.some(issue => issue.title === '排队/等待' && issue.status === '验证中')) {
+    throw new Error('VOC issue pool did not apply issue status update');
+  }
+  const waitingTrend = issuePoolUpdated.data?.issues?.find(issue => issue.title === '排队/等待');
+  if (!waitingTrend || waitingTrend.previousCount < 1 || waitingTrend.delta >= 0 || waitingTrend.trend !== 'down') {
+    throw new Error('VOC issue pool did not compare issue trend with previous run');
+  }
+  if (!String(issuePoolUpdated.assistantMessage || '').includes('## 问题变化')) {
+    throw new Error('VOC issue pool did not output issue change section');
+  }
+
+  const parsedFeedbackDeepDive = await runVocProblemDeepDive({
+    issue: '排队',
+    project: '样板门店A',
+    industry: '本地生活餐饮',
+    scenario: '门店经营',
+    audience: '到店顾客',
+    feedback: '这个店不想做等位饮品,更想先改评论区回复和预点单'
+  });
+  smokeDeepDiveMemoryBehavior(parsedFeedbackDeepDive, '等位饮品');
+  if (!String(parsedFeedbackDeepDive.summary.memoryPath || '').includes('样板门店a')) {
+    throw new Error('VOC problem deep dive did not use scoped default memory path');
+  }
+  const parsedMemory = parsedFeedbackDeepDive.data?.memory || {};
+  if (!parsedMemory.blockedActions?.includes('等位饮品') ||
+    !parsedMemory.preferredActions?.includes('评论区回复') ||
+    !parsedMemory.preferredActions?.includes('预点单')) {
+    throw new Error('VOC problem deep dive did not parse natural-language feedback into memory');
+  }
+
+  const actionWeightedDeepDive = await runVocProblemDeepDive({
+    issue: '排队',
+    project: '样板门店A',
+    industry: '本地生活餐饮',
+    scenario: '门店经营',
+    audience: '到店顾客',
+    memory: parsedFeedbackDeepDive.summary.memoryPath,
+    validatedActions: ['预点单'],
+    rejectedActions: ['等位饮品'],
+    feedback: '预点单有效,等位饮品无效'
+  });
+  const actionWeightedBody = String(actionWeightedDeepDive.assistantMessage || '');
+  if (!actionWeightedBody.includes('为什么优先推荐这些动作')) {
+    throw new Error('VOC problem deep dive did not explain action-weight memory');
+  }
+  const actionStats = actionWeightedDeepDive.data?.memory?.actionStats || {};
+  if (!actionStats['预点单']?.validatedCount || !actionStats['等位饮品']?.rejectedCount) {
+    throw new Error('VOC problem deep dive did not persist action validation stats');
+  }
+
+  const contentPlan = await runVocContentPlan({
+    brand: '样板门店A',
+    industry: '本地生活餐饮',
+    issues: ['第一次来怕踩雷', '觉得人均贵', '怕排队久']
+  });
+  const contentPlanBody = String(contentPlan.assistantMessage || '');
+  if (contentPlan.status !== 'ok' ||
+    !contentPlanBody.includes('7 天 VOC 内容选题') ||
+    !contentPlanBody.includes('前 3 秒钩子') ||
+    !contentPlanBody.includes('口播脚本') ||
+    contentPlan.data?.plan?.length !== 7) {
+    throw new Error('VOC content plan did not return 7-day script output');
+  }
+
+  const speakingScript = await runVocSpeakingScript({
+    brand: '样板门店A',
+    industry: '本地生活餐饮',
+    topic: '第一次来怎么点不踩雷',
+    userIssue: '第一次来怕点错',
+    evidence: ['第一次来不知道怎么点,怕踩雷'],
+    feedback: '开头更狠一点,老板视角,少讲概念,多给具体场景',
+    memory: path.join(OUTPUT_ROOT, 'voc-speaking-script-memory.json'),
+    finalize: true
+  });
+  const speakingScriptBody = String(speakingScript.assistantMessage || '');
+  if (speakingScript.status !== 'ok' ||
+    !speakingScriptBody.includes('VOC 口播脚本共创') ||
+    !speakingScriptBody.includes('60 秒口播稿') ||
+    !speakingScriptBody.includes('30 秒压缩版') ||
+    !speakingScript.data?.memory?.finalizedScripts?.length ||
+    !speakingScript.data?.memory?.preferredStyles?.includes('老板视角')) {
+    throw new Error('VOC speaking script did not return co-creation output with memory');
+  }
+
+  const competitorMap = await runVocCompetitorMap({
+    brand: '样板门店A',
+    city: '南昌',
+    category: '餐饮'
+  });
+  const competitorMapBody = String(competitorMap.assistantMessage || '');
+  if (competitorMap.status !== 'ok' ||
+    !competitorMapBody.includes('竞品图谱') ||
+    !competitorMapBody.includes('错位竞争') ||
+    !competitorMap.data?.competitors?.length) {
+    throw new Error('VOC competitor map did not return competitor opportunity output');
+  }
+
+  const businessWorkflow = await runBusinessWorkflow({
+    brand: '样板门店A',
+    industry: '本地生活餐饮',
+    platform: 'douyin',
+    collectionMode: 'sample',
+    keywords: ['南昌菜', '第一次来怎么点', '排队']
+  });
+  const businessWorkflowBody = String(businessWorkflow.assistantMessage || '');
+  if (businessWorkflow.status !== 'ok' ||
+    !businessWorkflowBody.includes('VOC经营闭环') ||
+    !businessWorkflowBody.includes('市场和用户声音') ||
+    !businessWorkflowBody.includes('先改哪个问题') ||
+    !businessWorkflowBody.includes('第一条口播稿') ||
+    businessWorkflow.summary?.workflowStage !== 'business_workflow_first_round') {
+    throw new Error('VOC business workflow did not return end-to-end workflow output');
+  }
+
+  await smokeNoTokenRechargePrompt();
+  await smokeDouyinNoTokenRechargePrompt();
+  smokeWorkspaceEnvLocalToken();
+  await smokeBusinessStatusErrors();
+  await smokeCrossIndustry({ root: ROOT });
+
+  const toolNames = await smokeMcpTools();
+  const requiredTools = [
+    'voc_business_workflow_run',
+    'voc_xiaohongshu_token_check',
+    'voc_xiaohongshu_trend_run',
+    'voc_xiaohongshu_preference_update',
+    'voc_douyin_token_check',
+    'voc_douyin_trend_run',
+    'voc_douyin_preference_update',
+    'voc_issue_pool_run',
+    'voc_problem_deep_dive_run',
+    'voc_content_plan_run',
+    'voc_speaking_script_run',
+    'voc_competitor_map_run'
+  ];
+  for (const tool of requiredTools) {
+    if (!toolNames.includes(tool)) {
+      throw new Error(`missing MCP tool: ${tool}`);
+    }
+  }
+
+  console.log(JSON.stringify({
+    status: 'ok',
+    checks: [
+      'sample report',
+      'evidence cards',
+      'local asset cache',
+      'xsec URL normalization',
+      'preference memory',
+      'sample report with memory',
+      'no-token recharge prompt',
+      'workspace .env.local token source',
+      'douyin sample report',
+      'douyin sample report with memory',
+      'VOC issue pool',
+      'VOC issue pool status memory',
+      'VOC issue pool trend comparison',
+      'VOC problem deep dive',
+      'VOC problem deep dive scoped memory',
+      'VOC problem deep dive feedback parser',
+      'VOC problem deep dive blocked-action guard',
+      'VOC problem deep dive action-weight memory',
+      'VOC content plan',
+      'VOC speaking script co-creation',
+      'VOC competitor map',
+      'VOC business workflow',
+      'douyin no-token recharge prompt',
+      'business-status recharge guard',
+      'cross-industry sample smoke',
+      'MCP tools'
+    ],
+    mcpTools: toolNames,
+    reportPreview: String(withMemory.assistantMessage || '').split('\n').slice(0, 8).join('\n'),
+    files: withMemory.files
+  }, null, 2));
+}
+
+main().catch(error => {
+  console.error(JSON.stringify({
+    status: 'error',
+    message: error.message
+  }, null, 2));
+  process.exit(1);
+});

+ 60 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/skill-package-manifest.json

@@ -0,0 +1,60 @@
+{
+  "name": "claude-code-voc-intelligence",
+  "version": "0.3.10",
+  "description": "Claude Code native VOC intelligence package for cross-industry social VOC workflows. Includes Xiaohongshu and Douyin trend intelligence, live token checks, evidence-card reports, single-issue VOC deep dives, boss/operator action plans, and scoped multi-turn memory.",
+  "plugin": "voc-intelligence",
+  "skills": [
+    "xiaohongshu-trend-intelligence",
+    "douyin-trend-intelligence",
+    "voc-issue-pool",
+    "voc-problem-deep-dive",
+    "voc-content-plan",
+    "voc-speaking-script",
+    "voc-competitor-map",
+    "voc-business-workflow"
+  ],
+  "mcpTools": [
+    "voc_business_workflow_run",
+    "voc_speaking_script_run",
+    "voc_issue_pool_run",
+    "voc_problem_deep_dive_run",
+    "voc_content_plan_run",
+    "voc_competitor_map_run",
+    "voc_xiaohongshu_token_check",
+    "voc_xiaohongshu_trend_run",
+    "voc_xiaohongshu_preference_update",
+    "voc_douyin_token_check",
+    "voc_douyin_trend_run",
+    "voc_douyin_preference_update"
+  ],
+  "entrySkill": "xiaohongshu-trend-intelligence",
+  "capabilityMap": "docs/capability-map.md",
+  "liveAcceptanceChecklist": "docs/live-manual-acceptance-checklist.md",
+  "paymentLinksDoc": "docs/payment-package-links.md",
+  "contextBudgetDoc": "docs/context-budget-implementation-plan.md",
+  "primaryUseCases": [
+    "First-round Xiaohongshu or Douyin trend intelligence from sample or live collection",
+    "Evidence-first customer concern and opportunity reports",
+    "Prioritized VOC issue pools with status, evidence, impact, and next actions",
+    "Single VOC problem deep dive into boss/operator actions and 7-day validation plans",
+    "7-day VOC content plans with speaking scripts and validation metrics",
+    "VOC-backed speaking script co-creation with revision, finalization, and script memory",
+    "End-to-end VOC business workflow from market voices to issue, action, content, and script",
+    "Competitor maps and differentiated opportunity reports from VOC evidence",
+    "Multi-turn preference and scoped memory so repeated use gets more tailored",
+    "Friendly token and recharge handling for live collection"
+  ],
+  "recommendedUserPrompts": [
+    "帮我做一份家装全屋定制行业的小红书趋势情报。先用演示样例跑通流程,不要真实采集。",
+    "帮我看下南昌餐饮行业抖音上用户最近在吐槽什么,先小规模真实采集。",
+    "继续深挖“排队”这个问题,站在老板视角告诉我怎么解决。"
+  ],
+  "sampleCommand": "npm run xhs:sample",
+  "mcpSmokeCommand": "npm run mcp:smoke",
+  "installCommand": "node install.js",
+  "npmInstallCommand": "npm install -g @gangvy/claude-code-voc-intelligence && claude-voc install",
+  "npxInstallCommand": "npx @gangvy/claude-code-voc-intelligence install",
+  "workspaceInstallCommand": "npx @gangvy/claude-code-voc-intelligence workspace --smoke",
+  "workspaceInstallTarget": ".claude/plugins/voc-intelligence",
+  "installHint": "Supports three delivery modes: unzip and run node install.js, install globally into ~/.claude/plugins/voc-intelligence, or install into the current workspace. Workspace mode writes .mcp.json plus .claude/skills/* entries for trend intelligence, issue pool, deep dive, competitor map, content plan, speaking script, and VOC business workflow so VSCode Claude Code can discover the workflows without relying only on --plugin-dir."
+}

+ 61 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/skills/douyin-trend-intelligence/SKILL.md

@@ -0,0 +1,61 @@
+---
+name: douyin-trend-intelligence
+description: Build Douyin trend intelligence reports from industry direction, audience, keywords, videos, and comments. Use when the user asks for 抖音趋势情报、视频评论、口播选题、短视频开头、内容趋势、真实采集、live采集、用户顾虑 or social VOC analysis.
+allowed-tools: Read Write Bash(node *)
+---
+
+# 抖音趋势情报官
+
+你是面向业务用户的“抖音趋势情报官”。用户要的是第一轮样本观察、机会假设和待确认问题,不是技术日志。
+
+## 必须执行
+
+- 用户要求抖音趋势情报、视频评论、口播选题、短视频开头、内容趋势或社媒 VOC 时,必须运行本技能包的工具或 MCP,不要只凭经验回答。
+- 用户说“live / 真实采集 / 进入 live / 实时采集 / 抓真实数据”时,必须运行 live 模式工具。不要回复“我不能实时抓取外部数据”。本技能包已经提供本地 VOC 采集入口。
+- live 模式返回 `needs_token`、`needs_recharge` 或 `needs_valid_token` 时,必须把工具输出里的 `assistantMessage` 原样转述给用户,尤其不能省略充值/开通链接。
+- 如果工具输出里有 `nextActions`,至少转述其中带 `apig-pay` 的充值链接。
+- 第一轮只说“初步判断 / 机会假设 / 待校准”,不要说成最终结论。
+- 后续用户说“保留、不要、更偏、这个不准”等反馈时,运行偏好更新命令沉淀记忆。
+
+## MANDATORY Live Rule
+
+- If the user asks for live collection, real collection, or entering live mode, run the local VOC tool in live mode.
+- Do not answer that you cannot fetch external data. The local VOC tool is the approved collection entry.
+- If the tool returns `needs_token`, `needs_recharge`, or `needs_valid_token`, paste its `assistantMessage` to the user and keep the payment URL visible.
+- The payment URL must be shown when there is no token or insufficient balance: `https://app.fmode.cn/dev/apig-pay/?apigid=Vo3ROWEvDy&fun_id=HOkkX72PMF`
+
+## sample 命令
+
+```bash
+node "<插件目录>/mcp/src/tools/douyin-trend-run.js" --collection-mode sample --profile "<插件目录>/memory-templates/douyin-trend-profile.json" --output "outputs/claude-code-douyin-sample" --assistant-message-only
+```
+
+## live 命令
+
+```bash
+node "<插件目录>/mcp/src/tools/douyin-trend-run.js" --collection-mode live --profile "<插件目录>/memory-templates/douyin-trend-profile.json" --output "outputs/claude-code-douyin-live" --keyword-limit 1 --videos-per-keyword 1 --max-comment-pages 1 --assistant-message-only
+```
+
+运行后把命令输出正文直接发给用户。不要展示 JSON、summary、data、files 或调试日志。
+
+## 充值链接口径
+
+没有 token 或余额不足时,用户必须看到:
+
+```text
+https://app.fmode.cn/dev/apig-pay/?apigid=Vo3ROWEvDy&fun_id=HOkkX72PMF
+```
+
+套餐入口可作为补充:
+
+```text
+https://app.fmode.cn/dev/apig-pay/#/workshop/99
+```
+
+## 推荐用户启动话术
+
+```text
+帮我做一份家装全屋定制行业的抖音趋势情报。
+先用演示样例跑通流程,不要真实采集。
+在聊天里给我第一轮样本观察和待确认问题。
+```

+ 27 - 0
dist/workshop-manual-install/VOC-workshop-ready/.claude/plugins/voc-intelligence/skills/douyin-trend-intelligence/references/live-mode.md

@@ -0,0 +1,27 @@
+# 抖音 live 模式
+
+## 默认规则
+
+- 先检查 token
+- 样本量保持小
+- 没 token 时给充值链接,不要暴露 403
+- 余额不足时也给充值链接,不要暴露底层错误
+- 用户只需要看到“怎么继续”
+
+## 推荐默认参数
+
+- `keywordLimit = 1`
+- `videosPerKeyword = 1`
+- `maxCommentPages = 1`
+- `cacheAssets = true`
+
+## 失败提示
+
+当 live 失败时,优先区分:
+
+1. 没有 token
+2. token 无效
+3. 额度不足
+4. 没有采到有效样本
+
+不要把底层接口报错直接丢给用户。

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott