Jelajahi Sumber

fix(live): correct token-type misclassification and harden error routing; v0.3.16

- Pre-check sk- AIGate keys in live mode and route 400 company-not-found to needs_valid_token instead of keyword/category errors

- Add request-level retry (5xx/network) to douyin/xiaohongshu voc-social calls to absorb upstream TLS jitter

- Split upstream 5xx/fetch-failed/timeout into a dedicated upstream_unstable status so it is never reported as needs_keyword_fix or needs_recharge

- Remove F&B-anchored example keywords from SKILLs, live-mode docs and tool messages; state any industry is collectable

- Update smoke-package assertions to new classification (persistent upstream -> upstream_unstable; transient -> request-retry recovery)

- Add fmode-image-analysis feature and skill, student course docs, course-deck builder and analysis tools

- Bump version 0.3.15 -> 0.3.16 and regenerate suite/npm artifacts
gangvy 3 bulan lalu
induk
melakukan
60fd099420
63 mengubah file dengan 8800 tambahan dan 234 penghapusan
  1. 14 0
      .gitignore
  2. 94 16
      NPM技能包发布管理.md
  3. 2 2
      claude-code/claude-code-voc-intelligence/.claude-plugin/plugin.json
  4. 2 2
      claude-code/claude-code-voc-intelligence/.mcp.json
  5. 8 3
      claude-code/claude-code-voc-intelligence/README.md
  6. 17 2
      claude-code/claude-code-voc-intelligence/bin/claude-voc.js
  7. 8 2
      claude-code/claude-code-voc-intelligence/docs/capability-map.md
  8. 184 49
      claude-code/claude-code-voc-intelligence/docs/npm-packaging-notes.md
  9. 1 1
      claude-code/claude-code-voc-intelligence/docs/payment-package-links.md
  10. 4 1
      claude-code/claude-code-voc-intelligence/install.js
  11. 1 1
      claude-code/claude-code-voc-intelligence/mcp/src/core/evidence-quality.js
  12. 20 1
      claude-code/claude-code-voc-intelligence/mcp/src/core/payment-links.js
  13. 1 1
      claude-code/claude-code-voc-intelligence/mcp/src/features/douyin-trend/live-collector.js
  14. 427 0
      claude-code/claude-code-voc-intelligence/mcp/src/features/fmode-image-analysis/image-analysis.js
  15. 55 30
      claude-code/claude-code-voc-intelligence/mcp/src/providers/douyin-api.js
  16. 44 22
      claude-code/claude-code-voc-intelligence/mcp/src/providers/xiaohongshu-api.js
  17. 75 1
      claude-code/claude-code-voc-intelligence/mcp/src/server.js
  18. 88 18
      claude-code/claude-code-voc-intelligence/mcp/src/tools/douyin-trend-run.js
  19. 69 0
      claude-code/claude-code-voc-intelligence/mcp/src/tools/fmode-image-analysis.js
  20. 84 10
      claude-code/claude-code-voc-intelligence/mcp/src/tools/xiaohongshu-trend-run.js
  21. 4 4
      claude-code/claude-code-voc-intelligence/package-lock.json
  22. 8 5
      claude-code/claude-code-voc-intelligence/package.json
  23. 91 0
      claude-code/claude-code-voc-intelligence/scripts/acceptance.js
  24. 108 0
      claude-code/claude-code-voc-intelligence/scripts/fmode-image-analysis-smoke.js
  25. 24 0
      claude-code/claude-code-voc-intelligence/scripts/smoke-mcp.js
  26. 15 6
      claude-code/claude-code-voc-intelligence/scripts/smoke-package.js
  27. 9 6
      claude-code/claude-code-voc-intelligence/skill-package-manifest.json
  28. 19 3
      claude-code/claude-code-voc-intelligence/skills/douyin-trend-intelligence/SKILL.md
  29. 7 6
      claude-code/claude-code-voc-intelligence/skills/douyin-trend-intelligence/references/live-mode.md
  30. 106 0
      claude-code/claude-code-voc-intelligence/skills/fmode-image-analysis/SKILL.md
  31. 4 0
      claude-code/claude-code-voc-intelligence/skills/fmode-image-analysis/agents/openai.yaml
  32. 16 0
      claude-code/claude-code-voc-intelligence/skills/xiaohongshu-trend-intelligence/SKILL.md
  33. 11 9
      dist/claude-code-voc-intelligence-suite-manifest.json
  34. TEMPAT SAMPAH
      dist/claude-code-voc-intelligence.zip
  35. 11 11
      dist/npm/claude-code-voc-npm-package-manifest.json
  36. 94 0
      docs/voc-skill-student-course/00-course-map.md
  37. 144 0
      docs/voc-skill-student-course/01-workspace-setup.md
  38. 124 0
      docs/voc-skill-student-course/02-sample-first-run.md
  39. 148 0
      docs/voc-skill-student-course/03-collection-matrix.md
  40. 158 0
      docs/voc-skill-student-course/04-live-collection.md
  41. 130 0
      docs/voc-skill-student-course/05-issue-pool.md
  42. 169 0
      docs/voc-skill-student-course/06-single-issue-deep-dive.md
  43. 174 0
      docs/voc-skill-student-course/07-content-plan-and-script.md
  44. 192 0
      docs/voc-skill-student-course/08-html-report.md
  45. 217 0
      docs/voc-skill-student-course/09-troubleshooting.md
  46. 241 0
      docs/voc-skill-student-course/10-recording-checklist.md
  47. 258 0
      docs/voc-skill-student-course/11-prompt-library.md
  48. 100 0
      docs/voc-skill-student-course/README.md
  49. 2 1
      package.json
  50. 44 15
      release/npm/claude-code-voc-intelligence/README.md
  51. 1 1
      release/npm/claude-code-voc-intelligence/publish.ps1
  52. 3 2
      release/npm/claude-code-voc-intelligence/release-checklist.md
  53. 1094 0
      scripts/course-ppt/build-voc-community-course-decks.mjs
  54. 2 1
      scripts/deploy/claude-code-voc-intelligence-suite.js
  55. 2 2
      scripts/deploy/claude-code-voc-npm-package.js
  56. 575 0
      tools/analyze_centrum_douyin_live.py
  57. 1621 0
      tools/analyze_centrum_transcripts.py
  58. 226 0
      tools/build_centrum_douyin_live_workbook.mjs
  59. 547 0
      tools/build_centrum_douyin_outputs.mjs
  60. 249 0
      tools/build_centrum_transcript_workbook.mjs
  61. 133 0
      tools/collect_centrum_douyin_live.js
  62. 119 0
      tools/probe_douyin_media_from_search.js
  63. 402 0
      tools/transcribe_centrum_douyin_videos.js

+ 14 - 0
.gitignore

@@ -8,3 +8,17 @@ release/npm/**/.env.local
 outputs/
 dist/npm/*.tgz
 
+# Scratch / tooling run dirs
+.codex-run/
+.playwright-cli/
+
+# Python caches
+__pycache__/
+*.pyc
+
+# Build artifacts (npm pack tarballs / loose zips)
+*.tgz
+claude-code/*.zip
+
+# Generated runtime memory
+memory/douyin-speaking-history.json

+ 94 - 16
NPM技能包发布管理.md

@@ -1,4 +1,4 @@
-# NPM 技能包发布管理
+# NPM 技能包发布管理
 
 本文记录 Claude Code / OpenClaw 技能包的 npm 发布、下载、安装和验证方式。
 
@@ -6,7 +6,8 @@
 
 | 技能包 | npm 包名 | 当前版本 | 状态 | 本地目录 | npm 链接 |
 |---|---|---:|---|---|---|
-| Claude Code VOC Intelligence | `@gangvy/claude-code-voc-intelligence` | `0.3.0` | 已发布 | `claude-code-voc-intelligence/` | https://www.npmjs.com/package/@gangvy/claude-code-voc-intelligence |
+| Claude Code VOC Intelligence | `@vocmarket/voc-skill` | `0.3.15` | 已发布 | `claude-code/claude-code-voc-intelligence/` | https://www.npmjs.com/package/@vocmarket/voc-skill |
+| 提号 SOP 博主名单 | `@vocmarket/tihao` | `1.0.10` | 已发布 | `E:\workspace\tihao-ai\claude-code-tihao-sourcing\` | https://www.npmjs.com/package/@vocmarket/tihao |
 | 装修量尺图缺标记检查 | `@gangvy/claude-code-measurement-marker-check` | `0.1.2` | 已发布 | `claude-code-measurement-marker-check/` | https://www.npmjs.com/package/@gangvy/claude-code-measurement-marker-check |
 
 ## VOC Intelligence 安装入口
@@ -14,7 +15,7 @@
 推荐给 VSCode Claude Code 插件使用工作区安装:
 
 ```powershell
-npx --yes @gangvy/claude-code-voc-intelligence@latest workspace --smoke
+npx --yes @vocmarket/voc-skill@latest workspace --smoke
 ```
 
 工作区安装会写入:
@@ -29,14 +30,14 @@ npx --yes @gangvy/claude-code-voc-intelligence@latest workspace --smoke
 全局安装:
 
 ```powershell
-npm install -g @gangvy/claude-code-voc-intelligence
+npm install -g @vocmarket/voc-skill
 claude-voc install
 ```
 
 npx 免全局安装:
 
 ```powershell
-npx --yes @gangvy/claude-code-voc-intelligence@latest install
+npx --yes @vocmarket/voc-skill@latest install
 ```
 
 Claude Code 命令行加载:
@@ -45,6 +46,64 @@ Claude Code 命令行加载:
 claude --plugin-dir "$env:USERPROFILE\.claude\plugins\voc-intelligence"
 ```
 
+## 提号 SOP 安装入口
+
+推荐给 VSCode Claude Code 插件使用工作区安装:
+
+```powershell
+npx --yes @vocmarket/tihao@latest workspace --smoke
+```
+
+工作区安装会写入:
+
+```text
+.\.mcp.json
+.\.claude\plugins\tihao
+.\.claude\skills\tihao\SKILL.md
+```
+
+全局安装:
+
+```powershell
+npm install -g @vocmarket/tihao
+tihao install --smoke
+```
+
+使用全局 CLI 安装到当前项目工作区:
+
+```powershell
+tihao workspace --smoke
+```
+
+发布后验证:
+
+```powershell
+npm view @vocmarket/tihao name version dist-tags.latest --registry=https://registry.npmjs.org/
+npx --yes @vocmarket/tihao@latest workspace --smoke
+```
+
+### 数据库 `skills` 表记录
+
+| 字段 | 值 |
+|---|---|
+| `skillName` | `tihao` |
+| `displayName` | `提号` |
+| `status` | `published` |
+| `npmPackage` | `@vocmarket/tihao` |
+| `npmVersion` | `1.0.10` |
+| `installCommand` | `npx --yes @vocmarket/tihao@latest workspace --smoke` |
+| `globalInstallCommand` | `npm install -g @vocmarket/tihao && tihao install --smoke` |
+| `workspaceSkillPath` | `.claude/skills/tihao/SKILL.md` |
+| `globalSkillPath` | `%USERPROFILE%/.claude/skills/tihao/SKILL.md` |
+| `pluginName` | `tihao` |
+| `mcpServerName` | `tihao` |
+| `legacyNpmPackages` | `@vocmarket/tihao-sop` |
+| `legacyCliNames` | `tihao-sourcing` |
+| `legacyPluginNames` | `tihao-sourcing` |
+| `legacySkillNames` | `tihao-creator-sourcing` |
+
+包内同步记录:`E:\workspace\tihao-ai\claude-code-tihao-sourcing\docs\skill-database-record.json`。
+
 ## 教学测试话术
 
 小红书趋势情报:
@@ -76,10 +135,11 @@ claude --plugin-dir "$env:USERPROFILE\.claude\plugins\voc-intelligence"
 发布前验证:
 
 ```powershell
-npm --prefix claude-code-voc-intelligence run mcp:smoke
-npm --prefix claude-code-voc-intelligence run smoke:package
-npm --prefix claude-code-voc-intelligence run xhs:sample
-npm --prefix claude-code-voc-intelligence run dy:sample
+npm --prefix claude-code/claude-code-voc-intelligence run mcp:smoke
+npm --prefix claude-code/claude-code-voc-intelligence run smoke:package
+npm --prefix claude-code/claude-code-voc-intelligence run xhs:sample
+npm --prefix claude-code/claude-code-voc-intelligence run dy:sample
+npm run claude-voc:acceptance
 ```
 
 构建套件与 npm 包:
@@ -98,10 +158,11 @@ npm run claude-voc:npm-publish
 发布后验证:
 
 ```powershell
-npm view @gangvy/claude-code-voc-intelligence version
-New-Item -ItemType Directory -Force outputs\vscode-workspace-plugin-npx-smoke-0.3.0 | Out-Null
-Push-Location outputs\vscode-workspace-plugin-npx-smoke-0.3.0
-npx --yes @gangvy/claude-code-voc-intelligence@latest workspace --smoke
+npm view @vocmarket/voc-skill name version dist-tags.latest --registry=https://registry.npmjs.org/
+$test = Join-Path $env:TEMP ("vocmarket-online-smoke-" + [guid]::NewGuid())
+New-Item -ItemType Directory -Path $test | Out-Null
+Push-Location $test
+npm exec --yes --package @vocmarket/voc-skill@latest -- claude-voc workspace --smoke
 Pop-Location
 ```
 
@@ -117,18 +178,35 @@ release\npm\claude-code-voc-intelligence\.env.local
 
 ## 本次发布记录
 
+- 2026-06-15 已发布 `@vocmarket/tihao@1.0.8`,npm `latest` 已验证为 `1.0.8`;本版把对外包名、主 CLI、插件目录和 MCP server 统一改为 `tihao`。
+- 已完成线上安装验证:`npx --yes @vocmarket/tihao@latest workspace --smoke` 与 `npm exec --yes --package @vocmarket/tihao@latest -- tihao install --smoke`。
+- 旧包 `@vocmarket/tihao-sop`、旧 CLI alias `tihao-sourcing` 和旧插件目录 `tihao-sourcing` 仅作为迁移兼容;客户新安装统一使用 `@vocmarket/tihao`。
+- 2026-06-15 已发布 `@vocmarket/tihao-sop@1.0.7`,npm `latest` 已验证为 `1.0.7`;本版同步区分项目级安装 `workspace --smoke` 和用户级全局 skill 安装 `install --smoke`。
+- 已完成线上安装验证:`npx --yes @vocmarket/tihao-sop@latest workspace --smoke`。
+- 本次 skill 改名后,CLI 仍为 `tihao-sourcing`,插件目录仍为 `tihao-sourcing`,Claude Code skill 改为 `tihao`;安装器会清理同目标下的 legacy skill `tihao-creator-sourcing`。
+- 2026-06-15 已发布 `@vocmarket/tihao-sop@1.0.6`,发布时 npm `latest` 已验证为 `1.0.6`。
+- 2026-06-05 已发布 `@vocmarket/tihao-sop@0.1.15`,npm `latest` 当时已验证为 `0.1.15`。
+- 发布 `0.1.14` 时出现过一次 `PUT 200`、`dist-tag latest` 可见但 `npm view`/`npx` 短暂 404 的半可见状态;已用 `0.1.15` 重新发布并通过安装验证。
+- 2026-06-05 已发布 `@vocmarket/voc-skill@0.3.15`,npm `latest` 已验证为 `0.3.15`。
+- 已完成线上安装验证:`npm exec --yes --package @vocmarket/voc-skill@latest -- claude-voc workspace --smoke`。
+- 本次改名只改 npm 对外包名,CLI 仍为 `claude-voc`,插件目录仍为 `voc-intelligence`,避免破坏 Claude Code 工作区识别。
+- 已将旧包 `@gangvy/claude-code-voc-intelligence@*` 标记为 deprecated,安装旧包时会提示改用:`npx --yes @vocmarket/voc-skill@latest workspace --smoke`。
+- 已借鉴 `tihao-sop` 的验收方式新增 `npm run claude-voc:acceptance`:串联包级 smoke、MCP smoke、npm pack dry-run、临时 workspace 安装和 `.mcp.json` 绝对路径断言。
+- 发布过程中出现过一次“publish 已成功但后置 npm view 短暂 404”的假失败;确认方式是等待后重新执行 `npm view @vocmarket/voc-skill@0.3.15 version`。
 - 2026-05-25 已发布 `@gangvy/claude-code-voc-intelligence@0.3.0`,npm `latest` 已验证为 `0.3.0`。
 - `0.3.0` 新增抖音趋势情报能力:`douyin-trend-intelligence` skill、`voc_douyin_trend_run`、`voc_douyin_token_check`、`voc_douyin_preference_update`。
 - 保留小红书趋势情报能力,并让工作区安装同时生成小红书和抖音两个 skill 入口。
 - 统一 token / 充值体验:没有 token 返回开通链接,余额不足或业务 403 返回充值链接,不暴露底层接口报错。
 - 新增 `business-status recharge guard` smoke,用来防止 HTTP 200 但业务码 403 时误判为成功。
 - 已通过 `mcp:smoke`、`smoke:package`、`xhs:sample`、`dy:sample`、`claude-voc:build`、`claude-voc:npm-pack`、`claude-voc:npm-publish`。
-- 发布后已用 `npx --yes @gangvy/claude-code-voc-intelligence@latest workspace --smoke` 从 npm registry 重新安装并验证通过。
+- 当前新包发布后已用 `npm exec --yes --package @vocmarket/voc-skill@latest -- claude-voc workspace --smoke` 从 npm registry 重新安装并验证通过。
 
 ## 后续更新规则
 
-- 升级功能时先改 `claude-code-voc-intelligence/package.json` 版本,再同步 `skill-package-manifest.json`、`.claude-plugin/plugin.json` 和发布管理文档。
+- 升级功能时先改 `claude-code/claude-code-voc-intelligence/package.json` 版本,再同步 `package-lock.json`、`skill-package-manifest.json`、`.claude-plugin/plugin.json` 和发布管理文档。
 - 保持输入简单:用户说自然语言即可,不要求理解 MCP schema。
 - 第一轮报告只输出“初步判断 / 机会假设 / 待校准”,不写成最终结论。
 - 新增平台能力优先作为并列 skill 和并列 MCP tool,不把完整业务闭环强塞进单一 skill。
-- 每次发布前必须跑包级 smoke,并包含 sample、workspace 安装、no-token 充值提示、余额/业务码保护和 MCP tool 发现。
+- 每次发布前必须跑 `npm run claude-voc:acceptance`,它会覆盖 sample、workspace 安装、no-token 充值提示、余额/业务码保护、MCP tool 发现和 npm pack dry-run。
+
+- 2026-06-17 已发布 `@vocmarket/tihao@1.0.10`,npm `latest` 已验证为 `1.0.10`;本版把首版报告默认改为 live + resultFirstMode 高质量首版,sample 仅在用户明确要演示时使用,token 缺失时主动要 sessionToken 而不退回 sample。

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

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

+ 2 - 2
claude-code/claude-code-voc-intelligence/.mcp.json

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

+ 8 - 3
claude-code/claude-code-voc-intelligence/README.md

@@ -95,7 +95,7 @@ VOC 内容选题会输出:
 推荐安装到当前工作区,适合 VSCode Claude Code:
 
 ```powershell
-npx --yes @gangvy/claude-code-voc-intelligence@latest workspace --smoke
+npx --yes @vocmarket/voc-skill@latest workspace --smoke
 ```
 
 安装后会生成:
@@ -110,20 +110,21 @@ npx --yes @gangvy/claude-code-voc-intelligence@latest workspace --smoke
 .claude/skills/voc-speaking-script/SKILL.md
 .claude/skills/voc-competitor-map/SKILL.md
 .claude/skills/voc-business-workflow/SKILL.md
+.claude/skills/fmode-image-analysis/SKILL.md
 .claude/plugins/voc-intelligence
 ```
 
 也可以全局安装:
 
 ```powershell
-npm install -g @gangvy/claude-code-voc-intelligence
+npm install -g @vocmarket/voc-skill
 claude-voc install --smoke
 ```
 
 或免全局安装:
 
 ```powershell
-npx --yes @gangvy/claude-code-voc-intelligence@latest install --smoke
+npx --yes @vocmarket/voc-skill@latest install --smoke
 ```
 
 ## 推荐用户话术
@@ -186,9 +187,11 @@ npx --yes @gangvy/claude-code-voc-intelligence@latest install --smoke
 - `voc-speaking-script`
 - `voc-competitor-map`
 - `voc-business-workflow`
+- `fmode-image-analysis`
 
 MCP 工具:
 
+- `fmode_image_analysis`
 - `voc_business_workflow_run`
 - `voc_xiaohongshu_trend_run`
 - `voc_xiaohongshu_preference_update`
@@ -207,6 +210,8 @@ MCP 工具:
 ```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 fmode:image:smoke
+claude-voc image --image-path E:\workspace\example\screenshot.png --prompt "识别截图里的文字、主体和风险点"
 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

+ 17 - 2
claude-code/claude-code-voc-intelligence/bin/claude-voc.js

@@ -15,6 +15,7 @@ const EXCLUDED_DIRS = new Set([
   'node_modules',
   'memory',
   'outputs',
+  '.npm-cache-acceptance',
   '.tmp',
   '.git',
   '.claude'
@@ -36,11 +37,12 @@ function usage() {
     '  claude-voc install --workspace --smoke',
     '  claude-voc check',
     '  claude-voc smoke',
+    '  claude-voc image --image-path <file> --prompt <text>',
     '  claude-voc path',
     '',
     'npx:',
-    '  npx @gangvy/claude-code-voc-intelligence install',
-    '  npx @gangvy/claude-code-voc-intelligence workspace --smoke',
+    '  npx @vocmarket/voc-skill install',
+    '  npx @vocmarket/voc-skill workspace --smoke',
     '',
     'Options:',
     '  --workspace      Install into ./.claude/plugins/voc-intelligence',
@@ -181,6 +183,8 @@ function assertInstalled(targetDir) {
     'skills/voc-speaking-script/SKILL.md',
     'skills/voc-competitor-map/SKILL.md',
     'skills/voc-business-workflow/SKILL.md',
+    'skills/fmode-image-analysis/SKILL.md',
+    'mcp/src/tools/fmode-image-analysis.js',
     'mcp/src/server.js'
   ];
   const missing = required.filter(rel => !fs.existsSync(path.join(targetDir, rel)));
@@ -641,6 +645,7 @@ function writeWorkspaceSkillEntries(targetDir) {
   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');
+  const fmodeImageSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'fmode-image-analysis');
   ensureDir(xhsSkillDir);
   ensureDir(douyinSkillDir);
   ensureDir(issuePoolSkillDir);
@@ -649,6 +654,7 @@ function writeWorkspaceSkillEntries(targetDir) {
   ensureDir(speakingScriptSkillDir);
   ensureDir(competitorMapSkillDir);
   ensureDir(businessWorkflowSkillDir);
+  ensureDir(fmodeImageSkillDir);
   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');
@@ -657,6 +663,7 @@ function writeWorkspaceSkillEntries(targetDir) {
   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');
+  fs.copyFileSync(path.join(targetDir, 'skills', 'fmode-image-analysis', 'SKILL.md'), path.join(fmodeImageSkillDir, 'SKILL.md'));
 }
 
 function writeWorkspaceActivation(targetDir) {
@@ -672,6 +679,7 @@ function writeWorkspaceActivation(targetDir) {
   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');
+  console.log('Wrote workspace skill entry: .\\.claude\\skills\\fmode-image-analysis\\SKILL.md');
 }
 
 function printNextSteps(targetDir) {
@@ -692,6 +700,7 @@ function printNextSteps(targetDir) {
     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\\skills\\fmode-image-analysis\\SKILL.md');
     console.log('  .\\.claude\\plugins\\voc-intelligence');
     console.log('');
     console.log('Restart the VSCode Claude Code session if it was already open.');
@@ -769,6 +778,12 @@ function printPath(opts) {
 }
 
 function main() {
+  const directCommand = process.argv[2];
+  if (directCommand === 'image' || directCommand === 'fmode-image') {
+    run(process.execPath, ['mcp/src/tools/fmode-image-analysis.js', ...process.argv.slice(3)], SOURCE_ROOT);
+    return;
+  }
+
   const opts = parseArgs(process.argv.slice(2));
   if (opts.help || opts.command === 'help') {
     console.log(usage());

+ 8 - 2
claude-code/claude-code-voc-intelligence/docs/capability-map.md

@@ -22,6 +22,12 @@
 | 选中一条内容继续改口播 | `voc-speaking-script` | `voc_speaking_script_run` | 60 秒稿、30 秒稿、提词器版、改稿反馈、定稿记忆 |
 | 下轮继续优化 | 同一 skill | 带同一 `memory`,或带 project/industry/scenario/audience 让工具自动分桶 | 应用历史记忆,避免从零开始 |
 
+## Fmode Image Analysis Add-on
+
+| 能力 | 推荐入口 | MCP 工具 | 输出重点 |
+| --- | --- | --- | --- |
+| 图片识别/截图分析 | `fmode-image-analysis` | `fmode_image_analysis` | 使用平台用户 token 调用 Fmode Doubao vision,默认模型 `doubao-seed-2-0-pro-260215` |
+
 ## 技能入口
 
 ### `voc-business-workflow`
@@ -167,8 +173,8 @@
 - `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` 通过。
+- npm 发布后 `npm view @vocmarket/voc-skill version` 返回新版本。
+- 测试工作区 `npx --yes @vocmarket/voc-skill@latest workspace --smoke` 通过。
 
 ## 下一轮优化补充
 

+ 184 - 49
claude-code/claude-code-voc-intelligence/docs/npm-packaging-notes.md

@@ -1,93 +1,228 @@
-# Claude Code VOC 技能包 npm 打包经验
+# Claude Code VOC 技能包 npm 打包与发布说明
 
-## 目标
+## 当前包名
 
-同一个技能包同时支持两种交付
+VOC 技能包当前对外 npm 包名
 
-- 链接下载 zip:适合不会 npm 的客户,解压后执行 `node install.js`。
-- npm/npx 安装:适合教学、远程交付和批量升级。
+```bash
+@vocmarket/voc-skill
+```
 
-推荐命令
+CLI 命令保持不变
 
 ```bash
-npm install -g @gangvy/claude-code-voc-intelligence
-claude-voc install
+claude-voc
 ```
 
-免全局安装
+插件安装目录也保持不变
 
-```bash
-npx @gangvy/claude-code-voc-intelligence install
+```text
+voc-intelligence
 ```
 
-## 关键设计
+这样做的目的:对外包名更短、更适合课程和市场传播;同时不破坏已经沉淀好的 Claude Code 插件目录、MCP server 名称和 skill 入口。
+
+## 用户安装方式
+
+推荐给 VSCode Claude Code 使用工作区安装:
 
-`npx` 下载的包可能在临时缓存里,不能直接让 Claude Code 长期加载这个临时目录。
+```powershell
+npx --yes @vocmarket/voc-skill@latest workspace --smoke
+```
 
-所以 `claude-voc install` 会把包复制到固定目录:
+工作区安装会写入
 
 ```text
-~/.claude/plugins/voc-intelligence
+.\.mcp.json
+.\.claude\plugins\voc-intelligence
+.\.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
 ```
 
-然后在该目录安装运行依赖,并输出:
+全局安装
 
-```bash
-claude --plugin-dir "<安装目录>"
+```powershell
+npm install -g @vocmarket/voc-skill
+claude-voc install
 ```
 
-这样全局安装和 npx 安装的结果一致,后续也便于升级。
+npx 免全局安装:
 
-## package.json 要点
+```powershell
+npx --yes @vocmarket/voc-skill@latest install
+```
 
-- npm 包名使用 scoped package:`@gangvy/claude-code-voc-intelligence`
-- CLI 入口:
+## package.json 要点
 
 ```json
 {
+  "name": "@vocmarket/voc-skill",
   "bin": {
     "claude-voc": "bin/claude-voc.js"
+  },
+  "publishConfig": {
+    "access": "public"
   }
 }
 ```
 
-- `files` 必须显式包含:
-  - `.claude-plugin/`
-  - `.mcp.json`
-  - `bin/`
-  - `skills/`
-  - `mcp/`
-  - `memory-templates/`
-  - `docs/`
-  - `install.js`
-  - `skill-package-manifest.json`
+`files` 必须显式包含:
+
+- `.claude-plugin/`
+- `.mcp.json`
+- `bin/`
+- `docs/`
+- `install.js`
+- `mcp/`
+- `memory-templates/`
+- `package.json`
+- `scripts/`
+- `skill-package-manifest.json`
+- `skills/`
+
+不要发布:
+
+- `.env`
+- `.env.local`
+- `.npmrc`
+- `node_modules/`
+- `memory/`
+- `outputs/`
+- 本地 smoke 临时目录
+
+## 发布前验证
+
+在仓库根目录执行:
+
+```powershell
+npm --prefix claude-code/claude-code-voc-intelligence run mcp:smoke
+npm --prefix claude-code/claude-code-voc-intelligence run smoke:package
+npm run claude-voc:acceptance
+npm run claude-voc:build
+npm run claude-voc:npm-pack
+```
+
+`npm run claude-voc:npm-pack` 会做三类验证:
 
-## 验证命令
+1. 校验 npm package metadata、CLI 入口和必要文件。
+2. 生成 `dist/npm/vocmarket-voc-skill-<version>.tgz`。
+3. 在临时目录模拟 npm install、npx install、workspace install,并执行 `--smoke`。
 
-根目录执行:
+`npm run claude-voc:acceptance` 是更完整的发布前验收,会额外断言 workspace 安装后的
 
-```bash
-npm run claude-voc:npm-pack
-```
+- `.mcp.json` 已生成
+- 8 个 `.claude/skills/*/SKILL.md` 入口已生成
+- MCP server 路径是安装后插件目录里的绝对路径
+- `npm pack --dry-run` 能看到关键文件
 
-这个命令会做三件事:
+## 正式发布
 
-1. 校验 npm package metadata 和 CLI 入口。
-2. 生成 `dist/npm/*.tgz`。
-3. 在临时目录里模拟 npm 安装,并执行 `claude-voc install --smoke`。
+推荐使用 release 脚本:
 
-发布命令:
+```powershell
+powershell -ExecutionPolicy Bypass -File release/npm/claude-code-voc-intelligence/publish.ps1
+```
 
-```bash
+也可以直接执行:
+
+```powershell
 npm run claude-voc:npm-publish
 ```
 
-发布前需要完成 `npm login`,或在 CI/部署环境配置 npm token。
+发布脚本会读取以下位置的 token:
+
+```text
+release/npm/claude-code-voc-intelligence/.env.local
+```
+
+文件内容格式:
+
+```text
+NPM_TOKEN=你的 npm automation token
+```
+
+也支持环境变量:
+
+```powershell
+$env:NPM_TOKEN="你的 npm automation token"
+```
+
+不要提交 `.env.local`、npm token、VOC session token、模型 API key 或任何真实用户凭证。
+
+## 发布后验证
+
+确认 npm latest:
+
+```powershell
+npm view @vocmarket/voc-skill name version dist-tags.latest --registry=https://registry.npmjs.org/
+```
+
+线上安装 smoke:
+
+```powershell
+$test = Join-Path $env:TEMP ("vocmarket-online-smoke-" + [guid]::NewGuid())
+New-Item -ItemType Directory -Path $test | Out-Null
+Push-Location $test
+npm exec --yes --package @vocmarket/voc-skill@latest -- claude-voc workspace --smoke
+Pop-Location
+```
+
+## npm 发布假失败处理
+
+有时 `npm publish` 已经返回成功,例如:
+
+```text
++ @vocmarket/voc-skill@0.3.15
+```
+
+但脚本紧接着执行 `npm view` 时,registry 还没同步完成,会短暂返回 404。
+
+处理方式:
+
+1. 等 20-60 秒后重新执行:
+
+```powershell
+npm view @vocmarket/voc-skill@0.3.15 version --registry=https://registry.npmjs.org/
+```
+
+2. 如果返回版本号,说明发布已经成功,不要重复发布同一个版本。
+3. 如果持续 404,再检查 npm 组织权限、token 权限和包名 scope。
+
+当前发布脚本已经把后置校验等待次数从 6 次增加到 12 次,减少这种假失败。
+
+## 当前发布记录
+
+- 2026-06-05:已发布 `@vocmarket/voc-skill@0.3.15`。
+- `npm view @vocmarket/voc-skill name version dist-tags.latest` 返回 `0.3.15`。
+- 线上 `npm exec --yes --package @vocmarket/voc-skill@latest -- claude-voc workspace --smoke` 已通过。
+- 旧包 `@gangvy/claude-code-voc-intelligence@*` 已标记为 deprecated,安装旧包时会提示改用 `@vocmarket/voc-skill`。
+- 已新增 `npm run claude-voc:acceptance` 一键发布前验收,覆盖包级 smoke、MCP smoke、npm pack dry-run 和 workspace 安装断言。
+- 包内包含 8 个 skill、12 个 MCP tools。
+- smoke 覆盖 sample report、证据卡、图片缓存、xsec URL 规范化、偏好记忆、no-token 充值提示、祖先 `.env.local` token 读取、抖音搜索入参错误保护、跨行业样例和 MCP tool discovery。
+
+## 后续版本同步
+
+升级版本时同步修改:
 
-## 注意点
+- `claude-code/claude-code-voc-intelligence/package.json`
+- `claude-code/claude-code-voc-intelligence/package-lock.json`
+- `claude-code/claude-code-voc-intelligence/skill-package-manifest.json`
+- `claude-code/claude-code-voc-intelligence/.claude-plugin/plugin.json`
+- 如涉及 MCP server 或记忆 schema,同步对应版本字段
+- `dist/npm/claude-code-voc-npm-package-manifest.json`
+- `dist/claude-code-voc-intelligence-suite-manifest.json`
+- `release/npm/claude-code-voc-intelligence/README.md`
+- `release/npm/claude-code-voc-intelligence/release-checklist.md`
 
-- zip 包和 npm 包共用同一套源码,但验证脚本分开。
-- npm 包不要发布 `node_modules/`、`memory/`、`outputs/`、`.env`。
-- `install.js` 不强依赖 `package-lock.json`,因为 npm publish 后 package-lock 不一定随包安装。
-- 用户侧不要暴露 profile、output、collectionMode 这类参数;安装完成后仍然用自然语言启动。
+## 体验原则
 
+- 用户侧不要暴露 `profile`、`output`、`collectionMode` 等底层参数。
+- 用户用自然语言触发 VOC 采集、问题池、单点深挖、内容计划和口播脚本。
+- 第一轮真实采集只输出“初步判断 / 机会假设 / 待校准”,不要包装成最终结论。
+- 无 token、余额不足、业务权限未开通时,返回开通/充值引导,不直接暴露底层 403、余额接口或 stack trace。

+ 1 - 1
claude-code/claude-code-voc-intelligence/docs/payment-package-links.md

@@ -46,7 +46,7 @@ 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
+npx @vocmarket/voc-skill@latest workspace --smoke
 ```
 
 安装后会写入:

+ 4 - 1
claude-code/claude-code-voc-intelligence/install.js

@@ -7,7 +7,8 @@ const ROOT = __dirname;
 const MIN_NODE_MAJOR = 18;
 const PACKAGE_NAMES = new Set([
   'claude-code-voc-intelligence',
-  '@gangvy/claude-code-voc-intelligence'
+  '@gangvy/claude-code-voc-intelligence',
+  '@vocmarket/voc-skill'
 ]);
 
 function parseArgs(argv) {
@@ -110,6 +111,8 @@ function checkFiles() {
     'skills/voc-speaking-script/SKILL.md',
     'skills/voc-competitor-map/SKILL.md',
     'skills/voc-business-workflow/SKILL.md',
+    'skills/fmode-image-analysis/SKILL.md',
+    'mcp/src/tools/fmode-image-analysis.js',
     'mcp/src/server.js'
   ].forEach(assertFile);
 

+ 1 - 1
claude-code/claude-code-voc-intelligence/mcp/src/core/evidence-quality.js

@@ -87,7 +87,7 @@ function buildNoSampleFallbackMessage({ platformLabel, profile, examples = [] })
   return [
     `本次 live 模式没有采到可用于分析的${platformLabel}样本。`,
     '',
-    '这不代表行业没有机会,更可能是关键词过窄、平台内容表达不一样,或本轮上游没有返回可用内容。',
+    '说明:这不是类目限制,也不是“系统只支持某些行业”——任何行业(含律师、法律、医美、教育、专业服务等)都能采集。更可能是关键词过窄、平台内容表达不一样,或本轮上游没有返回可用内容。',
     '',
     '建议按这三步继续:',
     `1. 先保留当前方向:${keywords}`,

+ 20 - 1
claude-code/claude-code-voc-intelligence/mcp/src/core/payment-links.js

@@ -105,6 +105,24 @@ function buildRechargeRequiredMessage(paymentUrl, recharge = {}, options = {}) {
   ].join('\n');
 }
 
+function buildWrongTokenTypeMessage(paymentUrl, recharge = {}, options = {}) {
+  const platformLabel = options.platformLabel || '小红书';
+  const packageUrl = recharge.recommendedPackageUrl || recharge.workshopUrl || paymentUrl;
+  return [
+    `检测到配置的是 sk- 开头的 AIGate API Key,这不是 ${platformLabel} 数据采集所需的 token,暂时不能进行真实采集。`,
+    '',
+    '这不是关键词或类目问题:任何关键词(含律师、法律、专业服务等)都可以采集,只要 token 类型正确并已开通额度。',
+    '',
+    `${platformLabel} 采集需要从充值/开通页复制的、以 r: 开头的会话 token(session token),不是 sk- 开头的 AIGate Key。`,
+    '',
+    `请打开充值/开通链接:${paymentUrl}`,
+    '',
+    `课程或工作坊用户也可以打开套餐入口:${packageUrl}`,
+    '',
+    '把以 r: 开头的 session token 配置到当前工作区 `.env.local` 或 `~/.claude/voc-credentials.json`(键名 vocToken / VOC_TOKEN),再重新运行 live 采集。'
+  ].join('\n');
+}
+
 module.exports = {
   VOC_SOCIAL_APIG_ID,
   VOC_RECHARGE_FUN_ID,
@@ -113,5 +131,6 @@ module.exports = {
   buildVocRechargeUrl,
   buildVocWorkshopUrl,
   buildMissingTokenMessage,
-  buildRechargeRequiredMessage
+  buildRechargeRequiredMessage,
+  buildWrongTokenTypeMessage
 };

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

@@ -318,7 +318,7 @@ async function collectLiveDataset({ profile, token, options = {} }) {
     errors.push({
       stage: 'search_videos',
       kind: 'invalid_keyword',
-      message: '请提供至少一个可用于抖音搜索的关键词,例如“茶饮 18元 22元”或“茶饮 性价比 新客”。',
+      message: '请提供至少一个可用于抖音搜索的关键词(任何行业都可以,例如“<品类/服务> <人群> <场景>”这种自然短语)。',
       httpStatus: 0
     });
     return { videos, comments, warnings, errors, keywords };

+ 427 - 0
claude-code/claude-code-voc-intelligence/mcp/src/features/fmode-image-analysis/image-analysis.js

@@ -0,0 +1,427 @@
+const fs = require('fs');
+const path = require('path');
+const { readVocToken, readEnvFileMaybe, envLocalCandidates } = require('../../core/credentials');
+const { buildVocRechargeInfo } = require('../../core/payment-links');
+const { okResult, errorResult } = require('../../core/result-envelope');
+
+const DEFAULT_BASE_URL = 'https://api.fmode.cn';
+const DEFAULT_PATH = 'v1/chat/completions';
+const DEFAULT_MODEL = 'doubao-seed-2-0-pro-260215';
+const DEFAULT_PROMPT = '请分析图片内容,提取可见文字、主体对象、场景、关键信息、潜在风险,并给出简洁结论。';
+const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
+
+async function analyzeFmodeImage(input = {}) {
+  let images;
+  try {
+    images = resolveImageInputs(input);
+  } catch (error) {
+    return errorResult(safeMessage(error), { errors: [] });
+  }
+
+  if (!images.length) {
+    return errorResult('请提供要分析的图片路径或图片 URL,例如 imagePath、imageUrl 或 images。', {
+      errors: [],
+      nextActions: ['传入本地图片路径、HTTP 图片 URL,或 data:image/... base64。']
+    });
+  }
+
+  const provider = resolveProvider(input);
+  const token = resolveFmodeToken(input);
+  if (!token) {
+    const recharge = await buildVocRechargeInfo();
+    return friendlyResult('needs_token', buildMissingTokenMessage(recharge.paymentUrl, recharge), {
+      summary: { imageCount: images.length, model: provider.model },
+      data: { recharge, model: provider.model, imageCount: images.length },
+      nextActions: [
+        `打开开通/充值链接:${recharge.paymentUrl}`,
+        '在当前工作区 .env.local 配置 VOC_TOKEN / FMODE_TOKEN / TIHAO_SESSION_TOKEN。',
+        '或在 MCP 工具参数中传入 vocToken / sessionToken / fmodeToken。'
+      ]
+    });
+  }
+
+  const payload = buildChatPayload(input, provider, images);
+  let response;
+  try {
+    response = await fetch(provider.url, {
+      method: 'POST',
+      headers: {
+        Accept: 'application/json',
+        'Content-Type': 'application/json',
+        Authorization: `Bearer ${token}`
+      },
+      body: JSON.stringify(payload)
+    });
+  } catch {
+    return errorResult('图片分析服务暂时无法连接,请稍后重试或检查 Fmode 网关地址。', {
+      errors: [],
+      summary: { imageCount: images.length, model: provider.model },
+      warnings: ['network_error']
+    });
+  }
+
+  if (response.status === 401) {
+    return friendlyResult('needs_valid_token', '当前平台 token 无效或已过期,请换成有效的 vocToken / sessionToken / fmodeToken 后重试。', {
+      summary: { imageCount: images.length, model: provider.model },
+      data: { model: provider.model, imageCount: images.length }
+    });
+  }
+
+  if (response.status === 402 || response.status === 403) {
+    const recharge = await buildVocRechargeInfo({ token });
+    return friendlyResult('needs_recharge', buildRechargeMessage(recharge.paymentUrl, recharge), {
+      summary: { imageCount: images.length, model: provider.model },
+      data: { recharge, model: provider.model, imageCount: images.length },
+      nextActions: [`打开开通/充值链接:${recharge.paymentUrl}`]
+    });
+  }
+
+  if (!response.ok) {
+    return errorResult('图片分析服务返回异常;上游错误详情已隐藏,避免泄露鉴权或计费信息。', {
+      errors: [],
+      summary: { imageCount: images.length, model: provider.model },
+      data: { providerStatus: 'http_error' }
+    });
+  }
+
+  let json;
+  try {
+    json = await response.json();
+  } catch {
+    return errorResult('图片分析服务返回内容无法解析,请稍后重试。', {
+      errors: [],
+      summary: { imageCount: images.length, model: provider.model },
+      warnings: ['parse_error']
+    });
+  }
+
+  const analysis = extractAssistantText(json).trim();
+  if (!analysis) {
+    return errorResult('图片分析服务没有返回可用文本结果。', {
+      errors: [],
+      summary: { imageCount: images.length, model: provider.model },
+      warnings: ['empty_result']
+    });
+  }
+
+  const files = writeOutputs(input, {
+    analysis,
+    model: provider.model,
+    imageCount: images.length,
+    usage: json.usage || {},
+    sources: images.map(item => item.label)
+  });
+
+  return okResult({
+    assistantMessage: buildAssistantMessage({ analysis, provider, images, files }),
+    summary: {
+      imageCount: images.length,
+      model: provider.model,
+      hasUsage: Boolean(json.usage)
+    },
+    data: {
+      analysis,
+      model: provider.model,
+      imageCount: images.length,
+      imageSources: images.map(item => item.label),
+      usage: json.usage || {},
+      finishReason: json.choices?.[0]?.finish_reason || ''
+    },
+    files,
+    nextActions: [
+      '如需结构化 JSON,可补充字段要求后再次调用 fmode_image_analysis。',
+      '如需转入 VOC 报告,可将图片分析结果作为 evidence、context 或 reportText 传给对应 VOC 工具。'
+    ]
+  });
+}
+
+function buildChatPayload(input, provider, images) {
+  const prompt = [
+    input.prompt || input.question || input.text || DEFAULT_PROMPT,
+    input.context ? `\n补充背景:${input.context}` : '',
+    input.outputFormat === 'json' || input.responseFormat === 'json'
+      ? '\n请返回 JSON 字符串,不要使用 markdown 代码块。'
+      : ''
+  ].filter(Boolean).join('\n');
+
+  return {
+    model: provider.model,
+    temperature: Number(input.temperature ?? 0.2),
+    max_tokens: Number(input.maxTokens || input.max_tokens || 1800),
+    messages: [
+      {
+        role: 'system',
+        content: 'You are an image analysis assistant. Answer in Chinese unless the user asks for another language. Do not expose tokens, headers, or upstream error details.'
+      },
+      {
+        role: 'user',
+        content: [
+          { type: 'text', text: prompt },
+          ...images.map(image => ({
+            type: 'image_url',
+            image_url: {
+              url: image.url,
+              detail: input.detail || 'auto'
+            }
+          }))
+        ]
+      }
+    ]
+  };
+}
+
+function resolveProvider(input = {}) {
+  const baseUrl = String(input.baseUrl || input.fmodeBaseUrl || input.doubaoVisionBaseUrl || input.videoAnalysisBaseUrl || process.env.FMODE_BASE_URL || process.env.DOUBAO_VISION_BASE_URL || DEFAULT_BASE_URL);
+  const providerPath = String(input.path || input.fmodePath || input.doubaoVisionPath || input.videoAnalysisPath || process.env.FMODE_PATH || process.env.DOUBAO_VISION_PATH || DEFAULT_PATH);
+  return {
+    baseUrl,
+    path: providerPath,
+    model: resolveModel(input),
+    url: buildProviderUrl(baseUrl, providerPath)
+  };
+}
+
+function resolveModel(input = {}) {
+  return String(input.model || input.fmodeModel || input.doubaoVisionModel || input.videoAnalysisModel || process.env.FMODE_MODEL || process.env.DOUBAO_VISION_MODEL || DEFAULT_MODEL);
+}
+
+function resolveFmodeToken(input = {}) {
+  const explicit = firstNonEmpty([
+    input.fmodeToken,
+    input.imageAnalysisToken,
+    input.doubaoVisionToken,
+    input.videoAnalysisToken,
+    input.sessionToken,
+    input.tihaoToken,
+    input.vocToken,
+    input.token,
+    input.apiToken
+  ]);
+  if (explicit) return explicit;
+  if (input.allowEnvToken === false) return '';
+
+  const envValues = readFmodeEnvValues();
+  return firstNonEmpty([
+    envValues.FMODE_TOKEN,
+    envValues.FMODE_SESSION_TOKEN,
+    envValues.IMAGE_ANALYSIS_TOKEN,
+    envValues.DOUBAO_VISION_TOKEN,
+    envValues.VIDEO_ANALYSIS_TOKEN,
+    envValues.TIHAO_SESSION_TOKEN,
+    envValues.VOC_TOKEN,
+    envValues.VOC_SOCIAL_TOKEN,
+    process.env.FMODE_TOKEN,
+    process.env.FMODE_SESSION_TOKEN,
+    process.env.IMAGE_ANALYSIS_TOKEN,
+    process.env.DOUBAO_VISION_TOKEN,
+    process.env.VIDEO_ANALYSIS_TOKEN,
+    process.env.TIHAO_SESSION_TOKEN,
+    readVocToken(input)
+  ]);
+}
+
+function readFmodeEnvValues() {
+  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 resolveImageInputs(input = {}) {
+  const raw = [
+    input.imagePath,
+    input.image,
+    input.imageUrl,
+    input.url,
+    input.file,
+    input.attachment,
+    ...(Array.isArray(input.imagePaths) ? input.imagePaths : []),
+    ...(Array.isArray(input.imageUrls) ? input.imageUrls : []),
+    ...(Array.isArray(input.images) ? input.images : []),
+    ...(Array.isArray(input.files) ? input.files : []),
+    ...(Array.isArray(input.attachments) ? input.attachments : [])
+  ].filter(Boolean);
+
+  const seen = new Set();
+  const images = [];
+  for (const item of raw) {
+    const normalized = normalizeImageInput(item, input);
+    if (!normalized) continue;
+    if (seen.has(normalized.url)) continue;
+    seen.add(normalized.url);
+    images.push(normalized);
+  }
+  return images;
+}
+
+function normalizeImageInput(item, input = {}) {
+  if (typeof item === 'object' && item) {
+    return normalizeImageInput(
+      item.url ||
+        item.imageUrl ||
+        item.path ||
+        item.imagePath ||
+        item.filePath ||
+        item.localPath ||
+        item.dataUrl ||
+        item.dataUri ||
+        item.href,
+      input
+    );
+  }
+  const value = String(item || '').trim();
+  if (!value) return null;
+  if (/^data:image\//i.test(value)) return { url: value, label: 'data-url-image' };
+  if (/^https?:\/\//i.test(value)) return { url: value, label: safeUrlLabel(value) };
+  return fileToImageUrl(value, input);
+}
+
+function fileToImageUrl(file, input = {}) {
+  const fsPath = path.resolve(input.cwd || process.cwd(), String(file));
+  if (!fs.existsSync(fsPath)) throw new Error(`图片文件不存在:${fsPath}`);
+  const stats = fs.statSync(fsPath);
+  if (!stats.isFile()) throw new Error(`不是可读取的图片文件:${fsPath}`);
+  if (stats.size > Number(input.maxImageBytes || MAX_IMAGE_BYTES)) {
+    throw new Error(`图片文件过大:${path.basename(fsPath)},请压缩到 20MB 以内后重试。`);
+  }
+  const mime = mimeFromPath(fsPath);
+  const base64 = fs.readFileSync(fsPath).toString('base64');
+  return {
+    url: `data:${mime};base64,${base64}`,
+    label: path.basename(fsPath)
+  };
+}
+
+function mimeFromPath(file) {
+  const ext = path.extname(file).toLowerCase();
+  if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
+  if (ext === '.png') return 'image/png';
+  if (ext === '.webp') return 'image/webp';
+  if (ext === '.gif') return 'image/gif';
+  if (ext === '.bmp') return 'image/bmp';
+  return 'image/png';
+}
+
+function safeUrlLabel(value) {
+  try {
+    const url = new URL(value);
+    return `${url.hostname}${url.pathname}`.slice(0, 120);
+  } catch {
+    return 'remote-image';
+  }
+}
+
+function buildProviderUrl(baseUrl, providerPath) {
+  const cleaned = String(baseUrl || '').replace(/\/+$/, '');
+  const pathPart = String(providerPath || '').replace(/^\/+/, '');
+  return new URL(pathPart ? `${cleaned}/${pathPart}` : cleaned);
+}
+
+function extractAssistantText(json) {
+  const content = json?.choices?.[0]?.message?.content || json?.choices?.[0]?.text || json?.output_text || json?.output || '';
+  if (typeof content === 'string') return content;
+  if (Array.isArray(content)) {
+    return content.map(part => {
+      if (typeof part === 'string') return part;
+      return part.text || part.content || '';
+    }).filter(Boolean).join('\n');
+  }
+  return '';
+}
+
+function writeOutputs(input, result) {
+  const target = input.output || input.outputPath || input.outputDir || '';
+  if (!target) return [];
+  const resolved = path.resolve(input.cwd || process.cwd(), String(target));
+  const ext = path.extname(resolved).toLowerCase();
+  const dir = ext ? path.dirname(resolved) : resolved;
+  fs.mkdirSync(dir, { recursive: true });
+
+  if (ext === '.json') {
+    fs.writeFileSync(resolved, JSON.stringify(result, null, 2), 'utf8');
+    return [resolved];
+  }
+  if (ext === '.md') {
+    fs.writeFileSync(resolved, `# Fmode Image Analysis\n\n${result.analysis}\n`, 'utf8');
+    return [resolved];
+  }
+
+  const jsonPath = path.join(dir, 'fmode-image-analysis.json');
+  const markdownPath = path.join(dir, 'fmode-image-analysis.md');
+  fs.writeFileSync(jsonPath, JSON.stringify(result, null, 2), 'utf8');
+  fs.writeFileSync(markdownPath, `# Fmode Image Analysis\n\n${result.analysis}\n`, 'utf8');
+  return [markdownPath, jsonPath];
+}
+
+function buildAssistantMessage({ analysis, provider, images, files }) {
+  return [
+    '## Fmode 图片分析结果',
+    '',
+    analysis,
+    '',
+    `模型:${provider.model}`,
+    `图片数量:${images.length}`,
+    files && files.length ? `输出文件:${files.join(';')}` : ''
+  ].filter(Boolean).join('\n');
+}
+
+function buildMissingTokenMessage(paymentUrl, recharge = {}) {
+  const packageUrl = recharge.recommendedPackageUrl || recharge.workshopUrl || paymentUrl;
+  return [
+    '还没有检测到可用于 Fmode 图片分析的平台 token,暂时不能调用 Doubao vision 模型。',
+    '',
+    `开通/充值链接:${paymentUrl}`,
+    `套餐入口:${packageUrl}`,
+    '',
+    '开通后,可把平台 token 配置到当前工作区 `.env.local`,字段可用 `VOC_TOKEN`、`FMODE_TOKEN` 或 `TIHAO_SESSION_TOKEN`;也可以在工具参数中传入 `vocToken` / `sessionToken` / `fmodeToken`。',
+    '',
+    '系统只会把 token 用作接口鉴权,不会在聊天、报告、日志或输出文件里回显。'
+  ].join('\n');
+}
+
+function buildRechargeMessage(paymentUrl, recharge = {}) {
+  const packageUrl = recharge.recommendedPackageUrl || recharge.workshopUrl || paymentUrl;
+  return [
+    'Fmode 图片分析服务未开通或额度不足,暂时不能继续调用 Doubao vision 模型。',
+    '',
+    `请打开开通/充值链接补充额度:${paymentUrl}`,
+    `套餐入口:${packageUrl}`,
+    '',
+    '底层鉴权或额度错误详情已隐藏,不会把上游响应、Authorization header 或 token 展示给用户。'
+  ].join('\n');
+}
+
+function friendlyResult(status, assistantMessage, payload = {}) {
+  return {
+    status,
+    assistantMessage,
+    summary: payload.summary || {},
+    data: payload.data || {},
+    files: payload.files || [],
+    nextActions: payload.nextActions || [],
+    warnings: payload.warnings || [],
+    errors: []
+  };
+}
+
+function firstNonEmpty(values) {
+  return values.find(value => typeof value === 'string' && value.trim()) || '';
+}
+
+function safeMessage(error) {
+  return String(error && error.message ? error.message : error || '')
+    .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/g, 'Bearer ***')
+    .replace(/token["':=\s]+[A-Za-z0-9._~+/=-]+/gi, 'token=***')
+    .replace(/sk-[A-Za-z0-9_-]+/g, 'sk-***');
+}
+
+module.exports = {
+  DEFAULT_MODEL,
+  analyzeFmodeImage,
+  buildChatPayload,
+  resolveFmodeToken,
+  resolveImageInputs
+};

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

@@ -61,16 +61,19 @@ function classifyApiError({ response, json, message }) {
   const businessStatus = pickBusinessStatus(json);
   const status = businessStatus || httpStatus;
   const text = String(message || '');
-  if (status === 401 || /invalid token|无效.*token|token.*invalid/i.test(text)) {
+  if (/company或用户信息不存在|用户信息不存在|用户不存在|账[号户]信息不存在|未登录|登录失效|请输入.*token|sessiontoken/i.test(text)) {
     return 'auth';
   }
-  if (status === 402 || /余额不足|insufficient|balance|quota|payment/i.test(text)) {
+  if (status === 401 || /invalid token|无效.*token|token.*invalid|未授权|unauthorized/i.test(text)) {
+    return 'auth';
+  }
+  if (status === 402 || /余额不足|额度不足|没开通|未开通|开通.*权限|权限.*额度|insufficient|balance|quota|payment|充值/i.test(text)) {
     return 'billing';
   }
-  if (status === 403 && /余额不足|insufficient|balance|quota|payment/i.test(text)) {
+  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)) {
+  if (/参数|入参|keyword|关键词|invalid request|bad request/i.test(text) || [400, 422].includes(status)) {
     return 'request';
   }
   if (status >= 500 && /社交平台接口请求错误|request error|接口请求错误/i.test(text)) {
@@ -85,32 +88,54 @@ function classifyApiError({ response, json, message }) {
   return 'upstream';
 }
 
-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.kind = classifyApiError({ response, json, message });
-    error.responseBody = redactSecret(text);
-    throw error;
-  }
-  return { json, data: unwrapData(json), rawText: text };
+const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
+
+async function requestJson(url, token, { method = 'GET', body, retries = 3 } = {}) {
+  let lastError;
+  for (let attempt = 0; attempt <= retries; attempt++) {
+    let response;
+    let text;
+    try {
+      response = await fetch(url, {
+        method,
+        headers: {
+          Authorization: `Bearer ${token}`,
+          Accept: 'application/json',
+          ...(method === 'POST' ? { 'Content-Type': 'application/json' } : {})
+        },
+        body: body ? JSON.stringify(body) : undefined
+      });
+      text = await response.text();
+    } catch (networkError) {
+      lastError = networkError;
+      if (attempt < retries) {
+        await delay(400 * (attempt + 1));
+        continue;
+      }
+      throw networkError;
+    }
+    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.kind = classifyApiError({ response, json, message });
+      error.responseBody = redactSecret(text);
+      if (error.httpStatus >= 500 && attempt < retries) {
+        lastError = error;
+        await delay(400 * (attempt + 1));
+        continue;
+      }
+      throw error;
+    }
+    return { json, data: unwrapData(json), rawText: text };
+  }
+  throw lastError;
 }
 
 class DouyinApi {

+ 44 - 22
claude-code/claude-code-voc-intelligence/mcp/src/providers/xiaohongshu-api.js

@@ -37,30 +37,52 @@ function redactSecret(value) {
     .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 delay = ms => new Promise(resolve => setTimeout(resolve, ms));
+
+async function requestJson(url, token, { retries = 3 } = {}) {
+  let lastError;
+  for (let attempt = 0; attempt <= retries; attempt++) {
+    let response;
+    let text;
+    try {
+      response = await fetch(url, {
+        method: 'GET',
+        headers: {
+          Authorization: `Bearer ${token}`,
+          Accept: 'application/json'
+        }
+      });
+      text = await response.text();
+    } catch (networkError) {
+      lastError = networkError;
+      if (attempt < retries) {
+        await delay(400 * (attempt + 1));
+        continue;
+      }
+      throw networkError;
     }
-  });
-  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;
+    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);
+      if (error.httpStatus >= 500 && attempt < retries) {
+        lastError = error;
+        await delay(400 * (attempt + 1));
+        continue;
+      }
+      throw error;
+    }
+    return { json, data: unwrapData(json), rawText: text };
   }
-  return { json, data: unwrapData(json), rawText: text };
+  throw lastError;
 }
 
 class XiaohongshuApi {

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

@@ -13,6 +13,7 @@ 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 { analyzeFmodeImage } = require('./tools/fmode-image-analysis');
 const { buildVocRechargeInfo, buildMissingTokenMessage } = require('./core/payment-links');
 
 function asToolResult(result) {
@@ -59,9 +60,82 @@ function normalizeToolResult(result = {}) {
 function createServer() {
   const server = new McpServer({
     name: 'voc-intelligence',
-    version: '0.3.15'
+    version: '0.3.16'
   });
 
+  server.registerTool(
+    'fmode_image_analysis',
+    {
+      title: 'Analyze Images With Fmode Doubao Vision',
+      description: [
+        'Analyze screenshots, product images, UI images, charts, posters, or local image files through Fmode OpenAI-compatible Doubao vision.',
+        'Use when the current Claude Code turn includes uploaded or pasted image attachments surfaced as local temp file paths, file objects, or data URLs.',
+        'Use when the user asks for 图片识别, 识图, 看图, 分析截图, 提取图片文字, UI 截图分析, or when a text-only model such as DeepSeek cannot read an image.',
+        'Default model is doubao-seed-2-0-pro-260215 and billing uses the configured platform user token instead of a separate model key.'
+      ].join(' '),
+      inputSchema: {
+        imagePath: z.string().optional(),
+        imagePaths: z.array(z.string()).optional(),
+        imageUrl: z.string().optional(),
+        imageUrls: z.array(z.string()).optional(),
+        image: z.union([z.string(), z.object({}).passthrough()]).optional(),
+        images: z.array(z.union([z.string(), z.object({}).passthrough()])).optional(),
+        file: z.union([z.string(), z.object({}).passthrough()]).optional(),
+        files: z.array(z.union([z.string(), z.object({}).passthrough()])).optional(),
+        attachment: z.union([z.string(), z.object({}).passthrough()]).optional(),
+        attachments: z.array(z.union([z.string(), z.object({}).passthrough()])).optional(),
+        prompt: z.string().optional(),
+        question: z.string().optional(),
+        text: z.string().optional(),
+        context: z.string().optional(),
+        outputFormat: z.enum(['text', 'json']).optional(),
+        responseFormat: z.enum(['text', 'json']).optional(),
+        detail: z.enum(['auto', 'low', 'high']).optional(),
+        temperature: z.number().optional(),
+        maxTokens: z.number().int().min(64).max(8192).optional(),
+        maxImageBytes: z.number().int().min(1024).optional(),
+        output: z.string().optional(),
+        outputPath: z.string().optional(),
+        outputDir: z.string().optional(),
+        baseUrl: z.string().optional(),
+        path: z.string().optional(),
+        fmodeBaseUrl: z.string().optional(),
+        fmodePath: z.string().optional(),
+        fmodeModel: z.string().optional(),
+        model: z.string().optional(),
+        doubaoVisionBaseUrl: z.string().optional(),
+        doubaoVisionPath: z.string().optional(),
+        doubaoVisionModel: z.string().optional(),
+        videoAnalysisBaseUrl: z.string().optional(),
+        videoAnalysisPath: z.string().optional(),
+        videoAnalysisModel: z.string().optional(),
+        fmodeToken: z.string().optional(),
+        imageAnalysisToken: z.string().optional(),
+        doubaoVisionToken: z.string().optional(),
+        videoAnalysisToken: z.string().optional(),
+        xiaohongshuToken: z.string().optional(),
+        douyinToken: z.string().optional(),
+        vocToken: z.string().optional(),
+        tihaoToken: z.string().optional(),
+        sessionToken: z.string().optional(),
+        token: z.string().optional(),
+        apiToken: z.string().optional(),
+        allowEnvToken: z.boolean().optional()
+      },
+      outputSchema: {
+        status: z.string(),
+        assistantMessage: z.string(),
+        summary: z.object({}).passthrough().optional(),
+        data: z.object({}).passthrough().optional(),
+        files: z.array(z.string()).optional(),
+        nextActions: z.array(z.string()).optional(),
+        warnings: z.array(z.any()).optional(),
+        errors: z.array(z.any()).optional()
+      }
+    },
+    async input => asToolResult(await analyzeFmodeImage(input))
+  );
+
   server.registerTool(
     'voc_business_workflow_run',
     {

+ 88 - 18
claude-code/claude-code-voc-intelligence/mcp/src/tools/douyin-trend-run.js

@@ -12,7 +12,8 @@ const { buildNoSampleFallbackMessage } = require('../core/evidence-quality');
 const {
   buildVocRechargeInfo,
   buildMissingTokenMessage,
-  buildRechargeRequiredMessage
+  buildRechargeRequiredMessage,
+  buildWrongTokenTypeMessage
 } = require('../core/payment-links');
 
 function parseArgs(argv) {
@@ -73,13 +74,16 @@ function readJsonIfExists(filePath) {
   return JSON.parse(fs.readFileSync(absolute, 'utf8'));
 }
 
+const AUTH_IDENTITY_RE = /company或用户信息不存在|用户信息不存在|用户不存在|账[号户]信息不存在|未登录|登录失效|Invalid API token|无效的?API?令牌|无效.*token|token.*invalid|未授权|unauthorized|请输入.*token|sessiontoken/i;
+
 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);
+    if (['request', 'upstream_search', 'invalid_keyword', 'billing', 'permission'].includes(kind)) return false;
+    return kind === 'auth' ||
+      Number(error && error.httpStatus) === 401 ||
+      AUTH_IDENTITY_RE.test(message);
   });
 }
 
@@ -101,25 +105,38 @@ function findSearchInputError(errors = []) {
     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');
+    if (kind === 'auth' || kind === 'billing' || kind === 'permission' || kind === 'upstream_search' || AUTH_IDENTITY_RE.test(message)) return false;
+    if (status >= 500) return false;
+    return ['request', 'invalid_keyword'].includes(kind) ||
+      ([400, 422].includes(status) && /社交平台接口请求错误|参数|入参|keyword|关键词|request error|bad request/i.test(message));
+  });
+}
+
+function findUpstreamError(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 (kind === 'auth' || kind === 'billing' || kind === 'permission' || AUTH_IDENTITY_RE.test(message)) return false;
+    return kind === 'upstream_search' ||
+      status >= 500 ||
+      /fetch failed|request error|社交平台接口请求错误|上游|timeout|超时|ECONNRESET|ETIMEDOUT|EAI_AGAIN/i.test(message);
   });
 }
 
 function buildSearchInputMessage(error = {}) {
   const keyword = error.keyword ? `“${error.keyword}”` : '当前关键词';
   return [
-    '抖音 live 搜索没有成功,但这次更像是关键词或搜索入参问题,不是余额不足。',
+    '抖音 live 搜索这次没有成功,更像是关键词或搜索入参的格式问题,不是余额不足,也不是类目不支持。',
     '',
     `出问题的关键词:${keyword}`,
     '',
-    '建议把关键词改成更自然的短语,并在品类、价格、人群/场景之间加空格,例如:',
-    '- 茶饮 18元 22元',
-    '- 茶饮 性价比 新客',
-    '- 奶茶 套餐 点单 避坑',
+    '提示:任何行业都能采集(含律师、法律、医美、教育、专业服务等),这不是类目限制。建议把关键词改成更自然的短语,并在品类、价格、人群/场景之间加空格,例如:',
+    '- <品类> <价格区间> <目标人群>',
+    '- <品类> 性价比 新客',
+    '- <品类/服务> 避坑 怎么选',
     '',
-    '我已经避免把这类搜索请求错误误报成“余额不足”。'
+    '我不会把这类入参/搜索错误误报成“余额不足”或“类目不支持”。'
   ].join('\n');
 }
 
@@ -164,6 +181,23 @@ async function runDouyinTrend(input = {}) {
     warnings.push('P0 阶段使用 sample 演示数据,真实采集需要接入 VOC 服务。');
   } else if (collectionMode === 'live') {
     const token = readVocToken(input);
+    if (token && /^\s*sk-/i.test(token)) {
+      const recharge = await buildVocRechargeInfo();
+      return {
+        status: 'needs_valid_token',
+        assistantMessage: buildWrongTokenTypeMessage(recharge.paymentUrl, recharge, { platformLabel: '抖音' }),
+        summary: { requestedCollectionMode: collectionMode, tokenType: 'sk-' },
+        data: { requestedCollectionMode: collectionMode, recharge },
+        files: [],
+        nextActions: [
+          '从充值/开通页复制以 r: 开头的会话 token(不是 sk- 开头的 AIGate API Key)',
+          `打开充值/开通链接:${recharge.paymentUrl}`,
+          '配置正确的 r: token 后重新运行 live 模式'
+        ],
+        warnings: [],
+        errors: []
+      };
+    }
     if (!token) {
       const recharge = await buildVocRechargeInfo();
       return {
@@ -317,7 +351,9 @@ async function runDouyinTrend(input = {}) {
           assistantMessage: [
             '当前 VOC-AI 数据服务 token 没有通过校验,暂时不能进行真实抖音采集。',
             '',
-            '请确认已经完成 VOC-AI 数据服务开通/充值,并重新配置服务方提供的 session token。',
+            '这是 token 无效或类型不对,不是关键词/类目问题:抖音采集需要从充值/开通页复制的、以 r: 开头的会话 token(不要用 sk- 开头的 AIGate API Key)。',
+            '',
+            '请确认已完成 VOC-AI 数据服务开通/充值,并重新配置以 r: 开头的 session token。',
             '',
             `充值/开通链接:${recharge.paymentUrl}`
           ].join('\n'),
@@ -364,15 +400,45 @@ async function runDouyinTrend(input = {}) {
         };
       }
 
+      const upstreamError = findUpstreamError(errors);
+      if (upstreamError) {
+        return {
+          status: 'upstream_unstable',
+          assistantMessage: [
+            '抖音 live 采集这次失败是上游数据接口返回了错误或暂时不稳定(如 5xx、连接超时、fetch failed)。',
+            '',
+            '这不是关键词或类目问题,也不是余额不足:任何行业(含律师、法律等专业服务)都能采集。',
+            '',
+            '工具已自动重试过;如果你看到这条,说明上游本次连续失败。请稍等片刻后重新运行同样的 live 命令即可,通常重试就能成功。'
+          ].join('\n'),
+          summary: {
+            videoCount: 0,
+            commentCount: dataset.comments.length,
+            errorCount: errors.length,
+            failedStage: upstreamError.stage || 'search_videos',
+            failedKeyword: upstreamError.keyword || ''
+          },
+          data: { profile },
+          files: [],
+          nextActions: [
+            '稍等片刻后重新运行同样的 live 命令(上游抖动多为偶发,重试即可成功)',
+            '如果连续多次失败,记录失败时间点反馈,便于排查上游服务',
+            '需要先演示流程时,可临时切换 sample 模式并标注为非真实采集'
+          ],
+          warnings: [],
+          errors: []
+        };
+      }
+
       return {
         status: 'no_live_samples',
         assistantMessage: buildNoSampleFallbackMessage({
           platformLabel: '抖音',
           profile,
           examples: [
-            '把行业词换成视频表达词,例如“第一次来怎么点”“老板推荐”“避坑”',
-            '加入城市/商圈/品类,例如“南昌 烧烤 第一次来”“南昌菜 排队”',
-            '从窄服务词扩展到用户情绪词,例如“父母礼物、家庭故事、人生感悟”'
+            '把行业词换成用户在视频里的真实说法,例如“第一次怎么选”“怎么避坑”“值不值”',
+            '加入人群/场景/价格等限定词,让关键词更贴近真实视频标题与评论',
+            '从很窄的服务词扩展到用户情绪/决策词,例如“怎么挑、踩过的坑、后悔/推荐”'
           ]
         }),
         summary: {
@@ -460,5 +526,9 @@ if (require.main === module) {
 }
 
 module.exports = {
-  runDouyinTrend
+  runDouyinTrend,
+  findAuthError,
+  findGatewayError,
+  findSearchInputError,
+  findUpstreamError
 };

+ 69 - 0
claude-code/claude-code-voc-intelligence/mcp/src/tools/fmode-image-analysis.js

@@ -0,0 +1,69 @@
+#!/usr/bin/env node
+const { analyzeFmodeImage } = require('../features/fmode-image-analysis/image-analysis');
+const { errorResult } = require('../core/result-envelope');
+
+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), coerce(token.slice(eq + 1), token.slice(2, eq)));
+      continue;
+    }
+    const key = token.slice(2);
+    const next = argv[i + 1];
+    if (next && !next.startsWith('--')) {
+      setArg(key, coerce(next, key));
+      i++;
+    } else {
+      setArg(key, true);
+    }
+  }
+  return args;
+}
+
+function coerce(value, key = '') {
+  if (value === 'true') return true;
+  if (value === 'false') return false;
+  if (/^\d+$/.test(value)) return Number(value);
+  if (['prompt', 'question', 'text', 'context'].includes(key)) return value;
+  if (value.includes(',') || value.includes(',')) return value.split(/[,,]/).map(item => item.trim()).filter(Boolean);
+  return value;
+}
+
+async function main() {
+  const args = parseArgs(process.argv.slice(2));
+  try {
+    const result = await analyzeFmodeImage(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 === 'error' ? 1 : 0;
+  } 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 = {
+  analyzeFmodeImage
+};

+ 84 - 10
claude-code/claude-code-voc-intelligence/mcp/src/tools/xiaohongshu-trend-run.js

@@ -12,7 +12,8 @@ const { buildNoSampleFallbackMessage } = require('../core/evidence-quality');
 const {
   buildVocRechargeInfo,
   buildMissingTokenMessage,
-  buildRechargeRequiredMessage
+  buildRechargeRequiredMessage,
+  buildWrongTokenTypeMessage
 } = require('../core/payment-links');
 
 function parseArgs(argv) {
@@ -72,11 +73,16 @@ function readJsonIfExists(filePath) {
   return JSON.parse(fs.readFileSync(absolute, 'utf8'));
 }
 
+const AUTH_IDENTITY_RE = /company或用户信息不存在|用户信息不存在|用户不存在|账[号户]信息不存在|未登录|登录失效|Invalid API token|无效的?API?令牌|无效.*token|token.*invalid|未授权|unauthorized|请输入.*token|sessiontoken/i;
+
 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);
+    const kind = String(error && (error.kind || error.errorKind) || '');
+    if (['billing', 'permission'].includes(kind)) return false;
+    return kind === 'auth' ||
+      Number(error && error.httpStatus) === 401 ||
+      AUTH_IDENTITY_RE.test(message);
   });
 }
 
@@ -84,9 +90,25 @@ 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 (kind === 'auth' || kind === 'upstream_search' || AUTH_IDENTITY_RE.test(message)) return false;
+    if (status >= 500) return false;
     return status === 402 ||
-      status === 403 ||
-      /社交平台接口请求错误|没有开通|余额不足|无权限|权限或余额|permission|quota|payment|insufficient|balance/i.test(message);
+      kind === 'billing' ||
+      (status === 403 && /没有开通|余额不足|无权限|权限或余额|permission|quota|balance/i.test(message)) ||
+      /没有开通|余额不足|无权限|权限或余额|insufficient|balance|quota|payment/i.test(message);
+  });
+}
+
+function findUpstreamError(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 (kind === 'auth' || kind === 'billing' || kind === 'permission' || AUTH_IDENTITY_RE.test(message)) return false;
+    return kind === 'upstream_search' ||
+      status >= 500 ||
+      /fetch failed|request error|社交平台接口请求错误|上游|timeout|超时|ECONNRESET|ETIMEDOUT|EAI_AGAIN/i.test(message);
   });
 }
 
@@ -129,6 +151,23 @@ async function runXiaohongshuTrend(input = {}) {
     warnings.push('P0 阶段使用 sample 演示数据,真实采集需要接入 VOC 服务。');
   } else if (collectionMode === 'live') {
     const token = readXiaohongshuToken(input);
+    if (token && /^\s*sk-/i.test(token)) {
+      const recharge = await buildVocRechargeInfo();
+      return {
+        status: 'needs_valid_token',
+        assistantMessage: buildWrongTokenTypeMessage(recharge.paymentUrl, recharge, { platformLabel: '小红书' }),
+        summary: { requestedCollectionMode: collectionMode, tokenType: 'sk-' },
+        data: { requestedCollectionMode: collectionMode, recharge },
+        files: [],
+        nextActions: [
+          '从充值/开通页复制以 r: 开头的会话 token(不是 sk- 开头的 AIGate API Key)',
+          `打开充值/开通链接:${recharge.paymentUrl}`,
+          '配置正确的 r: token 后重新运行 live 模式'
+        ],
+        warnings: [],
+        errors: []
+      };
+    }
     if (!token) {
       const recharge = await buildVocRechargeInfo();
       return {
@@ -253,7 +292,9 @@ async function runXiaohongshuTrend(input = {}) {
           assistantMessage: [
             '当前 VOC-AI 数据服务 token 没有通过校验,暂时不能进行真实小红书采集。',
             '',
-            '请确认已经完成 VOC-AI 数据服务开通/充值,并重新配置服务方提供的 session token。',
+            '这是 token 无效或类型不对,不是关键词/类目问题:小红书采集需要从充值/开通页复制的、以 r: 开头的会话 token(不要用 sk- 开头的 AIGate API Key)。',
+            '',
+            '请确认已完成 VOC-AI 数据服务开通/充值,并重新配置以 r: 开头的 session token。',
             '',
             `充值/开通链接:${recharge.paymentUrl}`
           ].join('\n'),
@@ -300,15 +341,45 @@ async function runXiaohongshuTrend(input = {}) {
         };
       }
 
+      const upstreamError = findUpstreamError(errors);
+      if (upstreamError) {
+        return {
+          status: 'upstream_unstable',
+          assistantMessage: [
+            '小红书 live 采集这次失败是上游数据接口返回了错误或暂时不稳定(如 5xx、连接超时、fetch failed)。',
+            '',
+            '这不是关键词或类目问题,也不是余额不足:任何行业(含律师、法律等专业服务)都能采集。',
+            '',
+            '工具已自动重试过;如果你看到这条,说明上游本次连续失败。请稍等片刻后重新运行同样的 live 命令即可,通常重试就能成功。'
+          ].join('\n'),
+          summary: {
+            noteCount: 0,
+            commentCount: dataset.comments.length,
+            errorCount: errors.length,
+            failedStage: upstreamError.stage || 'search_notes',
+            failedKeyword: upstreamError.keyword || ''
+          },
+          data: { profile },
+          files: [],
+          nextActions: [
+            '稍等片刻后重新运行同样的 live 命令(上游抖动多为偶发,重试即可成功)',
+            '如果连续多次失败,记录失败时间点反馈,便于排查上游服务',
+            '需要先演示流程时,可临时切换 sample 模式并标注为非真实采集'
+          ],
+          warnings: [],
+          errors: []
+        };
+      }
+
       return {
         status: 'no_live_samples',
         assistantMessage: buildNoSampleFallbackMessage({
           platformLabel: '小红书',
           profile,
           examples: [
-            '把品牌/服务词换成用户场景词,例如“第一次装修怕踩坑”“父母礼物”“家庭故事”',
-            '加入城市/品类/人群,例如“南昌 拌粉 第一次来”“小户型 衣柜 避坑”',
-            '用相关大词做降级验证,例如从“老人自传”扩展到“父母礼物、家庭故事、人生故事”'
+            '把品牌/服务词换成用户在笔记里的真实说法,例如“第一次怎么选”“怎么避坑”“值不值”',
+            '加入人群/场景/价格等限定词,让关键词更贴近真实笔记标题与评论',
+            '从很窄的服务词扩展到用户情绪/决策词,例如“怎么挑、踩过的坑、后悔/推荐”'
           ]
         }),
         summary: {
@@ -400,5 +471,8 @@ if (require.main === module) {
 }
 
 module.exports = {
-  runXiaohongshuTrend
+  runXiaohongshuTrend,
+  findAuthError,
+  findGatewayError,
+  findUpstreamError
 };

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

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

+ 8 - 5
claude-code/claude-code-voc-intelligence/package.json

@@ -1,7 +1,7 @@
 {
-  "name": "@gangvy/claude-code-voc-intelligence",
-  "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.",
+  "name": "@vocmarket/voc-skill",
+  "version": "0.3.16",
+  "description": "Claude Code VOC intelligence skills for Xiaohongshu and Douyin trend reports, live social VOC collection, Fmode image analysis, single-issue deep dives, boss/operator action plans, and multi-turn memory.",
   "type": "commonjs",
   "bin": {
     "claude-voc": "bin/claude-voc.js"
@@ -11,6 +11,8 @@
     "install:smoke": "node install.js --smoke",
     "smoke:package": "node scripts/smoke-package.js",
     "smoke:cross-industry": "node scripts/smoke-cross-industry.js",
+    "fmode:image": "node mcp/src/tools/fmode-image-analysis.js",
+    "fmode:image:smoke": "node scripts/fmode-image-analysis-smoke.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",
@@ -26,7 +28,8 @@
     "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"
+    "mcp:smoke": "node scripts/smoke-mcp.js",
+    "acceptance": "node scripts/acceptance.js"
   },
   "files": [
     ".claude-plugin/",
@@ -57,7 +60,7 @@
     "openclaw",
     "social-listening"
   ],
-  "homepage": "https://www.npmjs.com/package/@gangvy/claude-code-voc-intelligence",
+  "homepage": "https://www.npmjs.com/package/@vocmarket/voc-skill",
   "dependencies": {
     "@modelcontextprotocol/sdk": "^1.29.0",
     "zod": "^4.4.3"

+ 91 - 0
claude-code/claude-code-voc-intelligence/scripts/acceptance.js

@@ -0,0 +1,91 @@
+#!/usr/bin/env node
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { spawnSync } = require('child_process');
+
+const ROOT = path.resolve(__dirname, '..');
+const npmCache = path.join(os.tmpdir(), 'claude-voc-npm-cache-acceptance');
+
+function main() {
+  fs.mkdirSync(npmCache, { recursive: true });
+
+  run('npm', ['run', 'fmode:image:smoke'], ROOT);
+  run('npm', ['run', 'smoke:package'], ROOT);
+  run('npm', ['run', 'mcp:smoke'], ROOT);
+  run('npm', ['pack', '--dry-run'], ROOT);
+
+  const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'voc-workspace-install-'));
+  run(process.execPath, [path.join(ROOT, 'bin', 'claude-voc.js'), 'workspace', '--smoke'], workspace);
+
+  const mcpFile = path.join(workspace, '.mcp.json');
+  const pluginRoot = path.join(workspace, '.claude', 'plugins', 'voc-intelligence');
+  const pluginServer = path.join(pluginRoot, 'mcp', 'src', 'server.js');
+  const skillNames = [
+    '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',
+    'fmode-image-analysis'
+  ];
+
+  assert(fs.existsSync(mcpFile), 'workspace install should write .mcp.json');
+  assert(fs.existsSync(pluginServer), 'workspace install should copy plugin server');
+  for (const skillName of skillNames) {
+    assert(
+      fs.existsSync(path.join(workspace, '.claude', 'skills', skillName, 'SKILL.md')),
+      `workspace install should write skill entry: ${skillName}`
+    );
+  }
+
+  const mcp = JSON.parse(fs.readFileSync(mcpFile, 'utf8'));
+  const server = mcp.mcpServers && mcp.mcpServers.voc;
+  assert(server, 'workspace .mcp.json should register voc server');
+  assert(Array.isArray(server.args) && path.isAbsolute(server.args[0]), 'workspace MCP server path should be absolute');
+  assert(path.resolve(server.args[0]) === path.resolve(pluginServer), 'workspace MCP server path should point to installed plugin server');
+
+  console.log(`acceptance ok: ${workspace}`);
+}
+
+function run(command, args, cwd) {
+  const resolved = resolveCommand(command, args);
+  const result = spawnSync(resolved.command, resolved.args, {
+    cwd,
+    stdio: 'inherit',
+    shell: resolved.shell,
+    env: {
+      ...process.env,
+      npm_config_cache: npmCache,
+      NPM_CONFIG_CACHE: npmCache
+    },
+    maxBuffer: 1024 * 1024 * 100
+  });
+  if (result.status !== 0) {
+    throw new Error(`${command} ${args.join(' ')} failed in ${cwd}`);
+  }
+}
+
+function resolveCommand(command, args) {
+  if (command === 'npm' && process.env.npm_execpath) {
+    return { command: process.execPath, args: [process.env.npm_execpath, ...args], shell: false };
+  }
+  if (command === 'npm' && process.platform === 'win32') {
+    return { command: 'npm.cmd', args, shell: true };
+  }
+  return { command, args, shell: false };
+}
+
+function assert(condition, message) {
+  if (!condition) throw new Error(message);
+}
+
+try {
+  main();
+} catch (error) {
+  console.error(error && error.stack ? error.stack : String(error));
+  process.exit(1);
+}

+ 108 - 0
claude-code/claude-code-voc-intelligence/scripts/fmode-image-analysis-smoke.js

@@ -0,0 +1,108 @@
+#!/usr/bin/env node
+const fs = require('fs');
+const http = require('http');
+const os = require('os');
+const path = require('path');
+const { analyzeFmodeImage } = require('../mcp/src/tools/fmode-image-analysis');
+
+async function main() {
+  const noToken = await analyzeFmodeImage({
+    images: [tinyPngDataUrl()],
+    prompt: '识别图片内容',
+    allowEnvToken: false
+  });
+  assert(noToken.status === 'needs_token', 'missing token should return needs_token');
+  assert(Array.isArray(noToken.errors) && noToken.errors.length === 0, 'missing token should keep errors empty');
+  assert(!/Bearer\s+/i.test(JSON.stringify(noToken)), 'missing token output should not leak bearer headers');
+  assert(/apig-pay/.test(noToken.assistantMessage), 'missing token output should include recharge link');
+
+  const mock = await startMockFmode();
+  try {
+    const out = path.join(os.tmpdir(), `fmode-image-analysis-smoke-${Date.now()}`);
+    const result = await analyzeFmodeImage({
+      baseUrl: mock.url,
+      path: 'v1/chat/completions',
+      fmodeToken: 'mock-platform-token',
+      images: [tinyPngDataUrl()],
+      prompt: '请用中文识别图片中的文字和主体。',
+      output: out,
+      allowEnvToken: false
+    });
+
+    assert(result.status === 'ok', 'mock image analysis should return ok');
+    assert(result.summary.model === 'doubao-seed-2-0-pro-260215', 'default model should be doubao-seed-2-0-pro-260215');
+    assert(result.assistantMessage.includes('Fmode 图片分析结果'), 'assistant message should include result heading');
+    assert(result.data.analysis.includes('测试图片'), 'analysis text should be parsed from mock response');
+    assert(result.files.length === 2 && result.files.every(file => fs.existsSync(file)), 'output files should be written');
+
+    const request = mock.requests[0];
+    assert(request.headers.authorization === 'Bearer mock-platform-token', 'mock request should include platform token');
+    assert(request.body.model === 'doubao-seed-2-0-pro-260215', 'request should use the configured default model');
+    assert(Array.isArray(request.body.messages), 'request should use OpenAI-compatible messages');
+    const userMessage = request.body.messages.find(item => item.role === 'user');
+    assert(Array.isArray(userMessage.content), 'user content should be multimodal array');
+    assert(userMessage.content.some(item => item.type === 'image_url'), 'user content should include image_url');
+    assert(!JSON.stringify(result).includes('mock-platform-token'), 'public result should not leak token');
+  } finally {
+    await mock.close();
+  }
+
+  console.log('fmode image analysis smoke ok');
+}
+
+function startMockFmode() {
+  const requests = [];
+  const server = http.createServer((req, res) => {
+    if (!req.url.startsWith('/v1/chat/completions')) {
+      res.writeHead(404, { 'content-type': 'application/json' });
+      res.end(JSON.stringify({ error: 'not_found' }));
+      return;
+    }
+    let body = '';
+    req.on('data', chunk => { body += chunk; });
+    req.on('end', () => {
+      requests.push({
+        headers: req.headers,
+        body: body ? JSON.parse(body) : {}
+      });
+      res.writeHead(200, { 'content-type': 'application/json' });
+      res.end(JSON.stringify({
+        choices: [
+          {
+            finish_reason: 'stop',
+            message: {
+              content: '这是一张测试图片,可见一个小色块;未发现明显风险。'
+            }
+          }
+        ],
+        usage: {
+          prompt_tokens: 12,
+          completion_tokens: 18,
+          total_tokens: 30
+        }
+      }));
+    });
+  });
+  return new Promise(resolve => {
+    server.listen(0, '127.0.0.1', () => {
+      resolve({
+        url: `http://127.0.0.1:${server.address().port}`,
+        requests,
+        close: () => new Promise(done => server.close(done))
+      });
+    });
+  });
+}
+
+function tinyPngDataUrl() {
+  return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=';
+}
+
+function assert(condition, message) {
+  if (!condition) throw new Error(message);
+}
+
+main().catch(error => {
+  console.error(error && error.stack ? error.stack : String(error));
+  process.exit(1);
+});

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

@@ -29,6 +29,15 @@ async function main() {
   try {
     const tools = await client.listTools();
     const toolNames = tools.tools.map(tool => tool.name);
+    if (!toolNames.includes('fmode_image_analysis')) {
+      throw new Error(`Expected tool fmode_image_analysis, got: ${toolNames.join(', ')}`);
+    }
+    const imageTool = tools.tools.find(tool => tool.name === 'fmode_image_analysis');
+    const imageSchema = JSON.stringify(imageTool?.inputSchema || {});
+    if (!imageSchema.includes('imagePath') || !imageSchema.includes('fmodeToken')) {
+      throw new Error('Expected fmode_image_analysis to accept imagePath and fmodeToken inputs');
+    }
+
     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')) {
@@ -98,6 +107,21 @@ async function main() {
       throw new Error('Request-scoped token leaked in MCP token check result');
     }
 
+    const imageNoToken = await client.callTool({
+      name: 'fmode_image_analysis',
+      arguments: {
+        images: ['data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII='],
+        prompt: '识别这张测试图片',
+        allowEnvToken: false
+      }
+    });
+    if (!imageNoToken.structuredContent || imageNoToken.structuredContent.status !== 'needs_token') {
+      throw new Error('image analysis no-token path returned unexpected structured content');
+    }
+    if (Array.isArray(imageNoToken.structuredContent.errors) && imageNoToken.structuredContent.errors.length !== 0) {
+      throw new Error('image analysis no-token path should keep errors empty');
+    }
+
     const result = await client.callTool({
       name: 'voc_xiaohongshu_trend_run',
       arguments: {

+ 15 - 6
claude-code/claude-code-voc-intelligence/scripts/smoke-package.js

@@ -48,6 +48,14 @@ async function smokeMcpTools() {
   try {
     const tools = await client.listTools();
     const toolNames = tools.tools.map(tool => tool.name);
+    if (!toolNames.includes('fmode_image_analysis')) {
+      throw new Error(`Expected tool fmode_image_analysis, got: ${toolNames.join(', ')}`);
+    }
+    const imageTool = tools.tools.find(tool => tool.name === 'fmode_image_analysis');
+    const imageSchema = JSON.stringify(imageTool?.inputSchema || {});
+    if (!imageTool || !imageSchema.includes('imagePath') || !imageSchema.includes('fmodeToken')) {
+      throw new Error('Expected fmode_image_analysis to accept imagePath and fmodeToken inputs');
+    }
 
     const xhsTrendTool = tools.tools.find(tool => tool.name === 'voc_xiaohongshu_trend_run');
     const xhsTrendSchema = JSON.stringify(xhsTrendTool?.inputSchema || {});
@@ -607,12 +615,15 @@ async function smokeDouyinSearchInputErrors() {
       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 (result.status !== 'upstream_unstable') {
+      throw new Error(`expected persistent upstream search error to return upstream_unstable, got ${result.status}`);
     }
     if (/充值链接|apig-pay/.test(String(result.assistantMessage || ''))) {
       throw new Error('upstream search error was misreported as recharge/balance');
     }
+    if (result.status === 'needs_keyword_fix') {
+      throw new Error('upstream search error must not be misreported as a keyword problem');
+    }
   } finally {
     global.fetch = originalFetch;
   }
@@ -668,10 +679,7 @@ async function smokeDouyinSearchInputErrors() {
       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');
+      throw new Error(`expected transient upstream error to auto-recover via request retry, got ${repaired.status}`);
     }
   } finally {
     global.fetch = originalFetch;
@@ -938,6 +946,7 @@ async function main() {
 
   const toolNames = await smokeMcpTools();
   const requiredTools = [
+    'fmode_image_analysis',
     'voc_business_workflow_run',
     'voc_xiaohongshu_token_check',
     'voc_xiaohongshu_trend_run',

+ 9 - 6
claude-code/claude-code-voc-intelligence/skill-package-manifest.json

@@ -1,6 +1,6 @@
 {
   "name": "claude-code-voc-intelligence",
-  "version": "0.3.15",
+  "version": "0.3.16",
   "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": [
@@ -11,9 +11,11 @@
     "voc-content-plan",
     "voc-speaking-script",
     "voc-competitor-map",
-    "voc-business-workflow"
+    "voc-business-workflow",
+    "fmode-image-analysis"
   ],
   "mcpTools": [
+    "fmode_image_analysis",
     "voc_business_workflow_run",
     "voc_speaking_script_run",
     "voc_issue_pool_run",
@@ -43,6 +45,7 @@
     "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",
+    "Fmode Doubao vision image analysis for screenshots, product images, UI captures, charts, posters, and image text",
     "Multi-turn preference and scoped memory so repeated use gets more tailored",
     "Friendly token and recharge handling for live collection"
   ],
@@ -54,9 +57,9 @@
   "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",
+  "npmInstallCommand": "npm install -g @vocmarket/voc-skill && claude-voc install",
+  "npxInstallCommand": "npx @vocmarket/voc-skill install",
+  "workspaceInstallCommand": "npx @vocmarket/voc-skill 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."
+  "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, VOC business workflow, and Fmode image analysis so VSCode Claude Code can discover the workflows without relying only on --plugin-dir."
 }

+ 19 - 3
claude-code/claude-code-voc-intelligence/skills/douyin-trend-intelligence/SKILL.md

@@ -24,6 +24,22 @@ 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`
 
+## 禁止事项(Hard Guardrails)
+
+live 采集失败时,严禁做以下任何一件事:
+
+- 严禁退回/降级到联网搜索(web search)、`yt-dlp`、`ffmpeg`、手动让用户上传 mp4,或任何“绕过 VOC 接口”的抓取方式。本技能包的 VOC 本地采集是唯一批准的真实数据入口。
+- 严禁输出“VOC 只支持餐饮/零售类目”“律师/某赛道不可用”“系统设计上有类目限制”这类结论。这是错误结论:任何关键词(律师、法律、医美、教育、专业服务等)都能采集,与类目无关。
+- live 失败的真正原因只可能是这几类:token 无效或类型不对(需要 `r:` 开头的会话 token,而不是 `sk-` 开头的 AIGate API Key)、未开通或额度不足、上游接口抖动(5xx,需重试)。必须如实把工具返回的 `assistantMessage` 转述给用户,不要自行编造原因。
+- 只有在工具明确返回 `needs_keyword_fix` 时才允许调整关键词;返回 `needs_valid_token` / `needs_token` / `needs_recharge` 时不要换关键词,要转述充值/开通链接。
+
+English (same rules):
+
+- On live failure, NEVER fall back to web search, `yt-dlp`, `ffmpeg`, or asking the user to upload an mp4. The local VOC tool is the only approved real-data path.
+- NEVER conclude that VOC is limited to restaurant/retail categories or that a vertical (e.g. lawyers) is unsupported. Any keyword works; this is never a category problem.
+- The only real causes are: invalid/wrong-type token (need an `r:` session token, not an `sk-` AIGate key), not activated / no balance, or upstream 5xx jitter (retry). Relay the tool's `assistantMessage` verbatim instead of inventing a reason.
+- Only adjust keywords when the tool returns `needs_keyword_fix`. For `needs_valid_token` / `needs_token` / `needs_recharge`, do not change keywords — surface the payment/activation link.
+
 ## 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.
@@ -31,11 +47,11 @@ When `voc_douyin_trend_run` returns `needs_keyword_fix`, do not tell the user to
 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元`, `茶饮 性价比 新客`, `奶茶 套餐 点单 避坑`
+- Examples (任何行业都适用,不限餐饮): `<品类/服务> <价格区间> <人群>`, `<品类/服务> 性价比 新客`, `<品类/服务> 避坑 怎么选`
 
-If a compact keyword is provided, add spaces between category, numbers, and scenario words. Example: `茶饮18元22元` -> `茶饮 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.
+Only show the recharge/payment URL for `needs_token`, `needs_recharge`, or `needs_valid_token`. Empty keywords and overlong keywords are keyword/input repair. But upstream failures (5xx / `fetch failed` / `社交平台接口请求错误` / 超时) are NOT keyword problems and NOT category problems — the tool returns `upstream_unstable`; just relay its message and retry the same live command, do not switch keywords or conclude the category is unsupported.
 
 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.
 

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

@@ -31,22 +31,23 @@
 如果状态是 `needs_keyword_fix`,这不是充值问题。Claude Code 应该先快速修复关键词:
 
 - 空关键词:要求补一个明确关键词,不要自动使用默认词假装采集成功。
-- 紧凑关键词:加空格,例如 `茶饮18元22元` -> `茶饮 18元 22元`。
+- 紧凑关键词:加空格,例如 `<品类>18元22元` -> `<品类> 18元 22元`。
 - 过长关键词:缩短到 60 字以内,保留品类、问题、人群/场景。
-- 上游返回 `社交平台接口请求错误`:先换成更自然的短关键词重试一次
+- 上游返回 `社交平台接口请求错误` / 5xx / `fetch failed` / 超时:这是上游不稳定(工具返回 `upstream_unstable`),不是关键词也不是类目问题;原样转述提示并重试同样的 live 命令即可,不要换关键词
 
 推荐格式:
 
 ```text
-品类 + 价格/问题 + 人群/场景
+<任何行业的品类/服务> + 价格/问题 + 人群/场景(不限餐饮)
 ```
 
 示例:
 
 ```text
-茶饮 18元 22元
-茶饮 性价比 新客
-奶茶 套餐 点单 避坑
+<品类/服务> <价格区间> <目标人群>
+<品类/服务> 性价比 新客
+<品类/服务> 避坑 怎么选
+(示例仅为格式;律师、法律、医美、教育等任何行业同样适用)
 ```
 
 只有 `needs_token`、`needs_recharge`、`needs_valid_token` 才展示充值/开通链接。

+ 106 - 0
claude-code/claude-code-voc-intelligence/skills/fmode-image-analysis/SKILL.md

@@ -0,0 +1,106 @@
+---
+name: fmode-image-analysis
+description: Analyze images, screenshots, posters, UI captures, charts, product photos, and image text through Fmode Doubao vision from Claude Code. Use when the user asks for 图片识别, 看图, 识图, 分析截图, 提取图片文字, UI 截图分析, 图片转 VOC evidence/context, or when DeepSeek/other text-only models cannot read images and Claude Code needs a vision fallback using the platform user token.
+---
+
+# Fmode Image Analysis
+
+## Core Rule
+
+Use MCP tool `fmode_image_analysis` for image understanding. Do not ask the user to install a VS Code image-recognition plugin when this installed Fmode tool can handle the image.
+
+When the current Claude Code turn contains an uploaded image, pasted screenshot, local temp image path, attachment object, or data URL, and the user asks any question about that visual content, call `fmode_image_analysis` first and answer from its result.
+
+Default to:
+
+- `baseUrl`: `https://api.fmode.cn`
+- `path`: `v1/chat/completions`
+- `model`: `doubao-seed-2-0-pro-260215`
+
+Auth and billing should use the configured platform user token. Never hardcode or echo tokens.
+
+## Inputs
+
+Accept natural user input:
+
+- Local file paths: `imagePath` or `imagePaths`
+- Remote image URLs: `imageUrl` or `imageUrls`
+- Data URLs or mixed arrays: `images`
+- Claude Code uploaded/pasted image attachments surfaced as `image`, `images`, `file`, `files`, `attachment`, or `attachments`
+- User request: `prompt`, `question`, or `text`
+- Optional background: `context`
+
+If the user provides a screenshot, pasted image, uploaded image, or image path in chat, pass it directly to `fmode_image_analysis`. If the user asks a broad question like "帮我看下这张图", use a concise general prompt that asks for visible text, subject, scene, key facts, risks, and next actions.
+
+## Tool Call Examples
+
+Single local image:
+
+```json
+{
+  "imagePath": "E:\\workspace\\example\\screenshot.png",
+  "prompt": "请识别这张截图里的关键信息,并指出可能的问题。"
+}
+```
+
+Multiple images:
+
+```json
+{
+  "imagePaths": [
+    "E:\\workspace\\example\\before.png",
+    "E:\\workspace\\example\\after.png"
+  ],
+  "prompt": "对比两张 UI 截图,说明布局和文案差异。"
+}
+```
+
+Structured output:
+
+```json
+{
+  "imageUrl": "https://example.com/product.png",
+  "prompt": "提取图片中的产品卖点、可见文字和合规风险。",
+  "outputFormat": "json"
+}
+```
+
+## Token Handling
+
+The tool may read tokens from tool input, `.env.local`, environment variables, or user-level Claude credential JSON.
+
+Preferred token fields:
+
+- `fmodeToken`
+- `vocToken`
+- `sessionToken`
+- `tihaoToken`
+
+Environment variables:
+
+- `FMODE_TOKEN`
+- `VOC_TOKEN`
+- `TIHAO_SESSION_TOKEN`
+- `DOUBAO_VISION_TOKEN`
+- `VIDEO_ANALYSIS_TOKEN`
+
+Friendly states:
+
+- Missing token: return `needs_token` with the platform open/recharge link and `errors=[]`.
+- Invalid token: return `needs_valid_token` and ask for a valid platform token.
+- Not opened or quota exhausted: return `needs_recharge` with the open/recharge link and `errors=[]`.
+- Success: return `ok` with the analysis in chat.
+
+Do not expose raw upstream errors, `Authorization`, bearer tokens, session tokens, stack traces, or model keys.
+
+## Output Standard
+
+Return the analysis body in chat. For normal image reading, include:
+
+- Visible text or OCR-like extraction when present.
+- Main objects, people, product, interface, chart, or scene.
+- Important facts and uncertainty.
+- Risks, inconsistencies, or items that need manual review.
+- A short conclusion or next action.
+
+When the image analysis should support VOC reports, summarize the result as reusable evidence and pass it into the relevant VOC tool as `evidence`, `context`, or `reportText` only after the user asks to continue into VOC analysis.

+ 4 - 0
claude-code/claude-code-voc-intelligence/skills/fmode-image-analysis/agents/openai.yaml

@@ -0,0 +1,4 @@
+interface:
+  display_name: "Fmode Image Analysis"
+  short_description: "Analyze images with Fmode Doubao vision."
+  default_prompt: "Use $fmode-image-analysis to analyze this image with Fmode Doubao vision."

+ 16 - 0
claude-code/claude-code-voc-intelligence/skills/xiaohongshu-trend-intelligence/SKILL.md

@@ -24,6 +24,22 @@ 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`
 
+## 禁止事项(Hard Guardrails)
+
+live 采集失败时,严禁做以下任何一件事:
+
+- 严禁退回/降级到联网搜索(web search)、`yt-dlp`、`ffmpeg`、手动让用户上传图片/视频,或任何“绕过 VOC 接口”的抓取方式。本技能包的 VOC 本地采集是唯一批准的真实数据入口。
+- 严禁输出“VOC 只支持餐饮/零售类目”“律师/某赛道不可用”“系统设计上有类目限制”这类结论。这是错误结论:任何关键词(律师、法律、医美、教育、专业服务等)都能采集,与类目无关。
+- live 失败的真正原因只可能是这几类:token 无效或类型不对(需要 `r:` 开头的会话 token,而不是 `sk-` 开头的 AIGate API Key)、未开通或额度不足、上游接口抖动(5xx,需重试)。必须如实把工具返回的 `assistantMessage` 转述给用户,不要自行编造原因。
+- 只有在工具明确返回 `needs_keyword_fix` 时才允许调整关键词;返回 `needs_valid_token` / `needs_token` / `needs_recharge` 时不要换关键词,要转述充值/开通链接。
+
+English (same rules):
+
+- On live failure, NEVER fall back to web search, `yt-dlp`, `ffmpeg`, or asking the user to upload media. The local VOC tool is the only approved real-data path.
+- NEVER conclude that VOC is limited to restaurant/retail categories or that a vertical (e.g. lawyers) is unsupported. Any keyword works; this is never a category problem.
+- The only real causes are: invalid/wrong-type token (need an `r:` session token, not an `sk-` AIGate key), not activated / no balance, or upstream 5xx jitter (retry). Relay the tool's `assistantMessage` verbatim instead of inventing a reason.
+- Only adjust keywords when the tool returns `needs_keyword_fix`. For `needs_valid_token` / `needs_token` / `needs_recharge`, do not change keywords — surface the payment/activation link.
+
 ## sample 命令
 
 ```bash

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

@@ -1,9 +1,9 @@
 {
   "name": "claude-code-voc-intelligence",
-  "version": "0.3.15",
-  "generatedAt": "2026-05-30T12:18:58.723Z",
+  "version": "0.3.16",
+  "generatedAt": "2026-06-21T10:13:42.698Z",
   "packageZip": "dist/claude-code-voc-intelligence.zip",
-  "packageZipBytes": 207800,
+  "packageZipBytes": 228199,
   "plugin": "voc-intelligence",
   "skills": [
     "xiaohongshu-trend-intelligence",
@@ -13,9 +13,11 @@
     "voc-content-plan",
     "voc-speaking-script",
     "voc-competitor-map",
-    "voc-business-workflow"
+    "voc-business-workflow",
+    "fmode-image-analysis"
   ],
   "mcpTools": [
+    "fmode_image_analysis",
     "voc_business_workflow_run",
     "voc_speaking_script_run",
     "voc_issue_pool_run",
@@ -29,14 +31,14 @@
     "voc_douyin_trend_run",
     "voc_douyin_preference_update"
   ],
-  "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.",
+  "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, VOC business workflow, and Fmode image analysis so VSCode Claude Code can discover the workflows without relying only on --plugin-dir.",
   "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",
+  "npmInstallCommand": "npm install -g @vocmarket/voc-skill && claude-voc install",
+  "npxInstallCommand": "npx @vocmarket/voc-skill install",
+  "workspaceInstallCommand": "npx @vocmarket/voc-skill workspace --smoke",
   "workspaceInstallTarget": ".claude/plugins/voc-intelligence",
   "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": 84
+  "copiedFiles": 90
 }

TEMPAT SAMPAH
dist/claude-code-voc-intelligence.zip


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

@@ -1,19 +1,19 @@
 {
-  "name": "@gangvy/claude-code-voc-intelligence",
-  "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,
+  "name": "@vocmarket/voc-skill",
+  "version": "0.3.16",
+  "generatedAt": "2026-06-21T10:04:04.086Z",
+  "tarball": "dist/npm/vocmarket-voc-skill-0.3.16.tgz",
+  "tarballBytes": 160944,
+  "unpackedBytes": 578748,
+  "entryCount": 88,
   "installCommands": [
-    "npm install -g @gangvy/claude-code-voc-intelligence",
+    "npm install -g @vocmarket/voc-skill",
     "claude-voc install",
-    "npx @gangvy/claude-code-voc-intelligence install",
-    "npx @gangvy/claude-code-voc-intelligence workspace --smoke"
+    "npx @vocmarket/voc-skill install",
+    "npx @vocmarket/voc-skill workspace --smoke"
   ],
   "tested": true,
   "npxTested": true,
   "workspaceTested": true,
-  "published": true
+  "published": false
 }

+ 94 - 0
docs/voc-skill-student-course/00-course-map.md

@@ -0,0 +1,94 @@
+# 00 课程路线图和课堂操作总流程
+
+本节目标:让学员知道这套课不是学工具按钮,而是学一套从用户声音到经营动作的流程。
+
+## 课前准备
+
+1. 打开课程工作区。
+2. 确认 Claude Code 能正常对话。
+3. 准备一个干净的截图留档文件夹。
+4. 打开本目录下的课程文档,按顺序录。
+
+截图位置:
+
+```text
+【截图 00-01:课程目录和工作区文件结构】
+```
+
+下一步:打开 `01-workspace-setup.md`,开始录工作区准备。
+
+## 课程全流程
+
+```text
+第 1 步:装好并确认 VOC 技能可用
+第 2 步:用 sample 模式跑通,不消耗真实采集额度
+第 3 步:设计采集矩阵,避免乱采
+第 4 步:进入 live 真实采集,拿平台样本
+第 5 步:整理问题池,判断先改哪个
+第 6 步:深挖一个问题,转成门店动作
+第 7 步:生成内容计划和口播脚本
+第 8 步:整理成 HTML 报告
+第 9 步:遇到报错时按状态修复
+```
+
+截图位置:
+
+```text
+【截图 00-02:课程流程图或这段流程文本】
+```
+
+下一步:说明统一案例背景。
+
+## 统一案例
+
+后续演示都使用:
+
+```text
+茶饮新客转化
+价格带 18-22 元
+用户关心值不值、会不会踩雷、首单怎么点最稳
+```
+
+为什么用这个案例:
+
+- 用户问题足够具体。
+- 可以同时覆盖小红书和抖音。
+- 能演示价格、套餐、评论区、口播脚本和 HTML 报告。
+
+截图位置:
+
+```text
+【截图 00-03:茶饮案例背景】
+```
+
+下一步:提醒学员其它行业如何替换。
+
+## 其它行业怎么套用
+
+如果学员不是茶饮行业,只替换 5 个字段:
+
+```text
+行业:
+业务目标:
+价格带或决策门槛:
+用户最担心的问题:
+已有用户原话:
+```
+
+示例:
+
+```text
+行业:家装全屋定制
+业务目标:提升咨询转化
+价格带或决策门槛:8-15 万
+用户最担心的问题:环保、增项、效果图和落地差异
+已有用户原话:怕签完合同后一直加钱
+```
+
+截图位置:
+
+```text
+【截图 00-04:行业替换字段示例】
+```
+
+下一步:进入工作区安装与检查。

+ 144 - 0
docs/voc-skill-student-course/01-workspace-setup.md

@@ -0,0 +1,144 @@
+# 01 工作区准备与 VOC 技能检查
+
+本节目标:让学员确认 Claude Code 能识别 VOC 技能包,并能跑通 smoke 检查。
+
+## 第一步:打开课程工作区
+
+操作:
+
+1. 打开 VS Code。
+2. 选择课程工作区文件夹。
+3. 打开终端。
+
+截图位置:
+
+```text
+【截图 01-01:VS Code 打开课程工作区】
+```
+
+下一步:检查 Node 和 npx。
+
+## 第二步:检查 Node 和 npx
+
+在 VS Code 终端输入:
+
+```powershell
+node --version
+npm --version
+npx --version
+```
+
+正常结果:
+
+```text
+三条命令都返回版本号。
+```
+
+截图位置:
+
+```text
+【截图 01-02:node/npm/npx 版本号】
+```
+
+下一步:安装 VOC 技能包到当前工作区。
+
+## 第三步:安装 VOC 技能包
+
+在课程工作区终端执行:
+
+```powershell
+npx --yes @vocmarket/voc-skill@latest workspace --smoke
+```
+
+正常结果里应该能看到:
+
+```text
+VSCode workspace mode is ready.
+Generated project files:
+.\.mcp.json
+.\.claude\skills\...
+.\.claude\plugins\voc-intelligence
+```
+
+截图位置:
+
+```text
+【截图 01-03:运行 workspace --smoke 命令】
+【截图 01-04:安装成功输出】
+```
+
+下一步:检查生成文件。
+
+## 第四步:检查文件是否生成
+
+在终端输入:
+
+```powershell
+dir .\.claude\plugins
+dir .\.claude\skills
+dir .\.mcp.json
+```
+
+正常结果:
+
+```text
+.claude/plugins 下面有 voc-intelligence
+.claude/skills 下面有小红书、抖音、问题池、单点深挖、内容计划等技能入口
+当前工作区有 .mcp.json
+```
+
+截图位置:
+
+```text
+【截图 01-05:.claude/plugins 和 .claude/skills 目录】
+```
+
+下一步:重启 Claude Code 会话。
+
+## 第五步:重启 Claude Code
+
+操作:
+
+1. 关闭当前 Claude Code 会话。
+2. 重新打开 Claude Code。
+3. 输入:
+
+```text
+你好,请确认当前工作区是否可以使用 VOC 技能。
+```
+
+截图位置:
+
+```text
+【截图 01-06:Claude Code 重启后正常回复】
+```
+
+下一步:进入 sample 模式跑第一份趋势情报。
+
+## 常见问题
+
+### npx 无法识别
+
+处理:
+
+1. 关闭 VS Code 终端。
+2. 重新打开终端。
+3. 重新执行版本检查。
+4. 仍然失败时安装 Node.js LTS。
+
+### 安装成功但 Claude Code 不触发技能
+
+处理:
+
+1. 检查 `.claude/skills` 是否存在。
+2. 检查 `.mcp.json` 是否存在。
+3. 重启 Claude Code。
+4. 在提示词里明确写“请使用已安装的 VOC 技能,不要凭经验回答”。
+
+## 本节验收
+
+- 能看到 `voc-intelligence` 插件目录。
+- 能看到 8 个 skill 入口。
+- `workspace --smoke` 通过。
+- Claude Code 能正常对话。
+

+ 124 - 0
docs/voc-skill-student-course/02-sample-first-run.md

@@ -0,0 +1,124 @@
+# 02 第一次运行:先用 sample 模式
+
+本节目标:先用演示样例跑通完整格式,让学员知道一份 VOC 趋势情报长什么样。不要第一步就做真实采集。
+
+## 第一步:输入 sample 提示词
+
+在 Claude Code 输入:
+
+```text
+请使用已安装的 VOC 技能,不要凭经验回答。
+
+帮我做一份茶饮新客转化的小红书趋势情报。
+先用 sample 模式跑通流程,不要真实采集。
+业务目标:提升新客转化。
+价格带:18-22 元。
+重点看:值不值、会不会踩雷、首单怎么点最稳。
+
+请在聊天里输出:
+1. 第一轮初步判断
+2. 样本观察
+3. 用户顾虑
+4. 证据样本
+5. 待确认问题
+```
+
+截图位置:
+
+```text
+【截图 02-01:输入 sample 提示词】
+```
+
+下一步:等待工具输出。
+
+## 第二步:确认它调用的是 VOC 技能
+
+正常表现:
+
+- 输出包含“小红书趋势情报”。
+- 明确写“演示样例”或“sample”。
+- 不是只凭经验写行业分析。
+- 不是只返回文件路径。
+
+截图位置:
+
+```text
+【截图 02-02:VOC 技能输出趋势情报】
+```
+
+下一步:检查报告结构。
+
+## 第三步:检查报告结构
+
+报告应包含:
+
+```text
+第一轮初步判断(待校准)
+数据口径
+样本观察 Top 5
+用户顾虑/决策阻力
+证据样本
+可继续验证的方向
+下一轮待确认问题
+```
+
+截图位置:
+
+```text
+【截图 02-03:报告结构】
+```
+
+下一步:带学员读懂“待校准”。
+
+## 第四步:读懂 sample 结果
+
+重点看三件事:
+
+1. 用户反复提到什么。
+2. 哪些顾虑会影响转化。
+3. 下一轮要用真实数据确认什么。
+
+不要把 sample 当最终结论。sample 只用于:
+
+- 检查流程是否通。
+- 检查报告格式是否对。
+- 让学员理解“证据 → 判断 → 下一步”的结构。
+
+截图位置:
+
+```text
+【截图 02-04:初步判断和待确认问题】
+```
+
+下一步:切换到抖音 sample。
+
+## 第五步:跑抖音 sample
+
+输入:
+
+```text
+请继续使用已安装的 VOC 技能。
+
+帮我做一份茶饮新客转化的抖音趋势情报。
+先用 sample 模式跑通流程,不要真实采集。
+重点看:低价新品、单人下午茶、买一送一、团购规则、评论区会不会继续问值不值。
+
+请输出第一轮样本观察、用户顾虑、证据样本和下一轮待确认问题。
+```
+
+截图位置:
+
+```text
+【截图 02-05:输入抖音 sample 提示词】
+【截图 02-06:抖音 sample 输出】
+```
+
+下一步:进入采集矩阵设计。
+
+## 本节验收
+
+- 小红书 sample 能输出报告。
+- 抖音 sample 能输出报告。
+- 学员能指出报告里的证据样本。
+- 学员知道 sample 不是最终结论。
+

+ 148 - 0
docs/voc-skill-student-course/03-collection-matrix.md

@@ -0,0 +1,148 @@
+# 03 VOC 采集矩阵教学
+
+本节目标:让学员先设计采集矩阵,再进入真实采集,避免一上来乱搜关键词。
+
+## 第一步:说明为什么要采集矩阵
+
+采集矩阵解决四个问题:
+
+```text
+采哪个平台
+采什么关键词
+采多少样本
+采完用来回答什么业务问题
+```
+
+截图位置:
+
+```text
+【截图 03-01:采集矩阵的四个问题】
+```
+
+下一步:输入矩阵生成提示词。
+
+## 第二步:输入采集矩阵提示词
+
+在 Claude Code 输入:
+
+```text
+请先不要开始 live 采集。
+
+请基于下面 VOC 背景,帮我设计一份采集矩阵:
+
+业务目标:提升茶饮新客转化。
+价格带:18-22 元。
+核心问题:用户判断“值不值”“会不会踩雷”“首单怎么点最稳”。
+
+本轮真实线索:
+1. 小红书评论:“波霸奶茶用的什么茶底?我要避雷!!下午喝的,晚上睁眼到天亮”
+2. 抖音低价新品、11.9 单人下午茶、21 元买一送一能触发注意,但还需要解释规则、口味和是否有坑。
+3. 用户会比较套餐、券后价、自制成本、茶底、甜度、配料表、外卖到手体验。
+4. 评论区是转化前验真的核心场域。
+
+请输出一张采集矩阵,字段包括:
+平台、目标人群、场景、关键词、样本量、证据类型、判断标准、显著问题、下一轮补采关键词。
+```
+
+截图位置:
+
+```text
+【截图 03-02:输入采集矩阵提示词】
+```
+
+下一步:检查矩阵字段。
+
+## 第三步:检查矩阵是否合格
+
+合格矩阵应包含:
+
+| 字段 | 合格标准 |
+|---|---|
+| 平台 | 小红书和抖音分开写 |
+| 目标人群 | 新客、价格敏感用户、外卖用户等 |
+| 场景 | 首单、团购、点单、外卖、避坑 |
+| 关键词 | 不是泛泛“茶饮”,而是用户会搜索的话 |
+| 样本量 | 第一轮小规模,不要一上来全量 |
+| 证据类型 | 原帖、视频、评论、高赞评论、互动数据 |
+| 判断标准 | 如何判断这个问题值得继续 |
+| 补采关键词 | 下一轮用来修正方向 |
+
+截图位置:
+
+```text
+【截图 03-03:采集矩阵输出】
+```
+
+下一步:把其它行业替换方法讲清楚。
+
+## 第四步:其它行业怎么填
+
+给学员一个替换模板:
+
+```text
+业务目标:填写要改善的结果
+价格带/决策门槛:填写用户犹豫点
+核心问题:填写用户决策前最常问的问题
+已有线索:填写评论、私信、差评、销售反馈
+平台:填写用户做功课的平台
+关键词:填写用户会搜的自然语言
+```
+
+示例:家装行业
+
+```text
+业务目标:提升咨询转化
+价格带/决策门槛:8-15 万
+核心问题:怕增项、怕环保不达标、怕效果图落不了地
+已有线索:评论里反复问“会不会后期加钱”
+平台:小红书、抖音
+关键词:全屋定制 避坑、装修 增项、环保 板材 怎么选
+```
+
+截图位置:
+
+```text
+【截图 03-04:其它行业替换模板】
+```
+
+下一步:用矩阵进入 live 采集。
+
+## 第五步:矩阵回填要求
+
+采集完成后,要回填:
+
+```text
+样本数
+评论数
+显著问题
+下一轮补采关键词
+```
+
+回填提示词:
+
+```text
+请把本轮小红书和抖音 live 采集结果回填到刚才的采集矩阵里。
+重点补充:
+1. 样本数
+2. 评论数
+3. 显著问题
+4. 下一轮要补采的关键词
+5. 哪些问题适合进入问题池
+```
+
+截图位置:
+
+```text
+【截图 03-05:回填矩阵提示词】
+【截图 03-06:回填后的矩阵】
+```
+
+下一步:打开 `04-live-collection.md`,开始真实采集。
+
+## 本节验收
+
+- 学员能说出本轮为什么采这些关键词。
+- 每个关键词都能对应一个用户问题。
+- 矩阵里明确第一轮样本量。
+- 采集后能回填样本数、评论数、显著问题和补采关键词。
+

+ 158 - 0
docs/voc-skill-student-course/04-live-collection.md

@@ -0,0 +1,158 @@
+# 04 小红书和抖音 live 真实采集
+
+本节目标:让学员知道如何从 sample 切到 live,并理解 token、余额、关键词错误分别怎么处理。
+
+## 第一步:确认 token 文件
+
+在当前工作区创建或检查:
+
+```text
+.env.local
+```
+
+内容格式:
+
+```text
+VOC_TOKEN=你的服务 token
+```
+
+注意:
+
+- 不要把 token 截进课程截图。
+- 不要提交到 Git。
+- 可以打开文件名给学员确认位置,但不要展示 token 值。
+
+截图位置:
+
+```text
+【截图 04-01:.env.local 文件存在,但不要展示 token 内容】
+```
+
+下一步:输入 live 采集提示词。
+
+## 第二步:小规模 live 采集
+
+在 Claude Code 输入:
+
+```text
+请进入真实采集 live 模式。
+
+业务目标:提升茶饮新客转化。
+价格带:18-22 元。
+平台:小红书、抖音。
+采集范围:先小规模采集,不要全量。
+
+重点关键词:
+小红书:茶饮 值不值、茶饮 首单、茶饮 套餐 点单、茶饮 避坑、茶饮 茶底 甜度
+抖音:茶饮 性价比 新客、茶饮 团购、茶饮 买一送一、茶饮 券后价、奶茶 点单 避坑
+
+请先输出第一轮市场声音、样本数、评论数、用户顾虑、证据样本和待确认问题。
+不要直接写最终策略报告。
+```
+
+截图位置:
+
+```text
+【截图 04-02:输入 live 采集提示词】
+```
+
+下一步:观察工具调用和状态。
+
+## 第三步:判断输出状态
+
+正常状态:
+
+```text
+status: ok
+有样本数
+有评论数
+有证据样本
+```
+
+常见非正常状态:
+
+| 状态 | 含义 | 下一步 |
+|---|---|---|
+| `needs_token` | 没有检测到 token | 配置 `.env.local` |
+| `needs_valid_token` | token 无效 | 换有效 token |
+| `needs_recharge` | 服务未开通或余额不足 | 打开充值/开通链接 |
+| `needs_keyword_fix` | 关键词不适合平台搜索 | 缩短关键词后重试 |
+| `no_live_samples` | 没采到有效样本 | 换关键词或扩大范围 |
+
+截图位置:
+
+```text
+【截图 04-03:live 输出状态】
+```
+
+下一步:如果 ok,记录样本数和评论数。
+
+## 第四步:记录小红书结果
+
+要记录:
+
+```text
+样本数:
+评论数:
+显著问题:
+证据样本:
+下一轮补采关键词:
+```
+
+截图位置:
+
+```text
+【截图 04-04:小红书样本数、评论数、显著问题】
+```
+
+下一步:记录抖音结果。
+
+## 第五步:记录抖音结果
+
+要记录:
+
+```text
+样本数:
+评论数:
+显著问题:
+证据样本:
+下一轮补采关键词:
+```
+
+截图位置:
+
+```text
+【截图 04-05:抖音样本数、评论数、显著问题】
+```
+
+下一步:回填采集矩阵。
+
+## 第六步:回填采集矩阵
+
+输入:
+
+```text
+请把刚才小红书和抖音 live 采集结果回填到采集矩阵。
+请补充:
+1. 每个平台样本数
+2. 评论数
+3. 最显著问题
+4. 下一轮补采关键词
+5. 哪些问题适合进入 VOC 问题池
+```
+
+截图位置:
+
+```text
+【截图 04-06:回填后的采集矩阵】
+```
+
+下一步:进入 VOC 问题池。
+
+## 本节验收
+
+- 至少完成一个平台 live 采集。
+- 输出有样本数和评论数。
+- 有证据样本。
+- 已回填采集矩阵。
+- 结论仍然是“第一轮初步判断”,不是最终结论。

+ 130 - 0
docs/voc-skill-student-course/05-issue-pool.md

@@ -0,0 +1,130 @@
+# 05 VOC 问题池:判断先改哪个
+
+本节目标:把采集结果整理成问题优先级,避免只停留在“用户说了很多”。
+
+## 第一步:准备输入材料
+
+从前面结果中复制:
+
+- 小红书显著问题
+- 抖音显著问题
+- 用户原话
+- 高赞评论
+- 样本观察
+- 采集矩阵回填结果
+
+截图位置:
+
+```text
+【截图 05-01:准备粘贴到问题池的采集结果】
+```
+
+下一步:输入问题池提示词。
+
+## 第二步:生成 VOC 问题池
+
+输入:
+
+```text
+请把上面的采集结果整理成 VOC 问题池。
+
+业务目标:提升茶饮新客转化。
+价格带:18-22 元。
+重点影响:新客转化、客单结构、评论信任。
+
+要求:
+1. 按业务影响排序,不要只按词频排序
+2. 每个问题都要有用户证据
+3. 标注影响环节
+4. 给出建议动作
+5. 给出下一步是否适合继续深挖
+6. 输出 Top 3 问题
+```
+
+截图位置:
+
+```text
+【截图 05-02:输入问题池提示词】
+```
+
+下一步:检查问题池结构。
+
+## 第三步:检查问题池结构
+
+合格问题池应包含:
+
+```text
+问题名称
+优先级
+严重度
+证据
+影响环节
+建议动作
+下一步
+```
+
+截图位置:
+
+```text
+【截图 05-03:问题优先级列表】
+【截图 05-04:Top 问题证据】
+```
+
+下一步:选择 Top 1 进入深挖。
+
+## 第四步:选择 Top 1 问题
+
+本案例建议选择:
+
+```text
+价格/性价比
+```
+
+选择理由:
+
+- 直接影响新客第一次下单。
+- 与 18-22 元价格带相关。
+- 能转成套餐、点单路径、价值解释、评论区回复。
+- 能在 7 天内验证。
+
+截图位置:
+
+```text
+【截图 05-05:选择价格/性价比作为 Top 1】
+```
+
+下一步:复制证据进入单点深挖。
+
+## 第五步:整理深挖输入
+
+准备下面格式:
+
+```text
+VOC 问题:价格/性价比
+严重度:高
+业务目标:提升新客转化
+证据:
+- 探店内容别只拍氛围,评论区最关心值不值
+- 波霸奶茶用的什么茶底?我要避雷!!
+- 21 元买一送一会触发注意,但用户还会追问规则和口味
+影响:
+- 新客转化
+- 客单结构
+- 评论信任
+```
+
+截图位置:
+
+```text
+【截图 05-06:整理给单点深挖的输入】
+```
+
+下一步:打开 `06-single-issue-deep-dive.md`。
+
+## 本节验收
+
+- 有 Top 3 问题。
+- Top 1 不是凭感觉选的,有证据。
+- 每个问题都能对应业务影响。
+- 已准备好单点深挖输入。
+

+ 169 - 0
docs/voc-skill-student-course/06-single-issue-deep-dive.md

@@ -0,0 +1,169 @@
+# 06 单点 VOC 深挖:价格/性价比
+
+本节目标:把“价格/性价比”从一句抱怨,转成老板能执行的门店动作、内容动作和 7 天验证计划。
+
+## 第一步:输入单点深挖提示词
+
+输入:
+
+```text
+请继续深挖这个 VOC 问题:价格/性价比。
+
+业务背景:
+我们是茶饮连锁,现有产品定价在 18-22 元区间。
+主要目标:提升新客转化。
+
+证据:
+1. “探店内容别只拍氛围,评论区最关心值不值”
+2. “波霸奶茶用的什么茶底?我要避雷!!下午喝的,晚上睁眼到天亮”
+3. 抖音低价新品、11.9 单人下午茶、21 元买一送一能触发注意,但还需要解释规则、口味和是否有坑。
+
+请站在老板/运营视角输出:
+1. 表层问题和深层问题
+2. 对新客转化的影响
+3. 今天就能做的动作
+4. 7 天内验证动作
+5. 评论区回复方向
+6. 可转化内容选题
+7. 下轮验证指标
+
+不要建议单纯降价,要把价格解释成点单路径、价值证据和避坑说明。
+```
+
+截图位置:
+
+```text
+【截图 06-01:输入单点深挖提示词】
+```
+
+下一步:检查表层问题和深层问题。
+
+## 第二步:检查问题拆解
+
+期望看到:
+
+```text
+表层问题:用户说贵、不值、怕踩雷
+深层问题:用户不知道怎么点最稳,也没看到价格背后的价值证据
+```
+
+截图位置:
+
+```text
+【截图 06-02:表层问题 vs 深层问题】
+```
+
+下一步:检查老板视角影响。
+
+## 第三步:检查老板视角影响
+
+重点看是否影响:
+
+- 新客转化
+- 客单结构
+- 评论信任
+- 首单决策成本
+
+截图位置:
+
+```text
+【截图 06-03:老板视角影响】
+```
+
+下一步:检查可执行动作。
+
+## 第四步:检查今天能做的动作
+
+合格动作示例:
+
+```text
+做 2 人尝鲜点单路径
+把券后价讲清楚
+把招牌款写清适合谁
+把茶底、甜度、配料说明前置
+置顶评论区避坑问答
+```
+
+不合格动作:
+
+```text
+提升品牌调性
+加强营销宣传
+优化用户体验
+打造爆款
+```
+
+截图位置:
+
+```text
+【截图 06-04:今天能做的动作】
+```
+
+下一步:检查 7 天验证计划。
+
+## 第五步:检查 7 天验证计划
+
+计划要能落地:
+
+```text
+第 1 天:整理首单点单路径
+第 2 天:发布“第一次来怎么点”内容
+第 3 天:评论区统一回复券后价和套餐规则
+第 4 天:测试买一送一规则说明
+第 5 天:发布茶底/甜度避坑内容
+第 6 天:整理外卖到手避坑
+第 7 天:统计评论关键词和咨询量
+```
+
+截图位置:
+
+```text
+【截图 06-05:7 天验证计划】
+```
+
+下一步:进入内容计划。
+
+## 第六步:保留示例 Prompt,方便学员复制
+
+学员课后可复制:
+
+```text
+请基于下面 VOC 问题做单点深挖:
+
+行业:【填写行业】
+业务目标:【填写目标】
+价格带/决策门槛:【填写】
+VOC 问题:【填写问题】
+证据:
+1.【用户原话】
+2.【评论或样本】
+3.【销售/门店反馈】
+
+请输出:
+1. 表层问题和深层问题
+2. 对业务目标的影响
+3. 今天能做的动作
+4. 7 天验证计划
+5. 评论区/客服/销售回复方向
+6. 可转成内容的选题
+7. 下轮验证指标
+
+要求建议必须具体,不要空泛品牌宣传。
+```
+
+截图位置:
+
+```text
+【截图 06-06:课后可复制 Prompt】
+```
+
+下一步:打开 `07-content-plan-and-script.md`。
+
+## 本节验收
+
+- 有深层原因。
+- 有今天能做的动作。
+- 有 7 天验证计划。
+- 有评论区回复方向。
+- 有内容选题入口。
+

+ 174 - 0
docs/voc-skill-student-course/07-content-plan-and-script.md

@@ -0,0 +1,174 @@
+# 07 7 天内容计划与口播脚本
+
+本节目标:把 VOC 问题变成能直接拍摄的 7 天短视频内容计划。
+
+## 第一步:输入内容计划 Prompt
+
+输入:
+
+```text
+请基于下面 VOC 背景,生成 7 天短视频内容计划和可直接拍摄的口播脚本。
+
+业务目标:提升茶饮新客转化。
+价格带:18-22 元。
+核心问题:用户判断“值不值”“会不会踩雷”“首单怎么点最稳”。
+
+本轮真实线索:
+1. 小红书评论:“波霸奶茶用的什么茶底?我要避雷!!下午喝的,晚上睁眼到天亮”
+2. 抖音低价新品、11.9 单人下午茶、21 元买一送一能触发注意,但还需要解释规则、口味和是否有坑。
+3. 用户会比较套餐、券后价、自制成本、茶底、甜度、配料表、外卖到手体验。
+4. 评论区是转化前验真的核心场域。
+
+请生成 7 天内容计划。
+每条内容都要包含:
+对应 VOC 问题、标题、前 3 秒钩子、镜头建议、口播脚本、评论区引导、验证指标。
+
+内容方向请覆盖:
+第一次来怎么点;
+18-22 元贵在哪/值在哪;
+券后价和套餐怎么买最划算;
+茶底/甜度/配料怎么选;
+哪几款不容易踩雷;
+外卖到手怎么避免翻车;
+买一送一/团购规则怎么讲清楚。
+```
+
+截图位置:
+
+```text
+【截图 07-01:输入 7 天内容计划 Prompt】
+```
+
+下一步:检查是否使用 VOC 问题,而不是泛泛爆款。
+
+## 第二步:检查 7 天内容是否覆盖完整
+
+应覆盖 7 类内容:
+
+```text
+Day 1 第一次来怎么点
+Day 2 18-22 元贵在哪/值在哪
+Day 3 券后价和套餐怎么买
+Day 4 茶底/甜度/配料怎么选
+Day 5 哪几款不容易踩雷
+Day 6 外卖到手怎么避免翻车
+Day 7 买一送一/团购规则怎么讲清楚
+```
+
+截图位置:
+
+```text
+【截图 07-02:7 天内容表】
+```
+
+下一步:检查单条脚本。
+
+## 第三步:检查单条内容结构
+
+每条内容必须有:
+
+```text
+对应 VOC 问题
+标题
+前 3 秒钩子
+镜头建议
+口播脚本
+评论区引导
+验证指标
+```
+
+截图位置:
+
+```text
+【截图 07-03:单条内容结构】
+```
+
+下一步:选择一条进入脚本共创。
+
+## 第四步:选择 Day 1 进入脚本共创
+
+输入:
+
+```text
+请把 Day 1“第一次来怎么点最稳”继续改成老板/店长口吻的 60 秒口播稿。
+
+要求:
+1. 开头直接回应“第一次来怕踩雷”
+2. 不要像广告
+3. 要讲清楚适合谁、怎么点、为什么这样点更稳
+4. 给出 30 秒压缩版
+5. 给出提词器文本
+6. 给出拍摄镜头顺序
+```
+
+截图位置:
+
+```text
+【截图 07-04:进入单条口播脚本共创】
+```
+
+下一步:检查脚本是否可拍。
+
+## 第五步:检查脚本可拍性
+
+合格脚本应包含:
+
+- 真实顾虑开头
+- 产品或菜单画面
+- 点单路径
+- 适合人群
+- 评论区问题引导
+
+不合格表现:
+
+- 只有漂亮文案
+- 没有镜头建议
+- 没有产品细节
+- 没有验证指标
+
+截图位置:
+
+```text
+【截图 07-05:60 秒口播稿和镜头建议】
+```
+
+下一步:保存可复制模板。
+
+## 第六步:保留课后可复制 Prompt
+
+```text
+请基于我的 VOC 问题生成 7 天短视频内容计划。
+
+行业:【填写行业】
+业务目标:【填写目标】
+核心 VOC 问题:
+1.【问题 1】
+2.【问题 2】
+3.【问题 3】
+
+真实用户线索:
+1.【用户原话】
+2.【评论或私信】
+3.【采集样本观察】
+
+要求:
+每条内容都要包含:
+对应 VOC 问题、标题、前 3 秒钩子、镜头建议、口播脚本、评论区引导、验证指标。
+不要输出泛泛爆款标题,必须能直接拍摄。
+```
+
+截图位置:
+
+```text
+【截图 07-06:课后可复制 Prompt】
+```
+
+下一步:进入 HTML 报告。
+
+## 本节验收
+
+- 7 条内容不重复。
+- 每条都绑定 VOC 问题。
+- 至少一条脚本能直接开拍。
+- 每条都有验证指标。
+

+ 192 - 0
docs/voc-skill-student-course/08-html-report.md

@@ -0,0 +1,192 @@
+# 08 HTML 策略报告交付
+
+本节目标:把前面采集矩阵、采集结果、问题池、单点深挖和内容计划整理成老板能看的 HTML 报告。
+
+## 重要原则
+
+HTML 报告不需要进入复杂 workflow。Claude Code 本身就能基于现有材料生成 HTML 文件。
+
+正确做法:
+
+```text
+把基础样本数据 + 采集矩阵 + 问题池 + 单点深挖 + 内容计划
+交给 Claude Code
+让它直接生成 HTML 文件
+再打开浏览器检查和优化
+```
+
+截图位置:
+
+```text
+【截图 08-01:HTML 报告原则】
+```
+
+下一步:准备报告材料。
+
+## 第一步:整理报告材料
+
+准备下面内容:
+
+```text
+1. 业务背景
+2. 采集矩阵
+3. 小红书采集结果
+4. 抖音采集结果
+5. VOC 问题池
+6. 价格/性价比单点深挖
+7. 7 天内容计划
+8. 下一轮补采关键词
+9. 视觉风格要求
+```
+
+截图位置:
+
+```text
+【截图 08-02:报告材料清单】
+```
+
+下一步:输入 HTML 生成 Prompt。
+
+## 第二步:输入 HTML 生成 Prompt
+
+```text
+请基于下面 VOC 分析材料,生成一份老板/运营视角可直接阅读的 HTML 策略报告。
+
+业务背景:
+茶饮新客转化,价格带 18-22 元。
+
+报告目标:
+解释用户为什么在意“值不值、会不会踩雷、首单怎么点最稳”,并给出门店动作、内容动作和 7 天验证计划。
+
+已有材料:
+1. 采集矩阵:平台、关键词、样本数、评论数、显著问题、补采关键词
+2. 小红书结果:价格、踩雷、口味、套餐、分量是主要顾虑
+3. 抖音结果:人均、套餐、避坑、点单、团购是主要触发和阻力
+4. 问题池 Top 问题:价格/性价比、首单点单路径、茶底/甜度/配料、团购规则、外卖到手体验
+5. 单点深挖结论:18-22 元不是绝对贵,关键是用户不知道怎么判断值不值
+6. 7 天内容计划:第一次来怎么点、18-22 元贵在哪、券后价套餐、茶底甜度、避坑款、外卖、团购规则
+
+视觉风格:
+青花瓷风格,白底、瓷蓝、细线纹样、清爽高级。
+
+页面要求:
+1. 生成一个可打开的 HTML 文件
+2. 有目录导航
+3. 有关键结论高亮
+4. 有采集矩阵表格
+5. 有问题池优先级
+6. 有单点深挖模块
+7. 有 7 天内容计划模块
+8. 有下一轮补采关键词
+9. 有老板行动清单
+10. 内容必须基于现有材料,不要编造不存在的数据
+
+生成后请打开或告诉我 HTML 文件路径。
+```
+
+截图位置:
+
+```text
+【截图 08-03:输入 HTML 生成 Prompt】
+```
+
+下一步:检查生成文件。
+
+## 第三步:检查 HTML 文件
+
+检查内容:
+
+- 文件是否生成。
+- 是否能打开。
+- 页面是否乱码。
+- 表格是否完整。
+- 移动端是否能看。
+
+截图位置:
+
+```text
+【截图 08-04:HTML 文件生成】
+【截图 08-05:浏览器打开首页】
+```
+
+下一步:优化报告结构。
+
+## 第四步:优化报告结构
+
+如果报告太像普通文章,输入:
+
+```text
+请优化这份 HTML 报告结构。
+要求:
+1. 第一屏直接看到核心结论
+2. 第二屏展示采集矩阵和样本口径
+3. 第三屏展示 VOC 问题池
+4. 第四屏展示价格/性价比深挖
+5. 第五屏展示 7 天内容计划
+6. 最后一屏展示老板下周行动清单
+请保持青花瓷视觉风格,不要改变事实和数据。
+```
+
+截图位置:
+
+```text
+【截图 08-06:优化后的报告结构】
+```
+
+下一步:优化视觉。
+
+## 第五步:优化视觉风格
+
+输入:
+
+```text
+请继续优化 HTML 的视觉风格。
+要求:
+1. 使用青花瓷色系,但不要过度花哨
+2. 标题层级清晰
+3. 表格易读
+4. 关键数据用瓷蓝色强调
+5. 卡片不要太圆,保持专业报告感
+6. 移动端不要文字重叠
+```
+
+截图位置:
+
+```text
+【截图 08-07:优化后的青花瓷视觉】
+```
+
+下一步:做最终验收。
+
+## 第六步:最终验收
+
+验收清单:
+
+```text
+报告有业务背景
+报告有采集矩阵
+报告有样本数和评论数
+报告有 VOC 问题池
+报告有 Top 问题深挖
+报告有 7 天内容计划
+报告有下一轮补采关键词
+报告有老板行动清单
+报告没有编造数据
+HTML 可以打开
+移动端不重叠
+```
+
+截图位置:
+
+```text
+【截图 08-08:最终报告页面】
+【截图 08-09:移动端或窄屏检查】
+```
+
+## 本节验收
+
+- 学员能生成 HTML。
+- 学员能指出报告中的证据来源。
+- 学员知道 HTML 报告来自前面的分析结果,不是单独空写。
+- 学员能根据结构和视觉要求继续优化。
+

+ 217 - 0
docs/voc-skill-student-course/09-troubleshooting.md

@@ -0,0 +1,217 @@
+# 09 常见问题与快速修复
+
+本节目标:让学员遇到错误时知道下一步,而不是卡在报错页面。
+
+## 问题 1:旧安装命令还能用吗
+
+旧包:
+
+```text
+@gangvy/claude-code-voc-intelligence
+```
+
+已迁移到:
+
+```text
+@vocmarket/voc-skill
+```
+
+新安装命令:
+
+```powershell
+npx --yes @vocmarket/voc-skill@latest workspace --smoke
+```
+
+如果学员使用旧包,npm 会提示改用新包。
+
+截图位置:
+
+```text
+【截图 09-01:新安装命令】
+```
+
+## 问题 2:Claude Code 没触发 VOC 技能
+
+处理提示词:
+
+```text
+请使用当前工作区已安装的 VOC 技能包,不要凭经验回答。
+如果可以调用工具,请优先调用 voc_xiaohongshu_trend_run 或 voc_douyin_trend_run。
+```
+
+检查文件:
+
+```powershell
+dir .\.claude\plugins\voc-intelligence
+dir .\.claude\skills
+dir .\.mcp.json
+```
+
+截图位置:
+
+```text
+【截图 09-02:检查 .claude 和 .mcp.json】
+```
+
+下一步:重启 Claude Code。
+
+## 问题 3:needs_token
+
+含义:没有检测到 token。
+
+处理:
+
+1. 在工作区根目录创建 `.env.local`。
+2. 写入:
+
+```text
+VOC_TOKEN=你的服务 token
+```
+
+3. 重启 Claude Code。
+4. 重新运行 live 采集。
+
+截图位置:
+
+```text
+【截图 09-03:needs_token 状态,不展示 token 值】
+```
+
+## 问题 4:needs_recharge
+
+含义:服务未开通或余额不足。
+
+处理:
+
+1. 打开工具输出中的充值/开通链接。
+2. 完成后重新采集。
+3. 如果课堂现场暂时无法处理,先切 sample 模式继续练习。
+
+截图位置:
+
+```text
+【截图 09-04:needs_recharge 状态和充值链接】
+```
+
+## 问题 5:needs_keyword_fix
+
+含义:关键词或搜索入参不适合平台搜索,不一定是余额问题。
+
+处理 Prompt:
+
+```text
+这次 live 采集返回 needs_keyword_fix。
+请不要当成余额不足处理。
+请根据当前业务目标,帮我把关键词改成 3 个更适合抖音搜索的短关键词,并重新小规模采集。
+```
+
+关键词修复示例:
+
+| 原关键词 | 修复后 |
+|---|---|
+| 茶饮18元22元 | 茶饮 18元 22元 |
+| 茶饮价格性价比新客套餐点单避坑评论区问题很多 | 茶饮 性价比 新客 |
+| 奶茶第一次来不知道怎么点怕踩雷 | 奶茶 点单 避坑 |
+
+截图位置:
+
+```text
+【截图 09-05:needs_keyword_fix 状态】
+【截图 09-06:修复关键词后重新采集】
+```
+
+## 问题 6:小红书链接 404 或首图不显示
+
+可能原因:
+
+- 平台没有返回可核验原帖链接。
+- 原始链接缺少 `xsec_token`。
+- 图片资源需要平台权限或已失效。
+
+处理:
+
+```text
+请检查本轮小红书证据样本:
+1. 哪些原帖链接可核验
+2. 哪些链接可能 404
+3. 首图是否有本地缓存
+4. 如果首图不可展示,请在报告中明确写“平台未返回可展示首图”
+不要把不可核验链接当作原帖证据。
+```
+
+截图位置:
+
+```text
+【截图 09-07:链接或首图异常说明】
+```
+
+## 问题 7:报告输出太像最终结论
+
+修正 Prompt:
+
+```text
+请把这份报告改成第一轮 VOC 初步判断。
+要求:
+1. 明确写“待校准”
+2. 不要写成最终结论
+3. 每个判断都要回到证据
+4. 最后给出下一轮待确认问题
+```
+
+截图位置:
+
+```text
+【截图 09-08:把最终结论改成待校准】
+```
+
+## 问题 8:单点深挖卡住,不推进下一步
+
+修正 Prompt:
+
+```text
+请不要继续停留在单点深挖。
+现在请把这个问题推进到下一步:
+1. 需要补采哪些关键词
+2. 是否要进入 live 采集
+3. 是否要生成问题池
+4. 是否要转成 7 天内容计划
+请给出下一步操作,而不是继续重复分析同一个点。
+```
+
+截图位置:
+
+```text
+【截图 09-09:从深挖推进下一步】
+```
+
+## 问题 9:内容计划没有用到 VOC 技能
+
+判断:
+
+- 如果输出重复。
+- 如果内容像餐饮通用模板。
+- 如果茶饮案例里出现“招牌菜、下饭菜”等不适合词。
+
+修正 Prompt:
+
+```text
+请重新生成内容计划。
+必须基于茶饮 VOC 背景:
+18-22 元、值不值、首单怎么点、茶底、甜度、配料表、外卖到手、团购规则。
+不要使用餐饮菜品模板,不要出现“招牌菜、下饭菜”。
+每条都要有对应 VOC 问题和验证指标。
+```
+
+截图位置:
+
+```text
+【截图 09-10:修正内容计划】
+```
+
+## 本节验收
+
+- 学员知道 token、余额、关键词错误的区别。
+- 学员知道旧包已迁移到新包。
+- 学员知道链接 404 时不能强行当证据。
+- 学员知道深挖后要推进到采集、问题池、内容计划或报告。
+

+ 241 - 0
docs/voc-skill-student-course/10-recording-checklist.md

@@ -0,0 +1,241 @@
+# 10 操作留档清单
+
+本文件给课堂操作和学员课后复盘使用。每完成一节,按清单逐项打勾,避免漏掉截图、Prompt、下一步。
+
+## 课前总检查
+
+- [ ] VS Code 能打开课程工作区。
+- [ ] Claude Code 能正常回复。
+- [ ] 终端能运行 `node --version`。
+- [ ] 已准备截图保存文件夹。
+- [ ] 确认 VS Code、终端、浏览器都能正常打开。
+- [ ] `.env.local` 如果存在,不展示 token 值。
+- [ ] 统一案例背景已复制到剪贴板或便签。
+
+## 每节课统一操作结构
+
+每节都按这个顺序录:
+
+1. 本节目标
+2. 本节要输入什么
+3. 复制 Prompt
+4. 等待 Claude Code 输出
+5. 检查正常结果
+6. 截图留档
+7. 说明下一步去哪一节
+
+## 00 课程路线图
+
+- [ ] 展示课程目录。
+- [ ] 展示完整流程:采集矩阵 → live → 问题池 → 深挖 → 内容 → HTML。
+- [ ] 展示统一茶饮案例。
+- [ ] 展示其它行业替换字段。
+- [ ] 结尾打开 `01-workspace-setup.md`。
+
+截图:
+
+```text
+【截图 00-01:课程目录】
+【截图 00-02:课程流程】
+【截图 00-03:统一案例背景】
+【截图 00-04:其它行业替换字段】
+```
+
+## 01 工作区准备
+
+- [ ] 打开 VS Code 工作区。
+- [ ] 运行 Node/npm/npx 检查。
+- [ ] 运行新包安装命令。
+- [ ] 展示 `.claude/plugins` 和 `.claude/skills`。
+- [ ] 重启 Claude Code。
+- [ ] 输入确认提示词。
+
+截图:
+
+```text
+【截图 01-01:VS Code 工作区】
+【截图 01-02:Node/npm/npx 版本】
+【截图 01-03:workspace --smoke 命令】
+【截图 01-04:安装成功输出】
+【截图 01-05:.claude 文件结构】
+【截图 01-06:Claude Code 正常回复】
+```
+
+## 02 sample 首跑
+
+- [ ] 输入小红书 sample Prompt。
+- [ ] 展示第一轮初步判断。
+- [ ] 展示证据样本。
+- [ ] 展示待确认问题。
+- [ ] 输入抖音 sample Prompt。
+- [ ] 对比小红书和抖音的差异。
+
+截图:
+
+```text
+【截图 02-01:小红书 sample Prompt】
+【截图 02-02:小红书输出】
+【截图 02-03:证据样本】
+【截图 02-04:待确认问题】
+【截图 02-05:抖音 sample Prompt】
+【截图 02-06:抖音输出】
+```
+
+## 03 采集矩阵
+
+- [ ] 输入采集矩阵 Prompt。
+- [ ] 展示矩阵字段。
+- [ ] 展示关键词如何对应用户问题。
+- [ ] 展示其它行业替换方式。
+- [ ] 输入矩阵回填 Prompt。
+
+截图:
+
+```text
+【截图 03-01:矩阵目标】
+【截图 03-02:矩阵 Prompt】
+【截图 03-03:矩阵输出】
+【截图 03-04:其它行业替换】
+【截图 03-05:回填 Prompt】
+【截图 03-06:回填结果】
+```
+
+## 04 live 采集
+
+- [ ] 展示 `.env.local` 文件存在但不展示 token 值。
+- [ ] 输入 live 采集 Prompt。
+- [ ] 展示小红书结果。
+- [ ] 展示抖音结果。
+- [ ] 展示样本数、评论数、显著问题。
+- [ ] 回填矩阵。
+
+截图:
+
+```text
+【截图 04-01:.env.local 文件存在】
+【截图 04-02:live Prompt】
+【截图 04-03:live 状态】
+【截图 04-04:小红书结果】
+【截图 04-05:抖音结果】
+【截图 04-06:矩阵回填】
+```
+
+## 05 问题池
+
+- [ ] 准备采集结果。
+- [ ] 输入问题池 Prompt。
+- [ ] 展示 Top 3 问题。
+- [ ] 展示每个问题证据。
+- [ ] 选择 Top 1。
+- [ ] 整理给单点深挖的输入。
+
+截图:
+
+```text
+【截图 05-01:采集结果输入材料】
+【截图 05-02:问题池 Prompt】
+【截图 05-03:问题优先级】
+【截图 05-04:Top 问题证据】
+【截图 05-05:选择 Top 1】
+【截图 05-06:深挖输入】
+```
+
+## 06 单点深挖
+
+- [ ] 输入价格/性价比深挖 Prompt。
+- [ ] 展示表层和深层问题。
+- [ ] 展示老板视角影响。
+- [ ] 展示今天能做的动作。
+- [ ] 展示 7 天验证计划。
+- [ ] 展示课后可复制 Prompt。
+
+截图:
+
+```text
+【截图 06-01:深挖 Prompt】
+【截图 06-02:表层 vs 深层】
+【截图 06-03:老板视角影响】
+【截图 06-04:今天能做】
+【截图 06-05:7 天验证】
+【截图 06-06:可复制 Prompt】
+```
+
+## 07 内容计划和口播
+
+- [ ] 输入 7 天内容计划 Prompt。
+- [ ] 展示 7 天表。
+- [ ] 展示单条内容结构。
+- [ ] 选择 Day 1 进入口播脚本。
+- [ ] 展示 60 秒稿、30 秒稿和镜头。
+- [ ] 展示课后可复制 Prompt。
+
+截图:
+
+```text
+【截图 07-01:内容计划 Prompt】
+【截图 07-02:7 天内容表】
+【截图 07-03:单条内容结构】
+【截图 07-04:脚本共创 Prompt】
+【截图 07-05:口播稿和镜头】
+【截图 07-06:可复制 Prompt】
+```
+
+## 08 HTML 报告
+
+- [ ] 展示报告材料清单。
+- [ ] 输入 HTML 生成 Prompt。
+- [ ] 展示 HTML 文件生成。
+- [ ] 浏览器打开报告。
+- [ ] 优化结构。
+- [ ] 优化视觉。
+- [ ] 做最终验收。
+
+截图:
+
+```text
+【截图 08-01:HTML 原则】
+【截图 08-02:材料清单】
+【截图 08-03:HTML Prompt】
+【截图 08-04:文件生成】
+【截图 08-05:浏览器打开】
+【截图 08-06:结构优化】
+【截图 08-07:视觉优化】
+【截图 08-08:最终报告】
+【截图 08-09:窄屏检查】
+```
+
+## 09 故障处理
+
+- [ ] 展示新旧包名迁移。
+- [ ] 展示技能不触发的修复 Prompt。
+- [ ] 展示 `needs_token`。
+- [ ] 展示 `needs_recharge`。
+- [ ] 展示 `needs_keyword_fix`。
+- [ ] 展示链接 404 / 首图问题。
+- [ ] 展示深挖卡住如何推进下一步。
+- [ ] 展示内容计划没用 VOC 背景如何修正。
+
+截图:
+
+```text
+【截图 09-01:新安装命令】
+【截图 09-02:检查 .claude】
+【截图 09-03:needs_token】
+【截图 09-04:needs_recharge】
+【截图 09-05:needs_keyword_fix】
+【截图 09-06:关键词修复】
+【截图 09-07:链接/首图异常】
+【截图 09-08:待校准修正】
+【截图 09-09:推进下一步】
+【截图 09-10:内容计划修正】
+```
+
+## 录完后的检查
+
+- [ ] 每节都有明确“下一步”。
+- [ ] 每节都有至少 5 张截图位。
+- [ ] 所有 Prompt 都能复制。
+- [ ] 没有展示 token。
+- [ ] 没有使用旧包安装命令作为主命令。
+- [ ] 没有把 sample 说成真实数据。
+- [ ] 没有把第一轮判断说成最终结论。

+ 258 - 0
docs/voc-skill-student-course/11-prompt-library.md

@@ -0,0 +1,258 @@
+# 11 学员可复制 Prompt 总库
+
+本文件用于课堂操作时快速复制。学员课后也可以直接替换字段使用。
+
+## 1. 检查当前工作区 VOC 技能
+
+```text
+请确认当前工作区是否可以使用已安装的 VOC 技能包。
+如果可以调用工具,请优先使用 VOC 技能,不要凭经验回答。
+```
+
+## 2. 小红书 sample 首跑
+
+```text
+请使用已安装的 VOC 技能,不要凭经验回答。
+
+帮我做一份茶饮新客转化的小红书趋势情报。
+先用 sample 模式跑通流程,不要真实采集。
+业务目标:提升新客转化。
+价格带:18-22 元。
+重点看:值不值、会不会踩雷、首单怎么点最稳。
+
+请在聊天里输出:
+1. 第一轮初步判断
+2. 样本观察
+3. 用户顾虑
+4. 证据样本
+5. 待确认问题
+```
+
+## 3. 抖音 sample 首跑
+
+```text
+请继续使用已安装的 VOC 技能。
+
+帮我做一份茶饮新客转化的抖音趋势情报。
+先用 sample 模式跑通流程,不要真实采集。
+重点看:低价新品、单人下午茶、买一送一、团购规则、评论区会不会继续问值不值。
+
+请输出第一轮样本观察、用户顾虑、证据样本和下一轮待确认问题。
+```
+
+## 4. 采集矩阵
+
+```text
+请先不要开始 live 采集。
+
+请基于下面 VOC 背景,帮我设计一份采集矩阵:
+
+业务目标:提升茶饮新客转化。
+价格带:18-22 元。
+核心问题:用户判断“值不值”“会不会踩雷”“首单怎么点最稳”。
+
+本轮真实线索:
+1. 小红书评论:“波霸奶茶用的什么茶底?我要避雷!!下午喝的,晚上睁眼到天亮”
+2. 抖音低价新品、11.9 单人下午茶、21 元买一送一能触发注意,但还需要解释规则、口味和是否有坑。
+3. 用户会比较套餐、券后价、自制成本、茶底、甜度、配料表、外卖到手体验。
+4. 评论区是转化前验真的核心场域。
+
+请输出一张采集矩阵,字段包括:
+平台、目标人群、场景、关键词、样本量、证据类型、判断标准、显著问题、下一轮补采关键词。
+```
+
+## 5. live 小规模采集
+
+```text
+请进入真实采集 live 模式。
+
+业务目标:提升茶饮新客转化。
+价格带:18-22 元。
+平台:小红书、抖音。
+采集范围:先小规模采集,不要全量。
+
+重点关键词:
+小红书:茶饮 值不值、茶饮 首单、茶饮 套餐 点单、茶饮 避坑、茶饮 茶底 甜度
+抖音:茶饮 性价比 新客、茶饮 团购、茶饮 买一送一、茶饮 券后价、奶茶 点单 避坑
+
+请先输出第一轮市场声音、样本数、评论数、用户顾虑、证据样本和待确认问题。
+不要直接写最终策略报告。
+```
+
+## 6. 回填采集矩阵
+
+```text
+请把刚才小红书和抖音 live 采集结果回填到采集矩阵。
+请补充:
+1. 每个平台样本数
+2. 评论数
+3. 最显著问题
+4. 下一轮补采关键词
+5. 哪些问题适合进入 VOC 问题池
+```
+
+## 7. VOC 问题池
+
+```text
+请把上面的采集结果整理成 VOC 问题池。
+
+业务目标:提升茶饮新客转化。
+价格带:18-22 元。
+重点影响:新客转化、客单结构、评论信任。
+
+要求:
+1. 按业务影响排序,不要只按词频排序
+2. 每个问题都要有用户证据
+3. 标注影响环节
+4. 给出建议动作
+5. 给出下一步是否适合继续深挖
+6. 输出 Top 3 问题
+```
+
+## 8. 单点深挖
+
+```text
+请继续深挖这个 VOC 问题:价格/性价比。
+
+业务背景:
+我们是茶饮连锁,现有产品定价在 18-22 元区间。
+主要目标:提升新客转化。
+
+证据:
+1. “探店内容别只拍氛围,评论区最关心值不值”
+2. “波霸奶茶用的什么茶底?我要避雷!!下午喝的,晚上睁眼到天亮”
+3. 抖音低价新品、11.9 单人下午茶、21 元买一送一能触发注意,但还需要解释规则、口味和是否有坑。
+
+请站在老板/运营视角输出:
+1. 表层问题和深层问题
+2. 对新客转化的影响
+3. 今天就能做的动作
+4. 7 天内验证动作
+5. 评论区回复方向
+6. 可转化内容选题
+7. 下轮验证指标
+
+不要建议单纯降价,要把价格解释成点单路径、价值证据和避坑说明。
+```
+
+## 9. 7 天内容计划
+
+```text
+请基于下面 VOC 背景,生成 7 天短视频内容计划和可直接拍摄的口播脚本。
+
+业务目标:提升茶饮新客转化。
+价格带:18-22 元。
+核心问题:用户判断“值不值”“会不会踩雷”“首单怎么点最稳”。
+
+本轮真实线索:
+1. 小红书评论:“波霸奶茶用的什么茶底?我要避雷!!下午喝的,晚上睁眼到天亮”
+2. 抖音低价新品、11.9 单人下午茶、21 元买一送一能触发注意,但还需要解释规则、口味和是否有坑。
+3. 用户会比较套餐、券后价、自制成本、茶底、甜度、配料表、外卖到手体验。
+4. 评论区是转化前验真的核心场域。
+
+请生成 7 天内容计划。
+每条内容都要包含:
+对应 VOC 问题、标题、前 3 秒钩子、镜头建议、口播脚本、评论区引导、验证指标。
+
+内容方向请覆盖:
+第一次来怎么点;
+18-22 元贵在哪/值在哪;
+券后价和套餐怎么买最划算;
+茶底/甜度/配料怎么选;
+哪几款不容易踩雷;
+外卖到手怎么避免翻车;
+买一送一/团购规则怎么讲清楚。
+```
+
+## 10. 单条脚本共创
+
+```text
+请把 Day 1“第一次来怎么点最稳”继续改成老板/店长口吻的 60 秒口播稿。
+
+要求:
+1. 开头直接回应“第一次来怕踩雷”
+2. 不要像广告
+3. 要讲清楚适合谁、怎么点、为什么这样点更稳
+4. 给出 30 秒压缩版
+5. 给出提词器文本
+6. 给出拍摄镜头顺序
+```
+
+## 11. HTML 报告
+
+```text
+请基于下面 VOC 分析材料,生成一份老板/运营视角可直接阅读的 HTML 策略报告。
+
+业务背景:
+茶饮新客转化,价格带 18-22 元。
+
+报告目标:
+解释用户为什么在意“值不值、会不会踩雷、首单怎么点最稳”,并给出门店动作、内容动作和 7 天验证计划。
+
+已有材料:
+1. 采集矩阵:平台、关键词、样本数、评论数、显著问题、补采关键词
+2. 小红书结果:价格、踩雷、口味、套餐、分量是主要顾虑
+3. 抖音结果:人均、套餐、避坑、点单、团购是主要触发和阻力
+4. 问题池 Top 问题:价格/性价比、首单点单路径、茶底/甜度/配料、团购规则、外卖到手体验
+5. 单点深挖结论:18-22 元不是绝对贵,关键是用户不知道怎么判断值不值
+6. 7 天内容计划:第一次来怎么点、18-22 元贵在哪、券后价套餐、茶底甜度、避坑款、外卖、团购规则
+
+视觉风格:
+青花瓷风格,白底、瓷蓝、细线纹样、清爽高级。
+
+页面要求:
+1. 生成一个可打开的 HTML 文件
+2. 有目录导航
+3. 有关键结论高亮
+4. 有采集矩阵表格
+5. 有问题池优先级
+6. 有单点深挖模块
+7. 有 7 天内容计划模块
+8. 有下一轮补采关键词
+9. 有老板行动清单
+10. 内容必须基于现有材料,不要编造不存在的数据
+
+生成后请打开或告诉我 HTML 文件路径。
+```
+
+## 12. needs_keyword_fix 修复
+
+```text
+这次 live 采集返回 needs_keyword_fix。
+请不要当成余额不足处理。
+请根据当前业务目标,帮我把关键词改成 3 个更适合抖音搜索的短关键词,并重新小规模采集。
+```
+
+## 13. 深挖卡住时推进下一步
+
+```text
+请不要继续停留在单点深挖。
+现在请把这个问题推进到下一步:
+1. 需要补采哪些关键词
+2. 是否要进入 live 采集
+3. 是否要生成问题池
+4. 是否要转成 7 天内容计划
+请给出下一步操作,而不是继续重复分析同一个点。
+```
+
+## 14. 其它行业通用替换 Prompt
+
+```text
+请基于下面行业背景,按 VOC 流程帮我完成第一轮分析。
+
+行业:【填写行业】
+业务目标:【填写业务目标】
+价格带/决策门槛:【填写】
+目标人群:【填写】
+用户最关心的问题:
+1.【问题 1】
+2.【问题 2】
+3.【问题 3】
+
+已有用户原话:
+1.【评论/私信/差评】
+2.【评论/私信/差评】
+3.【评论/私信/差评】
+
+请先设计采集矩阵,不要直接写最终报告。
+```

+ 100 - 0
docs/voc-skill-student-course/README.md

@@ -0,0 +1,100 @@
+# VOC 技能包学员实战课总入口
+
+适用对象:门店老板、运营负责人、内容运营、品牌负责人、工作坊学员  
+学习目标:用 Claude Code + VOC 技能包,从用户真实声音出发,完成采集矩阵、平台采集、问题池、单点深挖、内容计划和 HTML 报告交付。
+
+## 课程最终要学会什么
+
+完成这套课程后,学员应能独立完成一次 VOC 小闭环:
+
+```text
+业务目标
+→ 采集矩阵
+→ 小红书/抖音 sample 或 live 采集
+→ VOC 问题池
+→ 单点问题深挖
+→ 7 天内容计划和口播脚本
+→ HTML 策略报告
+→ 下轮补采关键词
+```
+
+## 建议学习顺序
+
+| 顺序 | 文档 | 本节产出 |
+|---|---|---|
+| 0 | `00-course-map.md` | 课程路线图和课堂操作总流程 |
+| 1 | `01-workspace-setup.md` | Claude Code 工作区可运行 VOC 技能 |
+| 2 | `02-sample-first-run.md` | 用 sample 模式跑通第一份趋势情报 |
+| 3 | `03-collection-matrix.md` | 设计并回填 VOC 采集矩阵 |
+| 4 | `04-live-collection.md` | 进入小红书/抖音 live 真实采集 |
+| 5 | `05-issue-pool.md` | 把采集结果整理成 VOC 问题池 |
+| 6 | `06-single-issue-deep-dive.md` | 深挖价格/性价比问题 |
+| 7 | `07-content-plan-and-script.md` | 生成 7 天短视频计划和口播脚本 |
+| 8 | `08-html-report.md` | 生成可交付 HTML 报告 |
+| 9 | `09-troubleshooting.md` | 处理 token、余额、关键词、工具不触发等问题 |
+
+## 统一案例背景
+
+后续所有文档默认使用同一个案例,方便连续学习:
+
+```text
+业务目标:提升茶饮新客转化。
+价格带:18-22 元。
+核心问题:用户判断“值不值”“会不会踩雷”“首单怎么点最稳”。
+
+真实线索:
+1. 小红书评论:“波霸奶茶用的什么茶底?我要避雷!!下午喝的,晚上睁眼到天亮”
+2. 抖音低价新品、11.9 单人下午茶、21 元买一送一能触发注意,但还需要解释规则、口味和是否有坑。
+3. 用户会比较套餐、券后价、自制成本、茶底、甜度、配料表、外卖到手体验。
+4. 评论区是转化前验真的核心场域。
+```
+
+## 其它行业怎么替换
+
+课堂中先用茶饮案例跑通。学员换行业时,只替换下面这些字段:
+
+| 茶饮案例字段 | 替换成其它行业时填什么 | 示例 |
+|---|---|---|
+| 业务目标 | 当前最想改善的经营结果 | 提升家装咨询转化 |
+| 价格带 | 用户会比较或犹豫的价格范围 | 全屋定制 8-15 万 |
+| 核心问题 | 用户决策前最常问的问题 | 环保、设计落地、增项 |
+| 用户原话 | 评论、私信、差评、门店反馈 | “怕后期加钱”“效果图和落地不一样” |
+| 平台 | 目标用户真实做功课的平台 | 小红书、抖音、点评、淘宝、Amazon |
+| 关键词 | 用户会搜索的自然表达 | 全屋定制 避坑、儿童益生菌 怎么选 |
+
+不要替换的部分:
+
+- 先 sample 再 live
+- 第一轮只写“初步判断 / 机会假设 / 待校准”
+- 每条洞察都要回到证据
+- 每个建议都要有验证指标
+
+## 截图留档建议
+
+每节课截图放到:
+
+```text
+docs/voc-skill-student-course/screenshots/<lesson-id>/
+```
+
+命名建议:
+
+```text
+01-open-workspace.png
+02-input-prompt.png
+03-tool-running.png
+04-result-output.png
+05-check-files.png
+06-next-step.png
+```
+
+## 学员完成标准
+
+学员最终至少交付:
+
+- 一张 VOC 采集矩阵
+- 一份小红书或抖音趋势情报
+- 一张 VOC 问题池
+- 一个单点问题深挖结果
+- 一份 7 天内容计划
+- 一份 HTML 报告或报告大纲

+ 2 - 1
package.json

@@ -23,7 +23,8 @@
     "claude-voc:build": "node scripts/deploy/claude-code-voc-intelligence-suite.js --validate --build --test-package",
     "claude-voc:upload": "node scripts/deploy/claude-code-voc-intelligence-suite.js --validate --build --test-package --upload",
     "claude-voc:npm-pack": "node scripts/deploy/claude-code-voc-npm-package.js --pack --test",
-    "claude-voc:npm-publish": "node scripts/deploy/claude-code-voc-npm-package.js --pack --test --publish"
+    "claude-voc:npm-publish": "node scripts/deploy/claude-code-voc-npm-package.js --pack --test --publish",
+    "claude-voc:acceptance": "npm --prefix claude-code/claude-code-voc-intelligence run acceptance"
   },
   "repository": {
     "type": "git",

+ 44 - 15
release/npm/claude-code-voc-intelligence/README.md

@@ -1,13 +1,13 @@
 # Claude Code VOC Intelligence npm 发布包
 
-这个文件夹用于沉淀 `@gangvy/claude-code-voc-intelligence` 的 npm 发布流程。
+这个文件夹用于沉淀 `@vocmarket/voc-skill` 的 npm 发布流程。
 
 ## 用户安装方式
 
 推荐给 VSCode Claude Code 插件使用的工作区安装:
 
 ```bash
-npx @gangvy/claude-code-voc-intelligence@latest workspace --smoke
+npx @vocmarket/voc-skill@latest workspace --smoke
 ```
 
 这种方式会把技能包安装到当前工作区:
@@ -21,14 +21,14 @@ npx @gangvy/claude-code-voc-intelligence@latest workspace --smoke
 全局安装:
 
 ```bash
-npm install -g @gangvy/claude-code-voc-intelligence
+npm install -g @vocmarket/voc-skill
 claude-voc install
 ```
 
 免全局安装:
 
 ```bash
-npx @gangvy/claude-code-voc-intelligence install
+npx @vocmarket/voc-skill install
 ```
 
 两种方式都会把技能包安装到:
@@ -50,11 +50,19 @@ npm run claude-voc:npm-pack
 这个命令会:
 
 1. 校验 `package.json`、CLI 入口和必要文件。
-2. 生成 `dist/npm/gangvy-claude-code-voc-intelligence-<version>.tgz`。
+2. 生成 `dist/npm/vocmarket-voc-skill-<version>.tgz`。
 3. 模拟全局安装后的 `claude-voc install --smoke`。
 4. 模拟 `npx` 方式的 `claude-voc install --smoke`。
 5. 模拟 VSCode Claude Code 推荐的 `claude-voc workspace --smoke`。
 
+完整发布前验收:
+
+```powershell
+npm run claude-voc:acceptance
+```
+
+这个命令会串联包级 smoke、MCP smoke、`npm pack --dry-run`、临时 workspace 安装,并断言 `.mcp.json` 里的 MCP server 路径是安装后插件目录内的绝对路径。
+
 也可以直接运行本目录脚本:
 
 ```powershell
@@ -98,22 +106,43 @@ npm publish <tgz> --access public
 
 ## 当前发布状态
 
-- 当前 npm latest:`@gangvy/claude-code-voc-intelligence@0.1.14`
-- 本地 dist zip/tgz manifest 已重建到 `0.1.14`
-- 已验证:`npx --yes @gangvy/claude-code-voc-intelligence@latest workspace --smoke`
+- 当前 npm latest:`@vocmarket/voc-skill@0.3.15`
+- 本地 dist zip/tgz manifest 已重建到 `0.3.15`
+- 已验证:`npm view @vocmarket/voc-skill name version dist-tags.latest` 返回 `0.3.15`
+- 已验证:`npm exec --yes --package @vocmarket/voc-skill@latest -- claude-voc workspace --smoke`
+- 已迁移提示:旧包 `@gangvy/claude-code-voc-intelligence@*` 已标记为 deprecated,提示改用 `@vocmarket/voc-skill`。
+- 已新增:`npm run claude-voc:acceptance` 一键发布前验收,借鉴 `tihao-sop` 的 workspace 安装断言方式。
 - 已新增:图文报告资产缓存,证据样本首图默认缓存到 `outputs/<run>/assets/`,Markdown 报告优先引用本地 `assets/...`,JSON 保留原图 URL 与本地路径。
 - 已修复:无 VOC token、余额不足或未开通时,不再暴露底层 403/权限/余额报错,统一返回充值链接引导。
 - 已优化:小红书/社媒任务优先调用 VOC Skill/MCP,不用 WebSearch 替代;报告证据样本包含原帖链接、首图、原文摘录、高赞评论和“为什么值得看”。
 - 已修复:小红书原帖链接优先使用 `mini_program_info.webpage_url` 或带 `xsec_token` 的 `share_info.link`,不再把 `/explore/<noteId>` 作为主链接。
 
+## 发布成功但校验 404 的处理
+
+如果 `npm publish` 已经输出:
+
+```text
++ @vocmarket/voc-skill@版本号
+```
+
+但脚本后置 `npm view` 立刻返回 404,通常是 npm registry 同步延迟,不要马上重复发布同一个版本。
+
+建议等待 20-60 秒后执行:
+
+```powershell
+npm view @vocmarket/voc-skill@版本号 version --registry=https://registry.npmjs.org/
+```
+
+如果能返回版本号,说明发布成功。当前发布脚本已延长后置校验等待次数,降低误报概率。
+
 ## 后续版本更新
 
 1. 修改技能包代码、文档或 MCP 工具。
 2. 升级版本号:
-   - `claude-code-voc-intelligence/package.json`
-   - `claude-code-voc-intelligence/package-lock.json`
-   - `claude-code-voc-intelligence/skill-package-manifest.json`
-   - `claude-code-voc-intelligence/.claude-plugin/plugin.json`
+   - `claude-code/claude-code-voc-intelligence/package.json`
+   - `claude-code/claude-code-voc-intelligence/package-lock.json`
+   - `claude-code/claude-code-voc-intelligence/skill-package-manifest.json`
+   - `claude-code/claude-code-voc-intelligence/.claude-plugin/plugin.json`
 3. 如涉及 MCP server 或记忆 schema,同步更新对应版本字段。
 4. 执行 `npm run claude-voc:npm-pack`。
 5. 执行 `npm run claude-voc:npm-publish`。
@@ -121,19 +150,19 @@ npm publish <tgz> --access public
 用户升级:
 
 ```bash
-npm install -g @gangvy/claude-code-voc-intelligence@latest
+npm install -g @vocmarket/voc-skill@latest
 claude-voc install
 ```
 
 或:
 
 ```bash
-npx @gangvy/claude-code-voc-intelligence@latest install
+npx @vocmarket/voc-skill@latest install
 ```
 
 VSCode Claude Code 工作区升级:
 
 ```bash
-npx --yes @gangvy/claude-code-voc-intelligence@latest workspace --smoke
+npx --yes @vocmarket/voc-skill@latest workspace --smoke
 ```
 

+ 1 - 1
release/npm/claude-code-voc-intelligence/publish.ps1

@@ -43,7 +43,7 @@ try {
     throw "npm publish failed."
   }
 
-  Write-Host "[done] published @gangvy/claude-code-voc-intelligence" -ForegroundColor Green
+  Write-Host "[done] published @vocmarket/voc-skill" -ForegroundColor Green
 }
 finally {
   Pop-Location

+ 3 - 2
release/npm/claude-code-voc-intelligence/release-checklist.md

@@ -4,6 +4,7 @@
 
 - [ ] 版本号已升级,且 `package-lock.json` 已同步。
 - [ ] `README.md`、客户说明和技能文档已更新。
+- [ ] `npm run claude-voc:acceptance` 通过,确认包级 smoke、MCP smoke、npm pack dry-run 和 workspace 安装断言都正常。
 - [ ] `npm run claude-voc:npm-pack` 通过。
 - [ ] `npm run claude-voc:build` 通过,zip 交付方式没有被破坏。
 - [ ] 无 VOC token / 余额不足 / 未开通权限时,工具返回充值链接,不向用户暴露底层 403 或接口报错。
@@ -22,7 +23,7 @@ powershell -ExecutionPolicy Bypass -File release/npm/claude-code-voc-intelligenc
 
 ## 发布后
 
-- [ ] 执行 `npm view @gangvy/claude-code-voc-intelligence version` 确认版本。
-- [ ] 执行 `npx --yes @gangvy/claude-code-voc-intelligence@latest workspace --smoke` 做线上工作区安装验证。
+- [ ] 执行 `npm view @vocmarket/voc-skill version` 确认版本。
+- [ ] 执行 `npx --yes @vocmarket/voc-skill@latest workspace --smoke` 做线上工作区安装验证。
 - [ ] 更新教学文档或下载页里的版本信息。
 

+ 1094 - 0
scripts/course-ppt/build-voc-community-course-decks.mjs

@@ -0,0 +1,1094 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+import { spawnSync } from "node:child_process";
+import { pathToFileURL } from "node:url";
+
+const ROOT = path.resolve("E:/workspace/openclaw-voc-skill");
+const OUT_DIR = path.join(ROOT, "docs", "voc-skill-student-course");
+const WORKSPACE = path.join(
+  ROOT,
+  "outputs",
+  process.env.CODEX_THREAD_ID || `manual-${new Date().toISOString().slice(0, 10).replaceAll("-", "")}`,
+  "presentations",
+  "voc-community-course-two-lessons",
+);
+const PREVIEW_DIR = path.join(WORKSPACE, "preview");
+const LAYOUT_DIR = path.join(WORKSPACE, "layout");
+const QA_DIR = path.join(WORKSPACE, "qa");
+
+const NODE_HOME = process.env.USERPROFILE || process.env.HOME;
+const ARTIFACT_TOOL = path.join(
+  NODE_HOME,
+  ".cache",
+  "codex-runtimes",
+  "codex-primary-runtime",
+  "dependencies",
+  "node",
+  "node_modules",
+  "@oai",
+  "artifact-tool",
+  "dist",
+  "artifact_tool.mjs",
+);
+const PYTHON =
+  process.env.PYTHON ||
+  path.join(
+    NODE_HOME,
+    ".cache",
+    "codex-runtimes",
+    "codex-primary-runtime",
+    "dependencies",
+    "python",
+    "python.exe",
+  );
+const MAKE_CONTACT_SHEET = path.join(
+  NODE_HOME,
+  ".codex",
+  "plugins",
+  "cache",
+  "openai-primary-runtime",
+  "presentations",
+  "26.601.10930",
+  "skills",
+  "presentations",
+  "scripts",
+  "make_contact_sheet.py",
+);
+
+const W = 1280;
+const H = 720;
+const palette = {
+  porcelain: "#F8FBFF",
+  paper: "#FFFFFF",
+  ink: "#133B68",
+  ink2: "#0A2440",
+  blue: "#1E68A8",
+  mist: "#E8F2FC",
+  mist2: "#F1F7FC",
+  jade: "#1F7A72",
+  jadeSoft: "#DDF2EE",
+  cinnabar: "#C9513B",
+  cinnabarSoft: "#F8E5DE",
+  gold: "#B88932",
+  charcoal: "#13202B",
+  muted: "#5F7082",
+  line: "#C7D9EA",
+  dark: "#0F1A24",
+};
+
+const FONT = {
+  title: "Microsoft YaHei UI",
+  body: "Microsoft YaHei",
+  mono: "Cascadia Mono",
+};
+
+function line(fill = "#00000000", width = 0, style = "solid") {
+  return { style, fill, width };
+}
+
+function addShape(slide, options) {
+  const {
+    geometry = "rect",
+    fill = "#00000000",
+    border = "#00000000",
+    borderWidth = 0,
+    left,
+    top,
+    width,
+    height,
+    name,
+  } = options;
+  return slide.shapes.add({
+    geometry,
+    name,
+    position: { left, top, width, height },
+    fill,
+    line: line(border, borderWidth),
+  });
+}
+
+function addText(slide, options) {
+  const {
+    text,
+    left,
+    top,
+    width,
+    height,
+    size = 24,
+    color = palette.charcoal,
+    bold = false,
+    typeface = FONT.body,
+    align = "left",
+    valign = "top",
+    fill = "#00000000",
+    border = "#00000000",
+    borderWidth = 0,
+    insets = { left: 0, right: 0, top: 0, bottom: 0 },
+  } = options;
+  const shape = addShape(slide, {
+    left,
+    top,
+    width,
+    height,
+    fill,
+    border,
+    borderWidth,
+  });
+  shape.text = text;
+  shape.text.fontSize = size;
+  shape.text.color = color;
+  shape.text.bold = bold;
+  shape.text.typeface = typeface;
+  shape.text.alignment = align;
+  shape.text.verticalAlignment = valign;
+  shape.text.insets = insets;
+  return shape;
+}
+
+function addFooter(slide, deckLabel, index, total) {
+  addText(slide, {
+    text: deckLabel,
+    left: 64,
+    top: 668,
+    width: 600,
+    height: 22,
+    size: 13,
+    color: palette.muted,
+  });
+  addText(slide, {
+    text: `${String(index).padStart(2, "0")} / ${String(total).padStart(2, "0")}`,
+    left: 1112,
+    top: 666,
+    width: 100,
+    height: 24,
+    size: 14,
+    color: palette.muted,
+    typeface: FONT.mono,
+    align: "right",
+  });
+}
+
+function addTitle(slide, title, kicker, subtitle) {
+  if (kicker) {
+    addText(slide, {
+      text: kicker,
+      left: 64,
+      top: 46,
+      width: 680,
+      height: 28,
+      size: 16,
+      bold: true,
+      color: palette.jade,
+    });
+  }
+  addText(slide, {
+    text: title,
+    left: 64,
+    top: kicker ? 82 : 58,
+    width: 860,
+    height: 88,
+    size: 38,
+    bold: true,
+    color: palette.ink2,
+    typeface: FONT.title,
+  });
+  if (subtitle) {
+    addText(slide, {
+      text: subtitle,
+      left: 66,
+      top: 174,
+      width: 780,
+      height: 44,
+      size: 18,
+      color: palette.muted,
+    });
+  }
+}
+
+function bg(slide, tone = "light") {
+  addShape(slide, { left: 0, top: 0, width: W, height: H, fill: tone === "dark" ? palette.ink2 : palette.porcelain });
+  if (tone !== "dark") {
+    addShape(slide, { left: 0, top: 0, width: 24, height: H, fill: palette.ink });
+    addShape(slide, { left: 24, top: 0, width: 6, height: H, fill: palette.jade });
+  }
+}
+
+function cover(presentation, spec, ctx) {
+  const slide = presentation.slides.add();
+  bg(slide, "light");
+  addShape(slide, { left: 760, top: 0, width: 520, height: H, fill: spec.accent || palette.ink });
+  addShape(slide, { left: 820, top: 78, width: 330, height: 330, fill: "#FFFFFF12", border: "#FFFFFF22", borderWidth: 1 });
+  addShape(slide, { left: 880, top: 138, width: 210, height: 210, fill: "#FFFFFF12", border: "#FFFFFF2A", borderWidth: 1 });
+  addText(slide, {
+    text: spec.kicker,
+    left: 70,
+    top: 78,
+    width: 560,
+    height: 30,
+    size: 17,
+    bold: true,
+    color: palette.jade,
+  });
+  addText(slide, {
+    text: spec.title,
+    left: 68,
+    top: 128,
+    width: 610,
+    height: 170,
+    size: 54,
+    bold: true,
+    color: palette.ink2,
+    typeface: FONT.title,
+  });
+  addText(slide, {
+    text: spec.subtitle,
+    left: 72,
+    top: 330,
+    width: 585,
+    height: 72,
+    size: 23,
+    color: palette.charcoal,
+  });
+  addText(slide, {
+    text: spec.rightTitle,
+    left: 820,
+    top: 468,
+    width: 330,
+    height: 64,
+    size: 32,
+    bold: true,
+    color: palette.paper,
+    typeface: FONT.title,
+  });
+  addText(slide, {
+    text: spec.rightText,
+    left: 822,
+    top: 545,
+    width: 330,
+    height: 84,
+    size: 18,
+    color: "#D8EAF8",
+  });
+  addFooter(slide, spec.footer, ctx.index, ctx.total);
+  return slide;
+}
+
+function agenda(presentation, spec, ctx) {
+  const slide = presentation.slides.add();
+  bg(slide);
+  addTitle(slide, spec.title, spec.kicker, spec.subtitle);
+  const y = 260;
+  const gap = 26;
+  const w = (1088 - gap * (spec.items.length - 1)) / spec.items.length;
+  spec.items.forEach((item, i) => {
+    const x = 76 + i * (w + gap);
+    addShape(slide, { left: x, top: y, width: w, height: 245, fill: item.fill || palette.paper, border: palette.line, borderWidth: 1 });
+    addText(slide, {
+      text: `0${i + 1}`,
+      left: x + 24,
+      top: y + 22,
+      width: 90,
+      height: 58,
+      size: 48,
+      bold: true,
+      color: item.accent || palette.blue,
+      typeface: FONT.mono,
+    });
+    addText(slide, {
+      text: item.title,
+      left: x + 24,
+      top: y + 96,
+      width: w - 48,
+      height: 54,
+      size: 25,
+      bold: true,
+      color: palette.ink2,
+      typeface: FONT.title,
+    });
+    addText(slide, {
+      text: item.text,
+      left: x + 24,
+      top: y + 164,
+      width: w - 48,
+      height: 58,
+      size: 17,
+      color: palette.muted,
+    });
+  });
+  addFooter(slide, spec.footer, ctx.index, ctx.total);
+  return slide;
+}
+
+function claim(presentation, spec, ctx) {
+  const slide = presentation.slides.add();
+  bg(slide, spec.dark ? "dark" : "light");
+  const c = spec.dark ? palette.paper : palette.ink2;
+  const muted = spec.dark ? "#C8D9E8" : palette.muted;
+  addText(slide, { text: spec.kicker || "", left: 72, top: 56, width: 460, height: 28, size: 16, bold: true, color: spec.dark ? palette.jadeSoft : palette.jade });
+  addText(slide, { text: spec.title, left: 70, top: 96, width: 740, height: 150, size: spec.titleSize || 46, bold: true, color: c, typeface: FONT.title });
+  addText(slide, { text: spec.text, left: 74, top: 252, width: 600, height: 88, size: 22, color: muted });
+  addShape(slide, { left: 770, top: 112, width: 360, height: 360, fill: spec.blockFill || (spec.dark ? "#FFFFFF10" : palette.paper), border: spec.dark ? "#FFFFFF22" : palette.line, borderWidth: 1 });
+  addText(slide, { text: spec.proofTitle, left: 812, top: 158, width: 276, height: 50, size: 27, bold: true, color: c, typeface: FONT.title, align: "center" });
+  addText(slide, { text: spec.proofText, left: 820, top: 232, width: 260, height: 164, size: 20, color: muted, align: "center", valign: "mid" });
+  addShape(slide, { left: 812, top: 426, width: 276, height: 8, fill: spec.accent || palette.cinnabar });
+  addFooter(slide, spec.footer, ctx.index, ctx.total);
+  return slide;
+}
+
+function compare(presentation, spec, ctx) {
+  const slide = presentation.slides.add();
+  bg(slide);
+  addTitle(slide, spec.title, spec.kicker, spec.subtitle);
+  const colW = spec.columns.length === 2 ? 510 : 340;
+  const startX = spec.columns.length === 2 ? 108 : 86;
+  const gap = spec.columns.length === 2 ? 42 : 28;
+  spec.columns.forEach((col, i) => {
+    const x = startX + i * (colW + gap);
+    addShape(slide, { left: x, top: 232, width: colW, height: 348, fill: col.fill || palette.paper, border: col.border || palette.line, borderWidth: 1 });
+    addShape(slide, { left: x, top: 232, width: colW, height: 12, fill: col.accent || palette.blue });
+    addText(slide, { text: col.title, left: x + 28, top: 270, width: colW - 56, height: 48, size: 28, bold: true, color: col.accent || palette.ink2, typeface: FONT.title });
+    addText(slide, { text: col.body, left: x + 30, top: 340, width: colW - 60, height: 155, size: 19, color: palette.charcoal });
+    addText(slide, { text: col.bottom, left: x + 30, top: 518, width: colW - 60, height: 36, size: 17, color: palette.muted });
+  });
+  addFooter(slide, spec.footer, ctx.index, ctx.total);
+  return slide;
+}
+
+function flow(presentation, spec, ctx) {
+  const slide = presentation.slides.add();
+  bg(slide);
+  addTitle(slide, spec.title, spec.kicker, spec.subtitle);
+  const n = spec.steps.length;
+  const startX = 78;
+  const y = 278;
+  const boxW = (1090 - (n - 1) * 18) / n;
+  spec.steps.forEach((step, i) => {
+    const x = startX + i * (boxW + 18);
+    addShape(slide, { left: x, top: y, width: boxW, height: 190, fill: step.fill || palette.paper, border: palette.line, borderWidth: 1 });
+    addText(slide, { text: String(i + 1).padStart(2, "0"), left: x + 18, top: y + 18, width: 60, height: 32, size: 22, bold: true, color: step.accent || palette.blue, typeface: FONT.mono });
+    addText(slide, { text: step.title, left: x + 18, top: y + 64, width: boxW - 36, height: 56, size: 21, bold: true, color: palette.ink2 });
+    addText(slide, { text: step.text, left: x + 18, top: y + 126, width: boxW - 36, height: 40, size: 14, color: palette.muted });
+    if (i < n - 1) {
+      addText(slide, { text: "→", left: x + boxW + 2, top: y + 70, width: 18, height: 28, size: 24, bold: true, color: palette.line, align: "center" });
+    }
+  });
+  if (spec.bottom) {
+    addText(slide, { text: spec.bottom, left: 100, top: 530, width: 1040, height: 40, size: 20, bold: true, color: palette.cinnabar, align: "center" });
+  }
+  addFooter(slide, spec.footer, ctx.index, ctx.total);
+  return slide;
+}
+
+function matrix(presentation, spec, ctx) {
+  const slide = presentation.slides.add();
+  bg(slide);
+  addTitle(slide, spec.title, spec.kicker, spec.subtitle);
+  const x = 76;
+  const y = 225;
+  const w = 1120;
+  const rowH = 66;
+  const colW = [210, 290, 310, 310];
+  addShape(slide, { left: x, top: y, width: w, height: rowH, fill: palette.ink, border: palette.ink, borderWidth: 1 });
+  spec.headers.forEach((h, i) => {
+    const left = x + colW.slice(0, i).reduce((a, b) => a + b, 0);
+    addText(slide, { text: h, left: left + 16, top: y + 18, width: colW[i] - 32, height: 28, size: 17, bold: true, color: palette.paper });
+  });
+  spec.rows.forEach((row, r) => {
+    const top = y + rowH * (r + 1);
+    addShape(slide, { left: x, top, width: w, height: rowH, fill: r % 2 ? palette.paper : palette.mist2, border: palette.line, borderWidth: 1 });
+    row.forEach((cell, i) => {
+      const left = x + colW.slice(0, i).reduce((a, b) => a + b, 0);
+      addText(slide, { text: cell, left: left + 14, top: top + 10, width: colW[i] - 28, height: rowH - 24, size: i === 0 ? 16 : 14, bold: i === 0, color: i === 0 ? palette.ink2 : palette.charcoal });
+    });
+  });
+  addFooter(slide, spec.footer, ctx.index, ctx.total);
+  return slide;
+}
+
+function prompt(presentation, spec, ctx) {
+  const slide = presentation.slides.add();
+  bg(slide);
+  addTitle(slide, spec.title, spec.kicker, spec.subtitle);
+  addShape(slide, { left: 82, top: 230, width: 1110, height: 350, fill: palette.dark, border: "#27394A", borderWidth: 1 });
+  addShape(slide, { left: 82, top: 230, width: 1110, height: 50, fill: "#162A3D" });
+  addShape(slide, { left: 106, top: 246, width: 12, height: 12, fill: palette.cinnabar });
+  addShape(slide, { left: 128, top: 246, width: 12, height: 12, fill: palette.gold });
+  addShape(slide, { left: 150, top: 246, width: 12, height: 12, fill: palette.jade });
+  addText(slide, { text: spec.label || "Prompt / Command", left: 184, top: 241, width: 400, height: 22, size: 14, color: "#AFC6D8", typeface: FONT.mono });
+  addText(slide, { text: spec.code, left: 112, top: 298, width: 1028, height: 248, size: spec.size || 18, color: "#ECF5FF", typeface: FONT.mono });
+  addFooter(slide, spec.footer, ctx.index, ctx.total);
+  return slide;
+}
+
+function checklist(presentation, spec, ctx) {
+  const slide = presentation.slides.add();
+  bg(slide);
+  addTitle(slide, spec.title, spec.kicker, spec.subtitle);
+  const x = 92;
+  let y = 230;
+  spec.items.forEach((item, i) => {
+    addShape(slide, { left: x, top: y, width: 1040, height: 58, fill: i % 2 ? palette.paper : palette.mist2, border: palette.line, borderWidth: 1 });
+    addText(slide, { text: item[0], left: x + 22, top: y + 15, width: 215, height: 26, size: 18, bold: true, color: palette.ink2 });
+    addText(slide, { text: item[1], left: x + 265, top: y + 15, width: 420, height: 28, size: 17, color: palette.charcoal });
+    addText(slide, { text: item[2], left: x + 720, top: y + 15, width: 290, height: 28, size: 16, color: palette.jade, bold: true });
+    y += 66;
+  });
+  addFooter(slide, spec.footer, ctx.index, ctx.total);
+  return slide;
+}
+
+function cards(presentation, spec, ctx) {
+  const slide = presentation.slides.add();
+  bg(slide);
+  addTitle(slide, spec.title, spec.kicker, spec.subtitle);
+  const cols = spec.cards.length === 4 ? 4 : 3;
+  const gap = 24;
+  const cardW = (1094 - gap * (cols - 1)) / cols;
+  spec.cards.forEach((card, i) => {
+    const row = Math.floor(i / cols);
+    const col = i % cols;
+    const x = 76 + col * (cardW + gap);
+    const y = 230 + row * 168;
+    addShape(slide, { left: x, top: y, width: cardW, height: 142, fill: card.fill || palette.paper, border: palette.line, borderWidth: 1 });
+    addText(slide, { text: card.title, left: x + 24, top: y + 22, width: cardW - 48, height: 34, size: 21, bold: true, color: card.accent || palette.ink2 });
+    addText(slide, { text: card.text, left: x + 24, top: y + 68, width: cardW - 48, height: 52, size: 16, color: palette.muted });
+  });
+  if (spec.note) {
+    addText(slide, { text: spec.note, left: 96, top: 596, width: 1040, height: 28, size: 18, bold: true, color: palette.cinnabar, align: "center" });
+  }
+  addFooter(slide, spec.footer, ctx.index, ctx.total);
+  return slide;
+}
+
+function section(presentation, spec, ctx) {
+  const slide = presentation.slides.add();
+  bg(slide, "dark");
+  addText(slide, { text: spec.number, left: 74, top: 78, width: 200, height: 90, size: 74, bold: true, color: palette.jadeSoft, typeface: FONT.mono });
+  addText(slide, { text: spec.title, left: 78, top: 210, width: 760, height: 120, size: 48, bold: true, color: palette.paper, typeface: FONT.title });
+  addText(slide, { text: spec.text, left: 82, top: 360, width: 690, height: 72, size: 23, color: "#CADBEA" });
+  addShape(slide, { left: 880, top: 120, width: 240, height: 420, fill: "#FFFFFF0F", border: "#FFFFFF22", borderWidth: 1 });
+  addText(slide, { text: spec.side, left: 910, top: 206, width: 180, height: 230, size: 30, bold: true, color: palette.paper, align: "center", valign: "mid" });
+  addFooter(slide, spec.footer, ctx.index, ctx.total);
+  return slide;
+}
+
+const theorySlides = [
+  {
+    type: "cover",
+    kicker: "理论课 01",
+    title: "VOC 不是抓数据",
+    subtitle: "它是一条从用户声音到经营判断、内容动作和复盘指标的证据链。",
+    rightTitle: "社区课定位",
+    rightText: "免费技能用于 OPC / 测试\n付费技能用于企业级稳定交付",
+    footer: "VOC 理论课",
+  },
+  {
+    type: "agenda",
+    kicker: "本节学习路线",
+    title: "先建立判断框架,再进入工具",
+    subtitle: "学员要先知道为什么采、采什么、采完怎么判断。",
+    items: [
+      { title: "为什么", text: "从经验判断转向证据判断", accent: palette.ink },
+      { title: "看什么", text: "平台声音、评论追问和转化阻力", accent: palette.jade },
+      { title: "怎么分层", text: "OPC / 测试 / 企业级场景", accent: palette.cinnabar },
+      { title: "交付什么", text: "问题池、行动计划、报告", accent: palette.gold },
+    ],
+    footer: "VOC 理论课",
+  },
+  {
+    type: "claim",
+    kicker: "课程开场问题",
+    title: "老板真正缺的不是更多内容,而是可验证的判断",
+    text: "如果没有用户证据,短视频脚本、产品建议和活动方案都会变成拍脑袋。",
+    proofTitle: "证据链",
+    proofText: "用户原话\n→ 问题聚类\n→ 优先级\n→ 动作\n→ 指标复盘",
+    accent: palette.cinnabar,
+    footer: "VOC 理论课",
+  },
+  {
+    type: "compare",
+    kicker: "概念纠偏",
+    title: "VOC 和普通市场分析的区别",
+    subtitle: "这页要让学员明白:VOC 的核心不是结论漂亮,而是能追溯。",
+    columns: [
+      {
+        title: "普通分析",
+        body: "先写观点,再找行业常识支撑。\n容易出现:空泛、像模板、无法落地。",
+        bottom: "结果:看完觉得对,但不知道明天改什么。",
+        accent: palette.muted,
+        fill: palette.mist2,
+      },
+      {
+        title: "VOC 分析",
+        body: "先抓用户表达,再归纳问题和阻力。\n每个建议都能回到样本、评论、关键词。",
+        bottom: "结果:能做动作、能验证、能复盘。",
+        accent: palette.jade,
+        fill: palette.paper,
+      },
+    ],
+    footer: "VOC 理论课",
+  },
+  {
+    type: "section",
+    number: "01",
+    title: "先讲清业务场景",
+    text: "采集前不先问平台,而是先问:这次要改善哪个经营结果?",
+    side: "业务问题\n决定数据质量",
+    footer: "VOC 理论课",
+  },
+  {
+    type: "matrix",
+    kicker: "采集前置条件",
+    title: "一条合格 VOC 任务,至少要有 4 个输入",
+    subtitle: "输入越像业务问题,输出越像经营情报;输入越像泛泛关键词,输出越像闲聊。",
+    headers: ["输入", "茶饮案例", "为什么重要", "换行业怎么填"],
+    rows: [
+      ["业务目标", "提升新客转化", "决定报告回答什么", "咨询转化 / 复购 / 客诉"],
+      ["决策门槛", "18-22 元价格带", "决定用户比较方式", "客单价 / 合同金额 / 使用成本"],
+      ["核心问题", "值不值、踩不踩雷", "决定关键词和判断口径", "怕增项 / 怕无效 / 怕售后"],
+      ["用户原话", "茶底是什么?我要避雷", "防止凭经验发散", "评论、私信、差评、问答"],
+    ],
+    footer: "VOC 理论课",
+  },
+  {
+    type: "flow",
+    kicker: "VOC 小闭环",
+    title: "不是采完就结束,而是跑完一轮证据闭环",
+    subtitle: "这页是整门课的主流程,后面的实操课全部围绕它展开。",
+    steps: [
+      { title: "定义问题", text: "业务目标 + 用户阻力", accent: palette.ink },
+      { title: "采集矩阵", text: "平台、人群、关键词", accent: palette.blue },
+      { title: "真实采集", text: "样本、评论、链接", accent: palette.jade },
+      { title: "问题池", text: "严重度和优先级", accent: palette.gold },
+      { title: "单点深挖", text: "动作和验证指标", accent: palette.cinnabar },
+      { title: "交付复盘", text: "内容、报告、补采", accent: palette.ink2 },
+    ],
+    bottom: "每一环都要能回到用户证据,而不是回到模型想象。",
+    footer: "VOC 理论课",
+  },
+  {
+    type: "compare",
+    kicker: "技能组合定位",
+    title: "免费技能和付费技能,不是互相替代",
+    subtitle: "它们服务于不同阶段:学习验证 vs 企业级稳定交付。",
+    columns: [
+      {
+        title: "免费 / 开源技能",
+        body: "适合 OPC、教学演示、小范围测试。\n重点是理解流程、验证方法、低成本起步。",
+        bottom: "判断标准:能跑通,能形成初步洞察。",
+        accent: palette.blue,
+        fill: palette.mist2,
+      },
+      {
+        title: "付费 VOC 技能",
+        body: "适合稳定采集、大批量样本、企业报告。\n重点是权限、余额、错误处理、审计和复用。",
+        bottom: "判断标准:能稳定交付,能复盘追溯。",
+        accent: palette.cinnabar,
+        fill: palette.paper,
+      },
+    ],
+    footer: "VOC 理论课",
+  },
+  {
+    type: "cards",
+    kicker: "企业级场景为什么需要付费能力",
+    title: "专业 VOC 交付要解决 6 件免费工具常常忽略的事",
+    subtitle: "这页用于解释商业边界,不要讲成“免费不好”,而是讲“使用场景不同”。",
+    cards: [
+      { title: "稳定采集", text: "接口失败、限流、重试和余额状态可解释", accent: palette.cinnabar },
+      { title: "批量样本", text: "多关键词、多平台、持续补采", accent: palette.ink },
+      { title: "证据审计", text: "样本、评论、链接、批次能追溯", accent: palette.jade },
+      { title: "错误修复", text: "关键词、token、余额和平台异常有明确下一步", accent: palette.gold },
+      { title: "报告交付", text: "从趋势、问题池到 HTML 报告可复用", accent: palette.blue },
+      { title: "团队协作", text: "老板、运营、内容、产品都能读懂", accent: palette.cinnabar },
+    ],
+    note: "企业级价值不是“多一个工具”,而是减少采集和交付过程中的不确定性。",
+    footer: "VOC 理论课",
+  },
+  {
+    type: "section",
+    number: "02",
+    title: "以茶饮新客转化为统一案例",
+    text: "所有方法都要落回一个具体业务:18-22 元价格带,如何降低新客首单决策成本。",
+    side: "值不值\n会不会踩雷\n怎么点最稳",
+    footer: "VOC 理论课",
+  },
+  {
+    type: "claim",
+    kicker: "案例洞察",
+    title: "18-22 元不是绝对贵,真正阻力是看不懂值在哪",
+    titleSize: 42,
+    text: "用户会比较套餐、券后价、自制成本、茶底、甜度、配料表和外卖到手体验。",
+    proofTitle: "用户追问",
+    proofText: "茶底是什么?\n甜度怎么选?\n买一送一有没有坑?\n外卖到手会翻车吗?",
+    accent: palette.jade,
+    footer: "VOC 理论课",
+  },
+  {
+    type: "matrix",
+    kicker: "平台分工",
+    title: "抖音和小红书看到的用户心态不同",
+    subtitle: "同一个业务问题,需要用不同平台补齐决策链路。",
+    headers: ["平台", "更像什么", "重点看什么", "转成什么动作"],
+    rows: [
+      ["小红书", "做功课和避坑", "值不值、茶底、甜度、配料", "解释型笔记、避坑指南"],
+      ["抖音", "触发注意和下单", "低价新品、团购、买一送一", "短视频钩子、活动规则"],
+      ["评论区", "转化前验真", "怎么点、多少钱、会不会踩雷", "置顶回复、统一话术"],
+      ["门店/外卖", "体验兑现", "出杯、排队、到手口感", "流程提示、外卖稳定款"],
+    ],
+    footer: "VOC 理论课",
+  },
+  {
+    type: "cards",
+    kicker: "报告不等于策略口号",
+    title: "一份合格 VOC 报告至少包含 6 类内容",
+    subtitle: "后面的实战课会把每一类都跑出来。",
+    cards: [
+      { title: "数据口径", text: "平台、关键词、样本数、评论数", accent: palette.ink },
+      { title: "显著问题", text: "用户反复表达的阻力", accent: palette.cinnabar },
+      { title: "证据样本", text: "原话、链接、互动和截图", accent: palette.jade },
+      { title: "优先级", text: "按业务影响排序,不只看词频", accent: palette.gold },
+      { title: "行动计划", text: "今天、7 天、长期沉淀", accent: palette.blue },
+      { title: "验证指标", text: "评论变化、咨询、下单、复购", accent: palette.cinnabar },
+    ],
+    footer: "VOC 理论课",
+  },
+  {
+    type: "claim",
+    kicker: "学员最终要带走的能力",
+    title: "看到一条评论,就能判断它该进入哪个经营环节",
+    text: "不是把评论复制给 AI,而是识别它影响产品、价格、内容、服务还是信任。",
+    proofTitle: "判断题",
+    proofText: "“我要避雷”\n不是情绪词\n而是首单风险信号",
+    accent: palette.cinnabar,
+    dark: true,
+    footer: "VOC 理论课",
+  },
+  {
+    type: "checklist",
+    kicker: "理论课收尾",
+    title: "进入实战课前,学员需要记住 5 个标准",
+    subtitle: "这页可以作为第一节课的课后检查。",
+    items: [
+      ["先业务", "先写经营目标,再写关键词", "避免乱采"],
+      ["先 sample", "先跑通格式,再 live 采集", "降低卡点"],
+      ["有证据", "判断必须能回到原话或样本", "防止空泛"],
+      ["分场景", "免费用于测试,付费用于稳定交付", "边界清楚"],
+      ["可验证", "每个建议都要有 7 天指标", "能复盘"],
+    ],
+    footer: "VOC 理论课",
+  },
+  {
+    type: "section",
+    number: "NEXT",
+    title: "下一节:从 FmodeStudio 开始实操",
+    text: "创建项目模板,安装技能,跑 sample,进入 live,生成问题池、脚本和 HTML 报告。",
+    side: "打开工具\n开始交付",
+    footer: "VOC 理论课",
+  },
+];
+
+const practicalSlides = [
+  {
+    type: "cover",
+    kicker: "实战课 02",
+    title: "FmodeStudio VOC 实操",
+    subtitle: "从技能安装、项目模板创建,到小红书/抖音采集和 HTML 报告交付。",
+    rightTitle: "两段演示",
+    rightText: "免费技能安装与演示\n付费 VOC 技能企业级展示",
+    accent: palette.jade,
+    footer: "VOC 实战课",
+  },
+  {
+    type: "agenda",
+    kicker: "本节产出",
+    title: "学员跟完后,要拿到一套能复用的项目样板",
+    subtitle: "不是只看老师演示,而是知道自己下一次怎么换行业复跑。",
+    items: [
+      { title: "建项目", text: "FmodeStudio + 项目模板", accent: palette.ink },
+      { title: "装技能", text: "免费技能与付费技能两条线", accent: palette.blue },
+      { title: "跑采集", text: "sample、矩阵、live 小规模", accent: palette.jade },
+      { title: "交付", text: "问题池、内容计划、HTML 报告", accent: palette.cinnabar },
+    ],
+    footer: "VOC 实战课",
+  },
+  {
+    type: "flow",
+    kicker: "完整演示路径",
+    title: "实操课按 8 个动作推进",
+    subtitle: "每一步都有截图点和通过标准,方便课程发布后学员独立复盘。",
+    steps: [
+      { title: "打开 Studio", text: "创建课程项目" },
+      { title: "选模板", text: "VOC 项目结构" },
+      { title: "装免费技能", text: "跑通 OPC/测试" },
+      { title: "装付费技能", text: "workspace smoke" },
+      { title: "sample", text: "先看格式" },
+      { title: "live", text: "小规模真实采集" },
+    ],
+    bottom: "先证明流程可用,再追求样本规模;先小范围验证,再进入企业级采集。",
+    footer: "VOC 实战课",
+  },
+  {
+    type: "checklist",
+    kicker: "课前准备",
+    title: "开始前先检查这 5 项",
+    subtitle: "这页是实操课开场,不要等出错了再补环境。",
+    items: [
+      ["FmodeStudio", "能打开并创建项目", "通过"],
+      ["Claude Code", "能在项目内正常对话", "通过"],
+      ["Node / npx", "终端能返回版本号", "通过"],
+      ["截图文件夹", "为每个步骤留空截图", "通过"],
+      [".env.local", "如有 token,不展示真实值", "通过"],
+    ],
+    footer: "VOC 实战课",
+  },
+  {
+    type: "matrix",
+    kicker: "项目模板",
+    title: "VOC 项目模板建议包含这些文件夹",
+    subtitle: "FmodeStudio 里创建项目后,学员能用这个结构沉淀材料。",
+    headers: ["目录", "放什么", "为什么需要", "截图点"],
+    rows: [
+      ["01-inputs", "业务背景、关键词、用户原话", "防止忘记输入口径", "项目初始化"],
+      ["02-samples", "sample 输出和第一轮观察", "先跑通流程", "sample 结果"],
+      ["03-live-data", "live 样本、评论、链接", "保留真实证据", "工具输出"],
+      ["04-analysis", "矩阵、问题池、深挖", "形成经营判断", "问题排序"],
+      ["05-delivery", "脚本、HTML、复盘指标", "交付给老板/团队", "最终报告"],
+    ],
+    footer: "VOC 实战课",
+  },
+  {
+    type: "section",
+    number: "A",
+    title: "免费技能安装与演示",
+    text: "免费技能用于让学员理解技能调用、OPC 和小范围验证,不承诺企业级稳定采集。",
+    side: "学习\n测试\n验证",
+    footer: "VOC 实战课",
+  },
+  {
+    type: "cards",
+    kicker: "免费技能演示口径",
+    title: "免费技能这一段要演示 4 件事",
+    subtitle: "这里不要过度承诺,只让学员看懂技能如何进入业务流程。",
+    cards: [
+      { title: "找到技能", text: "在 FmodeStudio / Claude Code 中确认技能入口", accent: palette.blue },
+      { title: "安装或启用", text: "按该免费技能的 README 操作", accent: palette.jade },
+      { title: "跑 sample", text: "先用课程案例或内置样例输出", accent: palette.gold },
+      { title: "读结果", text: "看是否有证据、边界和下一步", accent: palette.cinnabar },
+    ],
+    note: "如果免费技能名单暂未确定,这一页保留为“替换实际开源技能名称和链接”的通用演示页。",
+    footer: "VOC 实战课",
+  },
+  {
+    type: "prompt",
+    kicker: "免费技能演示 Prompt",
+    title: "先让 Claude Code 明确使用当前免费技能",
+    subtitle: "这段用于 OPC/测试,不进入大批量 live 采集。",
+    label: "复制给 Claude Code",
+    code: `请使用当前项目中已安装的免费 / 开源 VOC 技能,先不要做大批量真实采集。\n\n业务目标:提升茶饮新客转化。\n价格带:18-22 元。\n核心问题:值不值、会不会踩雷、首单怎么点最稳。\n\n请先用 sample 或小范围测试数据输出:\n1. 第一轮初步判断\n2. 证据样本\n3. 用户顾虑\n4. 数据边界\n5. 下一步需要真实采集验证的问题`,
+    size: 16,
+    footer: "VOC 实战课",
+  },
+  {
+    type: "compare",
+    kicker: "免费技能的边界",
+    title: "演示结束后,要把边界讲清楚",
+    subtitle: "让学员知道什么时候免费技能够用,什么时候需要企业级能力。",
+    columns: [
+      {
+        title: "够用的场景",
+        body: "学习 VOC 方法\nOPC / 概念验证\n小样本跑流程\n检查 prompt 是否合理",
+        bottom: "目标:理解流程和判断口径",
+        accent: palette.jade,
+        fill: palette.mist2,
+      },
+      {
+        title: "不建议硬撑的场景",
+        body: "客户交付\n多平台批量采集\n长期复盘\n需要审计和错误处理",
+        bottom: "目标:稳定、可追溯、可复用",
+        accent: palette.cinnabar,
+        fill: palette.paper,
+      },
+    ],
+    footer: "VOC 实战课",
+  },
+  {
+    type: "section",
+    number: "B",
+    title: "付费 VOC 技能企业级展示",
+    text: "这一段演示正式安装、token、sample、live 和错误处理,让企业客户看到稳定交付链路。",
+    side: "稳定\n批量\n审计",
+    footer: "VOC 实战课",
+  },
+  {
+    type: "prompt",
+    kicker: "付费技能安装",
+    title: "在项目工作区安装 VOC 技能包",
+    subtitle: "这一页要让学员知道:安装发生在当前项目,不是口头说“我有技能”。",
+    label: "PowerShell",
+    code: `npx --yes @vocmarket/voc-skill@latest workspace --smoke\n\n# 安装后检查\n# .\\.mcp.json\n# .\\.claude\\plugins\\voc-intelligence\n# .\\.claude\\skills\\xiaohongshu-trend-intelligence\n# .\\.claude\\skills\\douyin-trend-intelligence`,
+    footer: "VOC 实战课",
+  },
+  {
+    type: "checklist",
+    kicker: "安装通过标准",
+    title: "看到这些结果,才算安装成功",
+    subtitle: "不要只看命令跑完,要看项目里是否写入了技能和 MCP 配置。",
+    items: [
+      ["smoke 通过", "终端没有报错,提示 ready", "截图"],
+      [".mcp.json", "工作区根目录生成 MCP 配置", "截图"],
+      ["plugins", "出现 voc-intelligence 插件目录", "截图"],
+      ["skills", "出现小红书和抖音技能说明", "截图"],
+      ["Claude Code", "重启后能触发 VOC 技能", "截图"],
+    ],
+    footer: "VOC 实战课",
+  },
+  {
+    type: "prompt",
+    kicker: "token 配置",
+    title: "live 采集前,先配置 token,但不要展示真实值",
+    subtitle: "课程截图里可以展示文件名,不展示 token 内容。",
+    label: ".env.local",
+    code: `VOC_TOKEN=你的服务 token\n\n# 注意:\n# 1. 不要提交到 Git\n# 2. 不要放进截图\n# 3. 不要发到聊天记录\n# 4. token 异常时先做 token / 余额预检`,
+    footer: "VOC 实战课",
+  },
+  {
+    type: "prompt",
+    kicker: "sample 首跑",
+    title: "先用 sample 跑通输出格式",
+    subtitle: "这一步的目标不是拿真实结论,而是确认技能调用、结构和报告阅读方式。",
+    label: "复制给 Claude Code",
+    code: `请使用已安装的 VOC 技能,不要凭经验回答。\n\n帮我做一份茶饮新客转化的小红书趋势情报。\n先用 sample 模式跑通流程,不要真实采集。\n业务目标:提升新客转化。\n价格带:18-22 元。\n重点看:值不值、会不会踩雷、首单怎么点最稳。\n\n请输出第一轮初步判断、样本观察、用户顾虑、证据样本和待确认问题。`,
+    size: 17,
+    footer: "VOC 实战课",
+  },
+  {
+    type: "matrix",
+    kicker: "采集矩阵",
+    title: "进入 live 前,先写采集矩阵",
+    subtitle: "矩阵让采集从“乱搜关键词”变成“围绕业务问题取证”。",
+    headers: ["字段", "本案例填写", "合格标准", "截图点"],
+    rows: [
+      ["平台", "小红书 / 抖音", "平台分开,不混写", "矩阵表头"],
+      ["目标人群", "茶饮新客、价格敏感用户", "必须对应转化目标", "人群列"],
+      ["关键词", "券后价、首单、避坑、茶底", "像用户会搜索的话", "关键词列"],
+      ["样本量", "小规模先跑", "不要第一轮全量", "样本量列"],
+      ["判断标准", "是否影响首单转化", "能判断是否深挖", "标准列"],
+    ],
+    footer: "VOC 实战课",
+  },
+  {
+    type: "prompt",
+    kicker: "live 小规模采集",
+    title: "真实采集先小规模,不要一上来全量",
+    subtitle: "这段是正式演示付费技能价值:平台、关键词、样本数、评论数和错误处理。",
+    label: "复制给 Claude Code",
+    code: `请进入真实采集 live 模式。\n\n业务目标:提升茶饮新客转化。\n价格带:18-22 元。\n平台:小红书、抖音。\n采集范围:先小规模采集,不要全量。\n\n重点关键词:\n小红书:茶饮 值不值、茶饮 首单、茶饮 套餐 点单、茶饮 避坑、茶饮 茶底 甜度\n抖音:茶饮 性价比 新客、茶饮 团购、茶饮 买一送一、茶饮 券后价、奶茶 点单 避坑\n\n请先输出第一轮市场声音、样本数、评论数、用户顾虑、证据样本和待确认问题。`,
+    size: 15,
+    footer: "VOC 实战课",
+  },
+  {
+    type: "cards",
+    kicker: "采集结果怎么读",
+    title: "live 结果出来后,不要立刻写最终策略",
+    subtitle: "先判断数据质量,再决定是否补采。",
+    cards: [
+      { title: "样本数", text: "看笔记/视频数量是否足够", accent: palette.ink },
+      { title: "评论数", text: "看是否有真实追问", accent: palette.jade },
+      { title: "显著问题", text: "看是否集中到转化阻力", accent: palette.cinnabar },
+      { title: "证据样本", text: "看能否回到原帖/原视频", accent: palette.gold },
+      { title: "补采词", text: "看下一轮该补什么", accent: palette.blue },
+      { title: "边界", text: "低样本只写阶段性信号", accent: palette.cinnabar },
+    ],
+    footer: "VOC 实战课",
+  },
+  {
+    type: "flow",
+    kicker: "从采集到交付",
+    title: "后半段把结果转成 4 个交付物",
+    subtitle: "学员看到这里要知道:采集只是开始,交付才是课程成果。",
+    steps: [
+      { title: "问题池", text: "Top 3 转化阻力" },
+      { title: "单点深挖", text: "价格/性价比" },
+      { title: "内容计划", text: "7 天短视频脚本" },
+      { title: "HTML 报告", text: "老板/运营可读" },
+    ],
+    bottom: "每个交付物都要有:证据、动作、指标。",
+    footer: "VOC 实战课",
+  },
+  {
+    type: "prompt",
+    kicker: "问题池 Prompt",
+    title: "把采集结果整理成 VOC 问题池",
+    subtitle: "问题池要按业务影响排序,不要只按词频排序。",
+    label: "复制给 Claude Code",
+    code: `请把上面的采集结果整理成 VOC 问题池。\n\n业务目标:提升茶饮新客转化。\n价格带:18-22 元。\n重点影响:新客转化、客单结构、评论信任。\n\n要求:\n1. 按业务影响排序,不要只按词频排序\n2. 每个问题都要有用户证据\n3. 标注影响环节\n4. 给出建议动作\n5. 输出 Top 3 问题和下一步是否适合深挖`,
+    size: 17,
+    footer: "VOC 实战课",
+  },
+  {
+    type: "prompt",
+    kicker: "单点深挖 Prompt",
+    title: "选择 Top 1:价格/性价比",
+    subtitle: "不要建议单纯降价,要把价格解释成点单路径、价值证据和避坑说明。",
+    label: "复制给 Claude Code",
+    code: `请继续深挖这个 VOC 问题:价格/性价比。\n\n业务背景:茶饮连锁,产品定价在 18-22 元区间。\n主要目标:提升新客转化。\n\n证据:\n1. 探店内容别只拍氛围,评论区最关心值不值\n2. 茶底是什么?我要避雷\n3. 低价新品、11.9 单人下午茶、21 元买一送一能触发注意,但仍需解释规则、口味和是否有坑。\n\n请输出表层问题、深层问题、今天动作、7 天验证动作、评论区回复方向和验证指标。`,
+    size: 15,
+    footer: "VOC 实战课",
+  },
+  {
+    type: "prompt",
+    kicker: "内容计划 Prompt",
+    title: "把 VOC 问题变成 7 天短视频计划",
+    subtitle: "每条内容都必须绑定真实用户问题,不写泛泛爆款标题。",
+    label: "复制给 Claude Code",
+    code: `请基于下面 VOC 背景,生成 7 天短视频内容计划和可直接拍摄的口播脚本。\n\n业务目标:提升茶饮新客转化。\n价格带:18-22 元。\n核心问题:用户判断“值不值”“会不会踩雷”“首单怎么点最稳”。\n\n每条内容包含:对应 VOC 问题、标题、前 3 秒钩子、镜头建议、口播脚本、评论区引导、验证指标。\n\n方向覆盖:第一次来怎么点;18-22 元值在哪;券后价和套餐;茶底甜度配料;避坑款;外卖到手;团购规则。`,
+    size: 15,
+    footer: "VOC 实战课",
+  },
+  {
+    type: "prompt",
+    kicker: "HTML 报告 Prompt",
+    title: "HTML 报告让 Claude Code 直接生成文件",
+    subtitle: "不要让它进入复杂 workflow;用前面材料直接生成可打开页面。",
+    label: "复制给 Claude Code",
+    code: `请基于前面 VOC 分析材料,直接生成一份老板/运营视角可阅读的 HTML 策略报告。\n\n要求:\n1. 使用基础样本数据、采集矩阵、问题池、单点深挖和内容计划\n2. 生成一个可打开的 HTML 文件\n3. 风格参考青花瓷:白底、瓷蓝、细线纹样、清爽高级\n4. 包含关键结论、采集矩阵、问题池、单点深挖、7 天内容计划、补采关键词\n5. 生成后请打开浏览器预览,并根据页面效果继续优化`,
+    size: 16,
+    footer: "VOC 实战课",
+  },
+  {
+    type: "checklist",
+    kicker: "常见报错",
+    title: "实操中遇到错误,按状态判断下一步",
+    subtitle: "这页要让学员不再被“余额不足/关键词错误/token 缺失”卡死。",
+    items: [
+      ["needs_token", "没有检测到 token", "配置 .env.local"],
+      ["needs_recharge", "未开通或余额不足", "打开充值/开通链接"],
+      ["关键词错误", "搜索接口不接受入参", "缩短关键词、加空格重试"],
+      ["链接 404", "平台链接或原帖失效", "保留样本编号和截图"],
+      ["工具不触发", "Claude Code 没调用技能", "明确要求使用 VOC 技能"],
+    ],
+    footer: "VOC 实战课",
+  },
+  {
+    type: "compare",
+    kicker: "课程商业表达",
+    title: "免费技能满足学习需求,付费技能满足企业稳定需求",
+    subtitle: "这页可以作为社区课转化页,但语气要克制。",
+    columns: [
+      {
+        title: "买课用户",
+        body: "能用免费技能或 sample 跑通 VOC 方法。\n重点是理解流程、学会判断、能做小范围分析。",
+        bottom: "课程价值:学会做一次 VOC 小闭环。",
+        accent: palette.jade,
+        fill: palette.mist2,
+      },
+      {
+        title: "企业用户",
+        body: "需要稳定采集、批量样本、权限管理、错误处理、审计和报告复用。",
+        bottom: "技能价值:稳定地把 VOC 变成交付。",
+        accent: palette.cinnabar,
+        fill: palette.paper,
+      },
+    ],
+    footer: "VOC 实战课",
+  },
+  {
+    type: "checklist",
+    kicker: "实战课交付检查",
+    title: "学员最终提交 6 个成果",
+    subtitle: "这页作为实战课收尾,明确学完以后手上应该有什么。",
+    items: [
+      ["项目模板", "有 inputs / samples / live-data / analysis / delivery", "可复用"],
+      ["技能可用", "免费技能演示 + 付费技能 smoke", "可截图"],
+      ["采集矩阵", "平台、人群、关键词、样本量、标准", "可复跑"],
+      ["问题池", "Top 3 问题和证据", "可决策"],
+      ["内容计划", "7 天标题、口播、指标", "可拍摄"],
+      ["HTML 报告", "老板/运营可直接阅读", "可交付"],
+    ],
+    footer: "VOC 实战课",
+  },
+  {
+    type: "section",
+    number: "END",
+    title: "下一次换行业,只替换输入,不替换流程",
+    text: "业务目标、价格门槛、用户原话、关键词、平台可以换;证据链和交付结构不要换。",
+    side: "可复制\n可复盘\n可交付",
+    footer: "VOC 实战课",
+  },
+];
+
+const renderers = { cover, agenda, claim, compare, flow, matrix, prompt, checklist, cards, section };
+
+async function saveBlobToFile(blob, filePath) {
+  await fs.mkdir(path.dirname(filePath), { recursive: true });
+  await fs.writeFile(filePath, Buffer.from(await blob.arrayBuffer()));
+}
+
+async function writeDeck({ fileName, slides }) {
+  const artifact = await import(pathToFileURL(ARTIFACT_TOOL).href);
+  const { Presentation, PresentationFile } = artifact;
+  const presentation = Presentation.create({ slideSize: { width: W, height: H } });
+  slides.forEach((spec, idx) => {
+    renderers[spec.type](presentation, spec, { index: idx + 1, total: slides.length });
+  });
+
+  const base = fileName.replace(/\.pptx$/i, "");
+  const deckPreviewDir = path.join(PREVIEW_DIR, base);
+  const deckLayoutDir = path.join(LAYOUT_DIR, base);
+  const deckQaDir = path.join(QA_DIR, base);
+  await fs.mkdir(deckPreviewDir, { recursive: true });
+  await fs.mkdir(deckLayoutDir, { recursive: true });
+  await fs.mkdir(deckQaDir, { recursive: true });
+
+  const previewPaths = [];
+  for (let i = 0; i < presentation.slides.count; i += 1) {
+    const slide = presentation.slides.getItem(i);
+    const previewPath = path.join(deckPreviewDir, `slide-${String(i + 1).padStart(2, "0")}.png`);
+    const png = await presentation.export({ slide, format: "png", scale: 1 });
+    await saveBlobToFile(png, previewPath);
+    previewPaths.push(previewPath);
+
+    const layout = await presentation.export({ slide, format: "layout" });
+    await fs.writeFile(
+      path.join(deckLayoutDir, `slide-${String(i + 1).padStart(2, "0")}.layout.json`),
+      await layout.text(),
+      "utf8",
+    );
+  }
+
+  const pptx = await PresentationFile.exportPptx(presentation);
+  const outPath = path.join(OUT_DIR, fileName);
+  await fs.mkdir(OUT_DIR, { recursive: true });
+  await pptx.save(outPath);
+
+  const contactSheet = path.join(deckQaDir, "contact-sheet.png");
+  const cs = spawnSync(PYTHON, [MAKE_CONTACT_SHEET, "--output", contactSheet, ...previewPaths], {
+    encoding: "utf8",
+  });
+  if (cs.status !== 0) {
+    throw new Error(`contact sheet failed\n${cs.stdout}\n${cs.stderr}`);
+  }
+
+  return { outPath, previewDir: deckPreviewDir, layoutDir: deckLayoutDir, contactSheet, slides: slides.length };
+}
+
+async function main() {
+  await fs.mkdir(WORKSPACE, { recursive: true });
+  const theory = await writeDeck({
+    fileName: "VOC理论课-从用户声音到经营证据链.pptx",
+    slides: theorySlides,
+  });
+  const practical = await writeDeck({
+    fileName: "VOC实战课-FmodeStudio技能项目模板.pptx",
+    slides: practicalSlides,
+  });
+  const manifest = { generatedAt: new Date().toISOString(), workspace: WORKSPACE, decks: [theory, practical] };
+  await fs.writeFile(path.join(WORKSPACE, "manifest.json"), JSON.stringify(manifest, null, 2), "utf8");
+  console.log(JSON.stringify(manifest, null, 2));
+}
+
+main().catch((error) => {
+  console.error(error.stack || error.message || String(error));
+  process.exit(1);
+});

+ 2 - 1
scripts/deploy/claude-code-voc-intelligence-suite.js

@@ -10,7 +10,7 @@ const SUITE_DIR = path.join(PROJECT_ROOT, 'claude-code', 'claude-code-voc-intell
 const MANIFEST_PATH = path.join(SUITE_DIR, 'skill-package-manifest.json');
 const DIST_DIR = path.join(PROJECT_ROOT, 'dist');
 const PACKAGE_ID = 'claude-code-voc-intelligence';
-const NPM_PACKAGE_NAME = '@gangvy/claude-code-voc-intelligence';
+const NPM_PACKAGE_NAME = '@vocmarket/voc-skill';
 const PACKAGE_ZIP = path.join(DIST_DIR, `${PACKAGE_ID}.zip`);
 const DIST_MANIFEST = path.join(DIST_DIR, `${PACKAGE_ID}-suite-manifest.json`);
 const TEMP_ROOT = path.join(os.tmpdir(), 'claude-code-voc-pkg', PACKAGE_ID);
@@ -67,6 +67,7 @@ const EXCLUDED_DIRS = new Set([
   'node_modules',
   'memory',
   'outputs',
+  '.npm-cache-acceptance',
   '.tmp',
   '.git',
   '.claude'

+ 2 - 2
scripts/deploy/claude-code-voc-npm-package.js

@@ -9,7 +9,7 @@ const SUITE_DIR = path.join(PROJECT_ROOT, 'claude-code', 'claude-code-voc-intell
 const RELEASE_NPM_DIR = path.join(PROJECT_ROOT, 'release', 'npm', 'claude-code-voc-intelligence');
 const DIST_DIR = path.join(PROJECT_ROOT, 'dist', 'npm');
 const DIST_MANIFEST = path.join(DIST_DIR, 'claude-code-voc-npm-package-manifest.json');
-const PACKAGE_NAME = '@gangvy/claude-code-voc-intelligence';
+const PACKAGE_NAME = '@vocmarket/voc-skill';
 const BIN_NAME = 'claude-voc';
 const TEST_ROOT = path.join(os.tmpdir(), 'claude-code-voc-npm-test', String(process.pid));
 
@@ -272,7 +272,7 @@ function verifyPublishedPublic() {
   delete publicEnv.NODE_AUTH_TOKEN;
   delete publicEnv.NPM_CONFIG_USERCONFIG;
   let lastError = '';
-  for (let attempt = 1; attempt <= 6; attempt++) {
+  for (let attempt = 1; attempt <= 12; attempt++) {
     const child = spawnSync(process.platform === 'win32' ? 'cmd.exe' : 'npm', process.platform === 'win32'
       ? ['/d', '/s', '/c', 'npm', ...args]
       : args, {

+ 575 - 0
tools/analyze_centrum_douyin_live.py

@@ -0,0 +1,575 @@
+import json
+import math
+import re
+from collections import Counter, defaultdict
+from datetime import datetime, timezone
+from pathlib import Path
+
+
+OUTPUT_DIR = Path("outputs/centrum_douyin_audit_2026-06-08/live-full")
+INPUT_FILES = [
+    OUTPUT_DIR / "live-raw-dataset.json",
+    OUTPUT_DIR / "live-raw-dataset-batch2.json",
+]
+RULE_TEXT = Path("outputs/centrum_douyin_audit_2026-06-08/rule_pack_extracted.txt")
+
+
+def clean(value):
+    return re.sub(r"\s+", " ", str(value or "")).strip()
+
+
+def as_int(value):
+    try:
+        number = int(float(value or 0))
+    except Exception:
+        number = 0
+    return number
+
+
+def load_json(path):
+    return json.loads(path.read_text(encoding="utf-8"))
+
+
+def video_score(video):
+    return (
+        as_int(video.get("likeCount"))
+        + as_int(video.get("commentCount")) * 3
+        + as_int(video.get("shareCount")) * 2
+        + as_int(video.get("playCount")) * 0.02
+    )
+
+
+def video_key(video):
+    return clean(video.get("id") or video.get("videoId") or video.get("url"))
+
+
+def comment_key(comment):
+    return clean(comment.get("id") or comment.get("cid") or f"{comment.get('videoId')}:{comment.get('text')}")
+
+
+def merge_datasets():
+    videos = {}
+    comments = {}
+    warnings = []
+    errors = []
+    effective_keywords = []
+    requested_keywords = []
+    for path in INPUT_FILES:
+        if not path.exists():
+            continue
+        data = load_json(path)
+        requested_keywords.extend(data.get("profile", {}).get("keywords", []))
+        effective_keywords.extend(data.get("effectiveKeywords", []))
+        warnings.extend(data.get("warnings", []))
+        errors.extend(data.get("errors", []))
+        for video in data.get("videos", []):
+            key = video_key(video)
+            if not key:
+                continue
+            if key not in videos or video_score(video) > video_score(videos[key]):
+                videos[key] = video
+        for comment in data.get("comments", []):
+            key = comment_key(comment)
+            if key and key not in comments:
+                comments[key] = comment
+    return {
+        "videos": list(videos.values()),
+        "comments": list(comments.values()),
+        "warnings": warnings,
+        "errors": errors,
+        "requestedKeywords": sorted(set(map(clean, requested_keywords)) - {""}),
+        "effectiveKeywords": sorted(set(map(clean, effective_keywords)) - {""}),
+    }
+
+
+SKU_RULES = [
+    ("善存PRO/每日营养包", r"PRO营养包|每日营养包|营养包|代谢包|状态全开|气血满格|新生焕活"),
+    ("银善存/脑力瓶", r"银善存|脑力瓶|大脑维生素|脑力|大脑"),
+    ("善存复合维生素/多维", r"复合维生素|多维|维生素|多维元素|26种营养"),
+    ("善存男士/女士/50+", r"男士|女士|女性|男性|50岁|50\+|中老年|老人|Costco|美国"),
+    ("善存小佳维/儿童", r"小佳维|儿童|孩子|宝宝|长高"),
+    ("善存孕妇复合维生素", r"孕妇|孕期|备孕|叶酸|胎儿"),
+    ("善存海外/跨境版本", r"海外|跨境|澳洲|美国版|澳版|天猫国际|京东国际|包邮"),
+]
+
+
+RULE_PATTERNS = {
+    "RP01": ("绝对化/保证性/最高级", r"全网第一|第一|唯一|100%|保证|最[好强]|立刻|马上|闭眼入|爆火"),
+    "RP02": ("疾病治疗/治愈/诊断暗示", r"甲流|感冒|肿瘤|医生|治疗|治好|预防|缓解|疼|病|症|免疫"),
+    "RP03": ("未经确认功效/健康利益宣称", r"精力|脑力|大脑|气血|代谢|瘦|吃瘦|状态|焕活|免疫力|长高|睡眠|抗疲劳"),
+    "RP04": ("数字/实验/前后对比造成明确功效承诺", r"\d+\s*(天|周|月|%|倍|种|粒|岁)|提升|改善|对比|实测|检测"),
+    "RP05": ("产品法规属性误用/三品一械边界错误", r"海外|跨境|保健|蓝帽|普通食品|美国|澳洲|Costco|天猫国际|京东国际"),
+    "RP06": ("包装/标签/备案/说明书不一致", r"正品|真假|喷码|批号|效期|保质期|版本|包装|标签"),
+    "RP07": ("用量/用法/禁忌/适用人群误导", r"每天|一粒|几粒|怎么吃|吃法|空腹|饭后|孕妇|儿童|孩子|老人|全家|适合"),
+    "RP11": ("竞品贬损/虚假比较/不公平竞争", r"红黑榜|黑榜|智商税|别买|不如|对比|哪家|排名|踩坑"),
+    "RP13": ("表达强度过高但可弱化", r"满格|全开|必看|不行|不能少|值得拥有|还不快|妙招"),
+    "RP14": ("证据引用不足或上下文不清", r"营养师|专家|医生|高管|实测|研究|标准|干货|教你"),
+    "RP16": ("平台敏感词/卡审词疑似命中", r"甲流|吃瘦|瘦|气血|脑力|大脑|病|医生|减肥"),
+    "RP18": ("促销/价格/库存/券后信息不清", r"活动|包邮|直播|京东|天猫|券|价格|优惠|买|下单"),
+    "RP19": ("达人/KOL/KOC/专家身份表达边界", r"营养师|专家|医生|高管|主持人|明星|教你"),
+    "RP21": ("CTA/购买引导/场景导向不清", r"买|下单|链接|购物车|看看|拥有|囤|包邮|直播"),
+    "RP22": ("灰区创意/趋势模仿/情绪钩子", r"必看|改命|焦虑|竞争力|长续婚姻|身材管理|小妙招|闭眼入"),
+}
+
+
+COMMENT_THEMES = [
+    ("低质互动/刷金币", r"金币|坚持互动|多互动|串门|涨粉|许愿|抖音大大|满减券|6000|7000|5000|3000|稳定得|互关|看广告|下图回复"),
+    ("购买路径/价格", r"怎么买|哪里买|链接|购物车|多少钱|价格|贵|便宜|活动|券|京东|天猫|直播|下单|店铺"),
+    ("正品/渠道/版本信任", r"正品|真假|喷码|批号|效期|保质期|过期|版本|美国版|澳洲|海外|进口|天猫国际|京东国际"),
+    ("用法用量/适用人群", r"怎么吃|几粒|一天吃|每天吃|饭前|饭后|空腹|孕妇|儿童|孩子|老人|哺乳|妈妈能吃|适合.*(孕妇|儿童|老人|孩子|中老年)"),
+    ("功效/健康诉求", r"有用|效果|精力|气血|代谢|免疫|甲流|睡眠|脑力|瘦|长高|补什么|缺"),
+    ("体验/副作用/剂型", r"反胃|恶心|吞|吞咽|颗粒|大粒|便秘|拉肚子|不舒服|味道|腥"),
+    ("质疑/比较/性价比", r"智商税|真的|靠谱吗|有必要|不如|对比|区别|性价比|划算|坑|贵"),
+    ("内容互动/达人明星", r"喜欢|好看|姐姐|明星|秋瓷炫|颖儿|维嘉|百克力|老师|收藏|学到了"),
+]
+
+
+def classify_sku(text):
+    hits = [label for label, pattern in SKU_RULES if re.search(pattern, text, flags=re.I)]
+    return hits or ["善存泛品牌/其他"]
+
+
+def classify_rules(text):
+    hits = []
+    for rule_id, (name, pattern) in RULE_PATTERNS.items():
+        if re.search(pattern, text, flags=re.I):
+            hits.append({"ruleId": rule_id, "ruleName": name})
+    return hits
+
+
+def classify_comment(text):
+    if re.search(COMMENT_THEMES[0][1], text, flags=re.I):
+        return [COMMENT_THEMES[0][0]]
+    hits = [label for label, pattern in COMMENT_THEMES if re.search(pattern, text, flags=re.I)]
+    return hits or ["泛互动/情绪反馈"]
+
+
+def risk_level(rule_ids):
+    red = {"RP01", "RP02", "RP03", "RP04", "RP05", "RP06", "RP07", "RP11"}
+    if len(red.intersection(rule_ids)) >= 3:
+        return "高"
+    if red.intersection(rule_ids):
+        return "中高"
+    if rule_ids:
+        return "中"
+    return "低"
+
+
+def truncate(text, length=120):
+    text = clean(text)
+    return text if len(text) <= length else text[: length - 1] + "…"
+
+
+def comment_rows(comments, videos_by_id):
+    rows = []
+    for comment in comments:
+        text = clean(comment.get("text"))
+        if not text:
+            continue
+        video = videos_by_id.get(clean(comment.get("videoId")), {})
+        themes = classify_comment(text)
+        rows.append({
+            "commentId": comment.get("id") or comment.get("cid") or "",
+            "videoId": comment.get("videoId") or "",
+            "videoTitle": video.get("title") or "",
+            "videoAuthor": video.get("author") or "",
+            "keyword": comment.get("keyword") or video.get("keyword") or "",
+            "themes": themes,
+            "themeText": "、".join(themes),
+            "text": text,
+            "likeCount": as_int(comment.get("likeCount")),
+            "replyCount": as_int(comment.get("replyCommentTotal")),
+            "ipLocation": comment.get("ipLocation") or "",
+        })
+    rows.sort(key=lambda row: (row["likeCount"], len(row["text"])), reverse=True)
+    return rows
+
+
+def analyze_videos(videos, comments_by_video):
+    rows = []
+    for video in videos:
+        text = clean(" ".join([
+            video.get("title"),
+            video.get("desc"),
+            video.get("content"),
+            " ".join(video.get("tags") or []),
+        ]))
+        sku_hits = classify_sku(text)
+        rule_hits = classify_rules(text)
+        rule_ids = [item["ruleId"] for item in rule_hits]
+        comments = comments_by_video.get(clean(video.get("id")), [])
+        top_comment = sorted(comments, key=lambda c: as_int(c.get("likeCount")), reverse=True)[:1]
+        rows.append({
+            "videoId": video.get("id") or "",
+            "keyword": video.get("keyword") or "",
+            "skuGroup": "、".join(sku_hits),
+            "author": video.get("author") or "",
+            "title": clean(video.get("title") or video.get("desc")),
+            "publishTime": video.get("timestamp") or "",
+            "likeCount": as_int(video.get("likeCount")),
+            "commentCount": as_int(video.get("commentCount")),
+            "shareCount": as_int(video.get("shareCount")),
+            "collectCount": as_int(video.get("collectCount")),
+            "playCount": as_int(video.get("playCount")),
+            "score": round(video_score(video), 2),
+            "url": video.get("url") or video.get("sourceUrl") or video.get("fallbackUrl") or "",
+            "coverUrl": video.get("coverUrl") or "",
+            "tags": "、".join(video.get("tags") or []),
+            "ruleHits": "、".join(rule_ids),
+            "ruleNames": "、".join([item["ruleName"] for item in rule_hits]),
+            "riskLevel": risk_level(set(rule_ids)),
+            "commentCaptured": len(comments),
+            "topComment": top_comment[0].get("text") if top_comment else "",
+            "topCommentLike": as_int(top_comment[0].get("likeCount")) if top_comment else 0,
+        })
+    rows.sort(key=lambda row: row["score"], reverse=True)
+    return rows
+
+
+def build_theme_summary(comment_data):
+    buckets = defaultdict(list)
+    for row in comment_data:
+        for theme in row["themes"]:
+            buckets[theme].append(row)
+    summaries = []
+    for theme, rows in buckets.items():
+        sorted_rows = sorted(rows, key=lambda r: r["likeCount"], reverse=True)
+        examples = [r["text"] for r in sorted_rows[:5]]
+        summaries.append({
+            "theme": theme,
+            "commentCount": len(rows),
+            "likedCommentCount": sum(1 for row in rows if row["likeCount"] > 0),
+            "totalLikes": sum(row["likeCount"] for row in rows),
+            "sampleComments": examples,
+            "opportunity": comment_opportunity(theme),
+        })
+    summaries.sort(key=lambda row: (row["commentCount"], row["totalLikes"]), reverse=True)
+    return summaries
+
+
+def comment_opportunity(theme):
+    mapping = {
+        "购买路径/价格": "置顶评论和商品卡要明确官方购买路径、活动口径和客服入口,避免用户在评论区反复问链接/价格。",
+        "正品/渠道/版本信任": "补充正品辨别、版本差异、喷码/批号/效期说明,跨境 SKU 尤其要把包装版本说清楚。",
+        "用法用量/适用人群": "所有用法、用量、特殊人群问题都回到标签/说明书,并引导咨询专业人士。",
+        "功效/健康诉求": "用户会把内容理解成功效承诺,后续稿件应减少结果承诺,改为日常营养支持和生活方式组合。",
+        "体验/副作用/剂型": "建立颗粒大小、吞咽、服用时间、胃部不适等体验 FAQ,但不要自行给医疗建议。",
+        "质疑/比较/性价比": "用成分、剂型、预算、版本和适用场景做中性比较,避免红黑榜和竞品贬损。",
+        "内容互动/达人明星": "高互动来自达人/情绪内容时,要用评论区和商品卡补足 SKU 承接,否则曝光不等于转化。",
+        "泛互动/情绪反馈": "保留情绪互动价值,同时在高赞评论下补充产品边界与购买答疑。",
+    }
+    return mapping.get(theme, "需要人工复核评论上下文后再定动作。")
+
+
+def build_goldens():
+    return [
+        {
+            "id": "GD-LIVE-01",
+            "riskPrototype": "左右脑互博、40+精力满格、脑力/精力状态承诺",
+            "ruleHits": "RP02、RP03、RP13、RP16",
+            "beforeExample": "梗王蓄力局,40+精力满格 #善存PRO营养包",
+            "afterExample": "40+忙碌阶段更要关注饮食、作息和日常营养补充;产品请按标签建议选择。",
+            "why": "保留年龄场景和生活状态,但不承诺精力/脑力改善。",
+            "sourceEvidence": "李维嘉/善存PRO营养包 live 样本",
+        },
+        {
+            "id": "GD-LIVE-02",
+            "riskPrototype": "吃瘦不饿瘦、身材管理小妙招、代谢包",
+            "ruleHits": "RP03、RP04、RP13、RP16",
+            "beforeExample": "开工前我的身材管理小妙招来啦 #善存代谢包 #状态全开",
+            "afterExample": "开工阶段也别忽略均衡饮食和规律运动;如需补充维生素矿物质,请按产品标签建议。",
+            "why": "删除减重/代谢结果暗示,把内容改为生活方式+日常补充。",
+            "sourceEvidence": "颖儿/善存代谢包 live 样本",
+        },
+        {
+            "id": "GD-LIVE-03",
+            "riskPrototype": "气血满格、新生焕活,就这一包",
+            "ruleHits": "RP03、RP13、RP16",
+            "beforeExample": "气血满格,就这一包;新生焕活,就这一包。",
+            "afterExample": "忙碌和年龄阶段可以关注日常营养管理,按标签建议补充相关营养成分。",
+            "why": "弱化强感受和单包解决感,减少平台卡审和功效风险。",
+            "sourceEvidence": "善存海外旗舰店/PRO营养包 live 样本",
+        },
+        {
+            "id": "GD-LIVE-04",
+            "riskPrototype": "甲流有哪些维生素可以吃",
+            "ruleHits": "RP02、RP03、RP16",
+            "beforeExample": "甲流有哪些维生素可以吃?一粒补充26种营养。",
+            "afterExample": "特殊健康时期更要注意均衡饮食和休息;本品为日常营养补充,不替代药物或治疗。",
+            "why": "删除疾病场景和营养品解决病症的关联。",
+            "sourceEvidence": "善存复合维生素 live 样本",
+        },
+        {
+            "id": "GD-LIVE-05",
+            "riskPrototype": "一粒就可以补充26人体所需营养/一粒补全",
+            "ruleHits": "RP01、RP03、RP04、RP07",
+            "beforeExample": "一粒就可以补充26人体所需营养。",
+            "afterExample": "含多种维生素和矿物质,适合按产品标签建议作为日常营养补充。",
+            "why": "保留成分数量,但不推导全部所需或一粒补全。",
+            "sourceEvidence": "善存复合维生素 live 样本",
+        },
+        {
+            "id": "GD-LIVE-06",
+            "riskPrototype": "国外国内爆火、海外好物闭眼入",
+            "ruleHits": "RP01、RP05、RP13、RP18",
+            "beforeExample": "国外国内爆火的善存女性维生素,海外好物闭眼入。",
+            "afterExample": "不同渠道/版本的包装和说明可能不同,请以当前购买页面、包装标签和官方客服说明为准。",
+            "why": "把热销背书和跨境信任改为版本与渠道说明。",
+            "sourceEvidence": "善存海外/女士维生素 live 样本",
+        },
+        {
+            "id": "GD-LIVE-07",
+            "riskPrototype": "成人儿童营养好物、不花冤枉钱的泛比较",
+            "ruleHits": "RP07、RP11、RP14",
+            "beforeExample": "成人&儿童营养好物,看了不花冤枉钱。",
+            "afterExample": "成人和儿童适用产品不同,建议按年龄、标签说明和自身需求选择。",
+            "why": "避免成人儿童泛化和价值判断过强。",
+            "sourceEvidence": "营养师/山姆营养品区 live 样本",
+        },
+        {
+            "id": "GD-LIVE-08",
+            "riskPrototype": "正品/喷码/效期/版本问题散落在评论区",
+            "ruleHits": "RP05、RP06、RP18、RP21",
+            "beforeExample": "评论区反复追问真假、效期、喷码、美国版/澳洲版区别。",
+            "afterExample": "置顶评论:购买请认准官方授权渠道;不同版本包装/成分/适用说明请以购买页和实物标签为准,效期/喷码可咨询客服。",
+            "why": "将售前信任问题前置,降低投诉和客服压力。",
+            "sourceEvidence": "多条善存海外/男士/女士/50+评论",
+        },
+        {
+            "id": "GD-LIVE-09",
+            "riskPrototype": "明星都在吃/达人高管背书",
+            "ruleHits": "RP14、RP19、RP22",
+            "beforeExample": "秋瓷炫、王艳、百克力都在吃的营养包。",
+            "afterExample": "达人分享仅代表个人体验;产品信息以包装、标签和官方说明为准。",
+            "why": "保留达人种草,但不把身份/使用行为变成效果背书。",
+            "sourceEvidence": "善存海外旗舰店/营养包 live 样本",
+        },
+        {
+            "id": "GD-LIVE-10",
+            "riskPrototype": "50岁以上善存怎么选、男士/女士/海外版混讲",
+            "ruleHits": "RP05、RP06、RP07、RP14",
+            "beforeExample": "美国 Costco 三款 50 岁以上善存如何选择。",
+            "afterExample": "不同年龄和版本适用说明不同,请按具体 SKU、包装标签和购买渠道说明选择。",
+            "why": "SKU/版本边界清楚,避免跨版本套用。",
+            "sourceEvidence": "50+版本选择 live 样本",
+        },
+    ]
+
+
+def build_script_breakdowns(top_videos):
+    rows = []
+    for video in top_videos[:30]:
+        title = video["title"]
+        rules = set(filter(None, video["ruleHits"].split("、")))
+        if re.search(r"参鸡汤|餐厅|身材管理|开工|婚姻|阅读|高管|梗王", title):
+            hook = "生活方式/达人故事钩子"
+            structure = "先用生活或人物情境吸引停留,再用标签、话题或轻口播承接善存 SKU。"
+            fix = "保留故事,但结尾补 SKU 边界:日常营养补充、按标签建议、非治疗。"
+        elif re.search(r"甲流|维生素可以吃|医生|营养师", title):
+            hook = "专家/健康问题钩子"
+            structure = "用健康问题或专业身份制造可信度。"
+            fix = "删疾病解决路径,改成均衡饮食与标签范围;专家身份不替代证据。"
+        elif re.search(r"正品|版本|Costco|怎么选|选择", title):
+            hook = "选购/信任答疑钩子"
+            structure = "围绕版本、渠道、真假、效期等购买前疑问展开。"
+            fix = "适合做置顶 FAQ 和客服承接,减少评论区重复追问。"
+        else:
+            hook = "商品卖点/活动钩子"
+            structure = "用成分数量、活动、包邮或热门标签推动转化。"
+            fix = "保留成分和促销信息,但避免一粒补全、保证效果、闭眼入。"
+        rows.append({
+            "videoId": video["videoId"],
+            "title": title,
+            "author": video["author"],
+            "skuGroup": video["skuGroup"],
+            "hookType": hook,
+            "currentStructure": structure,
+            "riskRules": "、".join(sorted(rules)),
+            "reusableFix": fix,
+        })
+    return rows
+
+
+def build_summary(video_rows, comment_data, theme_summary, merged):
+    sku_counts = Counter()
+    for row in video_rows:
+        for sku in row["skuGroup"].split("、"):
+            sku_counts[sku] += 1
+    risk_counts = Counter(row["riskLevel"] for row in video_rows)
+    author_counts = Counter(row["author"] for row in video_rows if row["author"])
+    keyword_counts = Counter(row["keyword"] for row in video_rows if row["keyword"])
+    return {
+        "generatedAt": datetime.now(timezone.utc).astimezone().isoformat(),
+        "requestedKeywordCount": len(merged["requestedKeywords"]),
+        "effectiveKeywordCount": len(merged["effectiveKeywords"]),
+        "videoCount": len(video_rows),
+        "commentCount": len(comment_data),
+        "actionableCommentCount": sum(1 for row in comment_data if "低质互动/刷金币" not in row["themes"]),
+        "lowQualityCommentCount": sum(1 for row in comment_data if "低质互动/刷金币" in row["themes"]),
+        "warningCount": len(merged["warnings"]),
+        "errorCount": len(merged["errors"]),
+        "skuCounts": [{"skuGroup": k, "videoCount": v} for k, v in sku_counts.most_common()],
+        "riskCounts": [{"riskLevel": k, "videoCount": v} for k, v in risk_counts.most_common()],
+        "topAuthors": [{"author": k, "videoCount": v} for k, v in author_counts.most_common(15)],
+        "topKeywords": [{"keyword": k, "videoCount": v} for k, v in keyword_counts.most_common(20)],
+        "topCommentThemes": theme_summary[:10],
+    }
+
+
+def build_markdown(data):
+    summary = data["summary"]
+    top_videos = data["videoRows"][:18]
+    top_themes = data["themeSummary"][:8]
+    goldens = data["goldenRows"]
+    script_rows = data["scriptBreakdowns"][:12]
+    lines = []
+    lines.append("# 善存抖音稿件审核润色与评论 VOC Live 版报告")
+    lines.append("")
+    lines.append(f"生成时间:{summary['generatedAt']}")
+    lines.append("")
+    lines.append("## 样本覆盖")
+    lines.append("")
+    lines.append(f"- live 去重视频样本:{summary['videoCount']} 条")
+    lines.append(f"- live 去重评论样本:{summary['commentCount']} 条")
+    lines.append(f"- 可行动 VOC 评论:{summary['actionableCommentCount']} 条;低质互动/刷金币类评论:{summary['lowQualityCommentCount']} 条")
+    lines.append(f"- 请求关键词:{summary['requestedKeywordCount']} 个;有效返回关键词:{summary['effectiveKeywordCount']} 个")
+    lines.append(f"- 接口警告/错误:{summary['warningCount']} / {summary['errorCount']},主要为部分关键词或单条评论页 fetch failed;已保留成功样本。")
+    lines.append("- 规则资料:QW1 Rule Pack Examples V0.1,已提取 28 个 Rule Pack,用于规则映射和黄金改写。")
+    lines.append("")
+    lines.append("## 方法说明与限制")
+    lines.append("")
+    lines.append("- 采集口径:使用 `claude-code-voc-intelligence` 的抖音采集能力,围绕善存、善存营养包、复合维生素、30+/50+、男士/女士、海外版/跨境版等关键词抓取视频与评论,并对跨批次视频、评论做去重。")
+    lines.append("- 脚本拆解口径:使用 `douyin-speaking-daily` 的稿件拆解能力,对标题、标签、描述、评论和可获取文本形成 transcript proxy 与脚本结构分析;未做完整视频 ASR 的样本,结论以可见文案和评论为准。")
+    lines.append("- 审核差异限制:本轮未取得品牌审核前原稿、驳回原因、修改版本流水和最终通过稿的一一对应关系,因此“做了哪些润色和修改”是基于已发布稿件、Rule Pack 命中风险和可复用通过话术的反推,不等同于逐条原稿差异审计。")
+    lines.append("- 证据强度:78 条视频和 471 条评论足以支撑本轮方向性判断;涉及合规结论时仍建议由品牌法务/平台审核团队结合原始投放链路复核。")
+    lines.append("")
+    lines.append("## 一句话结论")
+    lines.append("")
+    lines.append("善存抖音已投内容的主流过审路径不是直接讲“治疗/功效”,而是把产品包进明星生活方式、职场状态、选购答疑、跨境版本说明和日常营养补充场景中;但 live 样本里仍大量出现“精力满格、气血满格、代谢/吃瘦、甲流、脑力/大脑、闭眼入、一粒补充所需”等高风险表达。评论区最值得改进的是:正品与版本信任、用法用量/适用人群、购买路径/价格、功效追问和剂型体验。")
+    lines.append("")
+    lines.append("## 已发布稿件的润色/修改路径")
+    lines.append("")
+    lines.append("1. 明星生活方式化:秋瓷炫参鸡汤面、颖儿开工身材管理、李维嘉 40+梗王等内容,把产品从硬功效改为生活情境和标签承接。")
+    lines.append("2. 年龄/状态场景化:PRO 营养包常用 30+/40+、精力、气血、状态等表达,过审上比治疗承诺温和,但仍需进一步弱化为日常营养管理。")
+    lines.append("3. 店铺商品话术成分化:官方/旗舰店内容常用“26种营养、一粒、男女复合维生素、海外版”等成分和版本话术,需避免推导“一粒补全/疾病时期该吃”。")
+    lines.append("4. 跨境 SKU 信任化:海外/美国/澳洲/Costco/天猫国际相关内容更适合做版本、包装、渠道和效期说明,而不是用海外身份做安全背书。")
+    lines.append("5. 评论承接客服化:正品、喷码、效期、怎么吃、适合谁、哪里买等评论应前置成置顶 FAQ 和客服关键词库。")
+    lines.append("")
+    lines.append("## 黄金改写数据集")
+    lines.append("")
+    lines.append("| ID | 风险原型 | 命中规则 | 风险表达 | 建议通过版 | 依据 |")
+    lines.append("|---|---|---|---|---|---|")
+    for row in goldens:
+        lines.append(f"| {row['id']} | {row['riskPrototype']} | {row['ruleHits']} | {row['beforeExample']} | {row['afterExample']} | {row['why']} |")
+    lines.append("")
+    lines.append("## 评论 VOC 主题与改进空间")
+    lines.append("")
+    lines.append("| 主题 | 评论数 | 点赞评论数 | 高赞/典型评论摘录 | 改进动作 |")
+    lines.append("|---|---:|---:|---|---|")
+    for row in top_themes:
+        examples = "<br>".join(truncate(text, 60) for text in row["sampleComments"][:3])
+        lines.append(f"| {row['theme']} | {row['commentCount']} | {row['likedCommentCount']} | {examples} | {row['opportunity']} |")
+    lines.append("")
+    lines.append("## 样本案例 Top")
+    lines.append("")
+    for index, row in enumerate(top_videos, start=1):
+        lines.append(f"### {index}. {row['title'] or row['videoId']}")
+        lines.append(f"- 作者:{row['author']}|关键词:{row['keyword']}|SKU:{row['skuGroup']}")
+        lines.append(f"- 互动:{row['likeCount']}赞 / {row['commentCount']}评 / {row['shareCount']}转 / {row['collectCount']}藏")
+        lines.append(f"- 规则命中:{row['ruleHits'] or '未命中主要规则'}|风险等级:{row['riskLevel']}")
+        lines.append(f"- 链接:{row['url']}")
+        if row["topComment"]:
+            lines.append(f"- 高赞评论:{truncate(row['topComment'], 90)}({row['topCommentLike']}赞)")
+        lines.append("")
+    lines.append("## 逐字稿/脚本结构拆解")
+    lines.append("")
+    lines.append("本轮未批量下载视频做 ASR,逐字稿拆解使用 live 采集到的视频标题、描述、标签和评论作为脚本代理文本,并用本地 douyin-viral-script-analyzer 跑通结构拆解。正式复盘审核前后稿时,可把原始口播 ASR 替换进同一输入结构。")
+    lines.append("")
+    lines.append("| 样本 | 钩子类型 | 当前结构 | 风险规则 | 可复用改法 |")
+    lines.append("|---|---|---|---|---|")
+    for row in script_rows:
+        lines.append(f"| {truncate(row['title'], 36)} | {row['hookType']} | {row['currentStructure']} | {row['riskRules']} | {row['reusableFix']} |")
+    lines.append("")
+    lines.append("## 下一步建议")
+    lines.append("")
+    lines.append("1. 把品牌审核前原稿、驳回原因和通过稿补入 `content_id/version_id/audit_id`,即可把本报告从“已发布稿件推断”升级为真正的审核前后差异复盘。")
+    lines.append("2. 对评论数最高的官方/达人稿件追加 ASR,尤其是秋瓷炫、颖儿、李维嘉、善存海外旗舰店和 50+版本选择内容。")
+    lines.append("3. 建立置顶评论模板:正品/版本/效期、用法用量、特殊人群、非治疗声明、购买路径。")
+    return "\n".join(lines)
+
+
+def main():
+    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+    merged = merge_datasets()
+    comments_by_video = defaultdict(list)
+    for comment in merged["comments"]:
+        comments_by_video[clean(comment.get("videoId"))].append(comment)
+    videos_by_id = {clean(video.get("id")): video for video in merged["videos"]}
+    video_rows = analyze_videos(merged["videos"], comments_by_video)
+    comment_data = comment_rows(merged["comments"], videos_by_id)
+    theme_summary = build_theme_summary(comment_data)
+    summary = build_summary(video_rows, comment_data, theme_summary, merged)
+    script_breakdowns = build_script_breakdowns(video_rows)
+    data = {
+        "summary": summary,
+        "requestedKeywords": merged["requestedKeywords"],
+        "effectiveKeywords": merged["effectiveKeywords"],
+        "videoRows": video_rows,
+        "commentRows": comment_data,
+        "themeSummary": theme_summary,
+        "goldenRows": build_goldens(),
+        "scriptBreakdowns": script_breakdowns,
+        "warnings": merged["warnings"],
+        "errors": merged["errors"],
+        "rulePackSource": str(RULE_TEXT),
+    }
+    (OUTPUT_DIR / "live-analysis-data.json").write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
+    (OUTPUT_DIR / "善存抖音稿件审核润色与评论VOC-live.md").write_text(build_markdown(data), encoding="utf-8")
+    analyzer_input = {
+        "metadata": {"project": "善存抖音稿件审核润色与评论VOC-live", "generatedAt": summary["generatedAt"]},
+        "videos": [
+            {
+                "aweme_id": row["videoId"],
+                "keyword": row["keyword"],
+                "title": row["title"],
+                "author": {"nickname": row["author"]},
+                "likeCount": row["likeCount"],
+                "commentCount": row["commentCount"],
+                "shareCount": row["shareCount"],
+                "playCount": row["playCount"],
+                "url": row["url"],
+            }
+            for row in video_rows
+        ],
+        "comments": [
+            {
+                "id": row["commentId"],
+                "aweme_id": row["videoId"],
+                "text": row["text"],
+                "likeCount": row["likeCount"],
+            }
+            for row in comment_data
+        ],
+        "transcripts": [
+            {
+                "awemeId": row["videoId"],
+                "provider": "live-title-desc-proxy",
+                "text": "。".join(filter(None, [row["title"], row["topComment"]])),
+                "segments": [],
+            }
+            for row in video_rows
+        ],
+    }
+    (OUTPUT_DIR / "douyin_analyzer_input_live.json").write_text(json.dumps(analyzer_input, ensure_ascii=False, indent=2), encoding="utf-8")
+    print(json.dumps({
+        "status": "ok",
+        "videoCount": summary["videoCount"],
+        "commentCount": summary["commentCount"],
+        "effectiveKeywordCount": summary["effectiveKeywordCount"],
+        "output": str(OUTPUT_DIR / "live-analysis-data.json"),
+        "markdown": str(OUTPUT_DIR / "善存抖音稿件审核润色与评论VOC-live.md"),
+    }, ensure_ascii=False, indent=2))
+
+
+if __name__ == "__main__":
+    main()

+ 1621 - 0
tools/analyze_centrum_transcripts.py

@@ -0,0 +1,1621 @@
+import json
+import re
+import zipfile
+import xml.etree.ElementTree as ET
+from collections import Counter, defaultdict
+from datetime import datetime
+from pathlib import Path
+
+
+BASE = Path("outputs/centrum_douyin_audit_2026-06-08")
+LIVE_DIR = BASE / "live-full"
+TRANSCRIPT_DIR = BASE / "transcript-full"
+LIVE_DATA = LIVE_DIR / "live-analysis-data.json"
+RUN_LOG = TRANSCRIPT_DIR / "transcription-run-log.json"
+RULE_PACK_DOC = Path(r"E:\新建文件夹\WXWork\1688855615758934\Cache\File\2026-06\QW1_Rule_Pack_ExamplesV0.1.docx")
+ACCOUNT_LIST_XLSX = Path(r"E:\新建文件夹\WXWork\1688855615758934\Cache\File\2026-06\店铺与账号.xlsx")
+SUPPLEMENTAL_STRATEGY_MD = Path(r"E:\新建文件夹\WXWork\1688855615758934\Cache\File\2026-06\抖音保健品过审黄金公式与边界策略.md")
+RUNNER_SUPPLEMENT_JSON = BASE / "runner-supplement-check" / "daily-report.json"
+RUNNER_SUPPLEMENT_RAW = BASE / "runner-supplement-check" / "runner-raw-input.json"
+PUBLIC_PROFILE_ALL_VIDEOS = BASE / "recollection-2026-06-09" / "attempt-10-public-profile-extract" / "public-profile-videos.json"
+PUBLIC_PROFILE_NEW_VIDEOS = BASE / "recollection-2026-06-09" / "attempt-10-public-profile-extract" / "public-profile-new-videos.json"
+OUT_JSON = TRANSCRIPT_DIR / "transcript-analysis-data.json"
+OUT_MD = TRANSCRIPT_DIR / "善存抖音逐字稿话术审核分析.md"
+
+
+def clean(value):
+    return re.sub(r"\s+", " ", str(value or "")).strip()
+
+
+def load_json(path):
+    return json.loads(path.read_text(encoding="utf-8"))
+
+
+def chinese_count(text):
+    return len(re.findall(r"[\u4e00-\u9fff]", text or ""))
+
+
+def snippet(text, length=120):
+    text = clean(text)
+    return text if len(text) <= length else text[: length - 1] + "…"
+
+
+NORMALIZE_REPLACEMENTS = [
+    ("阴阳包", "营养包"),
+    ("30家", "30+"),
+    ("40家", "40+"),
+    ("女40", "女40+"),
+    ("男40", "男40+"),
+    ("富维", "复维"),
+]
+
+
+def normalize_asr(text):
+    text = clean(text)
+    for old, new in NORMALIZE_REPLACEMENTS:
+        text = text.replace(old, new)
+    return text
+
+
+RULE_PATTERNS = [
+    ("RP01", "绝对化/一粒补全/闭眼入", r"闭眼入|就够了|一包全部|一粒.*(就够|补齐|补充到|满足)|全营养|全部.*营养|需要的.*都有|都补充"),
+    ("RP02", "疾病/治疗/特殊健康时期", r"甲流|感冒|治疗|治好|预防|医生|肿瘤|病"),
+    ("RP03", "未经确认功效/状态承诺", r"精力|脑力|大脑|气血|代谢|吃瘦|瘦|焕活|状态|活力|疲惫|亏空|底子|皮肤状态|气色|睡眠|抗炎"),
+    ("RP04", "数字成分推导功效", r"\d+\s*(种|倍|个月|粒|大|天|年)|26种|22种|15倍|40多年|120粒|240粒"),
+    ("RP05", "海外/跨境/版本边界", r"海外|美国|澳洲|跨境|进口|本土版|Costco|版本|国内"),
+    ("RP06", "正品/效期/包装信任", r"正品|喷码|批号|效期|包装|保障|大品牌|国际品牌|官方"),
+    ("RP07", "用法用量/特殊人群", r"每天|一粒|一包|孕妇|儿童|孩子|老人|长辈|爸妈|男士|女士|30\+|40\+|50\+|适合|怎么吃"),
+    ("RP11", "竞品比较/排名/红黑榜", r"排行榜|排名|只认|不服|红黑榜|智商税|最差|不要买|别乱买|不如|单一的维生素"),
+    ("RP13", "表达强度过高", r"满格|全开|必须|强烈推荐|真的绝|离不开|快冲|不行|太懂|稳稳当当|直接爱上"),
+    ("RP14", "证据/专业身份背书不足", r"营养师|医生|研究|实测|专业|干货|建议|国际大品牌|40多年"),
+    ("RP18", "促销/价格/活动", r"直播间|下单|活动|大促|买一送一|买1送1|拍一发|送|价格|降价|满赠|福利|红包|冲"),
+    ("RP19", "明星/达人/身份背书", r"秋瓷炫|王艳|百克力|李维嘉|颖儿|明星|达人|他们都在吃|在吃"),
+    ("RP21", "购买引导/CTA", r"快来|赶紧|下单|直播间|拍|冲|购买|领券|到手价|链接"),
+    ("RP22", "情绪/趋势钩子", r"焦虑|白月光|趋势|梗王|小妙招|别乱吃|不花冤枉钱|热点|精致女人"),
+]
+
+
+PUBLIC_TITLE_RISK_PATTERNS = [
+    ("RP01", "一粒/一瓶解决感", r"一瓶顶多瓶|每天一粒|每日1粒|每日一片|从\s*1\s*粒开始|一粒就够|每天一片|就够了"),
+    ("RP03", "状态/功效感标题", r"精力|状态|活力|疲惫|少疲惫|亚健康|调理|自救|熬夜|脆皮|补足|营养缺口|内在保养|状态密码|拉满活力"),
+    ("RP04", "数字成分推导", r"\d+\s*(种|粒|片|岁)|24种|18-50|一瓶|多瓶"),
+    ("RP05", "海外/跨境版本边界", r"海外|VCHOICE|跨境|进口|版本"),
+    ("RP06", "正品/渠道信任", r"正品|直发|官方|旗舰店|专卖店|店铺"),
+    ("RP07", "人群/用法用量", r"男士|男性|老公|男友|打工人|健身党|职场|中年|年轻人|每日|每天|随餐吃"),
+    ("RP13", "表达强度过高", r"超炸|拉满|硬扛|精准补|实力派|必入|必看|错过再等"),
+    ("RP18", "促销/价格/活动", r"618|福利|活动|打折|抢跑|羊毛|直播间|链接|赠|大促"),
+    ("RP21", "强购买 CTA", r"必看|必入|看过来|速来|抓紧|错过再等|薅羊毛"),
+]
+
+
+MODULE_PATTERNS = [
+    ("明星/达人背书", r"秋瓷炫|王艳|百克力|李维嘉|颖儿|他们都在吃|达人|明星"),
+    ("年龄/人群场景", r"30\+|40\+|50\+|女生|女性|男士|中老年|孕妇|儿童|长辈|爸妈|上班族|当妈"),
+    ("功效/状态表达", r"精力|脑力|大脑|气血|代谢|吃瘦|焕活|状态|活力|气色|疲惫|亏空|底子|抗炎"),
+    ("成分/剂型说明", r"维生素|矿物质|维矿|叶酸|烟酰胺|生物素|钙|铁|锌|硒|黄芪|大枣|儿茶素|胶原蛋白|谷胱甘肽|颗|粒|包"),
+    ("一包/一粒解决感", r"一包|一粒|就够|都有|全部|补齐|满足|不费心|不用搭配|瓶瓶罐罐"),
+    ("版本/渠道信任", r"海外|跨境|美国|澳洲|进口|本土版|官方|正品|版本|大品牌|国际"),
+    ("促销/购买引导", r"直播间|下单|活动|买|送|拍|价格|福利|满赠|快冲|领券|到手价"),
+    ("比较/榜单/避坑", r"排行榜|排名|只认|红黑榜|智商税|不要买|别乱买|不花冤枉钱|最差"),
+]
+
+
+REWRITE_TEMPLATES = {
+    "明星/达人背书": "达人分享仅代表个人体验;产品信息以包装、标签和官方说明为准。",
+    "年龄/人群场景": "不同年龄/人群适用产品不同,请按具体 SKU、包装标签和自身需求选择。",
+    "功效/状态表达": "改为日常营养支持、均衡饮食和规律作息场景,不承诺精力、气血、代谢、焕活等结果。",
+    "成分/剂型说明": "可保留成分数量和剂型信息,但避免推导为全部所需、明显改善或特定健康结果。",
+    "一包/一粒解决感": "改为“按标签建议作为日常营养补充”,避免“一包解决/一粒补全”。",
+    "版本/渠道信任": "不同版本包装、成分和适用说明可能不同,请以购买页、实物标签和客服说明为准。",
+    "促销/购买引导": "活动、价格、赠品和库存以当前页面为准,CTA 不与功效承诺绑定。",
+    "比较/榜单/避坑": "避免排名、红黑榜和竞品贬损,改为中性选购维度:成分、剂型、标签、预算、适用人群。",
+}
+
+
+FULL_RULE_NAMES = {
+    "RP01": "绝对化 / 保证性 / 最高级表达",
+    "RP02": "疾病治疗 / 治愈 / 诊断暗示",
+    "RP03": "未经确认功效 / 状态承诺",
+    "RP04": "效果数据 / 成分数字推导 / 前后对比",
+    "RP05": "产品法规属性 / 跨境版本边界",
+    "RP06": "标签一致性 / 用法用量 / 包装信息",
+    "RP07": "特殊人群 / 用法用量 / 适用范围",
+    "RP08": "政府/监管/官方背书暗示",
+    "RP09": "恐惧营销 / 歧视弱势人群 / 制造焦虑",
+    "RP10": "第三方素材 / 肖像 / IP 未授权",
+    "RP11": "竞品贬损 / 排名 / 红黑榜",
+    "RP12": "隐私泄露 / 订单与评论截图",
+    "RP13": "表达强度过高 / 结果确定性",
+    "RP14": "研究证据 / 专业身份背书不足",
+    "RP15": "孕妇儿童老人等特殊人群强推荐",
+    "RP16": "平台禁用词 / 谐音规避",
+    "RP17": "口播字幕画面不一致 / A 产品 B 卖点",
+    "RP18": "促销 / 价格 / 活动 / 库存表达",
+    "RP19": "明星达人专家身份背书",
+    "RP20": "低俗擦边 / 品牌安全 / 公序良俗",
+    "RP21": "购买压力 / 强 CTA / 情绪化催单",
+    "RP22": "公共事件 / 灾难疾病恐慌 / 极端钩子",
+    "RP23": "平台反馈原因不明 / 候选风险",
+    "RP24": "待确认监管新规 / 候选风险",
+    "RP25": "历史投诉高发模式 / 候选风险",
+    "RP26": "AI 合成肖像 / 声音 / 商业使用",
+    "RP27": "新兴平台风险 / DP 反馈待确认",
+    "RP28": "Out of Scope / 自动审批发布等非本轮范围",
+}
+
+
+LEGAL_REFERENCES = [
+    {
+        "name": "《中华人民共和国广告法》",
+        "url": "https://www.gov.cn/guoqing/2021-10/29/content_5647620.htm",
+        "use": "广告真实合法、绝对化用语、疾病治疗功能、保健食品广告限制、广告代言与审查底线。",
+    },
+    {
+        "name": "《中华人民共和国食品安全法》",
+        "url": "https://www.nhc.gov.cn/fzs/c100048/201808/31b2202291464a5fb9e1f64bebc4d877.shtml",
+        "use": "保健食品标签说明书、适宜/不适宜人群、功效成分、广告审查批准和“不能代替药物”。",
+    },
+    {
+        "name": "《药品、医疗器械、保健食品、特殊医学用途配方食品广告审查管理暂行办法》",
+        "url": "https://www.gov.cn/gongbao/content/2020/content_5488918.htm",
+        "use": "三品一械广告发布前审查、保健食品广告内容以注册证书/备案凭证和说明书为准、审查通过内容一致性。",
+    },
+    {
+        "name": "市场监管总局 2026 年三品一械广告审查管理办法征求意见稿",
+        "url": "https://www.samr.gov.cn/hd/zjdc/art/2026/art_2a99498546434468b1fada4203fdc7ce.html",
+        "use": "提示三品一械广告审查口径仍在更新,正式定稿需以最新监管文件和法务确认版本为准。",
+    },
+    {
+        "name": "《互联网广告管理办法》",
+        "url": "https://www.gov.cn/zhengce/202305/content_6858084.htm",
+        "use": "用于互联网广告可识别性、变相广告、依法需审查广告发布前审查和达人电商场景的边界判断。",
+    },
+    {
+        "name": "GB 28050《预包装食品营养标签通则》(2011/2025 衔接口径)",
+        "url": "https://www.nhc.gov.cn/sps/c100087/202509/470fa4ff5de14dd38619223cce9da4e7.shtml",
+        "use": "营养标签、营养声称和营养成分功能/作用声称需匹配标准用语;2025 版实施前后需由法务确认适用版本。",
+    },
+]
+
+
+SPEECH_SYSTEM_ROWS = [
+    [
+        "功效描述",
+        "精力满格、气血变好、代谢提升、吃了变瘦、皮肤状态变好",
+        "作为日常营养补充;配合均衡饮食和规律作息;具体感受因人而异",
+        "《广告法》第十八条禁止保健食品广告作功效、安全性断言或保证;超出批文/备案范围的功能 claim 需截停",
+        "营养包样本里“状态/代谢/气血”多为边界表达,达人 brief 应弱化为生活场景。",
+    ],
+    [
+        "营养成分功能声称",
+        "某成分促进/修复/激活身体状态,或把国标语改成更强同义词",
+        "仅在符合标签与标准条件时使用 GB 28050 对应标准用语原文",
+        "GB 28050 对营养声称、营养成分功能/作用声称有标准用语和条件要求;不得自行放大",
+        "“26 种/22 种”可作为标签事实,不能接成“一粒补全/全部所需”。",
+    ],
+    [
+        "数字推导",
+        "一粒等于很多瓶、一次补齐、一天营养都够、含量翻倍吸收",
+        "每份/每粒/每包含有若干营养素;以商品页、包装标签和检测/备案资料为准",
+        "《广告法》第十一条要求广告中数据真实、准确并表明出处;数字不得推导不可证功效",
+        "多维 SKU 保留成分数量,但删除“满足全部/闭眼入/就够了”。",
+    ],
+    [
+        "适用人群",
+        "大人小孩都能吃、所有女生都需要、爸妈必须囤、孕妇老人也适合",
+        "适用人群、用法用量以具体 SKU 标签、说明书和客服说明为准",
+        "保健食品广告内容不得超出注册证书/备案凭证、说明书和标签范围",
+        "30+/40+/50+ 是选购入口,不是全人群适用承诺。",
+    ],
+    [
+        "疾病/特殊健康时期",
+        "甲流、感冒、肿瘤、术后、治疗、预防、改善症状",
+        "一般营养补充信息;特殊健康状况请咨询专业人士",
+        "《广告法》第十七条、第十八条禁止食品/保健食品涉及疾病预防、治疗功能",
+        "已发布疾病词只作为撞审边界记录,不进入可复制话术库。",
+    ],
+    [
+        "专业/明星背书",
+        "医生推荐、营养师强推、明星都在吃所以适合你",
+        "达人/明星分享仅代表个人体验;产品信息以包装标签、商品页和官方说明为准",
+        "保健食品广告不得利用广告代言人作推荐、证明,医务人员身份风险更高",
+        "秋瓷炫、颖儿、李维嘉等样本可看传播结构,不直接复制背书逻辑。",
+    ],
+    [
+        "促销 CTA",
+        "赶紧冲、手慢无、再不买就亏、下单解决状态问题",
+        "活动、赠品、价格和库存以当前页面为准;按需选择",
+        "互联网广告与平台虚假宣传规则要求价格、库存、优惠真实,不制造虚假紧迫感",
+        "官方店铺可承接活动规则,达人强 CTA 标为边界样本。",
+    ],
+    [
+        "跨境/版本",
+        "海外版更有效、国内外通用功效、进口所以更适合",
+        "不同版本包装、成分、标签、售后和适用说明可能不同,请以商品页和实物标签为准",
+        "跨境商品不能自动套用国内蓝帽、保健食品批文或国内标签功能口径",
+        "善存海外/Costco/澳洲版样本重点做版本信任和客服承接。",
+    ],
+]
+
+
+CORE_JUDGMENT_MATRIX_ROWS = [
+    ["备案/批文内保健功能", "默认红灯,资料齐全后可转绿", "可说,需蓝帽/备案/广审/提示语一致", "不可代言推荐", "产品批文、说明书、广告审查内容、商品页和口播一致", "需批文/广审核验后复制"],
+    ["营养成分功能声称", "黄灯", "可有限使用标准用语", "只能做中性科普,避免品牌推荐", "匹配 GB 28050 与产品标签;不得同义放大", "需标签/法务核验"],
+    ["成分/规格事实", "绿灯基础层", "可说", "可说", "数字、规格、剂型可在包装或商品页核验", "高可复制"],
+    ["个人食用体验", "黄灯灰区", "可说非功效体验", "严格限制", "仅限口感、包装、携带、便利性;不得涉及身体变化", "达人需弱化"],
+    ["状态/功效感", "红灯或高边界", "仅在批文/广审支持时可控表达", "不可直接说", "精力、气血、代谢、睡眠、皮肤等需逐条核验", "边界样本"],
+    ["疾病预防/治疗", "红灯截停", "不可说", "不可说", "含疾病、诊断、治疗、预防、症状改善即截停", "不可复制"],
+    ["促销/购买引导", "黄灯", "可说真实活动", "弱 CTA", "活动信息与页面一致,且不绑定功效承诺", "官方可复制,达人慎用"],
+]
+
+
+COMPLIANCE_PRINCIPLE_ROWS = [
+    {
+        "title": "原则1|强制提示语原则",
+        "content": "凡涉及保健食品广告/推广,应显著提示“保健食品不是药物,不能代替药物治疗疾病”。提示语不能抵消违法功效、疾病治疗或达人代言推荐风险。",
+        "check": "发布前核验标题、口播、字幕、画面、商品卡、落地页和评论区是否都没有把保健食品当药品表达。",
+        "evidence": "当前善存稿需客户补齐具体 SKU 的蓝帽/备案范围、广审批件与页面提示语位置。",
+    },
+    {
+        "title": "原则2|标签一致原则",
+        "content": "所有成分数量、适用人群、用法用量、版本差异、营养声称和功能声称都必须回到具体 SKU 标签、说明书、商品页和批文。",
+        "check": "同一视频不得出现口播 A SKU、画面 B SKU、商品卡 C SKU、评论区 D 功效的混配。",
+        "evidence": "善存营养包、多维、海外版、50+、小佳维等不能互相套用人群和功能口径。",
+    },
+    {
+        "title": "原则3|资质匹配原则",
+        "content": "同一句话在官方/店铺账号、达人账号、医生/营养师账号、分销/跨境账号下的审核上限不同;达人不能继承品牌官方的广告审查能力。",
+        "check": "先判断账号身份,再判断产品属性,再判断 claim 层级;没有账号与产品资质证据时,不把已发布样本写成绿灯。",
+        "evidence": "本轮用 `店铺与账号.xlsx` 匹配官方/店铺账号,未命中清单的店铺型账号仍标为待核验。",
+    },
+]
+
+
+ACCOUNT_CLAIM_STRATEGY_ROWS = [
+    ["官方/店铺账号", "Level 0-1 生活/成分事实", "直接可用", "用标签、商品页、客服和页面活动承接"],
+    ["官方/店铺账号", "Level 2 营养成分功能声称", "按标准用语谨慎使用", "需 GB 28050、标签和法务口径逐字校验"],
+    ["官方/店铺账号", "Level 3 备案内保健功能", "资料齐全后可用", "需蓝帽/备案/广审/提示语/页面一致"],
+    ["官方/店铺账号", "Level 4-5 备案外功能/疾病", "截停或改写", "改为日常营养支持、标签事实或专业咨询"],
+    ["达人/内容账号", "Level 0-0.5 生活/体验", "可用但弱化", "只说个人习惯、便利性、口感、场景,不说身体变化"],
+    ["达人/内容账号", "Level 1 成分事实", "可做中性选购", "不推荐具体功效,不强 CTA,不替代官方说明"],
+    ["达人/内容账号", "Level 2-3 功能声称", "高风险", "一般不建议说品牌功能;转为标签阅读或客服入口"],
+    ["医生/营养师账号", "任何品牌推荐", "高风险", "科普与产品推广分离,不用专业身份证明具体 SKU"],
+]
+
+
+PRODUCT_PERSON_STRATEGY_ROWS = [
+    ["善存营养包/PRO", "30+/忙碌/熬夜/身材管理人群", "省心搭配、一包便利、日常补充", "代谢、吃瘦、气血、皮肤变好", "弱化为生活场景和按标签补充"],
+    ["善存复合维生素/多维", "成人男女版", "成分数量、剂型、规格、每日习惯", "一粒补全、满足全部所需", "成分事实 + 标签回流"],
+    ["善存女性/男士/50+", "分龄分性别人群", "标签适用范围、版本差异", "所有女生/男士都适合、爸妈必须吃", "按具体 SKU 人群说明,不跨 SKU 套用"],
+    ["海外/跨境版本", "海淘/正品/版本关注人群", "包装、批号、效期、渠道、客服", "海外更有效、国内外通用功能", "版本事实 + 售后承接"],
+    ["小佳维/儿童相关", "儿童/家长", "儿童 SKU 标签和用量说明", "儿童都能吃、长高变聪明", "特殊人群只走标签与专业咨询"],
+    ["银善存/大脑类素材", "40+/50+/长辈", "画面隐喻、生活任务、标签信息", "改善记忆、提升脑力、治疗认知问题", "备案内才可直接说,其他转隐喻/场景"],
+]
+
+
+SELLING_POINT_REWRITE_ROWS = [
+    ["状态/精力/气血", "精力满格、气血满格、状态全开", "日常营养支持;配合规律作息;保持好状态的生活习惯之一", "《广告法》第十八条", "达人、官方都需慎用"],
+    ["代谢/身材", "代谢包、吃瘦、身材管理靠它", "作为日常营养补充选择之一,不替代饮食管理和运动", "不得暗示减肥/治疗/确定改善", "达人强风险"],
+    ["成分数量", "一粒补充 26 种,全部都够", "含多种维生素和矿物质,具体成分以包装标签为准", "广告数据真实准确;GB 28050 标签口径", "全账号可用,但不得推导"],
+    ["一包省心", "一包解决所有营养、懒人闭眼入", "独立小包/组合搭配,便于按标签建议补充", "绝对化/保证性表达风险", "营养包核心可复制卖点"],
+    ["明星/达人", "明星都在吃,你也快冲", "达人分享仅代表个人体验,产品信息以官方说明为准", "保健食品广告代言限制", "达人/明星样本需授权复核"],
+    ["跨境/正品", "海外版更好,进口更有效", "不同版本包装、成分和售后政策可能不同,以商品页和实物标签为准", "版本与资质不得混用", "跨境 SKU 必备"],
+    ["促销活动", "手慢无、再不买就没了", "当前活动以页面展示为准,按需选择", "虚假紧迫感/价格库存真实性", "官方可用,达人弱化"],
+    ["疾病词", "甲流/感冒/肿瘤/治疗/预防", "特殊健康状况请咨询专业人士;本内容仅作一般营养信息", "《广告法》第十七条、第十八条", "不可复制"],
+]
+
+
+AI_FORMULA_GROUPS = [
+    {
+        "title": "公式组A:话术改写类(可直接编码为规则引擎)",
+        "items": [
+            "A1 功效词替换:IF 文本含精力/气血/代谢/皮肤状态/睡眠改善 THEN 改为日常营养支持 + 个体差异 + 标签依据。",
+            "A2 疾病词截停:IF 文本含疾病名/治疗/预防/诊断/症状改善 THEN 标为红灯,不进入可复制话术。",
+            "A3 数字推导降级:IF 数字后接补全/翻倍/满足全部/相当于 THEN 保留标签数字,删除效果推导。",
+            "A4 人群泛化回流:IF 文本含全家/所有人/爸妈都适合/儿童孕妇 THEN 回到具体 SKU 标签和专业咨询。",
+            "A5 GB 28050 校验:IF 文本含营养成分功能/作用声称 THEN 校验是否与适用标准用语和标签条件一致。",
+        ],
+    },
+    {
+        "title": "公式组B:账号差异化规则",
+        "items": [
+            "B1 官方/店铺账号 = 产品资质 + 广审范围 + 商品页一致 + 强制提示语;缺任一项则降为待核验。",
+            "B2 达人账号 = 个人体验 + 生活场景 + 成分事实 + 弱 CTA;不得承接官方备案功能或代言证明。",
+            "B3 医生/营养师账号 = 科普与具体 SKU 分离;不得用专业身份推荐善存某 SKU。",
+            "B4 跨境/分销账号 = 版本事实 + 渠道承接;不得套用国内蓝帽或官方旗舰店能力。",
+        ],
+    },
+    {
+        "title": "公式组C:内容架构规则",
+        "items": [
+            "C1 稳妥脚本结构 = 生活场景 Hook + 成分/规格事实 + 标签/商品页承接 + 弱购买入口。",
+            "C2 边界脚本结构 = 强情绪 Hook + 状态隐喻 + 明星/达人场景 + 促销承接;必须逐条审核。",
+            "C3 评论承接结构 = 置顶 FAQ + 功效追问统一回复 + 版本/正品/用量分流客服。",
+            "C4 多模态一致性 = 标题、口播、字幕、画面、商品卡、评论区不得互相补违规 claim。",
+        ],
+    },
+    {
+        "title": "公式组D:宣称层次自动判定",
+        "items": [
+            "D0 生活场景:只含日常习惯/携带/包装/口感,默认绿灯基础层。",
+            "D1 成分规格:含成分名、数量、剂型、包装规格,需标签可核验。",
+            "D2 标准声称:含营养成分功能/作用声称,需 GB 28050 与标签条件校验。",
+            "D3 备案内保健功能:仅官方/店铺在批文和广审一致时可表达。",
+            "D4 备案外功效感:精力、气血、代谢、脑力、睡眠、抗炎等默认红灯或边界。",
+            "D5 疾病治疗:疾病、治疗、预防、诊断、症状改善,一律截停。",
+        ],
+    },
+    {
+        "title": "公式组E:三维决策矩阵(完整版)",
+        "items": [
+            "E1 账号维度:官方/店铺、达人、医生/营养师、分销/跨境分别判定上限。",
+            "E2 产品维度:保健食品、普通食品、跨境版本、OTC/药品先分流。",
+            "E3 Claim 维度:生活场景、成分事实、标准声称、备案内功能、备案外功能、疾病治疗分层。",
+            "E4 承载维度:口播、字幕、画面、商品卡、评论区、直播间任一处补违规 claim 都要回流审核。",
+            "E5 复用结论:高可复制、需资料核验、边界样本、不可复制四类输出到黄金数据集和 Excel。",
+        ],
+    },
+]
+
+
+SELF_CHECK_ROWS = [
+    ["1", "是否出现疾病名、症状、治疗、预防、诊断暗示?", "出现即红灯,改为一般营养信息或专业咨询。"],
+    ["2", "是否把精力、气血、代谢、脑力、睡眠、皮肤状态说成确定结果?", "降级为生活场景和日常营养支持。"],
+    ["3", "是否有“一粒/一包就够、全部补齐、闭眼入”等绝对化表达?", "保留规格事实,删除补全和保证性结论。"],
+    ["4", "营养成分功能/作用声称是否匹配 GB 28050 与产品标签条件?", "不匹配则改为成分事实或交法务确认。"],
+    ["5", "适用人群是否回到具体 SKU 标签?", "删除全家都能吃、所有女生/男士都适合等泛化话术。"],
+    ["6", "账号是否具备对应发布资质?", "官方/店铺、达人、医生/营养师、分销/跨境分开判定。"],
+    ["7", "官方功能 claim 是否具备蓝帽/备案/广审/强制提示语/页面一致?", "缺资料则标为需批文/广审核验后复制。"],
+    ["8", "达人内容是否在推荐、证明产品功效?", "改成个人生活习惯和非功效体验,不强导购。"],
+    ["9", "明星、医生、营养师、研究证据是否可核验并适用于该 SKU?", "无法补证时删除背书,只保留一般信息。"],
+    ["10", "海外/跨境/进口版本是否套用了国内蓝帽或其他 SKU 口径?", "改为版本、包装、效期、售后和商品页说明。"],
+    ["11", "价格、赠品、库存、限时活动是否与页面实时一致?", "不确定则写“以当前页面为准”,不制造紧迫感。"],
+    ["12", "标题、口播、字幕、画面、商品卡和评论区是否一致?", "发现 A 产品 B 卖点 C 功效时回炉修改。"],
+    ["13", "评论区是否追加了口播里没说的功效承诺?", "统一用 FAQ/客服分流,不在评论区补火。"],
+    ["14", "是否有红黑榜、智商税、竞品贬损或排名压制?", "改为中性选购维度和自身标签事实。"],
+    ["15", "是否已给每条样本标注复用结论?", "输出高可复制、需资料核验、边界样本、不可复制。"],
+]
+
+
+PRODUCT_TYPE_BOUNDARY_ROWS = [
+    ["保健食品/蓝帽", "国食健字/国食健注、备案或注册凭证", "仅限批准或备案范围内;需强制提示语和广审一致性", "官方/店铺账号可在资料齐全时表达;达人不可代言推荐。"],
+    ["普通食品", "SC 生产许可、普通食品标签", "不能使用保健功能、疾病改善、治疗预防等表达", "只能讲配料、口味、食用场景、包装和一般生活方式。"],
+    ["OTC/药品", "国药准字", "不纳入普通达人带货话术;需按药品广告与平台规则单独审查", "本报告仅作为排除项,不把药品话术迁移到善存营养包。"],
+    ["跨境/海外版", "海外标签、跨境商品信息、无国内蓝帽时需标版本差异", "不能套用国内蓝帽和国内保健功能口径", "只能讲版本、包装、渠道、标签差异和客服承接。"],
+]
+
+
+ACCOUNT_TRACK_ROWS = [
+    ["蓝V/官方/店铺账号", "企业认证、店铺归属清晰、产品资质和广审资料可核验", "商品事实、备案内保健功能、价格活动、客服承接", "超出备案范围、跨 SKU 套用、绝对化、疾病治疗、虚假紧迫感"],
+    ["蓝V海外/跨境店铺", "跨境资质、版本标签、售后和商品页清晰", "海外标签事实、版本差异、包装规格、购买渠道", "把海外版等同国内蓝帽;暗示更有效或国内外通用功效"],
+    ["达人/KOL账号", "不承接保健食品广告发布主体资格", "生活场景、成分事实、中性选购、口感/包装/便利性体验", "功效推荐、身体变化、强购买引导、代言式证明"],
+    ["医生/营养师/专家达人", "身份会放大医疗建议和权威背书风险", "一般科普、标签阅读方法、非品牌化知识", "推荐具体品牌/SKU、医疗机构背书、跨专业建议、诊疗暗示"],
+]
+
+
+CLAIM_LEVEL_ROWS = [
+    ["Level 0", "生活场景", "我日常会按标签补充;出门携带方便", "绿灯基础层", "一旦延伸到身体变化就升级为功效风险。"],
+    ["Level 0.5", "食用/使用体验", "颗粒大小、口感、包装、吞咽便利", "达人灰区", "只能讲口感和便利性,不讲精力、气色、睡眠、疼痛等身体变化。"],
+    ["Level 1", "成分/规格事实", "含多种维生素矿物质;每包含 X 类营养素", "可复制", "数字可说为标签事实,不能推导为补全、改善或替代饮食。"],
+    ["Level 2", "营养成分功能声称", "某营养素有助于某生理功能", "需国标/标签原文", "必须以适用国标、标签和法务确认口径为准,不自行改写放大。"],
+    ["Level 3", "备案内保健功能", "已备案或广审范围内的保健功能", "仅官方/店铺资料齐全时可说", "达人不能以个人身份推荐或证明。"],
+    ["Level 4", "备案外保健功能", "改善记忆力、焕活、代谢提升等超范围 claim", "红灯", "任何账号都不应直接说;只可观察隐喻边界。"],
+    ["Level 5", "疾病预防/治疗", "甲流、感冒、治疗、预防、诊断、疼痛改善", "红灯截停", "即使已发布也只记录为撞审样本,不作为可复制经验。"],
+]
+
+
+BOUNDARY_TECHNIQUE_ROWS = [
+    ["概念替换", "疾病/医疗词 -> 营养学/健康维护词", "甲流/感冒 -> 换季健康管理;治疗/改善 -> 日常营养支持", "中低", "善存样本中疾病词较少,后续需补采专题验证。"],
+    ["比喻暗示", "功效不写入口播,转为画面或角色隐喻", "银善存大脑球场、守门员/前锋", "中", "只能作为边界样本,不能把隐喻翻译成直接功效。"],
+    ["权威归因拆解", "荣誉归科学发现,不归产品效果", "国际品牌/40多年历史只能承载品牌信任,不能替代功效证据", "中", "善存样本多用“大品牌/国际品牌/40多年”,需要补证或回到品牌事实。"],
+    ["成分事实", "只说标签可核验内容,不说产品做到什么", "26种/22种/一包/一粒作为规格事实", "低", "善存多维和营养包样本命中最多,可复制价值高。"],
+    ["专利/技术事实", "可以说有技术或配方事实,不能说技术带来效果倍数", "升级配方、重点复配、男女版配比只讲事实", "中", "“配比/升级”不能接成“更有效/更适合所有人”。"],
+    ["个人体验叙事", "第三人称功效声明 -> 第一人称生活习惯 + 免责", "我日常会备/按标签补充/方便坚持", "中", "达人可以用,但不能讲身体变化或引导购买。"],
+    ["标签回流", "人群/用量/版本一律回到标签、商品页、客服", "30+/40+/50+、男女版、海外版", "低", "适合做置顶评论和客服 FAQ。"],
+    ["身份脱敏", "专家/医生/明星不作为功效证明", "达人分享仅代表个人体验", "中", "医生专家达人仍属高风险,建议品牌化内容与科普内容分离。"],
+    ["短脚本冲量", "短促销脚本保留强 Hook", "快冲、手慢无、直播间下单", "高", "可以解释已发布样本,但不建议沉淀为稳定话术。"],
+    ["隐性比较", "不点名竞品,只讲自身维度", "不同产品各有特点,按成分/剂型/预算选择", "中", "避免红黑榜、智商税、别买某品牌。"],
+]
+
+
+SYSTEM_FUNCTION_ROWS = [
+    ["资料接入", "读取 Rule Pack、客户理想稿、官方账号清单、产品批文/标签/广审资料", "形成项目规则底座和账号白名单,不靠模型空猜。"],
+    ["抖音采集", "按关键词、账号、SKU、claim 缺口采集视频和评论", "区分官方/达人/分销账号,保留已发布成功案例证据。"],
+    ["逐字稿转写", "优先转高风险、高互动、官方账号、边界 claim 样本", "把标题/标签风险推进到真实口播、字幕和评论诱因分析。"],
+    ["规则命中", "逐条命中 RP、宣称层次、账号类型、产品属性、复用判断", "输出黄金边界数据集,而不是单条改写建议。"],
+    ["脚本建议", "按账号和 claim 生成可复制、需核验、边界样本、不可复制四类动作", "同一卖点在蓝V和达人账号下自动给不同上限。"],
+    ["评论承接", "识别功效追问、版本信任、购买路径、用法人群、体验副作用", "同步生成置顶评论、客服 FAQ 和禁回复口径。"],
+    ["复盘闭环", "记录采集失败、转写失败、未命中 RP 和补采方向", "每轮报告都能说明样本覆盖与下一轮数据缺口。"],
+]
+
+
+STRATEGY_FORMULA_ROWS = [
+    {
+        "formula": "官方/店铺账号 × 善存营养包/PRO × 省心搭配 × 30+/忙碌人群 × 日常营养支持 × 商品页/标签承接 × 弱 CTA",
+        "condition": "账号归属清晰,产品标签、商品页、字幕和口播一致。",
+        "experience": "可把“一包/多种营养/方便坚持”作为便利性卖点,但不要推导成“全部都补齐、状态一定变好”。",
+        "reuse": "高可复制;适合官方 brief 和店铺短视频。",
+    },
+    {
+        "formula": "达人账号 × 善存营养包/PRO × 一包省心 × 护肤/身材/工作状态 × 状态感 claim × 个人体验 × 无强 CTA",
+        "condition": "达人只做生活化分享,不承接官方广告备案能力。",
+        "experience": "把卖点落在“我日常会备、懒人省心、按标签补充”,避免“吃瘦、气血好、皮肤变好、快冲下单”。",
+        "reuse": "可复制但需弱化;适合达人 brief。",
+    },
+    {
+        "formula": "官方/店铺账号 × 善存复合维生素/多维 × 26/22 种成分 × 成人男女版 × 泛营养 claim × 成分事实 × 商品页承接",
+        "condition": "成分数量、规格、适用说明能在包装或商品页核验。",
+        "experience": "数字可以说成“含有”,不要说成“一粒满足全部所需、一次补全、闭眼入”。",
+        "reuse": "高可复制;适合形成标准商品信息话术。",
+    },
+    {
+        "formula": "达人账号 × 善存复合维生素/多维 × 每天一粒 × 成人男女版 × 便利性 claim × 个人习惯 × 低购买压力",
+        "condition": "达人表达为个人选择,不做全人群适用判断。",
+        "experience": "可说“我会看复配、品牌、颗粒大小、性价比”,再回到“按标签选择”;不要说所有女生/男生都需要。",
+        "reuse": "可复制;注意删除绝对化和强导购。",
+    },
+    {
+        "formula": "官方/店铺账号 × 海外/跨境 SKU × 正品/版本 × 海淘用户 × 信任 claim × 包装/批号/效期 × 客服承接",
+        "condition": "跨境版本、商品资质、售后入口明确。",
+        "experience": "海外/进口/本土版只承载渠道和版本事实,不承载“更有效、更适合国内人群”。",
+        "reuse": "高可复制;适合置顶评论和商品卡 FAQ。",
+    },
+    {
+        "formula": "达人/分销账号 × 海外/Costco/澳洲版 × 版本比较 × 成人/50+ × 功效差异 claim × 选购经验 × 中性比较",
+        "condition": "账号非官方或授权未核验时,版本信息只能做经验观察。",
+        "experience": "可说包装、规格、购买场景和个人偏好;不要把不同版本差异说成功效优劣或国内外通用保健功能。",
+        "reuse": "边界经验;需要人工复核。",
+    },
+    {
+        "formula": "官方/店铺账号 × 银善存/50+ × 大脑/脑力素材 × 中老年/爸妈 × 备案内功能 claim × 官方素材 × 审查一致",
+        "condition": "必须核验产品批文、说明书、广告审查批件和画面素材。",
+        "experience": "若 claim 在备案和广审范围内,可相对直接;若不在范围内,只能转为营养支持、生活场景或画面隐喻。",
+        "reuse": "需批文/广审核验后复制。",
+    },
+    {
+        "formula": "达人账号 × 银善存/大脑类 × 守门员/前锋/球场隐喻 × 40+/脑力场景 × 大脑表现 claim × 画面比喻 × 无医学承诺",
+        "condition": "直接说“改善记忆/提升脑力/治疗认知问题”风险高。",
+        "experience": "已发布样本的边界价值在于用角色、运动、任务感承载“状态感”,不把隐喻翻译成医学或功能承诺。",
+        "reuse": "高边界;逐条审核后才可复用。",
+    },
+    {
+        "formula": "达人/明星账号 × 善存营养包 × 明星在吃 × 粉丝/女性 × 效果背书 claim × 场景分享 × 非功效背书",
+        "condition": "明星达人内容容易被用户理解为功效或适用性背书。",
+        "experience": "可保留生活场景和个人体验,不把“他们都在吃”写成“所以你也适合/一定有效”。",
+        "reuse": "边界样本;必须补授权和背书合规判断。",
+    },
+    {
+        "formula": "官方/店铺账号 × 明星素材 × 多 SKU × 品牌信任 × 背书 claim × 官方授权素材 × 商品页一致",
+        "condition": "素材授权、代言关系、广告审查内容和商品链接需要一致。",
+        "experience": "官方账号可以承接品牌信任,但明星素材仍不能替代产品功效证据。",
+        "reuse": "需素材授权和法务确认。",
+    },
+    {
+        "formula": "任意账号 × 代谢包/身材管理 × 代谢/吃瘦 × 女性/管理期 × 减重结果 claim × 生活方式组合 × 无结果承诺",
+        "condition": "代谢、吃瘦、身材管理容易被理解为减肥或治疗效果。",
+        "experience": "把表达转成日常营养补充、均衡饮食和运动作息的一部分,不说“代谢变快、吃了瘦”。",
+        "reuse": "不可直接复制;需大幅弱化。",
+    },
+    {
+        "formula": "官方/店铺账号 × 直播间活动 × 价格/赠品 × 已关注人群 × 购买信息 claim × 活动规则 × 页面承接",
+        "condition": "活动信息必须与页面实时一致。",
+        "experience": "可说活动、赠品、库存和到手价,但不能绑定功效承诺,也不能制造虚假紧迫感。",
+        "reuse": "可复制;需运营实时校验。",
+    },
+    {
+        "formula": "达人账号 × 橱窗/分销 × 快冲/下单 × 泛人群 × 强购买 CTA × 视频口播 × 评论承接",
+        "condition": "达人账号的强导购审核边界明显更紧。",
+        "experience": "即便已发布,也只记录为边界样本;更稳的做法是把购买动作放到商品卡自然承接。",
+        "reuse": "高风险;不作为标准话术。",
+    },
+    {
+        "formula": "医生/营养师/专业账号 × 任意 SKU × 科普建议 × 特殊健康人群 × 专业背书 claim × 可核验资质 × 不诊断不治疗",
+        "condition": "专业身份会放大用户对医疗建议的理解。",
+        "experience": "可做一般营养科普和标签阅读方法,不说诊断、治疗、预防,不把个人身份变成产品功效证明。",
+        "reuse": "需资质、素材和平台规则复核。",
+    },
+    {
+        "formula": "任意账号 × 孕妇/儿童/老人/慢病人群 × 适用人群 × 特殊人群 × 用量/适合 claim × 标签说明 × 专业咨询",
+        "condition": "特殊人群不能被一概而论。",
+        "experience": "把回答固定到具体 SKU 标签、说明书、客服和专业人士;不要说“爸妈都能吃、小孩也适合”。",
+        "reuse": "慎用;适合 FAQ 而非强口播。",
+    },
+    {
+        "formula": "任意账号 × 多 SKU/竞品比较 × 红黑榜/排名 × 选购人群 × 最好/只认 claim × 中性维度 × 无贬损",
+        "condition": "红黑榜、排名、智商税和竞品贬损容易触发平台和广告法风险。",
+        "experience": "改成“看成分、剂型、规格、标签、预算和适用场景”,只讲自身可核验信息。",
+        "reuse": "可复制中性框架;不可复制贬损表达。",
+    },
+    {
+        "formula": "官方/店铺账号 × 评论区承接 × 功效追问 × 已购买/观望人群 × 效果如何 claim × 标准 FAQ × 客服分流",
+        "condition": "评论区回复也是广告表达的一部分。",
+        "experience": "对“效果明显吗”统一回到日常营养补充、均衡饮食、个体差异和专业咨询,不在评论区追加功效承诺。",
+        "reuse": "高可复制;适合置顶评论模板。",
+    },
+    {
+        "formula": "任意账号 × 疾病/甲流/感冒 × 治疗/预防 × 特殊健康时期 × 医疗 claim × 删除或转咨询 × 无导购",
+        "condition": "出现疾病、治疗、预防、诊断暗示时,不因已发布就认定可复制。",
+        "experience": "只能作为撞审边界记录;稳定话术应回到一般营养信息和专业人士咨询。",
+        "reuse": "不可复制。",
+    },
+]
+
+
+UNOBSERVED_RULE_SAMPLING_GUIDE = {
+    "RP08": "补采含“国家认证、监管推荐、官方背书、平台认证截图、蓝帽等同万能资质”等表述的视频,区分真实资质展示和背书滥用。",
+    "RP09": "补采制造焦虑或歧视性表达,例如“30+不补就垮、爸妈不吃就危险、熬夜党必须囤”等,观察恐惧钩子能否发布。",
+    "RP10": "补采明星综艺截屏、影视/IP 素材、音乐音频、他人评论截图和未经授权图文素材样本,建立素材授权边界。",
+    "RP12": "补采订单、物流、聊天记录、用户评论截图和售后截图类视频,判断隐私遮挡、授权和截图来源要求。",
+    "RP15": "补采孕妇、儿童、老人、术后、慢病、备孕等特殊人群样本,和 RP07 的用法用量规则联动复盘。",
+    "RP16": "补采谐音避审、错别字规避、字幕替换禁词、口播不说字幕暗示等样本,判断平台是否按语义识别。",
+    "RP17": "补采口播 A 产品、画面 B 产品、标题 C 卖点、商品卡 D SKU 的混配样本,验证素材一致性风险。",
+    "RP20": "补采擦边玩梗、低俗隐喻、过度身材焦虑、攻击性表达等样本,判断品牌安全边界。",
+    "RP22": "补采公共事件、疾病流行、考试季焦虑、节日恐慌和趋势钩子样本,判断热点借势与恐惧营销边界。",
+    "RP23": "收集平台驳回、限流、申诉、人工审核反馈和发布时间差异样本,用于反推 DP/平台候选风险。",
+    "RP24": "跟踪三品一械广告审查新规、平台健康品规则更新和品牌法务口径变化,形成监管候选规则池。",
+    "RP25": "补采副作用、不适、吞咽困难、投诉、售后争议和夸大宣传质疑评论,沉淀历史投诉高发模式。",
+    "RP26": "补采 AI 合成明星脸、AI 声音、数字人带货、仿真人设口播样本,核验授权与平台标识要求。",
+    "RP27": "补采 DP 反馈、平台新增禁投词、新兴达人玩法和特殊账号类型样本,作为新增规则候选。",
+    "RP28": "该类多为流程或范围外事项,暂不主动补采;仅在平台审核流程、自动审批或发布机制影响结论时记录。",
+}
+
+
+RULE_REWRITE_TEMPLATES = {
+    "RP01": "删除“唯一、最好、全网第一、就够、补齐、全部所需”等绝对化或保证性表达;改为“含多种维生素/矿物质,可按标签建议作为日常营养补充”。",
+    "RP02": "删除甲流、感冒、治疗、预防、医生诊断等疾病/医疗暗示;改为“一般营养补充信息,特殊健康状况请咨询专业人士”。",
+    "RP03": "删除精力满格、气血改善、代谢变好、焕活、抗炎等结果承诺;改为“配合均衡饮食和规律作息,支持日常营养补充”。",
+    "RP04": "保留可核验的成分数量、规格和剂型,但不要把“26种/22种/120粒”等数字推导成效果、补全或改善结果。",
+    "RP05": "跨境/海外/不同版本只讲包装、标签、适用说明和购买渠道差异;不要默认等同国内蓝帽、保健功能或统一适用人群。",
+    "RP06": "正品、批号、喷码、效期、包装等信息以商品页、实物标签和客服确认为准,避免用“大品牌”替代合规凭证。",
+    "RP07": "按具体 SKU 标签建议说明用法用量和适用人群;孕妇、儿童、老人、30+/40+/50+等人群不要一概而论。",
+    "RP11": "删除红黑榜、智商税、别买某品牌、排名碾压等竞品贬损;改为中性选购维度和自身产品标签信息。",
+    "RP13": "弱化“必须、离不开、快冲、稳稳、真的有效”等强语气;改为“可作为日常补充选择之一,按需选择”。",
+    "RP14": "医生、营养师、专业建议或研究背书需要可核验证据;无法补证时改为个人经验/一般选购建议,并回到标签依据。",
+    "RP18": "活动、价格、赠品、库存和到手价以当前页面为准,不虚构限量或福利,也不把促销 CTA 与功效承诺绑定。",
+    "RP19": "明星/达人/专家内容仅作为分享场景,不作为功效或适用性背书;补充“产品信息以包装标签和官方说明为准”。",
+    "RP21": "删除“马上下单就解决、快冲改善状态”等强 CTA;改为“如需了解规格/适用说明,可查看商品页或咨询客服”。",
+    "RP22": "避免借焦虑、趋势、恐慌、白月光或梗化表达放大购买压力;改为平实生活场景和可核验产品信息。",
+}
+
+
+RULE_MODULE_MAP = {
+    "RP01": "一包/一粒解决感",
+    "RP02": "功效/状态表达",
+    "RP03": "功效/状态表达",
+    "RP04": "成分/剂型说明",
+    "RP05": "版本/渠道信任",
+    "RP06": "版本/渠道信任",
+    "RP07": "年龄/人群场景",
+    "RP11": "比较/榜单/避坑",
+    "RP13": "功效/状态表达",
+    "RP14": "版本/渠道信任",
+    "RP18": "促销/购买引导",
+    "RP19": "明星/达人背书",
+    "RP21": "促销/购买引导",
+    "RP22": "促销/购买引导",
+}
+
+
+COMMENT_REPLY_TEMPLATES = {
+    "泛互动/情绪反馈": "感谢喜欢,产品信息可查看商品页成分表、包装标签和客服说明。",
+    "功效/健康诉求": "营养补充品不承诺特定效果,建议结合均衡饮食和规律作息;特殊健康状况请咨询专业人士。",
+    "正品/渠道/版本信任": "不同版本包装、成分和标签说明可能不同,正品、批号、效期和售后以商品页及实物标签为准。",
+    "购买路径/价格": "活动、赠品、库存和价格以当前页面为准;如需规格或适用说明,可咨询客服。",
+    "质疑/比较/性价比": "可从成分、剂型、规格、标签说明、预算和适用人群做中性比较,不建议用红黑榜或贬损表达。",
+    "内容互动/达人明星": "达人分享仅代表个人体验,产品信息以包装标签、商品页和官方说明为准。",
+    "低质互动/刷金币": "需人工复核评论上下文后再互动,避免用模板回复强化无效互动。",
+    "用法用量/适用人群": "不同 SKU 适用人群和建议用量不同,请按包装标签、商品页和客服说明选择。",
+    "体验/副作用/剂型": "关于服用体验、颗粒大小、吞咽和不适感,可引导查看 FAQ 或咨询客服;不要在评论区给医疗建议。",
+}
+
+
+COMMENT_TRANSCRIPT_TRIGGERS = {
+    "泛互动/情绪反馈": "明星/达人场景、强生活化表达和高互动标题会带来大量情绪评论,但信息价值较低。",
+    "功效/健康诉求": "精力、气血、代谢、状态、抗炎、甲流等口播会触发用户追问“效果怎么样”。",
+    "正品/渠道/版本信任": "海外、跨境、官方、大品牌、国内外版本等表达会触发真假、版本、效期和售后疑问。",
+    "购买路径/价格": "直播间、拍一发四、送礼袋、活动数量有限、快冲等口播会把用户注意力拉到价格和购买路径。",
+    "质疑/比较/性价比": "红黑榜、排行榜、只认某款、别乱买等表达会引发比较、质疑和性价比讨论。",
+    "内容互动/达人明星": "明星都在吃、达人推荐、医生/营养师身份会带来背书追问,也会放大合规风险。",
+    "低质互动/刷金币": "高流量视频容易混入刷金币、求赞、无意义互动,需和真实疑问分开处理。",
+    "用法用量/适用人群": "30+/40+/50+、孕妇、儿童、爸妈、长辈等口播会触发适用人群和用量追问。",
+    "体验/副作用/剂型": "一粒/一包/颗粒/吞咽/搭配方式等口播会触发体验和服用方式问题。",
+}
+
+
+def load_account_list():
+    records = []
+    names = set()
+    if not ACCOUNT_LIST_XLSX.exists():
+        return {"records": records, "names": names}
+    try:
+        import openpyxl
+
+        wb = openpyxl.load_workbook(ACCOUNT_LIST_XLSX, data_only=True)
+        ws = wb.active
+        for row in ws.iter_rows(min_row=4, values_only=True):
+            store = clean(row[0] if len(row) > 0 else "")
+            live_account = clean(row[2] if len(row) > 2 else "")
+            douyin_id = clean(row[3] if len(row) > 3 else "")
+            if not store and not live_account:
+                continue
+            record = {
+                "store": store,
+                "liveAccount": live_account,
+                "douyinId": douyin_id,
+            }
+            records.append(record)
+            for name in (store, live_account):
+                if name:
+                    names.add(name)
+    except Exception:
+        return {"records": records, "names": names}
+    return {"records": records, "names": names}
+
+
+def classify_account(author, official_names):
+    author = clean(author)
+    if author in official_names:
+        return {
+            "accountType": "官方/店铺账号(清单匹配)",
+            "accountConfidence": "高",
+            "accountRuleLogic": "可承接商品资质、广告审查备案和官方说明;功能宣称是否可说,取决于产品属性、批文/备案范围和素材一致性。",
+        }
+    if re.search(r"善存.*(旗舰店|官方|专卖店|店铺|直播|营养品)|VCHOICE", author, flags=re.I):
+        return {
+            "accountType": "疑似店铺/分销账号(待核验)",
+            "accountConfidence": "中",
+            "accountRuleLogic": "可能具备商品承接能力,但不在本次官方清单内;结论需回查账号认证、店铺归属和广告备案。",
+        }
+    if re.search(r"旗舰店|官方|专卖店|店铺|优选|海外专营店", author, flags=re.I):
+        return {
+            "accountType": "其他店铺/分销账号(非官方清单)",
+            "accountConfidence": "中",
+            "accountRuleLogic": "可做购买承接但不应默认等同品牌官方;需要核验授权关系和商品资质。",
+        }
+    return {
+        "accountType": "达人/内容账号",
+        "accountConfidence": "中",
+        "accountRuleLogic": "应以个人体验、生活场景和中性科普为主;不得替代官方广告备案,不宜强 CTA 或直接导购。",
+    }
+
+
+def account_rule_notes(account_type):
+    if account_type.startswith("官方/店铺"):
+        return "官方/店铺账号可在资质与广审备案范围内表达功能或商品信息,但要保证产品、画面、口播、字幕和落地页一致。"
+    if account_type.startswith("疑似店铺") or account_type.startswith("其他店铺"):
+        return "店铺/分销账号要先核验授权和资质;在未确认前,不把功能宣称当作绿灯经验,只记录为边界样本。"
+    return "达人账号的成功经验主要是生活化、隐喻化、弱导购和个人体验化;功能/治疗/强购买引导仍按更严边界处理。"
+
+
+def claim_boundary_for_row(row):
+    text = f"{row.get('cleanText', '')} {row.get('title', '')}"
+    account_type = row.get("accountType", "")
+    sku = row.get("skuGroup", "")
+    if re.search(r"大脑|脑力|守门员|前锋", text):
+        claim = "脑力/大脑表现"
+        carrier = "画面隐喻或角色比喻优于直接效果承诺"
+    elif re.search(r"精力|状态|气血|代谢|焕活|抗炎|皮肤", text):
+        claim = "状态/功效感"
+        carrier = "生活场景 + 日常营养支持,避免直接承诺改善结果"
+    elif re.search(r"甲流|感冒|疾病|治疗|预防", text):
+        claim = "疾病/特殊健康时期"
+        carrier = "不作为成功经验复制;只能回到一般营养补充或专业咨询"
+    elif re.search(r"一粒|一包|26种|22种|全部|都在", text):
+        claim = "成分/剂型/便利性"
+        carrier = "可说成分与剂型事实,不把数字推导成全部所需或补全效果"
+    else:
+        claim = "泛营养/生活场景"
+        carrier = "保留场景和产品事实,减少效果与购买压力"
+    if account_type.startswith("官方/店铺"):
+        account_logic = "若属于保健食品且 claim 在批文/广审备案范围内,可作为绿灯表达;超出备案范围仍需弱化或改用隐喻。"
+    else:
+        account_logic = "达人/内容账号不承接官方备案能力,claim 需要个人体验化、场景化、隐喻化,并避免强购买引导。"
+    return {
+        "claimType": claim,
+        "claimCarrier": carrier,
+        "boundaryLogic": f"{sku or '当前SKU'} × {account_type or '未知账号'}:{account_logic}",
+    }
+
+
+def match_items(patterns, text):
+    hits = []
+    for item in patterns:
+        key, name, pattern = item if len(item) == 3 else (item[0], item[0], item[1])
+        if re.search(pattern, text, flags=re.I):
+            hits.append({"id": key, "name": name})
+    return hits
+
+
+def classify_modules(text):
+    hits = []
+    for label, pattern in MODULE_PATTERNS:
+        if re.search(pattern, text, flags=re.I):
+            hits.append(label)
+    return hits or ["低信息/泛口播"]
+
+
+def is_valid_transcript(text):
+    normalized = normalize_asr(text)
+    if chinese_count(normalized) < 30:
+        return False
+    if re.fullmatch(r"[A-Za-z0-9 ,.'!?-]+", normalized or ""):
+        return False
+    return True
+
+
+def build_rewrite(row):
+    modules = row["modules"]
+    suggestions = [REWRITE_TEMPLATES[m] for m in modules if m in REWRITE_TEMPLATES]
+    if not suggestions:
+        suggestions = ["保留生活场景和成分信息,删除无法证明的结果承诺,回到标签/说明书口径。"]
+    return ";".join(dict.fromkeys(suggestions))
+
+
+RULE_LOOKUP = {item[0]: {"name": item[1], "pattern": item[2]} for item in RULE_PATTERNS}
+
+
+def rule_name(rule_id):
+    return FULL_RULE_NAMES.get(rule_id) or RULE_LOOKUP.get(rule_id, {}).get("name", "")
+
+
+def rule_default_status(rule_id):
+    try:
+        num = int(rule_id.replace("RP", ""))
+    except ValueError:
+        return "Unknown"
+    if 1 <= num <= 12:
+        return "Active-Blocking"
+    if 13 <= num <= 22:
+        return "Advisory"
+    if 23 <= num <= 27:
+        return "Candidate"
+    return "Disabled/Park"
+
+
+def rule_audit_action(rule_id):
+    status = rule_default_status(rule_id)
+    if status == "Active-Blocking":
+        return "红灯截停;删除或改写后再审"
+    if status == "Advisory":
+        return "黄灯提示;弱化、补证或人工确认"
+    if status == "Candidate":
+        return "候选记录;与其他 Active/Advisory 规则联动判断"
+    return "不纳入正式判断"
+
+
+def primary_module_for_rule(row, rule_id):
+    preferred = RULE_MODULE_MAP.get(rule_id)
+    modules = row.get("modules") or []
+    if preferred in modules:
+        return preferred
+    for module in modules:
+        if module != "低信息/泛口播":
+            return module
+    return preferred or "低信息/泛口播"
+
+
+def extract_rule_matches(rule_id, text, max_items=3):
+    lookup = RULE_LOOKUP.get(rule_id)
+    if not lookup:
+        return []
+    values = []
+    for match in re.finditer(lookup["pattern"], text or "", flags=re.I):
+        value = snippet(match.group(0), 48)
+        if value and value not in values:
+            values.append(value)
+        if len(values) >= max_items:
+            break
+    return values
+
+
+def extract_rule_context(rule_id, text, length=180):
+    lookup = RULE_LOOKUP.get(rule_id)
+    if not lookup:
+        return snippet(text, length)
+    match = re.search(lookup["pattern"], text or "", flags=re.I)
+    if not match:
+        return snippet(text, length)
+    start = max(0, match.start() - 55)
+    end = min(len(text), match.end() + 115)
+    return snippet(text[start:end], length)
+
+
+def build_rule_rewrite(row, rule_id):
+    module = primary_module_for_rule(row, rule_id)
+    suggestions = [
+        RULE_REWRITE_TEMPLATES.get(rule_id),
+        REWRITE_TEMPLATES.get(module),
+    ]
+    return ";".join(dict.fromkeys(item for item in suggestions if item))
+
+
+def boundary_reusability(row, rule_id):
+    account_type = row.get("accountType", "")
+    claim_type = row.get("claimType", "")
+    if rule_id in {"RP02"}:
+        return "不可复制高风险"
+    if account_type.startswith("官方/店铺") and claim_type in {"状态/功效感", "脑力/大脑表现"}:
+        return "需批文/广审核验后复制"
+    if account_type.startswith("达人") and rule_id in {"RP18", "RP21"}:
+        return "达人强导购边界样本"
+    if rule_id in {"RP01", "RP03", "RP07"}:
+        return "边界样本,需弱化后复制"
+    return "可作为经验参考"
+
+
+def build_success_experience(row, rule_id):
+    account_type = row.get("accountType", "")
+    claim_type = row.get("claimType", "")
+    carrier = row.get("claimCarrier", "")
+    reusability = boundary_reusability(row, rule_id)
+    if account_type.startswith("官方/店铺"):
+        base = "官方/店铺账号的可迁移经验是:把表达落在商品页、包装标签、批文/备案和广告审查可覆盖的信息上;若是保健食品且功能 claim 在备案与广审范围内,可形成绿灯表达。"
+    elif account_type.startswith("疑似店铺") or account_type.startswith("其他店铺"):
+        base = "店铺/分销账号的经验需要先核验授权与资质;可复用的是商品事实、版本边界和客服承接,不应默认复制官方功能宣称。"
+    else:
+        base = "达人/内容账号的可迁移经验是:个人体验化、生活场景化、隐喻化,不替代官方广告备案,不把内容口播变成强购买引导。"
+    return f"{base} 本条属于“{claim_type}”,承载方式为“{carrier}”;复用判断:{reusability}。"
+
+
+def build_golden_cases(valid_rows):
+    rows_by_rule = defaultdict(list)
+    for row in valid_rows:
+        for rule_id in [item for item in row["ruleHits"].split("、") if item]:
+            rows_by_rule[rule_id].append(row)
+
+    golden = []
+
+    def sort_rule(rule_id):
+        return int(rule_id.replace("RP", "")) if rule_id.startswith("RP") else 999
+
+    for rule_id in sorted(rows_by_rule, key=sort_rule):
+        sorted_rows = sorted(
+            rows_by_rule[rule_id],
+            key=lambda r: (r["likeCount"], r["commentCount"], r["textLength"]),
+            reverse=True,
+        )
+        for index, row in enumerate(sorted_rows, start=1):
+            module = primary_module_for_rule(row, rule_id)
+            name = rule_name(rule_id)
+            matches = extract_rule_matches(rule_id, row["cleanText"])
+            comment_examples = [clean(item) for item in row.get("commentExamples", []) if clean(item)]
+            golden.append({
+                "id": f"TG-{rule_id}-{index:03d}",
+                "caseLayer": "逐字稿 Rule 命中案例",
+                "ruleId": rule_id,
+                "ruleName": name,
+                "defaultStatus": rule_default_status(rule_id),
+                "auditAction": rule_audit_action(rule_id),
+                "module": module,
+                "sourceVideoId": row["videoId"],
+                "sourceTitle": row["title"],
+                "author": row["author"],
+                "skuGroup": row["skuGroup"],
+                "accountType": row.get("accountType", ""),
+                "accountConfidence": row.get("accountConfidence", ""),
+                "claimType": row.get("claimType", ""),
+                "claimCarrier": row.get("claimCarrier", ""),
+                "boundaryLogic": row.get("boundaryLogic", ""),
+                "engagement": f"{row['likeCount']}赞/{row['commentCount']}评",
+                "riskRules": rule_id,
+                "allRiskRules": row["ruleHits"],
+                "triggerText": "、".join(matches),
+                "originalTranscriptSnippet": extract_rule_context(rule_id, row["cleanText"]),
+                "suggestedApprovedWording": build_rule_rewrite(row, rule_id),
+                "successExperience": build_success_experience(row, rule_id),
+                "reusability": boundary_reusability(row, rule_id),
+                "why": f"命中 {rule_id}({name}),默认状态为 {rule_default_status(rule_id)};该条从真实逐字稿中按规则拆行,用于扩充黄金测试集和审核训练样本。",
+                "commentEvidence": "\n".join(comment_examples[:3]),
+                "url": row["url"],
+            })
+    return golden
+
+
+def rule_pack_table_stats():
+    if not RULE_PACK_DOC.exists():
+        return {"tableCount": 0, "tableRows": 0}
+    try:
+        with zipfile.ZipFile(RULE_PACK_DOC) as docx:
+            root = ET.fromstring(docx.read("word/document.xml"))
+        ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
+        tables = root.findall(".//w:tbl", ns)
+        return {
+            "tableCount": len(tables),
+            "tableRows": sum(len(table.findall(".//w:tr", ns)) for table in tables),
+        }
+    except Exception:
+        return {"tableCount": 0, "tableRows": 0}
+
+
+def supplemental_strategy_stats():
+    if not SUPPLEMENTAL_STRATEGY_MD.exists():
+        return {"exists": False, "lineCount": 0, "headingCount": 0}
+    try:
+        text = SUPPLEMENTAL_STRATEGY_MD.read_text(encoding="utf-8", errors="ignore")
+    except Exception:
+        return {"exists": False, "lineCount": 0, "headingCount": 0}
+    return {
+        "exists": True,
+        "lineCount": len(text.splitlines()),
+        "headingCount": len(re.findall(r"^##+\s+", text, flags=re.M)),
+    }
+
+
+def runner_supplement_stats():
+    if not RUNNER_SUPPLEMENT_JSON.exists():
+        return {"exists": False, "status": "", "videoCount": 0, "commentCount": 0, "errorCount": 0, "qualityGate": ""}
+    try:
+        data = load_json(RUNNER_SUPPLEMENT_JSON)
+    except Exception:
+        return {"exists": False, "status": "", "videoCount": 0, "commentCount": 0, "errorCount": 0, "qualityGate": ""}
+    summary = data.get("summary", {})
+    quality_gate = data.get("qualityGate", {})
+    raw_errors = []
+    if RUNNER_SUPPLEMENT_RAW.exists():
+        try:
+            raw_errors = load_json(RUNNER_SUPPLEMENT_RAW).get("metadata", {}).get("errors", [])
+        except Exception:
+            raw_errors = []
+    return {
+        "exists": True,
+        "status": data.get("status", ""),
+        "videoCount": int(summary.get("videoCount") or 0),
+        "commentCount": int(summary.get("commentCount") or 0),
+        "errorCount": len(raw_errors) or int(summary.get("errorCount") or 0),
+        "qualityGate": quality_gate.get("level", ""),
+    }
+
+
+def classify_public_title_risk(title):
+    hits = match_items(PUBLIC_TITLE_RISK_PATTERNS, title)
+    if not hits:
+        return {
+            "riskText": "未命中高频标题风险;仍需逐字稿/画面复核",
+            "riskRules": "",
+            "reuse": "标题观察样本,待逐字稿确认",
+        }
+    rules = "、".join(item["id"] for item in hits)
+    names = ";".join(f"{item['id']} {item['name']}" for item in hits)
+    high_risk_rules = {item["id"] for item in hits}
+    if "RP18" in high_risk_rules or "RP21" in high_risk_rules:
+        reuse = "促销/CTA 边界样本;活动可承接,但不要绑定功效承诺"
+    elif {"RP01", "RP03", "RP13"} & high_risk_rules:
+        reuse = "标题边界样本;需弱化为日常营养补充/标签事实后再复制"
+    else:
+        reuse = "标题观察样本,需结合口播、字幕、商品卡确认"
+    return {
+        "riskText": names,
+        "riskRules": rules,
+        "reuse": reuse,
+    }
+
+
+def load_public_profile_samples(official_names):
+    def load_list(path):
+        if not path.exists():
+            return []
+        try:
+            data = load_json(path)
+        except Exception:
+            return []
+        return data if isinstance(data, list) else []
+
+    all_samples = load_list(PUBLIC_PROFILE_ALL_VIDEOS)
+    new_samples = load_list(PUBLIC_PROFILE_NEW_VIDEOS)
+    seen = set()
+    rows = []
+    for sample in new_samples:
+        video_id = clean(sample.get("videoId"))
+        if not video_id or video_id in seen:
+            continue
+        seen.add(video_id)
+        title = clean(sample.get("title"))
+        author = clean(sample.get("author"))
+        row = {
+            "videoId": video_id,
+            "author": author,
+            "title": title,
+            "url": clean(sample.get("url")),
+            "profileUrl": clean(sample.get("profileUrl")),
+            "source": clean(sample.get("source")) or "public_profile_playwright",
+            "collectedAt": clean(sample.get("collectedAt")),
+        }
+        row.update(classify_account(author, official_names))
+        row.update(claim_boundary_for_row(row))
+        risk = classify_public_title_risk(title)
+        row.update(risk)
+        row["evidenceStatus"] = "公开视频主页标题/账号可见证据;暂无评论、媒体下载和逐字稿,不并入逐字稿黄金案例。"
+        rows.append(row)
+    account_counter = Counter(row.get("accountType", "") for row in rows)
+    return {
+        "exists": PUBLIC_PROFILE_NEW_VIDEOS.exists(),
+        "allCount": len({clean(item.get("videoId")) for item in all_samples if clean(item.get("videoId"))}) or len(rows),
+        "newCount": len(rows),
+        "rows": rows,
+        "accountCounts": [{"accountType": k, "videoCount": v} for k, v in account_counter.most_common()],
+    }
+
+
+def main():
+    live = load_json(LIVE_DATA)
+    account_list = load_account_list()
+    official_names = account_list["names"]
+    video_rows = []
+    for row in live["videoRows"]:
+        item = dict(row)
+        item.update(classify_account(item.get("author", ""), official_names))
+        item.update(claim_boundary_for_row(item))
+        item["accountBoundaryNote"] = account_rule_notes(item["accountType"])
+        video_rows.append(item)
+    videos_by_id = {str(row["videoId"]): row for row in video_rows}
+    comments_by_video = defaultdict(list)
+    for row in live["commentRows"]:
+        comments_by_video[str(row.get("videoId"))].append(row)
+
+    transcript_rows = []
+    for transcript_path in sorted((TRANSCRIPT_DIR / "transcripts").glob("*/transcript.json")):
+        data = load_json(transcript_path)
+        video_id = str(data.get("awemeId") or transcript_path.parent.name)
+        video = videos_by_id.get(video_id, {})
+        raw_text = clean(data.get("text"))
+        text = normalize_asr(raw_text)
+        valid = data.get("status") == "ok" and is_valid_transcript(text)
+        modules = classify_modules(text) if valid else ["无效/低信息逐字稿"]
+        rules = match_items(RULE_PATTERNS, text) if valid else []
+        row_obj = {
+            "videoId": video_id,
+            "title": video.get("title", ""),
+            "author": video.get("author", ""),
+            "keyword": video.get("keyword", ""),
+            "skuGroup": video.get("skuGroup", ""),
+            "riskLevel": video.get("riskLevel", ""),
+            "likeCount": video.get("likeCount", 0),
+            "commentCount": video.get("commentCount", 0),
+            "url": video.get("url", ""),
+            "transcriptStatus": data.get("status", ""),
+            "durationMs": data.get("durationMs", 0),
+            "rawText": raw_text,
+            "cleanText": text,
+            "textLength": len(text),
+            "chineseCount": chinese_count(text),
+            "validTranscript": valid,
+            "modules": modules,
+            "moduleText": "、".join(modules),
+            "ruleHits": "、".join(item["id"] for item in rules),
+            "ruleNames": "、".join(item["name"] for item in rules),
+            "rewriteDirection": "",
+            "commentExamples": [c.get("text", "") for c in comments_by_video.get(video_id, [])[:3]],
+            "accountType": video.get("accountType", ""),
+            "accountConfidence": video.get("accountConfidence", ""),
+            "accountRuleLogic": video.get("accountRuleLogic", ""),
+        }
+        row_obj.update(claim_boundary_for_row(row_obj))
+        row_obj["accountBoundaryNote"] = account_rule_notes(row_obj["accountType"])
+        transcript_rows.append(row_obj)
+
+    for row in transcript_rows:
+        row["rewriteDirection"] = build_rewrite(row) if row["validTranscript"] else "不纳入话术结论;仅记录为转写低信息/疑似背景音乐。"
+
+    valid_rows = [row for row in transcript_rows if row["validTranscript"]]
+    invalid_rows = [row for row in transcript_rows if not row["validTranscript"]]
+
+    module_counter = Counter()
+    rule_counter = Counter()
+    account_counter = Counter(row.get("accountType", "") for row in video_rows)
+    valid_account_counter = Counter(row.get("accountType", "") for row in valid_rows)
+    for row in valid_rows:
+        module_counter.update(row["modules"])
+        rule_counter.update([item for item in row["ruleHits"].split("、") if item])
+
+    golden = build_golden_cases(valid_rows)
+
+    run_log = load_json(RUN_LOG) if RUN_LOG.exists() else []
+    run_log_video_ids = {str(row.get("videoId", "")) for row in run_log if row.get("videoId")}
+    transcript_video_ids = {str(row.get("videoId", "")) for row in transcript_rows if row.get("videoId")}
+    attempted_video_ids = run_log_video_ids | transcript_video_ids
+    failed_run_log_ids = {
+        str(row.get("videoId", ""))
+        for row in run_log
+        if row.get("videoId") and row.get("transcriptStatus") != "ok"
+    } - transcript_video_ids
+    rule_pack_stats = rule_pack_table_stats()
+    supplemental_stats = supplemental_strategy_stats()
+    runner_stats = runner_supplement_stats()
+    public_profile_stats = load_public_profile_samples(official_names)
+    summary = {
+        "generatedAt": datetime.now().astimezone().isoformat(),
+        "liveVideoCount": live["summary"]["videoCount"],
+        "liveCommentCount": live["summary"]["commentCount"],
+        "publicProfileVideoCount": public_profile_stats["allCount"],
+        "publicProfileNewVideoCount": public_profile_stats["newCount"],
+        "transcriptAttemptSelectedCount": len(attempted_video_ids),
+        "transcriptFileCount": len(transcript_rows),
+        "validTranscriptCount": len(valid_rows),
+        "invalidTranscriptCount": len(invalid_rows),
+        "runLogOkCount": sum(1 for row in transcript_rows if row.get("transcriptStatus") == "ok"),
+        "runLogFailedCount": len(failed_run_log_ids),
+        "goldenTranscriptCount": len(golden),
+        "rulePackTableCount": rule_pack_stats["tableCount"],
+        "rulePackTableRows": rule_pack_stats["tableRows"],
+        "supplementalStrategyDocExists": supplemental_stats["exists"],
+        "supplementalStrategyDocLines": supplemental_stats["lineCount"],
+        "supplementalStrategyDocHeadings": supplemental_stats["headingCount"],
+        "runnerSupplementExists": runner_stats["exists"],
+        "runnerSupplementStatus": runner_stats["status"],
+        "runnerSupplementVideoCount": runner_stats["videoCount"],
+        "runnerSupplementCommentCount": runner_stats["commentCount"],
+        "runnerSupplementErrorCount": runner_stats["errorCount"],
+        "runnerSupplementQualityGate": runner_stats["qualityGate"],
+        "officialAccountListCount": len(account_list["records"]),
+        "accountCounts": [{"accountType": k, "videoCount": v} for k, v in account_counter.most_common()],
+        "validTranscriptAccountCounts": [{"accountType": k, "validTranscriptCount": v} for k, v in valid_account_counter.most_common()],
+        "publicProfileAccountCounts": public_profile_stats["accountCounts"],
+        "moduleCounts": [{"module": k, "count": v} for k, v in module_counter.most_common()],
+        "ruleCounts": [{"rule": k, "count": v} for k, v in rule_counter.most_common()],
+    }
+
+    report = {
+        "summary": summary,
+        "transcriptRows": transcript_rows,
+        "moduleSummary": summary["moduleCounts"],
+        "ruleSummary": summary["ruleCounts"],
+        "goldenTranscriptRows": golden,
+        "invalidTranscriptRows": invalid_rows,
+        "liveSummary": live["summary"],
+        "commentThemeSummary": live["themeSummary"],
+        "videoRows": video_rows,
+        "commentRows": live["commentRows"],
+        "publicProfileTitleRows": public_profile_stats["rows"],
+        "officialAccountRecords": account_list["records"],
+    }
+    OUT_JSON.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
+    OUT_MD.write_text(build_markdown(report), encoding="utf-8")
+    print(json.dumps({"status": "ok", "output": str(OUT_JSON), "markdown": str(OUT_MD), **summary}, ensure_ascii=False, indent=2))
+
+
+def build_markdown(report):
+    s = report["summary"]
+    golden_rows = report["goldenTranscriptRows"]
+    public_profile_rows = report.get("publicProfileTitleRows", [])
+    golden_by_rule = defaultdict(list)
+    for row in golden_rows:
+        golden_by_rule[row.get("ruleId", row.get("riskRules", ""))].append(row)
+    observed_rule_ids = [row["rule"] for row in report["ruleSummary"]]
+    observed_rule_set = set(observed_rule_ids)
+    all_rule_ids = [f"RP{index:02d}" for index in range(1, 29)]
+    unobserved_rule_ids = [rule_id for rule_id in all_rule_ids if rule_id not in observed_rule_set]
+    account_counts = s.get("accountCounts", [])
+    valid_account_counts = s.get("validTranscriptAccountCounts", [])
+    account_video_map = {row.get("accountType", ""): row.get("videoCount", 0) for row in account_counts}
+    official_video_count = sum(count for account_type, count in account_video_map.items() if account_type.startswith("官方/店铺"))
+    creator_video_count = sum(count for account_type, count in account_video_map.items() if account_type.startswith("达人/内容"))
+    other_store_video_count = sum(
+        count
+        for account_type, count in account_video_map.items()
+        if account_type.startswith("其他店铺") or account_type.startswith("疑似店铺")
+    )
+    lines = [
+        "# 善存抖音逐字稿话术审核分析",
+        "",
+        f"生成时间:{s['generatedAt']}",
+        "",
+        "## 样本与方法",
+        "",
+        f"- 抖音网关实采去重视频:{s['liveVideoCount']} 条;评论:{s['liveCommentCount']} 条。",
+        f"- 公开主页补采:Playwright 已从官方/店铺公开主页抽取 {s.get('publicProfileVideoCount', 0)} 条公开视频列表,其中与既有网关样本去重后新增 {s.get('publicProfileNewVideoCount', 0)} 条标题样本;该批为标题/账号可见证据,未计入评论和逐字稿数量。",
+        f"- 本轮优先转写高互动/高风险/营养包及多 SKU 样本,已生成逐字稿文件:{s['transcriptFileCount']} 条,其中有效口播逐字稿:{s['validTranscriptCount']} 条,低信息/疑似背景音乐:{s['invalidTranscriptCount']} 条。",
+        f"- 参考资料文档包含 {s.get('rulePackTableCount', 0)} 个表格、{s.get('rulePackTableRows', 0)} 行表格样例;本轮黄金边界数据集已从摘要式 12 行扩充为 {s.get('goldenTranscriptCount', 0)} 行,按真实逐字稿的 Rule Pack 命中逐条拆分。",
+        f"- 补充参考稿 `抖音保健品过审黄金公式与边界策略.md` 已纳入结构对齐:已读取,行数={s.get('supplementalStrategyDocLines', 0)},标题数={s.get('supplementalStrategyDocHeadings', 0)}。",
+        f"- 本轮补跑说明:没有在网关权限失败后停止;已继续尝试关键词、官方账号、已知 sec_user_id、单视频详情、搜索页和公开主页。实时网关仍显示权限不足或搜索失败(runner 状态={s.get('runnerSupplementStatus', '') or '未生成'},错误数={s.get('runnerSupplementErrorCount', 0)}),但公开主页路径新增 {s.get('publicProfileNewVideoCount', 0)} 条官方/店铺标题样本;新增有效逐字稿仍为 0,逐字稿结论仍以现有 {s['transcriptFileCount']} 个逐字稿文件为证据。",
+        "- 媒体链路:从抖音搜索原始返回恢复视频播放地址,使用 ffmpeg/媒体直传处理后调用 iflytek-gateway 转写;逐字稿用于话术级审核分析。",
+        "- 限制说明:ASR 存在同音误识别,例如“营养包”可能识别成“阴阳包”;本报告已做轻量清洗,但关键结论仍建议结合视频画面和品牌原稿复核。",
+        "",
+        "## 采集执行审计(回应“是否按要求采集”)",
+        "",
+        "结论:本轮有按要求做抖音采集、补采、转写和评论复核,不是只基于资料文档脑补。当前可交付证据来自抖音网关已实采样本;追加扩量已执行多条路径,但实时网关权限/搜索链路未返回新增有效视频,所以本版把“已执行动作”和“新增数据受阻”分开呈现,避免把采集失败误写成未采集。",
+        "",
+        "| 采集/分析要求 | 已执行动作 | 当前证据 | 审视判断 | 后续补齐条件 |",
+        "|---|---|---|---|---|",
+        f"| 善存主品牌、营养包及其他 SKU | 以善存、善存 PRO/每日营养包、多维、男士/女士/50+、海外/跨境、小佳维等作为样本池入口。 | 网关实采去重视频 {s['liveVideoCount']} 条、评论 {s['liveCommentCount']} 条。 | 已形成本版证据底座。 | 后续继续补银善存/大脑、官方店铺专题和特殊人群专题。 |",
+        f"| 官方/店铺账号与达人账号分层 | 读取 `店铺与账号.xlsx`,将视频作者按官方/店铺、达人/内容、其他店铺/分销拆分。 | 官方/店铺清单 {s.get('officialAccountListCount', 0)} 条;样本中官方/店铺 {official_video_count} 条、达人/内容 {creator_video_count} 条、其他店铺/分销 {other_store_video_count} 条。 | 已满足老板强调的账号身份区分。 | 仍需后台蓝V认证、授权关系和具体产品广审资料确认。 |",
+        f"| 高风险话术逐字稿分析 | 优先选择高互动、高风险、营养包、多 SKU 和边界 claim 样本做 ASR 转写。 | 已选 {s.get('transcriptAttemptSelectedCount', 0)} 条;生成逐字稿 {s['transcriptFileCount']} 条,有效口播 {s['validTranscriptCount']} 条,低信息 {s['invalidTranscriptCount']} 条。 | 已从标题/标签推进到真实口播。 | 8 条旧样本媒体回查未找到,需新链接或可访问媒体源。 |",
+        f"| 黄金数据集与样本案例 | 按 Rule Pack 命中将有效逐字稿拆成案例行,而不是抽少量展示。 | 黄金边界案例 {s.get('goldenTranscriptCount', 0)} 行,覆盖当前有效逐字稿命中的 {len(observed_rule_ids)}/28 个 RP。 | 已满足“黄金数据集和样本案例”的 MD 版本要求。 | Excel 等 MD 口径确认后再按同字段重建。 |",
+        f"| 评论与改进空间 | 对已投放视频评论做主题复核,回看口播诱因和评论区承接。 | 评论 {s['liveCommentCount']} 条;已输出评论主题、典型评论、口播诱因、客服/置顶回复建议。 | 已覆盖用户反馈和改进空间。 | 下一轮可按高赞、负面体验、功效追问做更深分层。 |",
+        f"| 定向补采:官方、银善存/大脑、强 CTA、特殊人群 | 已尝试项目采集脚本、通用 runner、已知账号/关键词、单视频详情、搜索页和公开主页路径。 | runner 状态 `{s.get('runnerSupplementStatus', '') or '未生成'}`,错误数 {s.get('runnerSupplementErrorCount', 0)};公开主页新增标题样本 {s.get('publicProfileNewVideoCount', 0)} 条;新增有效逐字稿 0 条。 | 已执行但受权限/搜索链路阻塞,不能写成全网穷尽覆盖。 | 恢复抖音网关权限,或由客户提供可访问视频链接/文件。 |",
+        "",
+        "对老板的可交付判断:方向和结构已经按要求来,证据也用足了当前可访问数据;剩余缺口主要不是 MD 结构,而是新增抖音实时数据权限、银善存/大脑专题样本量,以及客户侧产品批文/蓝帽/广审/商品页资料。",
+        "",
+        "## 逐字稿结论",
+        "",
+        "本轮口径已调整:抖音采集到的是“已经发出来的成功案例”,不是待审核稿件。因此分析重点不是告诉业务“这条怎么改更好过审”,而是反推这些已发布稿件为什么能踩在边界上发出来:它用了什么账号身份、产品资质、卖点承载、隐喻画面、人群标签、弱化措辞或商品页承接,形成了怎样的成功经验。",
+        "",
+        "真实口播比标题/标签暴露出更强的边界动作:不少视频不只在标题里出现“精力满格、气血满格、代谢、甲流、一粒补充”,口播中还会进一步强化“全部都调配好了、每天一粒/一包就够、身体底子/皮肤状态慢慢养稳、明星都在吃、直播间快冲”等表达。这里要区分两类结论:一类是可迁移经验,例如“成分事实 + 标签建议 + 官方/客服承接”;另一类是撞审/边界样本,例如达人强 CTA、疾病词、未备案范围功效隐喻,只能作为风险边界观察,不能直接复制。",
+        "",
+        "本轮补齐后,MD 不再把黄金边界数据集压缩成少量展示样本,而是把每条有效逐字稿按 Rule Pack 命中拆成成功边界案例。这样做的好处是:同一视频里“每天一粒就够”“26 种营养”“直播间快冲”“30+女性”“海外版/正品”等风险点会各自成行,方便回流到规则测试、达人 brief、审核标注和后续 Excel 明细。",
+        "",
+        "## 目标口径校正",
+        "",
+        "| 旧口径 | 新口径 | 报告处理方式 |",
+        "|---|---|---|",
+        "| 怎么改这条稿件更好过审 | 为什么这条已发布稿件能发出来 | 每个黄金案例增加“账号类型、claim 类型、承载方式、边界逻辑、复用判断”。 |",
+        "| 把红灯都写成删改建议 | 区分可复制经验和撞审边界 | 官方/店铺账号若有保健食品批文与广审备案,部分功能 claim 可绿灯;达人账号不能借官方备案直接说功能或强导购。 |",
+        "| 按规则命中给风险 | 按账号×产品×卖点×人群×claim 组合找边界 | 增加策略公式清单,帮助后续批量跑样本时归纳成功路径。 |",
+        "| 不区分账号身份 | 明确蓝V/官方店铺、疑似店铺、达人/内容账号不同边界 | 使用 `店铺与账号.xlsx` 做官方/店铺账号清单匹配;未在清单但名称像店铺的账号标为待核验。 |",
+        "",
+        "## 老板要求逐条对照结论",
+        "",
+        "结论:当前 MD 已按老板补充方向来做,核心定位是“从抖音已发布成功案例反推审核边界和可复制经验”,不是单纯写敏感词删改。需要对外说明的是:当前样本是已采集可用数据,不等于全网最终覆盖;新增补采受抖音网关权限/搜索失败影响,后续恢复权限后可继续扩容。上方“采集执行审计”已单独回答是否按要求采集。",
+        "",
+        "| 老板/客户要求 | 当前文档落点 | 完成度 | 还需要补什么 |",
+        "|---|---|---|---|",
+        f"| 目标品牌善存,覆盖营养包及其他 SKU | 样本覆盖善存 PRO/每日营养包、复合维生素/多维、男士/女士/50+、海外/跨境版本、小佳维等;总计 {s['liveVideoCount']} 条去重视频。 | 已覆盖当前可用样本 | 后续按缺口补采银善存/大脑、官方店铺、特殊人群和强 CTA 专题。 |",
+        f"| 抖音渠道已发布稿件如何通过审核 | `逐字稿结论`、`目标口径校正`、`过审边界策略公式`、`Rule Pack 逐条复盘`均按“已发布成功案例反推边界”组织。 | 已落实 | 需要品牌提供原始待审稿/修改前稿时,才能进一步做“修改前后差异”。 |",
+        f"| 需要黄金数据集和样本案例 | 已把有效逐字稿按 Rule Pack 命中拆成 {s.get('goldenTranscriptCount', 0)} 行黄金边界案例,并保留重点逐字稿样本、视频 ID、触发词、账号类型、claim 类型和复用判断。 | 已落实 | Excel 待 MD 方向确认后再补齐筛选字段。 |",
+        f"| 是否要转逐字稿并分析话术 | 已生成逐字稿 {s['transcriptFileCount']} 条,其中有效口播 {s['validTranscriptCount']} 条;分析从标题/标签推进到真实口播。 | 已落实 | ASR 同音误识别处建议结合视频画面和品牌原稿复核。 |",
+        f"| 已投放稿件用户评论如何,有无改进空间 | 已基于 {s['liveCommentCount']} 条评论做主题复核,拆出泛互动、正品/渠道、购买路径、达人明星、低质互动、体验/副作用等,并给置顶评论/客服回复建议。 | 已落实 | 可在下一轮按“高赞评论、负面体验、功效追问”继续深挖。 |",
+        f"| 结合资料、规则和客户理想稿 | 已读取 Rule Pack 文档 {s.get('rulePackTableCount', 0)} 个表格/{s.get('rulePackTableRows', 0)} 行样例、补充参考稿 {s.get('supplementalStrategyDocLines', 0)} 行/{s.get('supplementalStrategyDocHeadings', 0)} 个标题,并接入官方/店铺账号清单 {len(report.get('officialAccountRecords', []))} 条。 | 已落实 | 仍需客户补产品批文、蓝帽/备案范围、广审批件、商品页标签资料。 |",
+        f"| 尽可能多的视频样本 | 当前已有 {s['liveVideoCount']} 条视频、{s['liveCommentCount']} 条评论;已做关键词、账号、单视频详情、搜索页、公开主页等补采尝试。 | 已执行,新增受阻 | 本轮补采因 runner 状态 `{s.get('runnerSupplementStatus', '') or '未生成'}`、错误数 {s.get('runnerSupplementErrorCount', 0)} 未新增有效样本,需恢复抖音网关权限或提供视频链接。 |",
+        "| 先补 MD,Excel 后确认 | 本轮只优先补 MD,并把后续 Excel 字段放进复用判断和下一步建议。 | 已按用户要求执行 | 等你确认 MD 口径后再生成/补齐 Excel。 |",
+        "",
+        "## 补充需求对齐:客户理想稿结构",
+        "",
+        "新增参考稿的核心不是单一品牌复盘,而是要沉淀一套“合规审核 + 脚本修改建议”的技能框架。本版将善存报告从样本复盘升级为三层结构:先给法规和账号/产品/claim 的通用判断框架,再给善存已发布案例的边界证据,最后给可系统化实现的功能模块。",
+        "",
+        "| 客户理想稿模块 | 本版善存报告承接方式 |",
+        "|---|---|",
+        "| 前置合规基础 | 增加强制提示语、产品身份区分、账号资质要求,明确不能把跨境/普通食品/蓝帽保健食品混用。 |",
+        "| 蓝V vs 达人双轨制 | 使用官方账号清单区分官方/店铺、疑似店铺、达人/内容账号,给出不同 claim 上限。 |",
+        "| 功效宣称红绿灯 | 将 claim 拆成 Level 0 到 Level 5,分别判断生活场景、食用体验、成分事实、备案内功能、备案外功能和疾病治疗。 |",
+        "| 撞审边界技巧 | 将概念替换、比喻暗示、成分事实、标签回流、身份脱敏、短脚本冲量等提炼为技巧库。 |",
+        "| 系统功能需求 | 追加“合规审核与脚本建议系统功能模块”,方便后续做成技能、工作流或产品模块。 |",
+        "",
+        "### 话语体系对照(含法规依据)",
+        "",
+        "参考稿的标准不是简单列敏感词,而是把“高危话术 -> 安全话术 -> 法规/平台依据 -> 善存样本落点”做成可复用替换库。以下按善存营养包、多维、海外版和达人/官方账号的真实样本改写。",
+        "",
+        "| 维度 | 高危话术 | 安全话术 | 法规/规则依据 | 善存样本处理 |",
+        "|---|---|---|---|---|",
+    ]
+    for row in SPEECH_SYSTEM_ROWS:
+        lines.append(f"| {row[0]} | {row[1]} | {row[2]} | {row[3]} | {row[4]} |")
+    lines.extend([
+        "",
+        "### 前置合规基础",
+        "",
+        "保健食品广告/推广内容需显著标明:**保健食品不是药物,不能代替药物治疗疾病**。这条不是“降低风险的建议”,而是保健食品广告内容里的硬约束;但它也不是万能护身符,达人账号即使加提示语,也不能因此获得保健食品广告发布或代言推荐资格。实际发布仍需以品牌法务、RA、广审批件、商品页和平台规则确认。",
+        "",
+        "| 产品类型 | 识别依据 | claim 上限 | 善存报告处理方式 |",
+        "|---|---|---|---|",
+    ])
+    for row in PRODUCT_TYPE_BOUNDARY_ROWS:
+        lines.append(f"| {row[0]} | {row[1]} | {row[2]} | {row[3]} |")
+    lines.extend([
+        "",
+        "### 账号类型双轨制",
+        "",
+        "| 账号类型 | 资质/身份前提 | 可承接内容 | 不可承接内容 |",
+        "|---|---|---|---|",
+    ])
+    for row in ACCOUNT_TRACK_ROWS:
+        lines.append(f"| {row[0]} | {row[1]} | {row[2]} | {row[3]} |")
+    lines.extend([
+        "",
+        "### 功效宣称五级层次",
+        "",
+        "| 层级 | 类型 | 示例 | 默认判断 | 关键边界 |",
+        "|---|---|---|---|---|",
+    ])
+    for row in CLAIM_LEVEL_ROWS:
+        lines.append(f"| {row[0]} | {row[1]} | {row[2]} | {row[3]} | {row[4]} |")
+    lines.extend([
+        "",
+        "善存现有逐字稿里最常见的是 Level 1 成分事实、Level 0/0.5 生活体验和 Level 4 边界功效感混在一起。例如“26 种营养”本身是 Level 1,但一旦接成“每天一粒就够、全部补齐、状态变好”,就上升到 Level 4 风险;“明星都在吃”若变成适用性或效果证明,则进入达人/代言红线。",
+        "",
+        "### 核心判断矩阵(含账号×渠道双维度)",
+        "",
+        "同一句话不能只看词,还要同时看账号身份、产品属性、claim 层级和发布承接。以下矩阵用于把已发布善存样本拆成“可复制、需核验、边界样本、不可复制”。",
+        "",
+        "| 宣称类型 | 默认状态 | 官方/店铺账号 | 达人/内容账号 | 前提条件 | 本报告复用结论 |",
+        "|---|---|---|---|---|---|",
+    ])
+    for row in CORE_JUDGMENT_MATRIX_ROWS:
+        lines.append(f"| {row[0]} | {row[1]} | {row[2]} | {row[3]} | {row[4]} | {row[5]} |")
+    lines.extend([
+        "",
+        "### 10 大技巧中的善存适配版",
+        "",
+        "| 技巧 | 公式 | 善存适配示例 | 风险 | 样本观察 |",
+        "|---|---|---|---|---|",
+    ])
+    for row in BOUNDARY_TECHNIQUE_ROWS:
+        lines.append(f"| {row[0]} | {row[1]} | {row[2]} | {row[3]} | {row[4]} |")
+    lines.extend([
+        "",
+        "### 合规审核与脚本建议系统功能模块",
+        "",
+        "| 模块 | 输入/动作 | 输出价值 |",
+        "|---|---|---|",
+    ])
+    for row in SYSTEM_FUNCTION_ROWS:
+        lines.append(f"| {row[0]} | {row[1]} | {row[2]} |")
+    lines.extend([
+        "",
+        "## 三个核心合规原则(确保所有策略落地)",
+        "",
+        "参考稿里最值得保留的是三个原则:不是为了写得像法规摘要,而是为了让脚本、评论、商品卡、达人 brief 和审核系统都能共用同一个判断顺序。",
+        "",
+    ])
+    for principle in COMPLIANCE_PRINCIPLE_ROWS:
+        lines.extend([
+            f"### {principle['title']}",
+            "",
+            f"- 原则说明:{principle['content']}",
+            f"- 发布前核验:{principle['check']}",
+            f"- 善存当前证据:{principle['evidence']}",
+            "",
+        ])
+    lines.extend([
+        "## 规则依据底座",
+        "",
+        "以下规则只作为边界判断底座,不能替代品牌法务、产品注册/备案资料、广告审查批件和平台最新规则。尤其是“功能宣称”:一般情况下属于红灯高风险;但如果产品是保健食品,且该功能在注册证书/备案凭证、说明书和广审备案范围内,并由官方/店铺账号按审查内容一致发布,则可以形成绿灯表达。",
+        "",
+        "| 依据 | 用在本报告中的作用 | 链接 |",
+        "|---|---|---|",
+    ])
+    for ref in LEGAL_REFERENCES:
+        lines.append(f"| {ref['name']} | {ref['use']} | {ref['url']} |")
+    lines.extend([
+        "",
+        "## 账号身份分层",
+        "",
+        f"本轮读取 `店铺与账号.xlsx` 后,识别到 {s.get('officialAccountListCount', 0)} 条官方/店铺账号记录。报告将清单命中的作者暂按“官方/店铺账号(清单匹配)”处理;是否蓝V、是否具备对应广告备案能力,仍需以账号后台认证和产品广审材料复核。",
+        "",
+        "| 账号类型 | 视频样本数 | 有效逐字稿数 | 可说边界 | 不宜复制边界 |",
+        "|---|---:|---:|---|---|",
+    ])
+    valid_account_map = {row["accountType"]: row.get("validTranscriptCount", 0) for row in valid_account_counts}
+    for row in account_counts:
+        account_type = row["accountType"]
+        if account_type.startswith("官方/店铺"):
+            allow = "可承接商品资质、标签说明、批文/备案范围内的功能 claim、活动规则和客服说明。"
+            avoid = "不能超出备案/广审范围;不能口播字幕画面与商品页不一致。"
+        elif account_type.startswith("疑似店铺") or account_type.startswith("其他店铺"):
+            allow = "可观察为店铺型边界样本,但要先核验授权、账号认证、商品资质和广告审查范围。"
+            avoid = "不能默认套用官方账号能力;不把未核验功能宣称写成绿灯经验。"
+        else:
+            allow = "可说个人体验、生活场景、成分事实、中性科普和非强导购互动。"
+            avoid = "不能借官方备案说功能;不宜强 CTA、价格催单、治疗/预防、保证效果。"
+        lines.append(f"| {account_type} | {row['videoCount']} | {valid_account_map.get(account_type, 0)} | {allow} | {avoid} |")
+    lines.extend([
+        "",
+        "## 过审边界策略公式",
+        "",
+        "可把后续样本按以下公式打标签:`账号身份 × 产品属性 × 卖点类型 × 人群标签 × 功效 claim × 承载方式 × 购买引导强度 = 发布边界`。",
+        "",
+        "### 账号类型 × 宣称层次 → 过审策略",
+        "",
+        "| 账号类型 | 宣称层次 | 过审策略 | 执行动作 |",
+        "|---|---|---|---|",
+    ])
+    for row in ACCOUNT_CLAIM_STRATEGY_ROWS:
+        lines.append(f"| {row[0]} | {row[1]} | {row[2]} | {row[3]} |")
+    lines.extend([
+        "",
+        "### 产品属性 × 目标人群 → 过审策略",
+        "",
+        "| 产品属性 | 目标人群 | 可承接卖点 | 高危延伸 | 过审策略 |",
+        "|---|---|---|---|---|",
+    ])
+    for row in PRODUCT_PERSON_STRATEGY_ROWS:
+        lines.append(f"| {row[0]} | {row[1]} | {row[2]} | {row[3]} | {row[4]} |")
+    lines.extend([
+        "",
+        "### 卖点维度 × 话术替换公式(含法规依据)",
+        "",
+        "| 卖点维度 | 高危话术 | 替换公式 | 规则依据 | 适用账号 |",
+        "|---|---|---|---|---|",
+    ])
+    for row in SELLING_POINT_REWRITE_ROWS:
+        lines.append(f"| {row[0]} | {row[1]} | {row[2]} | {row[3]} | {row[4]} |")
+    lines.extend([
+        "",
+        "### 善存已发布策略公式库",
+        "",
+        "| 公式 | 适用条件 | 已发布成功经验 | 复用判断 |",
+        "|---|---|---|---|",
+    ])
+    for row in STRATEGY_FORMULA_ROWS:
+        lines.append(f"| {row['formula']} | {row['condition']} | {row['experience']} | {row['reuse']} |")
+    lines.extend([
+        "",
+        "公式使用说明:同一个视频可以命中多个公式,例如“官方店铺账号 + 海外版 + 26 种成分 + 直播活动”应拆成成分事实、跨境版本、价格活动三条边界分别判断;达人账号出现同样内容时,不能自动继承官方/店铺账号的功能宣称能力。",
+        "",
+        "对老板关心的“撞审/边界通过”,本报告不把它写成稳定绿灯,而是用复用判断分为四类:`高可复制`、`需资料核验后复制`、`边界样本需逐条审核`、`不可复制`。后续 Excel 可把这四类做成筛选字段。",
+        "",
+        "## AI可用的过审策略公式清单",
+        "",
+        "这一节把前面的人工判断压缩成可编码规则。后续做成系统时,可以先用规则引擎打底,再让模型只负责解释、归类和生成改写建议,避免模型自由发挥法规结论。",
+        "",
+    ])
+    for group in AI_FORMULA_GROUPS:
+        lines.extend([
+            f"### {group['title']}",
+            "",
+            "| 规则 |",
+            "|---|",
+        ])
+        for item in group["items"]:
+            lines.append(f"| {item} |")
+        lines.append("")
+    lines.extend([
+        "## 与资料文档对齐",
+        "",
+        f"- 资料文件 `QW1_Rule_Pack_ExamplesV0.1.docx` 当前识别到 {s.get('rulePackTableCount', 0)} 个表格、{s.get('rulePackTableRows', 0)} 行表格内容。该文档的结构是“Rule Pack 总览 + 每个 RP 的红灯反例/黄灯样例/绿灯参考/识别线索/推荐改法”。",
+        f"- 当前抖音逐字稿样本命中 {len(observed_rule_ids)}/28 个 RP,扩充后的黄金边界数据集为 {s.get('goldenTranscriptCount', 0)} 行。它不是复制资料文档的规则行,而是把善存抖音真实口播补成可训练案例。",
+        "- 行数仍低于资料文档总行数,是因为本轮只基于已采集且有效转写的善存抖音样本;未出现的 RP 不应强行虚构案例,而应标记为“待补采/待人工构造测试语料”。",
+        "- 因此本版 MD 的置信度提升点在于:每个黄金案例都有真实视频、真实逐字稿上下文、触发词、Rule ID、账号类型、claim 类型、复用判断和边界经验,不再只有摘要结论。",
+        "",
+        "## 审核口径总览",
+        "",
+        "| Rule | 默认状态 | 命中逐字稿数 | 核心触发 | 成功边界经验 |",
+        "|---|---|---:|---|---|",
+    ])
+    for row in report["ruleSummary"]:
+        rule_id = row["rule"]
+        rule_cases = golden_by_rule.get(rule_id, [])
+        trigger_samples = []
+        for case in rule_cases:
+            for item in (case.get("triggerText") or "").split("、"):
+                item = clean(item)
+                if item and item not in trigger_samples:
+                    trigger_samples.append(item)
+                if len(trigger_samples) >= 5:
+                    break
+            if len(trigger_samples) >= 5:
+                break
+        trigger_text = "、".join(trigger_samples) or "见逐字稿上下文"
+        lines.append(
+            f"| {rule_id} {rule_name(rule_id)} | {rule_default_status(rule_id)} | {row['count']} | {snippet(trigger_text, 80)} | {RULE_REWRITE_TEMPLATES.get(rule_id, '回到标签、包装、客服和官方说明口径。')} |"
+        )
+    lines.extend([
+        "",
+        "## 未命中规则与补采建议",
+        "",
+        "以下 RP 在本轮有效逐字稿里没有稳定命中,不代表品牌没有风险,而是当前样本没有足够证据。为了避免“看起来覆盖很全但其实是编造”,本版不为这些 RP 虚构黄金案例。",
+        "",
+        "| 未命中 RP | 默认状态 | 建议补采方向 |",
+        "|---|---|---|",
+    ])
+    for rule_id in unobserved_rule_ids:
+        guide = UNOBSERVED_RULE_SAMPLING_GUIDE.get(
+            rule_id,
+            "继续采集对应风险场景的视频;如业务需要,可用资料文档红黄绿样例人工构造离线测试集。",
+        )
+        lines.append(
+            f"| {rule_id} {rule_name(rule_id)} | {rule_default_status(rule_id)} | {guide} |"
+        )
+    lines.extend([
+        "",
+        "## 品牌级成功经验原则",
+        "",
+        "| 场景 | 已发布稿件常见边界 | 成功经验 | 评论区承接 |",
+        "|---|---|---|---|",
+        "| 善存营养包/PRO 营养包 | “一包就够、全部调配好了、明星都在吃、状态变好”会同时触发一包解决感、达人背书和功效承诺。 | 成功稿件通常把它包装成“省心/方便/日常补充/标签建议”,不直接写成治疗或确定改善。 | 置顶说明适用人群、每日建议、版本差异和客服入口。 |",
+        "| 善存复合维生素/多维 | “一粒补充 26 种、每天一粒就够、冬天也要爱自己”容易把成分数量推成补全或效果。 | 成功稿件保留“26种/22种/一粒”等事实,但把结果承诺让位给商品页、标签和日常营养补充语境。 | 对“效果怎么样”统一回复为不承诺特定效果,建议结合饮食和作息。 |",
+        "| 30+/40+/50+ 人群 | “30+女生、40+精力、老人/爸妈/长辈都适合”容易一概而论。 | 成功稿件把人群作为标签和选购入口,不把人群标签推导为所有人适合或一定有效。 | 用 FAQ 区分 30+/40+/50+、男女版、海外版与国内版。 |",
+        "| 海外/跨境/正品 | “海外版更好、国内外通用、大品牌有保障”容易模糊版本、资质和渠道。 | 成功稿件把海外/正品表达放到渠道、包装、喷码、效期和客服承接,不把版本差异说成效果差异。 | 补充真伪查询、效期、版本差异、售后入口。 |",
+        "| 直播间促销 | “快冲、拍一发四、数量有限、下单就解决状态”容易把促销和功效绑定。 | 官方/店铺号可承接活动规则;达人号若出现强 CTA,应标记为边界样本,不能作为稳定经验直接复制。 | 置顶购买路径,不在评论区反复承诺功效或价格。 |",
+        "",
+        "## 话术模块分布",
+        "",
+        "| 模块 | 有效逐字稿数 |",
+        "|---|---:|",
+    ])
+    for row in report["moduleSummary"]:
+        lines.append(f"| {row['module']} | {row['count']} |")
+    lines.extend(["", "## Rule Pack 命中分布", "", "| 规则 | 命中逐字稿数 |", "|---|---:|"])
+    for row in report["ruleSummary"]:
+        lines.append(f"| {row['rule']} | {row['count']} |")
+    lines.extend([
+        "",
+        "## Rule Pack 逐条复盘",
+        "",
+        "这一节把资料文档里的红黄绿思路翻译成善存抖音口播的真实场景。每个 RP 先给判断口径,再列出本轮逐字稿里最有代表性的 3 条触发样本,方便后续和审核、法务或达人团队逐条校准。",
+        "",
+    ])
+    for row in report["ruleSummary"]:
+        rule_id = row["rule"]
+        rule_cases = golden_by_rule.get(rule_id, [])
+        lines.extend([
+            f"### {rule_id}|{rule_name(rule_id)}",
+            "",
+            f"- 默认动作:{rule_default_status(rule_id)},{rule_audit_action(rule_id)}。",
+            f"- 本轮命中:{row['count']} 条有效逐字稿;高频模块:{RULE_MODULE_MAP.get(rule_id, '按上下文判断')}。",
+            f"- 边界经验:{RULE_REWRITE_TEMPLATES.get(rule_id, '回到标签、包装、客服和官方说明口径。')}",
+            "",
+            "| 案例ID | 账号类型 | 来源 | 触发片段 | 可迁移经验 |",
+            "|---|---|---|---|---|",
+        ])
+        for case in rule_cases[:3]:
+            lines.append(
+                f"| {case['id']} | {case.get('accountType', '')} | {case['author']}:{snippet(case['sourceTitle'], 36)} | {snippet(case.get('triggerText') or case['originalTranscriptSnippet'], 90)} | {snippet(case.get('successExperience', ''), 140)} |"
+            )
+        lines.append("")
+    lines.extend([
+        "",
+        f"## 逐字稿成功边界案例集({s.get('goldenTranscriptCount', 0)} 行)",
+        "",
+        "说明:以下不是按视频抽 12 条,也不是给待审稿写“建议通过版”;它按“有效逐字稿 × Rule Pack 命中”拆行,用已发布内容反推成功边界。同一条视频同时命中多个 RP 时,会沉淀为多条可训练、可复核的边界案例。",
+        "",
+        "| ID | Rule | 账号类型 | claim类型 | 复用判断 | 来源 | 触发片段 | 边界逻辑/成功经验 |",
+        "|---|---|---|---|---|---|---|---|",
+    ])
+    for row in report["goldenTranscriptRows"]:
+        lines.append(
+            f"| {row['id']} | {row.get('ruleId', row['riskRules'])} {row.get('ruleName', '')} | {row.get('accountType', '')} | {row.get('claimType', '')} | {row.get('reusability', '')} | {row['author']}:{snippet(row['sourceTitle'], 32)} | {snippet(row.get('triggerText', ''), 60)} | {snippet(row.get('successExperience', ''), 180)} |"
+        )
+    lines.extend(["", "## 重点逐字稿样本", ""])
+    for row in sorted([r for r in report["transcriptRows"] if r["validTranscript"]], key=lambda r: (r["likeCount"], r["commentCount"]), reverse=True)[:18]:
+        lines.extend([
+            f"### {row['author']}|{snippet(row['title'], 50)}",
+            "",
+            f"- 视频 ID:{row['videoId']};互动:{row['likeCount']} 赞 / {row['commentCount']} 评论;SKU:{row['skuGroup']}",
+            f"- 账号类型:{row.get('accountType', '')};claim 类型:{row.get('claimType', '')};边界逻辑:{row.get('boundaryLogic', '')}",
+            f"- 话术模块:{row['moduleText']}",
+            f"- 命中规则:{row['ruleHits']}({row['ruleNames']})",
+            f"- 逐字稿摘录:{snippet(row['cleanText'], 260)}",
+            f"- 成功经验观察:{account_rule_notes(row.get('accountType', ''))} {row.get('claimCarrier', '')}",
+            "",
+        ])
+    lines.extend([
+        "## 评论改进空间复核",
+        "",
+        "| 评论主题 | 评论数 | 典型评论 | 口播诱因 | 改进动作 | 置顶/客服回复建议 |",
+        "|---|---:|---|---|---|---|",
+    ])
+    for row in report["commentThemeSummary"]:
+        sample_comments = " / ".join(snippet(item, 32) for item in (row.get("sampleComments") or [])[:3])
+        lines.append(
+            f"| {row['theme']} | {row['commentCount']} | {sample_comments} | {COMMENT_TRANSCRIPT_TRIGGERS.get(row['theme'], '由口播、标题、标签和商品页共同触发,需结合视频上下文复核。')} | {row['opportunity']} | {COMMENT_REPLY_TEMPLATES.get(row['theme'], '先确认用户真实问题,再回到商品页、包装标签和客服说明。')} |"
+        )
+    lines.extend([
+        "",
+        "逐字稿确认用户评论里的疑问并不是孤立售后问题,而是被口播触发:当口播强化“就这一包/一粒就够/明星都在吃/状态变好/直播间快冲”时,评论区自然追问功效、正品、版本、适用人群和购买路径。后续投放建议同步改三处:口播降风险、置顶评论答疑、商品卡/客服承接版本和标签信息。",
+        "",
+        "评论区不建议用“亲测有效、放心吃、适合所有人、闭眼入、快冲就完事”这类短回复补火。更稳的做法是把评论分成三类:真实购买疑问交给商品页/客服,功效健康诉求回到日常营养补充和专业咨询,低质互动只做轻回应或不回应。",
+        "",
+        "## 官方/店铺账号清单对照",
+        "",
+        "以下来自 `店铺与账号.xlsx`,用于本轮账号类型判定。清单命中代表“可按官方/店铺账号逻辑分析”,但不自动等同于所有功能 claim 都能说;仍需匹配具体产品批文、广审备案和平台发布身份。",
+        "",
+        "| 店铺 | 直播账号 | 抖音号ID |",
+        "|---|---|---|",
+    ])
+    for row in report.get("officialAccountRecords", []):
+        lines.append(f"| {row.get('store', '')} | {row.get('liveAccount', '')} | {row.get('douyinId', '')} |")
+    lines.extend([
+        "",
+        "## 自检清单(发布前逐条核验)",
+        "",
+        "这份清单用于把黄金数据集变成审核 SOP:每条脚本、标题、字幕、商品卡和评论区回复发布前都按同一顺序扫一遍。命中红灯项时,不建议靠谐音、错别字或画面暗示绕审;应回到产品资料、标签、广审和账号资质。",
+        "",
+        "| 序号 | 核验问题 | 处理动作 |",
+        "|---:|---|---|",
+    ])
+    for row in SELF_CHECK_ROWS:
+        lines.append(f"| {row[0]} | {row[1]} | {row[2]} |")
+    lines.extend([
+        "",
+        "## 下一步建议",
+        "",
+        "1. 继续多跑样本时优先按“官方/店铺账号、疑似店铺账号、达人账号”分层采集,避免把不同账号身份的成功经验混在一起。",
+        "2. 对每条新增样本都打 `账号身份 × 产品属性 × 卖点类型 × 人群标签 × 功效 claim × 承载方式 × 购买引导强度` 标签,累计成可筛选的策略公式库。",
+        "3. 对官方/店铺账号样本补齐产品批文、保健食品功能、广审备案范围和商品页信息,区分“备案内绿灯表达”和“超范围隐喻/撞审表达”。",
+        "4. 对达人样本重点观察隐喻、生活场景、弱化词、个人体验和非强导购路径,形成达人 brief 里的可复制经验与禁用边界。",
+        "5. 对银善存/大脑/脑力类 claim 另建专题:区分直接功能宣称、画面隐喻、角色比喻和备案范围,复核“守门员/前锋”等画面话术为什么能发。",
+    ])
+    return "\n".join(lines)
+
+
+if __name__ == "__main__":
+    main()

+ 226 - 0
tools/build_centrum_douyin_live_workbook.mjs

@@ -0,0 +1,226 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+import { SpreadsheetFile, Workbook } from "@oai/artifact-tool";
+
+const outputDir = path.resolve("outputs/centrum_douyin_audit_2026-06-08/live-full");
+const dataPath = path.join(outputDir, "live-analysis-data.json");
+const data = JSON.parse(await fs.readFile(dataPath, "utf8"));
+
+function rowsFromObjects(items, headers) {
+  return [headers.map(h => h.label), ...items.map(item => headers.map(h => {
+    const value = typeof h.value === "function" ? h.value(item) : item[h.value];
+    if (Array.isArray(value)) return value.join("、");
+    return value ?? "";
+  }))];
+}
+
+function writeRows(sheet, row, col, rows) {
+  if (!rows.length || !rows[0].length) return;
+  sheet.getRangeByIndexes(row, col, rows.length, rows[0].length).values = rows;
+}
+
+function styleRange(range) {
+  range.format = {
+    font: { name: "Microsoft YaHei", size: 10 },
+    wrapText: true,
+    verticalAlignment: "top",
+    borders: { preset: "all", style: "thin", color: "#D9E2EC" },
+  };
+}
+
+function styleHeader(range) {
+  range.format = {
+    fill: "#1F4E79",
+    font: { bold: true, color: "#FFFFFF" },
+    wrapText: true,
+    verticalAlignment: "middle",
+  };
+}
+
+function setWidths(sheet, widths) {
+  widths.forEach(([col, px]) => {
+    sheet.getRange(`${col}:${col}`).format.columnWidthPx = px;
+  });
+}
+
+function styleSheet(sheet, rangeAddress, widths) {
+  sheet.showGridLines = false;
+  if (rangeAddress) styleRange(sheet.getRange(rangeAddress));
+  setWidths(sheet, widths);
+}
+
+function safeSheetRange(rowCount, colCount) {
+  const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+  const col = colCount <= 26 ? letters[colCount - 1] : "Z";
+  return `A1:${col}${Math.max(1, rowCount)}`;
+}
+
+const workbook = Workbook.create();
+
+const summary = workbook.worksheets.add("执行摘要");
+summary.getRange("A1:H1").merge();
+summary.getRange("A1").values = [["善存抖音稿件审核润色与评论 VOC Live 版"]];
+summary.getRange("A1").format = {
+  fill: "#0B1F33",
+  font: { bold: true, color: "#FFFFFF", size: 16 },
+  horizontalAlignment: "center",
+};
+
+const generatedAtText = `生成于 ${String(data.summary.generatedAt || "").replace("T", " ").slice(0, 19)}`;
+const summaryRows = [
+  ["生成时间", generatedAtText, "去重视频样本", data.summary.videoCount],
+  ["去重评论样本", data.summary.commentCount, "请求关键词", data.summary.requestedKeywordCount],
+  ["有效返回关键词", data.summary.effectiveKeywordCount, "接口警告/错误", `${data.summary.warningCount} / ${data.summary.errorCount}`],
+  ["核心结论", "生活场景/达人内容/选购答疑是主要过审路径", "主要风险", "精力、气血、代谢、甲流、大脑、一粒补全"],
+  ["方法/限制", "抖音关键词采集并去重;脚本拆解基于标题、标签、描述、评论和可获取文本", "审核差异口径", "未取得审核前原稿/驳回流水;润色路径为基于已发布稿件和 Rule Pack 的反推"],
+];
+writeRows(summary, 2, 0, summaryRows);
+
+const riskRows = [["风险等级", "视频数"], ...data.summary.riskCounts.map(row => [row.riskLevel, row.videoCount])];
+writeRows(summary, 8, 0, riskRows);
+styleHeader(summary.getRange("A9:B9"));
+const riskChart = summary.charts.add("bar", summary.getRange(`A9:B${8 + riskRows.length}`));
+riskChart.title = "视频风险等级分布";
+riskChart.hasLegend = false;
+riskChart.setPosition("D9", "H24");
+
+const skuRows = [["SKU/方向", "视频数"], ...data.summary.skuCounts.slice(0, 10).map(row => [row.skuGroup, row.videoCount])];
+writeRows(summary, 26, 0, skuRows);
+styleHeader(summary.getRange("A27:B27"));
+styleSheet(summary, "A3:H40", [["A", 150], ["B", 260], ["C", 130], ["D", 180], ["E", 130], ["F", 180], ["G", 130], ["H", 180]]);
+
+const videos = workbook.worksheets.add("视频样本_78");
+const videoHeaders = [
+  { label: "videoId", value: "videoId" },
+  { label: "关键词", value: "keyword" },
+  { label: "SKU/方向", value: "skuGroup" },
+  { label: "作者", value: "author" },
+  { label: "标题/文案", value: "title" },
+  { label: "赞", value: "likeCount" },
+  { label: "评", value: "commentCount" },
+  { label: "转", value: "shareCount" },
+  { label: "藏", value: "collectCount" },
+  { label: "综合分", value: "score" },
+  { label: "风险等级", value: "riskLevel" },
+  { label: "命中规则", value: "ruleHits" },
+  { label: "规则名称", value: "ruleNames" },
+  { label: "抓取评论数", value: "commentCaptured" },
+  { label: "高赞评论", value: "topComment" },
+  { label: "链接", value: "url" },
+];
+const videoRows = rowsFromObjects(data.videoRows, videoHeaders);
+writeRows(videos, 0, 0, videoRows);
+styleHeader(videos.getRangeByIndexes(0, 0, 1, videoHeaders.length));
+styleSheet(videos, `A1:P${videoRows.length}`, [["A", 145], ["B", 150], ["C", 200], ["D", 130], ["E", 330], ["F", 75], ["G", 75], ["H", 75], ["I", 75], ["J", 85], ["K", 90], ["L", 160], ["M", 260], ["N", 90], ["O", 330], ["P", 360]]);
+videos.freezePanes.freezeRows(1);
+
+const themes = workbook.worksheets.add("评论主题");
+const themeHeaders = [
+  { label: "主题", value: "theme" },
+  { label: "评论数", value: "commentCount" },
+  { label: "有赞评论数", value: "likedCommentCount" },
+  { label: "总点赞", value: "totalLikes" },
+  { label: "典型评论", value: row => (row.sampleComments || []).slice(0, 5).join("\n") },
+  { label: "改进动作", value: "opportunity" },
+];
+const themeRows = rowsFromObjects(data.themeSummary, themeHeaders);
+writeRows(themes, 0, 0, themeRows);
+styleHeader(themes.getRange("A1:F1"));
+styleSheet(themes, `A1:F${themeRows.length}`, [["A", 180], ["B", 85], ["C", 95], ["D", 85], ["E", 420], ["F", 460]]);
+themes.freezePanes.freezeRows(1);
+
+const comments = workbook.worksheets.add("评论明细_471");
+const commentHeaders = [
+  { label: "commentId", value: "commentId" },
+  { label: "videoId", value: "videoId" },
+  { label: "视频标题", value: "videoTitle" },
+  { label: "作者", value: "videoAuthor" },
+  { label: "关键词", value: "keyword" },
+  { label: "主题", value: "themeText" },
+  { label: "评论", value: "text" },
+  { label: "点赞", value: "likeCount" },
+  { label: "回复数", value: "replyCount" },
+  { label: "IP", value: "ipLocation" },
+];
+const commentRows = rowsFromObjects(data.commentRows, commentHeaders);
+writeRows(comments, 0, 0, commentRows);
+styleHeader(comments.getRange("A1:J1"));
+styleSheet(comments, `A1:J${commentRows.length}`, [["A", 160], ["B", 145], ["C", 320], ["D", 120], ["E", 145], ["F", 190], ["G", 460], ["H", 70], ["I", 70], ["J", 90]]);
+comments.freezePanes.freezeRows(1);
+
+const goldens = workbook.worksheets.add("黄金改写集");
+const goldenHeaders = [
+  { label: "ID", value: "id" },
+  { label: "风险原型", value: "riskPrototype" },
+  { label: "命中规则", value: "ruleHits" },
+  { label: "风险表达", value: "beforeExample" },
+  { label: "建议通过版", value: "afterExample" },
+  { label: "为什么这样改", value: "why" },
+  { label: "样本依据", value: "sourceEvidence" },
+];
+const goldenRows = rowsFromObjects(data.goldenRows, goldenHeaders);
+writeRows(goldens, 0, 0, goldenRows);
+styleHeader(goldens.getRange("A1:G1"));
+styleSheet(goldens, `A1:G${goldenRows.length}`, [["A", 105], ["B", 250], ["C", 150], ["D", 300], ["E", 370], ["F", 310], ["G", 220]]);
+goldens.freezePanes.freezeRows(1);
+
+const scripts = workbook.worksheets.add("脚本拆解");
+const scriptHeaders = [
+  { label: "videoId", value: "videoId" },
+  { label: "标题", value: "title" },
+  { label: "作者", value: "author" },
+  { label: "SKU/方向", value: "skuGroup" },
+  { label: "钩子类型", value: "hookType" },
+  { label: "当前结构", value: "currentStructure" },
+  { label: "风险规则", value: "riskRules" },
+  { label: "可复用改法", value: "reusableFix" },
+];
+const scriptRows = rowsFromObjects(data.scriptBreakdowns, scriptHeaders);
+writeRows(scripts, 0, 0, scriptRows);
+styleHeader(scripts.getRange("A1:H1"));
+styleSheet(scripts, `A1:H${scriptRows.length}`, [["A", 145], ["B", 320], ["C", 120], ["D", 210], ["E", 180], ["F", 330], ["G", 180], ["H", 380]]);
+scripts.freezePanes.freezeRows(1);
+
+const keywords = workbook.worksheets.add("关键词与采集日志");
+const keywordRows = [
+  ["请求关键词", data.requestedKeywords.join("、")],
+  ["有效返回关键词", data.effectiveKeywords.join("、")],
+  ["警告数", data.summary.warningCount],
+  ["错误数", data.summary.errorCount],
+  ["说明", "部分关键词/单条评论页 fetch failed,已保留成功采集的视频与评论;未写入 VOC token。"],
+  ["限制", "未取得品牌审核前原稿、驳回原因、修改版本流水和最终通过稿的一一对应关系;本轮输出不等同逐条原稿差异审计。"],
+];
+writeRows(keywords, 0, 0, keywordRows);
+const errorsStart = keywordRows.length + 2;
+const errorRows = [["stage", "keyword", "videoId", "message", "httpStatus"], ...data.errors.map(err => [
+  err.stage || "",
+  err.keyword || "",
+  err.videoId || "",
+  err.message || "",
+  err.httpStatus || "",
+])];
+writeRows(keywords, errorsStart, 0, errorRows);
+styleHeader(keywords.getRangeByIndexes(errorsStart, 0, 1, 5));
+styleSheet(keywords, `A1:E${errorsStart + errorRows.length}`, [["A", 160], ["B", 260], ["C", 150], ["D", 480], ["E", 90]]);
+
+const inspect = await workbook.inspect({
+  kind: "match",
+  searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A",
+  options: { useRegex: true, maxResults: 100 },
+  summary: "formula error scan",
+});
+
+for (const sheetName of ["执行摘要", "视频样本_78", "评论主题", "黄金改写集", "脚本拆解", "关键词与采集日志"]) {
+  const preview = await workbook.render({ sheetName, autoCrop: "all", scale: 1, format: "png" });
+  await fs.writeFile(path.join(outputDir, `${sheetName}.png`), new Uint8Array(await preview.arrayBuffer()));
+}
+
+const xlsx = await SpreadsheetFile.exportXlsx(workbook);
+const xlsxPath = path.join(outputDir, "善存抖音稿件审核润色与评论VOC-live.xlsx");
+await xlsx.save(xlsxPath);
+
+console.log(JSON.stringify({
+  status: "ok",
+  xlsxPath,
+  formulaScan: inspect.ndjson,
+}, null, 2));

+ 547 - 0
tools/build_centrum_douyin_outputs.mjs

@@ -0,0 +1,547 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+import { SpreadsheetFile, Workbook } from "@oai/artifact-tool";
+
+const outputDir = path.resolve("outputs/centrum_douyin_audit_2026-06-08");
+const generatedAt = "2026-06-08";
+
+const sources = {
+  rulePack: "E:/workspace/openclaw-voc-skill/inputs/QW1_Rule_Pack_ExamplesV0.1.docx",
+  liveAttempt: "node claude-code/claude-code-voc-intelligence/mcp/src/tools/douyin-trend-run.js --collection-mode live ...",
+  douyinPrenatalHub: "https://www.douyin.com/shipin/7354325556445399075",
+  douyinMenAuthenticity: "https://www.douyin.com/shipin/7356548156433287195",
+  douyinBaikeli: "https://www.douyin.com/user/MS4wLjABAAAAzuNaUb6R1GvlPXhJuVKj5Kzz_8hzB6IOudRSokplS20",
+  douyinOlga: "https://www.douyin.com/user/MS4wLjABAAAAWwcXLQaOlIV4k04tSI4xYaYmCzRZt1a9_IDDutj7Wzra_yNzBUDrPQgV8UVJ_dsH",
+  douyinGeslook: "https://www.douyin.com/user/MS4wLjABAAAA2NBxS22UFoP99jNw5s5szwbpy1IHK8Ugl2t3LiXZu0GX9yKfoFtwIrqyilASUYxC",
+  centrumMen30: "https://centrum.com.hk/products/centrum-specialty-nutritional-blend/men-30-plus/",
+  oceanEngineRules: "https://support.oceanengine.com/support/content/130539",
+  douyinCrossBorderGovernance: "https://www.chinanews.com/cj/2026/05-26/10628777.shtml",
+  douyinQ1Report: "https://pdf.dfcfw.com/pdf/H3_AP202508211731540280_1.pdf?1755772760000.pdf="
+};
+
+const sampleRows = [
+  {
+    id: "DY-01",
+    sku: "善存孕妇/复合维生素相关竞品语境",
+    sourceType: "公开索引 shipin 页面",
+    author: "走丢的木头人",
+    title: "孕妇复合维生素口碑最好的前十名",
+    publishDate: "2026-05-21",
+    interaction: "23赞 / 4评 / 11藏 / 11转(页面索引)",
+    url: sources.douyinPrenatalHub,
+    evidence: "孕妇复维红黑榜、排名、体验型文稿;主页面与相关视频可见 AI 文稿。",
+    likelyApprovedEdits: "用红黑榜/成分体验替代直接治疗,但仍出现特殊人群和功效暗示。",
+    riskLevel: "高",
+    ruleHits: "RP03, RP04, RP07, RP11, RP15",
+    commentSignal: "评论全文不可公开索引;页面评论数低,相关视频主要吸引选购/踩坑型互动。",
+    improvement: "如善存自有稿件覆盖孕期,必须收回到标签、适用人群、用量和专业咨询,不做胎儿/产检/孕反改善承诺。"
+  },
+  {
+    id: "DY-02",
+    sku: "善存孕妇复合维生素(相关视频提及)",
+    sourceType: "相关视频直链",
+    author: "三傻",
+    title: "孕期入手的孕妇复合维生素到底该怎么选",
+    publishDate: "2026-05 页面索引",
+    interaction: "相关页显示 2 条左右互动入口,需 API 复核",
+    url: "https://www.douyin.com/video/7625973759477283170",
+    evidence: "把善存描述为基础营养补充,并对比其他品牌的复配成分。",
+    likelyApprovedEdits: "将善存定位为基础补充,没有直接写治疗;竞品对比力度较强。",
+    riskLevel: "中高",
+    ruleHits: "RP03, RP07, RP11, RP14",
+    commentSignal: "潜在评论点为基础/全面/性价比/孕吐场景,缺评论正文。",
+    improvement: "弱化竞品拉踩;补充版本与适用人群边界。"
+  },
+  {
+    id: "DY-03",
+    sku: "善存孕妇复合维生素(相关视频提及)",
+    sourceType: "相关视频直链",
+    author: "福宝麻麻",
+    title: "孕妇复合维生素实测!热门孕妇复合维生素选哪家?",
+    publishDate: "2026-05 页面索引",
+    interaction: "相关页显示低互动,需 API 复核",
+    url: "https://www.douyin.com/video/7641791455262231478",
+    evidence: "善存被称为经典老牌、孕早期稳妥,同时出现颗粒大、吞咽费劲等体验反馈。",
+    likelyApprovedEdits: "正向点控制在品牌/基础营养;负向点来自用户体验。",
+    riskLevel: "中",
+    ruleHits: "RP03, RP07, RP11, RP21",
+    commentSignal: "体验负反馈集中在颗粒大小、反胃、吞咽压力。",
+    improvement: "自有稿件可主动回应颗粒/吞咽/服用时机,并引导客服给 SKU 版本说明。"
+  },
+  {
+    id: "DY-04",
+    sku: "跨境营养品/善存海外相关信任语境",
+    sourceType: "相关视频直链",
+    author: "美好孕育",
+    title: "孕妇复合维生素推荐 1 点很关键",
+    publishDate: "2026-05 页面索引",
+    interaction: "相关页显示 9 条左右互动入口,需 API 复核",
+    url: "https://www.douyin.com/video/7638575776710905131",
+    evidence: "强调海外营养品安全标准、说明与中国标准差异,核心是信任与风险自担。",
+    likelyApprovedEdits: "非善存定向稿,但暴露跨境版本信任风险。",
+    riskLevel: "中高",
+    ruleHits: "RP05, RP06, RP14, RP21, RP25",
+    commentSignal: "用户会追问安全标准、正品渠道、版本区别。",
+    improvement: "善存跨境 SKU 应在商品卡/置顶评论解释版本、购买渠道、包装信息,不用模糊海外身份做背书。"
+  },
+  {
+    id: "DY-05",
+    sku: "善存复合维生素男士海外版",
+    sourceType: "公开索引 shipin 页面",
+    author: "在大草原上唱歌",
+    title: "善存复合维生素男士海外版怎么看正品",
+    publishDate: "2024-04-11",
+    interaction: "12赞 / 44评 / 1藏 / 1转(搜索索引)",
+    url: sources.douyinMenAuthenticity,
+    evidence: "标题直接是正品辨别、天猫国际、喷墨、到期时间等问题。",
+    likelyApprovedEdits: "用户自发内容不是品牌稿,但高度提示品牌投放后的评论疑虑。",
+    riskLevel: "中",
+    ruleHits: "RP05, RP06, RP18, RP21",
+    commentSignal: "评论数显著高于赞,说明用户更想问真假/批号/效期/渠道。",
+    improvement: "投放稿件下方必须有置顶正品辨别、效期、官方店铺与客服答疑。"
+  },
+  {
+    id: "DY-06",
+    sku: "善存PRO营养包 / 善存营养包",
+    sourceType: "达人账号页公开索引",
+    author: "主持人百克力",
+    title: "中年夫妻长续婚姻靠的是什么?",
+    publishDate: "2026-03 左右(账号页最新作品区)",
+    interaction: "1.3万(账号页作品卡索引)",
+    url: sources.douyinBaikeli,
+    evidence: "内容以婚姻/中年关系为主,标签含善存PRO营养包、善存营养包。",
+    likelyApprovedEdits: "产品从功效话术退到中年生活场景和关系叙事,降低合规风险。",
+    riskLevel: "中",
+    ruleHits: "RP03, RP13, RP21, RP22",
+    commentSignal: "互动可能更多围绕婚姻议题,而非 SKU;商品承接和评论答疑容易不足。",
+    improvement: "评论区补一个中性产品说明:适合关注日常营养补充的 30+/40+ 人群,按标签建议。"
+  },
+  {
+    id: "DY-07",
+    sku: "银善存脑力瓶 / 银善存大脑维生素",
+    sourceType: "达人账号页公开索引",
+    author: "Olga姐姐",
+    title: "35年外企高管教你精力管理,易累体质必看!",
+    publishDate: "2025-11 左右(账号页索引)",
+    interaction: "3998(账号页作品卡索引,需 API 复核)",
+    url: sources.douyinOlga,
+    evidence: "职场、精力管理、年龄增长与竞争力下降的叙事,账号页片段含银善存标签。",
+    likelyApprovedEdits: "从直接认知功效转为职场经验访谈,但仍用年龄/精力焦虑做钩子。",
+    riskLevel: "中高",
+    ruleHits: "RP02, RP03, RP09, RP13, RP19, RP22",
+    commentSignal: "评论大概率围绕职场焦虑、年龄、精力,而非成分;需补抓验证。",
+    improvement: "把'大脑/竞争力/易累体质'改成'关注日常营养与生活节律',专家身份不延伸为健康背书。"
+  },
+  {
+    id: "DY-08",
+    sku: "银善存脑力瓶 / 银善存大脑维生素",
+    sourceType: "达人账号页公开索引",
+    author: "歌白说Geslook",
+    title: "一定要大量阅读,真的可以改命",
+    publishDate: "2026-04 左右(账号页索引)",
+    interaction: "3.0万(账号页作品卡索引)",
+    url: sources.douyinGeslook,
+    evidence: "阅读/知识内容与银善存大脑维生素、脑力瓶、京东活动标签结合。",
+    likelyApprovedEdits: "品牌露出放在知识内容标签中,主体不直接宣称产品改变认知。",
+    riskLevel: "中",
+    ruleHits: "RP01, RP03, RP13, RP16, RP22",
+    commentSignal: "高互动来自阅读/改命议题,产品问题可能被淹没。",
+    improvement: "保留阅读主题,但避免'改命'和'大脑维生素'相邻造成暗示;置顶评论补充非治疗、非药物边界。"
+  }
+];
+
+const goldenRows = [
+  {
+    id: "GD-01",
+    observedRisk: "全网第一、唯一有效、改命、最强红榜",
+    ruleHits: "RP01, RP04, RP11",
+    riskLevel: "红/黄边界",
+    rewrite: "可作为日常营养补充选择之一;本内容仅基于成分信息与个人使用场景做分享。",
+    why: "去掉绝对化和排名承诺,把判断依据收回到成分/场景。",
+    sourceSample: "DY-01/DY-08"
+  },
+  {
+    id: "GD-02",
+    observedRisk: "大脑维生素、脑力瓶、年龄增长=职场竞争力下降",
+    ruleHits: "RP02, RP03, RP09, RP13",
+    riskLevel: "高",
+    rewrite: "30+/50+ 人群可以关注日常营养、作息和学习习惯;如需补充维生素矿物质,请按标签建议选择。",
+    why: "避免暗示改善认知/疾病/竞争力,把产品放到生活管理的辅助位置。",
+    sourceSample: "DY-07/DY-08"
+  },
+  {
+    id: "GD-03",
+    observedRisk: "易累体质必看、能量源源不断、精气神马上上来",
+    ruleHits: "RP03, RP04, RP13",
+    riskLevel: "中高",
+    rewrite: "忙碌阶段更容易忽略饮食均衡,可结合三餐、运动、休息和标签建议做日常营养补充。",
+    why: "保留场景张力,但不承诺身体状态变化。",
+    sourceSample: "DY-06/DY-07"
+  },
+  {
+    id: "GD-04",
+    observedRisk: "产检一路绿灯、胎儿发育稳稳的、改善胎儿偏小",
+    ruleHits: "RP02, RP03, RP04, RP07, RP15",
+    riskLevel: "红",
+    rewrite: "孕期/备孕/哺乳期属于特殊阶段,补充产品和用量建议先咨询医生或专业人士,并以包装说明为准。",
+    why: "特殊人群不能用结果承诺、胎儿发育或产检结果做产品功效。",
+    sourceSample: "DY-01/DY-02/DY-03"
+  },
+  {
+    id: "GD-05",
+    observedRisk: "跨境版本等同蓝帽保健功能、海外标准更安全",
+    ruleHits: "RP05, RP06, RP14, RP25",
+    riskLevel: "高",
+    rewrite: "请以当前购买页面、包装标签和官方客服说明为准;不同渠道/版本的包装、配方和适用说明可能不同。",
+    why: "版本与法规属性要清晰,避免跨属性套用。",
+    sourceSample: "DY-04/DY-05"
+  },
+  {
+    id: "GD-06",
+    observedRisk: "普通食品/营养品能治疗、预防、缓解病症",
+    ruleHits: "RP02, RP03, RP05",
+    riskLevel: "红",
+    rewrite: "本品为日常营养补充,不替代药物或治疗;如有疾病或特殊健康状况,请咨询专业人士。",
+    why: "与巨量引擎广告规范中虚假宣传/保健医疗混淆风险一致。",
+    sourceSample: "规则包/OceanEngine"
+  },
+  {
+    id: "GD-07",
+    observedRisk: "智商税、黑榜、别买、竞品不行",
+    ruleHits: "RP11, RP13, RP22",
+    riskLevel: "中高",
+    rewrite: "不同产品在成分组合、剂型、预算和适用阶段上各有侧重,建议按自身需求选择。",
+    why: "弱化贬损与不公平比较,保留选购指导。",
+    sourceSample: "DY-01/DY-02/DY-03"
+  },
+  {
+    id: "GD-08",
+    observedRisk: "营养师/专家/外企高管身份推导产品有效",
+    ruleHits: "RP14, RP19",
+    riskLevel: "中",
+    rewrite: "嘉宾分享仅代表个人经验;产品信息以包装、标签和品牌确认材料为准。",
+    why: "身份可服务内容可信度,不能替代 MA/RA/Legal 证据。",
+    sourceSample: "DY-01/DY-07"
+  },
+  {
+    id: "GD-09",
+    observedRisk: "全家都适合、孕妇儿童老人随便吃",
+    ruleHits: "RP07, RP15",
+    riskLevel: "红/黄边界",
+    rewrite: "适用人群、用法用量请以产品标签或说明书为准;特殊人群建议先咨询专业人士。",
+    why: "不泛化适用人群,不自行给用量建议。",
+    sourceSample: "规则包"
+  },
+  {
+    id: "GD-10",
+    observedRisk: "正品疑问、效期、喷码、渠道不清",
+    ruleHits: "RP05, RP06, RP18, RP21",
+    riskLevel: "中",
+    rewrite: "购买前可核对官方授权渠道、包装版本、喷码/批号和效期;疑问请联系店铺客服。",
+    why: "把评论区信任问题前置为购买指引,降低售后和投诉风险。",
+    sourceSample: "DY-05"
+  }
+];
+
+const commentRows = [
+  ["正品/渠道信任", "DY-05", "44评高于12赞,标题即为真假辨别", "高", "置顶正品辨别、官方渠道、效期/喷码说明;客服关键词:真假、喷码、效期、天猫国际"],
+  ["颗粒/吞咽/反胃体验", "DY-03", "孕期复维相关视频多次提到颗粒大、吞咽费劲、反胃", "中", "发布“怎么吃更舒服/何时吃/能否掰开”答疑,注意必须以标签为准"],
+  ["基础补充 vs 全面复配", "DY-02/DY-03", "善存被放在基础营养补充语境,竞品常强调复配更多", "中高", "把善存定位为分龄/日常营养管理,补清每个 SKU 的成分和适用场景"],
+  ["特殊人群安全", "DY-01/DY-04", "孕期视频容易出现胎儿/产检/孕反改善类诉求", "高", "避免主动投放孕期效果;如涉及特殊人群,固定专业咨询与标签边界"],
+  ["故事高互动但商品弱承接", "DY-06/DY-08", "婚姻/阅读/职场议题有高互动,产品只在标签或软植入", "中", "在不增加功效风险的前提下,用置顶评论承接 SKU、版本和购买疑问"],
+  ["年龄/精力焦虑", "DY-07", "年龄增长、精力管理、职场竞争力等钩子有效但有恐吓/功效暗示风险", "中高", "改为生活节律与日常营养支持,避免‘不补就下降’表达"]
+];
+
+const scriptRows = [
+  ["生活关系钩子", "DY-06", "中年夫妻/长续婚姻", "先讲人群故事,再用标签/轻口播承接产品", "商品弱承接,评论容易只聊关系", "结尾增加中性 SKU 答疑:适用人群、版本、标签建议。"],
+  ["职场精力钩子", "DY-07", "外企高管/精力管理/易累体质", "用经验访谈代替直接功效承诺", "大脑、竞争力、疲劳体质易触发 RP02/RP03/RP09", "改成作息、饮食、运动、日常营养支持,不暗示不补就下降。"],
+  ["知识成长钩子", "DY-08", "阅读/改命/自我提升", "产品退到标签或活动承接,主体讲知识内容", "改命与大脑维生素相邻会形成认知改善暗示", "保留阅读主题,产品句只说日常营养补充和标签建议。"],
+  ["选购评测钩子", "DY-01/DY-03", "红黑榜/实测/热门产品怎么选", "用对比和体验引发互动", "竞品贬损、特殊人群、效果排名风险高", "改为成分、剂型、预算、吞咽体验等中性维度。"],
+  ["信任答疑钩子", "DY-05", "怎么看正品/喷码/效期", "围绕用户问题做购买前确认", "版本和渠道不清会放大投诉风险", "置顶评论提供官方渠道、效期、包装版本和客服路径。"]
+];
+
+const ruleRows = [
+  ["RP01", "绝对化/保证性/最高级", "Active-Blocking", "去掉唯一、第一、100%、保证、立刻见效等表达"],
+  ["RP02", "疾病治疗/治愈/诊断暗示", "Active-Blocking", "删除病症、治疗、预防、医生推荐式药效表达"],
+  ["RP03", "未经确认功效/健康利益宣称", "Active-Blocking", "回到已确认 Claim、包装、标签、备案或说明书"],
+  ["RP04", "数字/实验/前后对比造成明确功效承诺", "Active-Blocking", "不使用人体结果数值、前后对比、产检/指标变化承诺"],
+  ["RP05", "法规属性误用/三品一械边界错误", "Active-Blocking", "不同渠道/跨境/保健食品/普通食品版本不能套用宣称"],
+  ["RP06", "包装/标签/备案/说明书不一致", "Active-Blocking", "画面、字幕、口播、商品卡必须对齐同一 SKU/版本"],
+  ["RP07", "用量/用法/禁忌/适用人群误导", "Active-Blocking", "用量、适用人群按标签;特殊人群咨询专业人士"],
+  ["RP11", "竞品贬损/虚假比较/不公平竞争", "Active-Blocking", "红黑榜、智商税、别买等表达要弱化为场景差异"],
+  ["RP14", "证据引用不足或上下文不清", "Advisory", "研究、专家、数据必须匹配 SKU、剂量、对象、场景"],
+  ["RP16", "平台敏感词/卡审词疑似命中", "Advisory", "记录候选,不因一次卡审直接升级正式规则"],
+  ["RP19", "达人/KOL/KOC/专家身份边界", "Advisory", "真实专家也不能超出资质和产品证据发言"],
+  ["RP21", "CTA/购买引导/场景导向不清", "Advisory", "补商品版本、购买路径、客服答疑,不诱导治疗性购买"]
+];
+
+const limitationRows = [
+  ["VOC live 采集", "已尝试", "needs_token", "当前工作区未检测到 VOC/Douyin token,无法批量抓取评论全文和更多视频。"],
+  ["公开网页索引", "已使用", "partial", "可看到部分 shipin 页面 AI 文稿、互动数、账号页作品卡;评论正文覆盖不足。"],
+  ["逐字稿拆解", "已使用/可复跑", "manual transcript", "使用公开 AI 文稿/人工摘录进入脚本拆解;正式报告建议用视频 ASR 或平台 AI 文稿补齐。"],
+  ["审核前后版本对比", "缺源", "needs_material", "目前没有品牌审核前原稿、驳回原因、通过版 Audit ID;本报告为已发布稿件的合规推断与黄金改写样本。"],
+  ["覆盖范围", "受限", "web-indexed", "公开索引覆盖 8 个样本/账号线索;配置 token 后可扩展到 10 关键词 x 10视频 x 5页评论。"]
+];
+
+const riskSummary = [
+  ["高", sampleRows.filter(row => row.riskLevel === "高").length],
+  ["中高", sampleRows.filter(row => row.riskLevel === "中高").length],
+  ["中", sampleRows.filter(row => row.riskLevel === "中").length]
+];
+
+function toMatrix(rows, headers) {
+  return [headers, ...rows.map(row => headers.map(header => row[header] ?? ""))];
+}
+
+function applyHeader(range) {
+  range.format = {
+    fill: "#1F4E79",
+    font: { bold: true, color: "#FFFFFF" },
+    wrapText: true
+  };
+}
+
+function styleSheet(sheet, usedRangeAddress, widths = []) {
+  sheet.showGridLines = false;
+  if (usedRangeAddress) {
+    const range = sheet.getRange(usedRangeAddress);
+    range.format = {
+      font: { name: "Microsoft YaHei", size: 10 },
+      wrapText: true,
+      verticalAlignment: "top",
+      borders: { preset: "all", style: "thin", color: "#D9E2EC" }
+    };
+  }
+  widths.forEach(([col, px]) => {
+    sheet.getRange(`${col}:${col}`).format.columnWidthPx = px;
+  });
+}
+
+function writeRows(sheet, startRow, startCol, rows) {
+  sheet.getRangeByIndexes(startRow, startCol, rows.length, rows[0].length).values = rows;
+}
+
+function buildMarkdown() {
+  const evidenceCards = sampleRows.map((row, index) => [
+    `### ${index + 1}. ${row.title}`,
+    `- SKU/方向:${row.sku}`,
+    `- 作者/来源:${row.author}|${row.sourceType}`,
+    `- 互动:${row.interaction}`,
+    `- 链接:${row.url}`,
+    `- 值得看:${row.evidence}`,
+    `- 审核润色推断:${row.likelyApprovedEdits}`,
+    `- 评论/VOC信号:${row.commentSignal}`,
+    `- 改进空间:${row.improvement}`,
+    ""
+  ].join("\n")).join("\n");
+
+  const goldenTable = goldenRows.map(row =>
+    `| ${row.id} | ${row.observedRisk} | ${row.ruleHits} | ${row.rewrite} | ${row.why} |`
+  ).join("\n");
+
+  const commentTable = commentRows.map(row => `| ${row.join(" | ")} |`).join("\n");
+  const scriptTable = scriptRows.map(row => `| ${row.join(" | ")} |`).join("\n");
+
+  return `# 善存抖音稿件审核润色与评论 VOC 初步报告
+
+生成日期:${generatedAt}
+
+## 一句话结论
+
+本轮可公开索引样本显示:善存相关抖音内容已经明显采用“生活场景/访谈/知识内容 + 产品标签或轻口播”的方式降低硬功效风险;但银善存脑力瓶、善存 PRO 营养包与孕妇复维相关语境里,仍容易滑向“精力/大脑/产检/胎儿/年龄焦虑/竞品红黑榜”等高风险表达。评论层面,公开网页无法抓到完整评论正文;能看到的强信号是正品渠道、版本效期、颗粒吞咽、基础补充是否足够、特殊人群安全边界。
+
+## 采集与限制
+
+- 已调用本地 \`claude-code-voc-intelligence\` 抖音 live 采集入口,状态为 \`needs_token\`:当前工作区没有 VOC/Douyin token,无法批量抓取更多视频和评论全文。
+- 已使用公开网页索引补样:抖音 \`shipin\` 页面、账号页作品卡、AI 文稿片段、互动数。
+- 已提取资料包:\`${sources.rulePack}\`,得到 28 个 Rule Pack、红黄绿样例、推荐改法与供应商配置字段。
+- 因缺少品牌审核前原稿、驳回记录、通过版 Audit ID,本报告里的“做了哪些润色和修改”是基于已发布稿件 + QW1 规则包 + 平台规则的合规推断,不等同于逐条审核日志复盘。
+
+## 已发布稿件的润色路径
+
+1. 从功效承诺改成生活场景:PRO 营养包样本借中年夫妻、40岁生活、关系经营切入,产品多在标签或轻植入出现。
+2. 从医疗/改善结果改成日常营养:较合规的表达会说基础营养、标签建议、日常补充,不直接说治疗或指标改善。
+3. 从产品硬广改成达人内容:银善存样本用阅读、职场、精力管理内容带出品牌,但要警惕“大脑维生素”“竞争力下降”等暗示。
+4. 从单向卖点改成评论答疑:正品、渠道、效期、版本差异应前置到置顶评论和商品卡,否则用户会在评论区追问。
+5. 从竞品拉踩改成场景差异:孕妇复维类红黑榜最容易违规,适合改成不同阶段、预算、剂型、吞咽体验的中性比较。
+
+## 黄金改写数据集
+
+| ID | 风险原型 | 规则命中 | 建议通过版 | 为什么这样改 |
+|---|---|---|---|---|
+${goldenTable}
+
+## 评论/VOC 改进空间
+
+| 主题 | 样本 | 证据 | 优先级 | 建议动作 |
+|---|---|---|---|---|
+${commentTable}
+
+## 逐字稿/脚本结构拆解
+
+| 结构类型 | 样本 | 开头钩子 | 当前承接方式 | 主要风险 | 可复用改法 |
+|---|---|---|---|---|---|
+${scriptTable}
+
+## 样本案例
+
+${evidenceCards}
+
+## 建议补采命令
+
+\`\`\`powershell
+node claude-code/claude-code-voc-intelligence/mcp/src/tools/douyin-trend-run.js \\
+  --collection-mode live \\
+  --project "善存抖音稿件审核与评论VOC" \\
+  --industry "营养健康/膳食补充剂" \\
+  --keywords "善存营养包,善存PRO营养包,银善存脑力瓶,善存复合维生素,善存孕妇复合维生素,善存多维元素片,善存佳维片,善存小佳维,Centrum善存,善存海外营养包" \\
+  --keyword-limit 10 --videos-per-keyword 10 --max-comment-pages 5 \\
+  --output outputs/centrum_douyin_audit_2026-06-08/live-full
+\`\`\`
+
+## 外部规则与产品边界来源
+
+- Centrum HK 男士30+产品页:${sources.centrumMen30}
+- 巨量引擎广告投放管理规范:${sources.oceanEngineRules}
+- 抖音跨境品牌资质与带货合规倡议报道:${sources.douyinCrossBorderGovernance}
+- 抖音 2025Q1 高增行业与爆款复盘 PDF:${sources.douyinQ1Report}
+`;
+}
+
+async function buildWorkbook() {
+  const workbook = Workbook.create();
+
+  const summary = workbook.worksheets.add("执行摘要");
+  summary.getRange("A1:H1").merge();
+  summary.getRange("A1").values = [["善存抖音稿件审核润色与评论 VOC 初步汇总"]];
+  summary.getRange("A1").format = {
+    fill: "#0B1F33",
+    font: { bold: true, color: "#FFFFFF", size: 16 },
+    horizontalAlignment: "center"
+  };
+  const kpiRows = [
+    ["生成日期", generatedAt, "样本数", sampleRows.length],
+    ["黄金改写样本", goldenRows.length, "评论/VOC主题", commentRows.length],
+    ["live采集状态", "needs_token", "结论等级", "初步判断,待补采校准"],
+    ["主要高风险", "孕期/胎儿/大脑/精力/正品", "建议", "补抓评论全文 + 审核前后稿"]
+  ];
+  writeRows(summary, 2, 0, kpiRows);
+  const riskTable = [["风险等级", "样本数"], ...riskSummary];
+  writeRows(summary, 8, 0, riskTable);
+  applyHeader(summary.getRange("A9:B9"));
+  const chart = summary.charts.add("bar", summary.getRange("A9:B12"));
+  chart.title = "样本风险等级分布";
+  chart.hasLegend = false;
+  chart.setPosition("D9", "H24");
+  styleSheet(summary, "A3:H24", [["A", 150], ["B", 190], ["C", 110], ["D", 120], ["E", 160], ["F", 190], ["G", 110], ["H", 140]]);
+
+  const samples = workbook.worksheets.add("样本_抖音稿件");
+  const sampleHeaders = ["id", "sku", "sourceType", "author", "title", "publishDate", "interaction", "url", "evidence", "likelyApprovedEdits", "riskLevel", "ruleHits", "commentSignal", "improvement"];
+  const sampleMatrix = toMatrix(sampleRows, sampleHeaders);
+  writeRows(samples, 0, 0, sampleMatrix);
+  applyHeader(samples.getRangeByIndexes(0, 0, 1, sampleHeaders.length));
+  styleSheet(samples, `A1:N${sampleMatrix.length}`, [["A", 70], ["B", 180], ["C", 130], ["D", 110], ["E", 240], ["F", 120], ["G", 160], ["H", 260], ["I", 260], ["J", 240], ["K", 80], ["L", 140], ["M", 240], ["N", 300]]);
+  samples.freezePanes.freezeRows(1);
+
+  const golden = workbook.worksheets.add("黄金改写集");
+  const goldenHeaders = ["id", "observedRisk", "ruleHits", "riskLevel", "rewrite", "why", "sourceSample"];
+  const goldenMatrix = toMatrix(goldenRows, goldenHeaders);
+  writeRows(golden, 0, 0, goldenMatrix);
+  applyHeader(golden.getRangeByIndexes(0, 0, 1, goldenHeaders.length));
+  styleSheet(golden, `A1:G${goldenMatrix.length}`, [["A", 75], ["B", 260], ["C", 140], ["D", 100], ["E", 330], ["F", 280], ["G", 120]]);
+  golden.freezePanes.freezeRows(1);
+
+  const comments = workbook.worksheets.add("评论VOC");
+  const commentMatrix = [["主题", "样本", "证据", "优先级", "建议动作"], ...commentRows];
+  writeRows(comments, 0, 0, commentMatrix);
+  applyHeader(comments.getRange("A1:E1"));
+  styleSheet(comments, `A1:E${commentMatrix.length}`, [["A", 170], ["B", 120], ["C", 300], ["D", 90], ["E", 360]]);
+  comments.freezePanes.freezeRows(1);
+
+  const scripts = workbook.worksheets.add("脚本拆解");
+  const scriptMatrix = [["结构类型", "样本", "开头钩子", "当前承接方式", "主要风险", "可复用改法"], ...scriptRows];
+  writeRows(scripts, 0, 0, scriptMatrix);
+  applyHeader(scripts.getRange("A1:F1"));
+  styleSheet(scripts, `A1:F${scriptMatrix.length}`, [["A", 150], ["B", 120], ["C", 210], ["D", 260], ["E", 300], ["F", 360]]);
+  scripts.freezePanes.freezeRows(1);
+
+  const rules = workbook.worksheets.add("规则映射");
+  const ruleMatrix = [["Rule ID", "规则包", "状态", "本项目用法"], ...ruleRows];
+  writeRows(rules, 0, 0, ruleMatrix);
+  applyHeader(rules.getRange("A1:D1"));
+  styleSheet(rules, `A1:D${ruleMatrix.length}`, [["A", 90], ["B", 240], ["C", 130], ["D", 420]]);
+  rules.freezePanes.freezeRows(1);
+
+  const limits = workbook.worksheets.add("采集限制");
+  const limitationMatrix = [["环节", "动作", "状态", "说明"], ...limitationRows];
+  writeRows(limits, 0, 0, limitationMatrix);
+  applyHeader(limits.getRange("A1:D1"));
+  styleSheet(limits, `A1:D${limitationMatrix.length}`, [["A", 160], ["B", 110], ["C", 130], ["D", 520]]);
+  limits.freezePanes.freezeRows(1);
+
+  const inspect = await workbook.inspect({
+    kind: "match",
+    searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A",
+    options: { useRegex: true, maxResults: 50 },
+    summary: "formula error scan"
+  });
+
+  for (const sheetName of ["执行摘要", "样本_抖音稿件", "黄金改写集", "评论VOC", "脚本拆解", "规则映射", "采集限制"]) {
+    const preview = await workbook.render({ sheetName, autoCrop: "all", scale: 1, format: "png" });
+    await fs.writeFile(
+      path.join(outputDir, `${sheetName}.png`),
+      new Uint8Array(await preview.arrayBuffer())
+    );
+  }
+
+  const xlsx = await SpreadsheetFile.exportXlsx(workbook);
+  const xlsxPath = path.join(outputDir, "善存抖音稿件审核润色与评论VOC.xlsx");
+  await xlsx.save(xlsxPath);
+  return { xlsxPath, inspect: inspect.ndjson };
+}
+
+function buildAnalyzerInput() {
+  return {
+    metadata: {
+      project: "善存抖音稿件审核润色与评论VOC",
+      generatedAt,
+      source: "public-indexed-douyin-plus-rule-pack"
+    },
+    videos: sampleRows.map(row => ({
+      aweme_id: row.id,
+      keyword: row.sku,
+      title: row.title,
+      desc: `${row.title}。${row.evidence}。${row.likelyApprovedEdits}`,
+      author: { nickname: row.author },
+      likeCount: 0,
+      commentCount: 0,
+      shareCount: 0,
+      playCount: 0,
+      url: row.url
+    })),
+    comments: [],
+    transcripts: sampleRows.map(row => ({
+      awemeId: row.id,
+      provider: "manual-public-indexed",
+      text: `${row.title}。${row.evidence}。${row.likelyApprovedEdits}。${row.improvement}`,
+      segments: []
+    }))
+  };
+}
+
+async function main() {
+  await fs.mkdir(outputDir, { recursive: true });
+  const mdPath = path.join(outputDir, "善存抖音稿件审核润色与评论VOC.md");
+  const dataPath = path.join(outputDir, "centrum_douyin_samples.json");
+  const analyzerInputPath = path.join(outputDir, "douyin_analyzer_input.json");
+  await fs.writeFile(mdPath, buildMarkdown(), "utf8");
+  await fs.writeFile(dataPath, JSON.stringify({ generatedAt, sources, sampleRows, goldenRows, commentRows, scriptRows, ruleRows, limitationRows }, null, 2), "utf8");
+  await fs.writeFile(analyzerInputPath, JSON.stringify(buildAnalyzerInput(), null, 2), "utf8");
+  const workbookResult = await buildWorkbook();
+  console.log(JSON.stringify({ mdPath, dataPath, analyzerInputPath, xlsxPath: workbookResult.xlsxPath, inspect: workbookResult.inspect }, null, 2));
+}
+
+main().catch(error => {
+  console.error(error);
+  process.exit(1);
+});

+ 249 - 0
tools/build_centrum_transcript_workbook.mjs

@@ -0,0 +1,249 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+import { SpreadsheetFile, Workbook } from "@oai/artifact-tool";
+
+const outputDir = path.resolve("outputs/centrum_douyin_audit_2026-06-08/transcript-full");
+const data = JSON.parse(await fs.readFile(path.join(outputDir, "transcript-analysis-data.json"), "utf8"));
+const runLogPath = path.join(outputDir, "transcription-run-log.json");
+const poolPath = path.join(outputDir, "transcript-sample-pool.json");
+const runLog = JSON.parse(await fs.readFile(runLogPath, "utf8"));
+const pool = JSON.parse(await fs.readFile(poolPath, "utf8"));
+
+function rowsFromObjects(items, headers) {
+  return [headers.map(h => h.label), ...items.map(item => headers.map(h => {
+    const value = typeof h.value === "function" ? h.value(item) : item[h.value];
+    if (Array.isArray(value)) return value.join("\n");
+    return value ?? "";
+  }))];
+}
+
+function writeRows(sheet, row, col, rows) {
+  if (!rows.length || !rows[0].length) return;
+  sheet.getRangeByIndexes(row, col, rows.length, rows[0].length).values = rows;
+}
+
+function styleRange(range) {
+  range.format = {
+    font: { name: "Microsoft YaHei", size: 10 },
+    wrapText: true,
+    verticalAlignment: "top",
+    borders: { preset: "all", style: "thin", color: "#D9E2EC" },
+  };
+}
+
+function styleHeader(range) {
+  range.format = {
+    fill: "#1F4E79",
+    font: { bold: true, color: "#FFFFFF" },
+    wrapText: true,
+    verticalAlignment: "middle",
+  };
+}
+
+function setWidths(sheet, widths) {
+  widths.forEach(([col, px]) => {
+    sheet.getRange(`${col}:${col}`).format.columnWidthPx = px;
+  });
+}
+
+function styleSheet(sheet, rangeAddress, widths) {
+  sheet.showGridLines = false;
+  if (rangeAddress) styleRange(sheet.getRange(rangeAddress));
+  setWidths(sheet, widths);
+}
+
+function validTranscriptRows() {
+  return data.transcriptRows
+    .filter(row => row.validTranscript)
+    .sort((a, b) => (b.likeCount || 0) - (a.likeCount || 0));
+}
+
+const workbook = Workbook.create();
+const summary = workbook.worksheets.add("执行摘要_逐字稿");
+summary.getRange("A1:H1").merge();
+summary.getRange("A1").values = [["善存抖音逐字稿话术审核分析"]];
+summary.getRange("A1").format = {
+  fill: "#0B1F33",
+  font: { bold: true, color: "#FFFFFF", size: 16 },
+  horizontalAlignment: "center",
+};
+
+const s = data.summary;
+const summaryRows = [
+  ["生成时间", String(s.generatedAt || "").replace("T", " ").slice(0, 19), "live 视频样本", s.liveVideoCount],
+  ["live 评论样本", s.liveCommentCount, "转写优先样本池", s.transcriptAttemptSelectedCount],
+  ["转写文件数", s.transcriptFileCount, "有效口播逐字稿", s.validTranscriptCount],
+  ["低信息/背景音乐", s.invalidTranscriptCount, "本轮转写失败", s.runLogFailedCount],
+  ["核心结论", "逐字稿确认主要风险集中在一包/一粒解决感、状态功效、促销 CTA、明星背书和版本边界", "使用限制", "ASR 存在同音误识别,合规定稿需结合原视频/原稿复核"],
+];
+writeRows(summary, 2, 0, summaryRows);
+
+const moduleRows = [["话术模块", "有效逐字稿数"], ...data.moduleSummary.map(row => [row.module, row.count])];
+writeRows(summary, 9, 0, moduleRows);
+styleHeader(summary.getRange("A10:B10"));
+const moduleChart = summary.charts.add("bar", summary.getRange(`A10:B${9 + moduleRows.length}`));
+moduleChart.title = "话术模块分布";
+moduleChart.hasLegend = false;
+moduleChart.setPosition("D9", "H25");
+styleSheet(summary, "A3:H35", [["A", 150], ["B", 280], ["C", 150], ["D", 180], ["E", 130], ["F", 170], ["G", 120], ["H", 180]]);
+
+const statusSheet = workbook.worksheets.add("转写状态_45");
+const poolMap = new Map((pool.selected || []).map(row => [String(row.videoId), row]));
+const statusRows = runLog.map(row => ({
+  ...row,
+  ...(poolMap.get(String(row.videoId)) || {}),
+  candidateAttemptsText: (row.candidateAttempts || []).map(item => `${item.keyPath}: ${item.transcriptStatus || item.stderr || item.exitCode}`).join("\n"),
+}));
+const statusHeaders = [
+  { label: "videoId", value: "videoId" },
+  { label: "作者", value: "author" },
+  { label: "标题", value: "title" },
+  { label: "SKU/方向", value: "skuGroup" },
+  { label: "风险等级", value: "riskLevel" },
+  { label: "转写状态", value: "transcriptStatus" },
+  { label: "文本长度", value: "textLength" },
+  { label: "媒体关键词", value: "mediaKeyword" },
+  { label: "候选尝试", value: "candidateAttemptsText" },
+];
+const statusTable = rowsFromObjects(statusRows, statusHeaders);
+writeRows(statusSheet, 0, 0, statusTable);
+styleHeader(statusSheet.getRange("A1:I1"));
+styleSheet(statusSheet, `A1:I${statusTable.length}`, [["A", 145], ["B", 120], ["C", 360], ["D", 220], ["E", 90], ["F", 100], ["G", 80], ["H", 170], ["I", 420]]);
+statusSheet.freezePanes.freezeRows(1);
+
+const transcriptSheet = workbook.worksheets.add("逐字稿明细_35");
+const transcriptHeaders = [
+  { label: "videoId", value: "videoId" },
+  { label: "作者", value: "author" },
+  { label: "标题", value: "title" },
+  { label: "SKU/方向", value: "skuGroup" },
+  { label: "赞", value: "likeCount" },
+  { label: "评", value: "commentCount" },
+  { label: "话术模块", value: "moduleText" },
+  { label: "命中规则", value: "ruleHits" },
+  { label: "规则名称", value: "ruleNames" },
+  { label: "逐字稿清洗文本", value: "cleanText" },
+  { label: "改写方向", value: "rewriteDirection" },
+  { label: "链接", value: "url" },
+];
+const transcriptTable = rowsFromObjects(validTranscriptRows(), transcriptHeaders);
+writeRows(transcriptSheet, 0, 0, transcriptTable);
+styleHeader(transcriptSheet.getRange("A1:L1"));
+styleSheet(transcriptSheet, `A1:L${transcriptTable.length}`, [["A", 145], ["B", 120], ["C", 330], ["D", 220], ["E", 70], ["F", 70], ["G", 210], ["H", 160], ["I", 260], ["J", 520], ["K", 420], ["L", 360]]);
+transcriptSheet.freezePanes.freezeRows(1);
+
+const moduleSheet = workbook.worksheets.add("话术模块");
+const moduleDetailRows = [["话术模块", "有效逐字稿数", "过审处理方向"], ...data.moduleSummary.map(row => [
+  row.module,
+  row.count,
+  ({
+    "明星/达人背书": "达人分享仅代表个人体验;产品信息以包装、标签和官方说明为准。",
+    "年龄/人群场景": "按具体 SKU、年龄、人群和标签说明选择,避免跨人群泛化。",
+    "功效/状态表达": "改为日常营养支持,不承诺精力、气血、代谢、焕活等结果。",
+    "成分/剂型说明": "保留成分/剂型事实,不推导全部所需或明显改善。",
+    "一包/一粒解决感": "避免一包解决/一粒补全,回到按标签建议补充。",
+    "版本/渠道信任": "以购买页、实物标签和客服说明解释版本/渠道。",
+    "促销/购买引导": "活动价格以当前页面为准,CTA 不与功效绑定。",
+    "比较/榜单/避坑": "避免红黑榜和竞品贬损,改为中性选购维度。",
+  })[row.module] || "保留低风险信息,减少强承诺。"
+])];
+writeRows(moduleSheet, 0, 0, moduleDetailRows);
+styleHeader(moduleSheet.getRange("A1:C1"));
+styleSheet(moduleSheet, `A1:C${moduleDetailRows.length}`, [["A", 180], ["B", 100], ["C", 520]]);
+
+const ruleSheet = workbook.worksheets.add("Rule命中");
+const ruleRows = [["Rule Pack", "命中逐字稿数"], ...data.ruleSummary.map(row => [row.rule, row.count])];
+writeRows(ruleSheet, 0, 0, ruleRows);
+styleHeader(ruleSheet.getRange("A1:B1"));
+styleSheet(ruleSheet, `A1:B${ruleRows.length}`, [["A", 150], ["B", 120]]);
+
+const goldenSheet = workbook.worksheets.add("黄金改写集_逐字稿");
+const goldenHeaders = [
+  { label: "ID", value: "id" },
+  { label: "模块", value: "module" },
+  { label: "来源视频", value: row => `${row.author}|${row.sourceTitle}` },
+  { label: "命中规则", value: "riskRules" },
+  { label: "原逐字稿片段", value: "originalTranscriptSnippet" },
+  { label: "建议通过版", value: "suggestedApprovedWording" },
+  { label: "依据", value: "why" },
+];
+const goldenTable = rowsFromObjects(data.goldenTranscriptRows, goldenHeaders);
+writeRows(goldenSheet, 0, 0, goldenTable);
+styleHeader(goldenSheet.getRange("A1:G1"));
+styleSheet(goldenSheet, `A1:G${goldenTable.length}`, [["A", 105], ["B", 160], ["C", 320], ["D", 160], ["E", 430], ["F", 480], ["G", 300]]);
+goldenSheet.freezePanes.freezeRows(1);
+
+const commentSheet = workbook.worksheets.add("评论主题复核");
+const commentRows = [["主题", "评论数", "典型评论", "改进动作"], ...data.commentThemeSummary.map(row => [
+  row.theme,
+  row.commentCount,
+  (row.sampleComments || []).slice(0, 5).join("\n"),
+  row.opportunity,
+])];
+writeRows(commentSheet, 0, 0, commentRows);
+styleHeader(commentSheet.getRange("A1:D1"));
+styleSheet(commentSheet, `A1:D${commentRows.length}`, [["A", 180], ["B", 90], ["C", 500], ["D", 520]]);
+commentSheet.freezePanes.freezeRows(1);
+
+const videoSheet = workbook.worksheets.add("视频样本_78");
+const videoHeaders = [
+  { label: "videoId", value: "videoId" },
+  { label: "关键词", value: "keyword" },
+  { label: "SKU/方向", value: "skuGroup" },
+  { label: "作者", value: "author" },
+  { label: "标题/文案", value: "title" },
+  { label: "赞", value: "likeCount" },
+  { label: "评", value: "commentCount" },
+  { label: "转", value: "shareCount" },
+  { label: "藏", value: "collectCount" },
+  { label: "风险等级", value: "riskLevel" },
+  { label: "命中规则", value: "ruleHits" },
+  { label: "规则名称", value: "ruleNames" },
+  { label: "链接", value: "url" },
+];
+const videoTable = rowsFromObjects(data.videoRows, videoHeaders);
+writeRows(videoSheet, 0, 0, videoTable);
+styleHeader(videoSheet.getRange("A1:M1"));
+styleSheet(videoSheet, `A1:M${videoTable.length}`, [["A", 145], ["B", 145], ["C", 210], ["D", 130], ["E", 360], ["F", 75], ["G", 75], ["H", 75], ["I", 75], ["J", 90], ["K", 160], ["L", 270], ["M", 360]]);
+videoSheet.freezePanes.freezeRows(1);
+
+const commentDetailSheet = workbook.worksheets.add("评论明细_471");
+const commentHeaders = [
+  { label: "commentId", value: "commentId" },
+  { label: "videoId", value: "videoId" },
+  { label: "视频标题", value: "videoTitle" },
+  { label: "作者", value: "videoAuthor" },
+  { label: "关键词", value: "keyword" },
+  { label: "主题", value: "themeText" },
+  { label: "评论", value: "text" },
+  { label: "点赞", value: "likeCount" },
+  { label: "回复数", value: "replyCount" },
+  { label: "IP", value: "ipLocation" },
+];
+const commentDetailTable = rowsFromObjects(data.commentRows, commentHeaders);
+writeRows(commentDetailSheet, 0, 0, commentDetailTable);
+styleHeader(commentDetailSheet.getRange("A1:J1"));
+styleSheet(commentDetailSheet, `A1:J${commentDetailTable.length}`, [["A", 160], ["B", 145], ["C", 320], ["D", 120], ["E", 145], ["F", 190], ["G", 460], ["H", 70], ["I", 70], ["J", 90]]);
+commentDetailSheet.freezePanes.freezeRows(1);
+
+const inspect = await workbook.inspect({
+  kind: "match",
+  searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A",
+  options: { useRegex: true, maxResults: 100 },
+  summary: "formula error scan",
+});
+
+for (const sheetName of ["执行摘要_逐字稿", "转写状态_45", "逐字稿明细_35", "话术模块", "Rule命中", "黄金改写集_逐字稿", "评论主题复核", "视频样本_78", "评论明细_471"]) {
+  const preview = await workbook.render({ sheetName, autoCrop: "all", scale: 1, format: "png" });
+  await fs.writeFile(path.join(outputDir, `${sheetName}.png`), new Uint8Array(await preview.arrayBuffer()));
+}
+
+const xlsx = await SpreadsheetFile.exportXlsx(workbook);
+const xlsxPath = path.join(outputDir, "善存抖音逐字稿话术审核分析.xlsx");
+await xlsx.save(xlsxPath);
+
+console.log(JSON.stringify({
+  status: "ok",
+  xlsxPath,
+  formulaScan: inspect.ndjson,
+}, null, 2));

+ 133 - 0
tools/collect_centrum_douyin_live.js

@@ -0,0 +1,133 @@
+#!/usr/bin/env node
+const fs = require('fs');
+const path = require('path');
+const { collectLiveDataset } = require('../claude-code/claude-code-voc-intelligence/mcp/src/features/douyin-trend/live-collector');
+
+function parseArgs(argv) {
+  const args = {};
+  for (let i = 0; i < argv.length; i += 1) {
+    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 += 1;
+    } else {
+      args[key] = true;
+    }
+  }
+  return args;
+}
+
+function splitList(value) {
+  if (!value) return [];
+  if (Array.isArray(value)) return value;
+  return String(value)
+    .split(/[,,、\n\r]+/)
+    .map(item => item.trim())
+    .filter(Boolean);
+}
+
+function numberArg(value, fallback) {
+  const number = Number(value);
+  return Number.isFinite(number) && number >= 0 ? Math.round(number) : fallback;
+}
+
+function videoKey(video) {
+  return String(video.id || video.videoId || video.url || '').trim();
+}
+
+function commentKey(comment) {
+  return String(comment.id || comment.cid || `${comment.videoId}:${comment.text}`).trim();
+}
+
+function dedupeBy(items, keyFn) {
+  const map = new Map();
+  for (const item of items) {
+    const key = keyFn(item);
+    if (!key) continue;
+    if (!map.has(key)) map.set(key, item);
+  }
+  return [...map.values()];
+}
+
+async function main() {
+  const args = parseArgs(process.argv.slice(2));
+  const token = process.env.VOC_TOKEN || process.env.VOC_DOUYIN_TOKEN || process.env.DOUYIN_TOKEN || '';
+  if (!token) {
+    throw new Error('VOC_TOKEN is required in process env.');
+  }
+
+  const output = path.resolve(args.output || 'outputs/centrum_douyin_audit_2026-06-08/live-full/live-raw-dataset.json');
+  const keywords = splitList(args.keywords);
+  if (!keywords.length) throw new Error('--keywords is required.');
+
+  const profile = {
+    project: args.project || 'Centrum Douyin VOC audit',
+    industry: args.industry || 'nutrition supplements',
+    targetAudience: splitList(args.audience || 'adults,30+,silver age,parents'),
+    keywords,
+    mustTrackSignals: ['price', 'authenticity', 'version', 'effect', 'energy', 'brain', 'pregnancy', 'dosage', 'ingredients']
+  };
+
+  const startedAt = new Date().toISOString();
+  const result = await collectLiveDataset({
+    profile,
+    token,
+    options: {
+      keywordLimit: numberArg(args.keywordLimit || args['keyword-limit'], keywords.length),
+      videosPerKeyword: numberArg(args.videosPerKeyword || args['videos-per-keyword'], 10),
+      maxCommentPages: numberArg(args.maxCommentPages || args['max-comment-pages'], 3),
+      sortType: args.sortType || args.sort || args['sort-type'] || '1',
+      publishTime: args.publishTime || args['publish-time'] || '180',
+      filterDuration: args.filterDuration || args['filter-duration'] || '0',
+      contentType: args.contentType || args['content-type'] || '1',
+      baseUrl: args.baseUrl || args['base-url'] || process.env.DOUYIN_API_BASE_URL || process.env.VOC_DOUYIN_API_BASE_URL
+    }
+  });
+
+  const videos = dedupeBy(result.videos || [], videoKey);
+  const comments = dedupeBy(result.comments || [], commentKey);
+  const doc = {
+    generatedAt: new Date().toISOString(),
+    startedAt,
+    profile,
+    requested: {
+      keywordCount: keywords.length,
+      keywordLimit: numberArg(args.keywordLimit || args['keyword-limit'], keywords.length),
+      videosPerKeyword: numberArg(args.videosPerKeyword || args['videos-per-keyword'], 10),
+      maxCommentPages: numberArg(args.maxCommentPages || args['max-comment-pages'], 3)
+    },
+    effectiveKeywords: result.keywords || [],
+    summary: {
+      videoCount: videos.length,
+      commentCount: comments.length,
+      warningCount: (result.warnings || []).length,
+      errorCount: (result.errors || []).length
+    },
+    videos,
+    comments,
+    warnings: result.warnings || [],
+    errors: result.errors || []
+  };
+
+  fs.mkdirSync(path.dirname(output), { recursive: true });
+  fs.writeFileSync(output, JSON.stringify(doc, null, 2), 'utf8');
+  console.log(JSON.stringify({
+    status: 'ok',
+    output,
+    summary: doc.summary,
+    effectiveKeywordCount: doc.effectiveKeywords.length
+  }, null, 2));
+}
+
+main().catch(error => {
+  console.error(JSON.stringify({ status: 'error', message: error.message }, null, 2));
+  process.exit(1);
+});

+ 119 - 0
tools/probe_douyin_media_from_search.js

@@ -0,0 +1,119 @@
+#!/usr/bin/env node
+const { DouyinApi } = require('../claude-code/claude-code-voc-intelligence/mcp/src/providers/douyin-api');
+
+function parseArgs(argv) {
+  const args = {};
+  for (let i = 0; i < argv.length; i += 1) {
+    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 += 1;
+    } else {
+      args[key] = true;
+    }
+  }
+  return args;
+}
+
+function decodeMaybeBase64Url(value) {
+  const text = String(value || '').trim();
+  if (!text) return '';
+  if (/^https?:\/\//i.test(text)) return text;
+  if (!/^[A-Za-z0-9+/=_-]+$/.test(text) || text.length < 24) return '';
+  try {
+    const decoded = Buffer.from(text.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8');
+    return /^https?:\/\//i.test(decoded) ? decoded : '';
+  } catch {
+    return '';
+  }
+}
+
+function collectUrls(node, path = [], out = []) {
+  if (!node) return out;
+  if (Array.isArray(node)) {
+    node.forEach((item, index) => collectUrls(item, [...path, String(index)], out));
+    return out;
+  }
+  if (typeof node !== 'object') {
+    if (typeof node === 'string') {
+      const url = decodeMaybeBase64Url(node);
+      if (url) out.push({ url, path: path.join('.') });
+    }
+    return out;
+  }
+  Object.entries(node).forEach(([key, value]) => collectUrls(value, [...path, key], out));
+  return out;
+}
+
+function isMediaUrl(url) {
+  const lower = String(url || '').toLowerCase();
+  return lower.includes('douyinvod.com')
+    || lower.includes('/aweme/v1/play')
+    || lower.includes('mime_type=video')
+    || /\.(mp4|m4a|mp3|aac|wav|webm)(?:\?|$)/i.test(lower);
+}
+
+function findAweme(node, awemeId, depth = 0) {
+  if (!node || depth > 8) return null;
+  if (Array.isArray(node)) {
+    for (const item of node) {
+      const found = findAweme(item, awemeId, depth + 1);
+      if (found) return found;
+    }
+    return null;
+  }
+  if (typeof node !== 'object') return null;
+  const id = String(node.aweme_id || node.id || '');
+  if (id === String(awemeId)) return node;
+  for (const value of Object.values(node)) {
+    const found = findAweme(value, awemeId, depth + 1);
+    if (found) return found;
+  }
+  return null;
+}
+
+async function main() {
+  const args = parseArgs(process.argv.slice(2));
+  const token = process.env.VOC_TOKEN || process.env.VOC_DOUYIN_TOKEN || process.env.DOUYIN_TOKEN;
+  if (!token) throw new Error('VOC_TOKEN is required in environment');
+  if (!args.keyword) throw new Error('--keyword is required');
+
+  const api = new DouyinApi({ token });
+  const response = await api.searchVideos({
+    keyword: args.keyword,
+    cursor: Number(args.cursor || 0),
+    sortType: args.sortType || '1',
+    publishTime: args.publishTime || '180',
+    filterDuration: args.filterDuration || '0',
+    contentType: args.contentType || '1',
+  });
+
+  const target = args.awemeId ? findAweme(response.data, args.awemeId) : response.data;
+  const urls = collectUrls(target || response.data)
+    .filter(item => isMediaUrl(item.url))
+    .filter((item, index, list) => list.findIndex(other => other.url === item.url) === index)
+    .slice(0, 20);
+
+  console.log(JSON.stringify({
+    status: 'ok',
+    foundTarget: Boolean(target),
+    mediaUrlCount: urls.length,
+    mediaCandidates: urls.map(item => ({
+      path: item.path,
+      urlPrefix: item.url.slice(0, 180),
+    })),
+  }, null, 2));
+}
+
+main().catch(error => {
+  console.error(JSON.stringify({ status: 'error', message: error.message }, null, 2));
+  process.exit(1);
+});

+ 402 - 0
tools/transcribe_centrum_douyin_videos.js

@@ -0,0 +1,402 @@
+#!/usr/bin/env node
+const fs = require('fs');
+const path = require('path');
+const { spawnSync } = require('child_process');
+const { DouyinApi } = require('../claude-code/claude-code-voc-intelligence/mcp/src/providers/douyin-api');
+
+function parseArgs(argv) {
+  const args = {};
+  for (let i = 0; i < argv.length; i += 1) {
+    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 += 1;
+    } else {
+      args[key] = true;
+    }
+  }
+  return args;
+}
+
+function ensureDir(dir) {
+  fs.mkdirSync(dir, { recursive: true });
+}
+
+function clean(value) {
+  return String(value || '').replace(/\s+/g, ' ').trim();
+}
+
+function numberArg(value, fallback) {
+  const number = Number(value);
+  return Number.isFinite(number) && number >= 0 ? Math.round(number) : fallback;
+}
+
+function splitList(value) {
+  if (!value) return [];
+  return String(value)
+    .split(/[,,、\s]+/)
+    .map(item => item.trim())
+    .filter(Boolean);
+}
+
+function isBrandRelevant(video) {
+  const text = [
+    video.title,
+    video.author,
+    video.keyword,
+    video.skuGroup,
+    video.ruleHits,
+  ].map(clean).join(' ');
+  if (/善存|Centrum|银善存|小佳维|紫瓶|每日营养包|PRO营养包|复合维生素/i.test(text)) {
+    return true;
+  }
+  return false;
+}
+
+function isWeakOrInvalid(video) {
+  const text = [video.title, video.author, video.skuGroup].map(clean).join(' ');
+  if (/善存|Centrum|银善存|小佳维|紫瓶/i.test(text)) return false;
+  return /安利|GNC|膳食套餐|磷虾油|牡蛎片|猴父子|Swisse/i.test(text);
+}
+
+function priorityScore(video) {
+  const riskBoost = { '高': 5000, '中高': 3000, '中': 1200, '低': 0 }[video.riskLevel] || 0;
+  const skuBoost = /营养包|PRO|每日营养包/.test(video.skuGroup || '') ? 2000 : 0;
+  const officialBoost = /善存.*(旗舰店|官方|店铺|专卖店)/.test(video.author || '') ? 900 : 0;
+  const commentBoost = Math.min(Number(video.commentCount || 0) * 2, 2000);
+  return Number(video.score || 0) + riskBoost + skuBoost + officialBoost + commentBoost;
+}
+
+function decodeMaybeBase64Url(value) {
+  const text = String(value || '').trim();
+  if (!text) return '';
+  if (/^https?:\/\//i.test(text)) return text;
+  if (!/^[A-Za-z0-9+/=_-]+$/.test(text) || text.length < 24) return '';
+  try {
+    const decoded = Buffer.from(text.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8');
+    return /^https?:\/\//i.test(decoded) ? decoded : '';
+  } catch {
+    return '';
+  }
+}
+
+function findAweme(node, awemeId, depth = 0) {
+  if (!node || depth > 8) return null;
+  if (Array.isArray(node)) {
+    for (const item of node) {
+      const found = findAweme(item, awemeId, depth + 1);
+      if (found) return found;
+    }
+    return null;
+  }
+  if (typeof node !== 'object') return null;
+  const id = String(node.aweme_id || node.id || '');
+  if (id === String(awemeId)) return node;
+  for (const value of Object.values(node)) {
+    const found = findAweme(value, awemeId, depth + 1);
+    if (found) return found;
+  }
+  return null;
+}
+
+function durationFromAweme(item) {
+  const raw = item?.video?.duration || item?.duration || item?.video_duration || 0;
+  const numeric = Number(raw || 0);
+  if (!Number.isFinite(numeric) || numeric <= 0) return 0;
+  return numeric > 10000 ? Math.round(numeric) : Math.round(numeric * 1000);
+}
+
+function collectVideoCandidates(item) {
+  const out = [];
+  const video = item?.video || {};
+  const bitRates = Array.isArray(video.bit_rate) ? video.bit_rate : [];
+  bitRates.forEach((rate, rateIndex) => {
+    const urls = rate?.play_addr?.url_list || [];
+    urls.forEach((value, urlIndex) => {
+      const url = decodeMaybeBase64Url(value);
+      if (!url) return;
+      out.push({
+        url,
+        kind: 'video',
+        keyPath: `video.bit_rate.${rateIndex}.play_addr.url_list.${urlIndex}`,
+        bitRate: Number(rate.bit_rate || rate.bitrate || rate.real_bitrate || 0),
+        dataSize: Number(rate.play_addr?.data_size || rate.data_size || 0),
+      });
+    });
+  });
+  const downloadUrls = video.download_addr?.url_list || [];
+  downloadUrls.forEach((value, index) => {
+    const url = decodeMaybeBase64Url(value);
+    if (url) out.push({ url, kind: 'video', keyPath: `video.download_addr.url_list.${index}`, bitRate: 0, dataSize: Number(video.download_addr?.data_size || 0) });
+  });
+
+  const unique = [];
+  const seen = new Set();
+  for (const item of out) {
+    if (seen.has(item.url)) continue;
+    seen.add(item.url);
+    unique.push(item);
+  }
+  unique.sort((a, b) => {
+    const hostRank = item => {
+      try {
+        const host = new URL(item.url).hostname.toLowerCase();
+        if (host.includes('api-play.amemv.com')) return 0;
+        if (host.includes('api.amemv.com')) return 1;
+        if (host.includes('douyinvod.com')) return 2;
+        return 3;
+      } catch {
+        return 4;
+      }
+    };
+    const aHostRank = hostRank(a);
+    const bHostRank = hostRank(b);
+    if (aHostRank !== bHostRank) return aHostRank - bHostRank;
+    const aSize = a.dataSize || Number.MAX_SAFE_INTEGER;
+    const bSize = b.dataSize || Number.MAX_SAFE_INTEGER;
+    if (aSize !== bSize) return aSize - bSize;
+    return (a.bitRate || Number.MAX_SAFE_INTEGER) - (b.bitRate || Number.MAX_SAFE_INTEGER);
+  });
+  return unique;
+}
+
+async function resolveMedia(api, video) {
+  const attempts = [
+    video.keyword,
+    clean(video.title).replace(/#.*$/g, '').slice(0, 50),
+    clean(video.title).split(/[,,。#]/)[0].slice(0, 50),
+  ].filter(Boolean);
+  const tried = [];
+  for (const keyword of attempts) {
+    if (tried.includes(keyword)) continue;
+    tried.push(keyword);
+    for (let retry = 0; retry < 3; retry += 1) {
+      try {
+        const response = await api.searchVideos({
+          keyword,
+          cursor: 0,
+          sortType: '1',
+          publishTime: '180',
+          filterDuration: '0',
+          contentType: '1',
+        });
+      const aweme = findAweme(response.data, video.videoId);
+      if (!aweme) continue;
+      const candidates = collectVideoCandidates(aweme);
+      if (!candidates.length) continue;
+      return {
+        status: 'ok',
+        keyword,
+        durationMs: durationFromAweme(aweme),
+        candidate: candidates[0],
+        candidates,
+        candidateCount: candidates.length,
+      };
+      } catch (error) {
+        tried.push(`${keyword}: ${error.message}`);
+        if (retry < 2) {
+          await new Promise(resolve => setTimeout(resolve, 1500 * (retry + 1)));
+        }
+      }
+    }
+  }
+  return { status: 'not_found', tried };
+}
+
+function runTranscriber({ video, media, outDir, ffmpegPath, transcriberPath, nodePath, env }) {
+  const videoDir = path.join(outDir, 'transcripts', String(video.videoId));
+  ensureDir(videoDir);
+  const args = [
+    transcriberPath,
+    '--provider', 'iflytek-gateway',
+    '--media-url', media.candidate.url,
+    '--aweme-id', String(video.videoId),
+    '--duration-ms', String(media.durationMs || 60000),
+    '--output', videoDir,
+    '--ffmpeg-path', ffmpegPath,
+    '--gateway-max-duration-ms', '600000',
+    '--max-download-mb', '160',
+    '--max-polls', '60',
+    '--poll-interval-ms', '4000',
+  ];
+  const result = spawnSync(nodePath, args, {
+    cwd: process.cwd(),
+    env,
+    encoding: 'utf8',
+    windowsHide: true,
+    maxBuffer: 1024 * 1024 * 20,
+    timeout: 420000,
+  });
+  let parsed = {};
+  const stdout = String(result.stdout || '').trim();
+  if (stdout) {
+    try {
+      parsed = JSON.parse(stdout.split(/\r?\n/).pop());
+    } catch {
+      parsed = { rawStdout: stdout.slice(-1000) };
+    }
+  }
+  return {
+    exitCode: result.status,
+    error: result.error ? result.error.message : '',
+    stderr: String(result.stderr || '').trim().slice(-2000),
+    stdout: stdout.slice(-2000),
+    parsed,
+    videoDir,
+  };
+}
+
+async function main() {
+  const args = parseArgs(process.argv.slice(2));
+  const token = process.env.VOC_TOKEN || process.env.VOC_DOUYIN_TOKEN || process.env.DOUYIN_TOKEN;
+  if (!token) throw new Error('VOC_TOKEN is required in environment');
+
+  const inputPath = path.resolve(args.input || 'outputs/centrum_douyin_audit_2026-06-08/live-full/live-analysis-data.json');
+  const outDir = path.resolve(args.output || 'outputs/centrum_douyin_audit_2026-06-08/transcript-full');
+  const limit = numberArg(args.limit, 1);
+  const nodePath = process.execPath;
+  const transcriberPath = path.resolve(args.transcriber || 'scripts/tools/douyin-video-transcriber.js');
+  const ffmpegPath = path.resolve(args.ffmpegPath || args['ffmpeg-path'] || '.codex-run/ffmpeg-installer/node_modules/@ffmpeg-installer/win32-x64/ffmpeg.exe');
+  ensureDir(outDir);
+
+  const data = JSON.parse(fs.readFileSync(inputPath, 'utf8'));
+  const allVideos = Array.isArray(data.videoRows) ? data.videoRows : [];
+  const excluded = allVideos.filter(video => !isBrandRelevant(video) || isWeakOrInvalid(video));
+  const requestedIds = new Set(splitList(args.ids || args.videoIds || args['video-ids']));
+  const eligible = allVideos
+    .filter(video => isBrandRelevant(video) && !isWeakOrInvalid(video))
+    .map(video => ({ ...video, transcriptPriority: priorityScore(video) }))
+    .sort((a, b) => b.transcriptPriority - a.transcriptPriority);
+  const selected = requestedIds.size
+    ? eligible.filter(video => requestedIds.has(String(video.videoId)))
+    : eligible.slice(0, limit);
+
+  const pool = {
+    generatedAt: new Date().toISOString(),
+    inputPath,
+    limit,
+    selectedCount: selected.length,
+    excludedCount: excluded.length,
+    selected: selected.map(video => ({
+      videoId: video.videoId,
+      title: video.title,
+      author: video.author,
+      keyword: video.keyword,
+      skuGroup: video.skuGroup,
+      riskLevel: video.riskLevel,
+      score: video.score,
+      transcriptPriority: video.transcriptPriority,
+    })),
+    excluded: excluded.map(video => ({
+      videoId: video.videoId,
+      title: video.title,
+      author: video.author,
+      keyword: video.keyword,
+      skuGroup: video.skuGroup,
+      reason: isWeakOrInvalid(video) ? 'weak_or_invalid_non_centrum' : 'no_clear_brand_signal',
+    })),
+  };
+  fs.writeFileSync(path.join(outDir, 'transcript-sample-pool.json'), JSON.stringify(pool, null, 2), 'utf8');
+
+  const api = new DouyinApi({ token });
+  const runLog = [];
+  const safeEnv = { ...process.env, VOC_TOKEN: token };
+  for (const video of selected) {
+    const existing = path.join(outDir, 'transcripts', String(video.videoId), 'transcript.json');
+    if (fs.existsSync(existing) && !args.force) {
+      const transcript = JSON.parse(fs.readFileSync(existing, 'utf8'));
+      runLog.push({ videoId: video.videoId, status: 'skipped_existing', transcriptStatus: transcript.status, textLength: clean(transcript.text).length });
+      continue;
+    }
+    const media = await resolveMedia(api, video);
+    if (media.status !== 'ok') {
+      runLog.push({ videoId: video.videoId, status: 'media_not_found', media });
+      continue;
+    }
+    const candidateAttempts = [];
+    let result = null;
+    const candidates = Array.isArray(media.candidates) && media.candidates.length
+      ? media.candidates.slice(0, numberArg(args.candidateLimit || args['candidate-limit'], 8))
+      : [media.candidate];
+    for (const candidate of candidates) {
+      const attemptMedia = { ...media, candidate };
+      result = runTranscriber({ video, media: attemptMedia, outDir, ffmpegPath, transcriberPath, nodePath, env: safeEnv });
+      const transcriptPath = path.join(result.videoDir, 'transcript.json');
+      let attemptTranscriptStatus = '';
+      let attemptTextLength = 0;
+      if (fs.existsSync(transcriptPath)) {
+        try {
+          const transcript = JSON.parse(fs.readFileSync(transcriptPath, 'utf8'));
+          attemptTranscriptStatus = transcript.status;
+          attemptTextLength = clean(transcript.text).length;
+        } catch {
+          attemptTranscriptStatus = 'unreadable_transcript';
+        }
+      }
+      candidateAttempts.push({
+        keyPath: candidate.keyPath,
+        exitCode: result.exitCode,
+        transcriptStatus: attemptTranscriptStatus,
+        textLength: attemptTextLength,
+        stderr: result.stderr,
+      });
+      if (result.exitCode === 0 && attemptTranscriptStatus) break;
+    }
+    let transcriptStatus = '';
+    let textLength = 0;
+    const transcriptPath = path.join(result.videoDir, 'transcript.json');
+    if (fs.existsSync(transcriptPath)) {
+      try {
+        const transcript = JSON.parse(fs.readFileSync(transcriptPath, 'utf8'));
+        transcriptStatus = transcript.status;
+        textLength = clean(transcript.text).length;
+      } catch {
+        transcriptStatus = 'unreadable_transcript';
+      }
+    }
+    runLog.push({
+      videoId: video.videoId,
+      title: video.title,
+      author: video.author,
+      status: result.exitCode === 0 ? 'transcriber_finished' : 'transcriber_error',
+      transcriptStatus,
+      textLength,
+      mediaKeyword: media.keyword,
+      mediaKeyPath: media.candidate.keyPath,
+      durationMs: media.durationMs,
+      candidateAttempts,
+      exitCode: result.exitCode,
+      error: result.error,
+      stderr: result.stderr,
+      parsed: result.parsed,
+    });
+    fs.writeFileSync(path.join(outDir, 'transcription-run-log.json'), JSON.stringify(runLog, null, 2), 'utf8');
+  }
+
+  const summary = {
+    status: 'ok',
+    outDir,
+    selectedCount: selected.length,
+    excludedCount: excluded.length,
+    okCount: runLog.filter(item => item.transcriptStatus === 'ok').length,
+    pendingCount: runLog.filter(item => item.transcriptStatus === 'transcription_pending').length,
+    failedCount: runLog.filter(item => item.status !== 'skipped_existing' && item.transcriptStatus !== 'ok').length,
+    runLogPath: path.join(outDir, 'transcription-run-log.json'),
+    poolPath: path.join(outDir, 'transcript-sample-pool.json'),
+  };
+  fs.writeFileSync(summary.runLogPath, JSON.stringify(runLog, null, 2), 'utf8');
+  console.log(JSON.stringify(summary, null, 2));
+}
+
+main().catch(error => {
+  console.error(JSON.stringify({ status: 'error', message: error.message }, null, 2));
+  process.exit(1);
+});