Skill: tomtrije/report-expert (version 1.6.15)

--- name: report-expert description: "生成 HTML 报告页面并部署到 Cloudflare Pages 站点。涵盖设计系统、页面结构、索引管理、iframe 内嵌查看、自动部署全流程。触发词：写报告、发布报告、部署报告、生成报告页面、report publisher、报告专家、升级报告专家、更新报告技能、发布技能升级。" --- # 报告专家 将调研内容生成符合设计规范的 HTML 报告页面，支持两种内容类型：**报告页面**（同站部署）和**外部页面**（独立部署，iframe 内嵌查看）。 --- ## 技能版本管理 所有远程地址均从 `manifest.json` 的 `repository` 字段动态获取，**不硬编码任何绝对 URL**。 ### 安装方式 技能支持两种安装渠道： | 渠道 | 安装命令 | 特点 | |------|---------|------| | **ClawHub** | `clawhub install report-expert` | 自动管理版本，安全扫描，推荐 | | | **CF Pages** | 将 manifest.json 地址发送给 Agent | 包含 upgrade.py 自更新脚本 | ### 拉取升级（用户指令触发） 当用户说「升级报告专家」「更新报告技能」「检查技能更新」时，根据安装方式选择升级命令： **ClawHub 安装的技能：** ```bash clawhub update report-expert ``` **CF Pages 安装的技能：** > **安全提示：** 升级会从 `manifest.repository` 指定的远端下载文件并覆盖本地技能文件（含 SHA256 校验）。请确认你信任该远端来源。 ```bash SKILL_DIR="<技能实际目录路径>" python3 "${SKILL_DIR}/upgrade.py" ``` > **注意：** `upgrade.py` 仅随 CF Pages 渠道分发，ClawHub 渠道通过 `clawhub update` 管理，不包含该文件。 ``` ### 发布升级（修改技能后触发） 当用户说「发布技能升级」「发布报告专家」「提交技能更新」时，执行发布流程。 > **安全提示：** 发布会将技能文件部署到 Cloudflare Pages，需要 `CLOUDFLARE_API_TOKEN`。请确认 token 权限范围合理（仅需 Pages 编辑权限）。 ```bash SKILL_DIR="<技能实际目录路径>" SKILL_DIR="$SKILL_DIR" python3 << 'PYEOF' import json, hashlib, os, sys from datetime import date skill_dir = os.environ.get("SKILL_DIR", os.path.dirname(os.path.abspath(__file__))) manifest_path = os.path.join(skill_dir, "manifest.json") with open(manifest_path, "r", encoding="utf-8") as f: manifest = json.load(f) # 1. 版本递增 old_ver = manifest.get("version", "0.0.0") parts = old_ver.lstrip("v").split(".") parts[-1] = str(int(parts[-1]) + 1) new_ver = ".".join(parts) manifest["version"] = new_ver manifest["updated"] = date.today().isoformat() # 2. 远程基础地址（从 repository 字段读取） repo = manifest.get("repository", "").rstrip("/") # 3. 重算所有文件哈希 for root, dirs, fnames in os.walk(skill_dir): dirs[:] = [d for d in dirs if d not in (".git", "__pycache__")] for fname in fnames: fpath = os.path.join(root, fname) rel = os.path.relpath(fpath, skill_dir) if rel == "manifest.json": continue with open(fpath, "rb") as f: sha = hashlib.sha256(f.read()).hexdigest() sz = os.path.getsize(fpath) if rel not in manifest.get("files", {}): manifest.setdefault("files", {})[rel] = {} manifest["files"][rel]["sha256"] = sha manifest["files"][rel]["size"] = sz # url 保持相对路径 manifest["files"][rel]["url"] = rel # 4. 清理已删除的文件 existing = set() for root, dirs, fnames in os.walk(skill_dir): dirs[:] = [d for d in dirs if d not in (".git", "__pycache__")] for fname in fnames: rel = os.path.relpath(os.path.join(root, fname), skill_dir) if rel != "manifest.json": existing.add(rel) for rel in list(manifest.get("files", {}).keys()): if rel not in existing: del manifest["files"][rel] # 5. 写入 manifest with open(manifest_path, "w", encoding="utf-8") as f: json.dump(manifest, f, ensure_ascii=False, indent=2) # 5.5. 同步版本号到技能介绍页面 index_html = os.path.join(skill_dir, "index.html") if os.path.exists(index_html): ih = open(index_html, "r", encoding="utf-8").read() # 替换版本号标记（匹配 v数字.数字.数字 格式） import re ih = re.sub(r'v\d+\.\d+\.\d+', f'v{new_ver}', ih, count=1) open(index_html, "w", encoding="utf-8").write(ih) print(f"📄 介绍页版本已同步: v{new_ver}") print(f"✅ 版本: v{old_ver} → v{new_ver}") print(f"📦 文件: {len(manifest['files'])} 个") for rel, meta in manifest["files"].items(): print(f" {meta['sha256'][:12]}... {meta['size']:>6}B {rel}") PYEOF # 6. 发布到预览环境（自动升级版本号 + 同步所有文件版本） SKILL_DIR="$SKILL_DIR" python3 "${SKILL_DIR}/deploy.py" publish # 7. 验证预览内容（检查输出的预览地址） # - manifest.json 版本号正确 # - index.html 版本号正确 # - SKILL.md 版本号正确 # 8. 验证无误后，发布到生产环境 python3 "${SKILL_DIR}/deploy.py" publish-prod ``` ### 验证发布 发布后执行以下命令确认： ```bash SKILL_DIR="<技能实际目录路径>" REPO=$(python3 -c "import json; print(json.load(open('${SKILL_DIR}/manifest.json'))['repository'])") # 检查 manifest 可达 curl -sL "${REPO}/manifest.json" | python3 -c "import json,sys; m=json.load(sys.stdin); print(f'v{m[\"version\"]} — {len(m[\"files\"])} files')" # 检查各文件可达 curl -sI "${REPO}/SKILL.md" | head -1 curl -sI "${REPO}/deploy.py" | head -1 ``` --- ## 前置配置（使用者必须完成） ### 四组部署目标配置 在 TOOLS.md 中按需配置： ```markdown ### Report Expert 技能配置 **部署模式：** local - `REPORT_DEPLOY_MODE=local` - `REPORT_LOCAL_DIR=/path/to/site` # 本地站点根目录 - `REPORT_LOCAL_URL=https://example.com/claw` # 站点访问地址 - `REPORT_SITE_NAME=传琪` # 远程报告部署（可选） - `REPORT_CF_PROJECT=reports-site` # 报告站 CF Pages 项目名 # 技能 CF 发布（可选） - [REDACTED_SECRET] # 技能站 CF Pages 项目名 # 技能 ClawHub 发布（可选） - [REDACTED_SECRET] - `SKILL_CLAWHUB_NAME=报告专家` ``` ### 共用凭据 - `CLOUDFLARE_API_TOKEN` — 环境变量或 TOOLS.md，一个 token 管所有 CF 操作 - 建议使用最小权限 token（仅需 Cloudflare Pages 编辑权限） - 建议使用环境变量方式配置，不要提交到公共仓库 ## 两种内容类型 ### 1. 报告页面（同站部署） 完整的调研报告、教程等，部署在报告主站的子目录下。 **流程：** 1. 生成 HTML body 内容（只需 `<style>` + 内容 HTML，不需要完整页面） 2. 运行 `python3 deploy.py deploy <category> <html_file> --title "标题" --desc "描述"` 3. 脚本自动：套模板、注入样式、添加页脚、更新索引 4. 运行 wrangler 部署到 Cloudflare Pages ### 2. 外部页面（独立部署 + iframe 内嵌） 游戏、工具、测试等独立页面，部署到独立的 Cloudflare Pages 项目，同时在报告主站索引中展示。 **流程：** 1. 生成完整的独立 HTML 页面 2. 创建独立的 Cloudflare Pages 项目并部署 3. 运行 `python3 deploy.py add <filename> --title "标题" --desc "描述" --category other --url "https://xxx.pages.dev/"` 4. 更新 dist/index.json 并部署报告主站 **首页行为：** - 报告页面：正常跳转到报告详情页 - 外部页面：在 iframe 中内嵌打开，保留页头、侧边栏和页脚导航 ## 分类体系 ### 分类命名规范 1. **双标识格式**：创建报告或新建分类时，必须使用 `英文标识 + 中文名称` 的双标识体系 2. **路径使用英文标识**：文件存储路径、URL 路径、索引过滤字段均使用英文标识（如 `research`、`analysis`） 3. **展示使用中文名称**：索引页面、用户界面展示时使用中文名称（如「深度研究」「数据分析」） 4. **逻辑执行用英文标识**：代码中的分类判断、过滤、路由等逻辑全部基于英文标识 ### 现有分类 | key | 名称 | 说明 | |-----|------|------| | research | 深度研究 | 系统性调研与分析 | | analysis | 数据分析 | 数据驱动的分析洞察 | | summary | 内容摘要 | 信息整理与要点提炼 | | comparison | 对比评测 | 产品、方案或工具的横向对比 | | tutorial | 教程指南 | 操作指南与最佳实践 | | other | 其他 | 游戏、工具、测试等外部页面 | ## CLI 命令 ```bash # 报告部署 python3 deploy.py deploy <category> <file> [--title T] [--desc D] # 自动选本地/远程 python3 deploy.py local-deploy <category> <file> [--title T] [--desc D] python3 deploy.py remote-deploy <category> <file> [--title T] [--desc D] # 技能发布 python3 deploy.py skill-cf-publish # 发布技能到 CF Pages 预览 python3 deploy.py skill-cf-publish-prod # 发布技能到 CF Pages 生产 python3 deploy.py skill-clawhub-publish [--changelog "xxx"] # 发布到 ClawHub # 索引和备份 python3 deploy.py add <filename> --title T --desc D --category C [--url U] python3 deploy.py rebuild_index python3 deploy.py scan [--ai] python3 deploy.py update python3 deploy.py backup [list|restore <name>] ``` ## 索引管理操作规范 当用户从索引页管理模式生成指令后，执行对应的索引管理操作： ### 移动分类 - **只修改 index.json 中的 category 字段，不要移动实际文件** - 修改后必须执行 `python3 deploy.py update` 部署更新 ```python # 修改 category 示例 import json f = 'dist/index.json' d = json.load(open(f)) for p in d['pages']: if p['filename'] == 'xxx.html': p['category'] = 'target-category' json.dump(d, open(f, 'w'), ensure_ascii=False, indent=2) ``` ### 删除页面 - 删除源文件（`REPORT_LOCAL_DIR` 下对应路径）和 dist 中的副本 - 执行 `python3 deploy.py rebuild_index` 重建索引并部署 ### 操作后必须部署 - **移动分类** → `python3 deploy.py update` - **删除页面** → `python3 deploy.py rebuild_index` - 不部署 = 没生效 --- ## 部署前检查清单 每份报告部署前必须逐项确认： ### 🔍 成果物 Review（部署前必须执行） **生成 HTML 后、部署前，必须对产出物执行自动化 review：** ```bash # 1. div 闭合校验（必须完全相等） OPEN=$(grep -c '<div' FILE); CLOSE=$(grep -c '</div>' FILE); echo "div: $OPEN open / $CLOSE close"; [ "$OPEN" = "$CLOSE" ] && echo "✅ PASS" || echo "❌ FAIL" # 2. 检查重复嵌套 report-wrap PATTERN=$(grep -c 'class="report-wrap"' FILE); [ "$PATTERN" -le 1 ] && echo "✅ PASS" || echo "❌ FAIL: $PATTERN report-wrap found" # 3. 检查重复 page-body PATTERN=$(grep -c 'class="page-body"' FILE); [ "$PATTERN" -le 1 ] && echo "✅ PASS" || echo "❌ FAIL: $PATTERN page-body found" # 4. 检查重复 scroll-progress / toc-sidebar / toc-toggle for CLS in scroll-progress toc-sidebar toc-toggle; do PATTERN=$(grep -c "$CLS" FILE); [ "$PATTERN" -le 1 ] && echo "✅ $CLS" || echo "❌ FAIL: $CLS x$PATTERN"; done # 5. 检查是否有手写 footer grep -q 'class="page-footer"' FILE && echo "❌ FAIL: 手写 footer detected" || echo "✅ PASS" # 6. 检查标题是否为中文（标题应在 h1.report-header__title 中） TITLE=$(grep -o 'report-header__title">[^<]*<' FILE | sed 's/.*>\(.*\)</.*/\1/') echo "标题: $TITLE" # 7. 检查深色背景 grep -qP 'background[^;]*#0[0-9a-f]|background[^;]*#1[0-9a-f]' FILE && echo "❌ FAIL: 深色背景" || echo "✅ PASS" # 8. 检查图表完整性（有容器必须有 data-option） CHART_DIVS=$(grep -c 'chart-box__canvas' FILE) CHART_OPTS=$(grep -c 'data-option=' FILE) echo "图表容器: $CHART_DIVS / data-option: $CHART_OPTS" if [ "$CHART_DIVS" -gt 0 ] && [ "$CHART_OPTS" -eq 0 ]; then echo "❌ FAIL: 有图表容器但没有 data-option 属性" fi [ "$CHART_DIVS" -eq 0 ] || [ "$CHART_OPTS" -gt 0 ] && echo "✅ 图表完整性 PASS" # 9. 检查 page-body 中是否有 script 标签 # （允许 body 底部的 script，但 page-body 内容区不应有） ``` **review 失败时必须修复后再部署，不可跳过此步骤。** ### 部署检查项 - [ ] 使用 base.css + main.js（引用 `../styles/base.css` 和 `../scripts/main.js`） - [ ] 包含 toc-sidebar + toc-toggle + scroll-progress 元素 - [ ] TOC 标题带 SVG 列表图标，关闭按钮用 SVG X 图标，展开按钮用 SVG 汉堡图标 - [ ] **页头页脚由 main.js 统一注入，HTML 中不要手写 `<footer>` 或面包屑链接** - [ ] report-header__breadcrumb 只需写结构（a + span），链接和 SVG 图标由 JS 自动填充 - [ ] 分类标签带 SVG 图标，日期带 SVG 日历图标 - [ ] 内容包裹在 report-wrap > report-header + page-body 中 - [ ] h2/h3 有 id 属性供大纲跳转（main.js 会自动补 id，但建议模板中直接写上） - [ ] **不要手写 `<footer class="page-footer">`**，JS 会自动注入统一页脚（logo + 传琪 + 年份） - [ ] **没有使用任何 emoji 作为图标或 UI 元素** - [ ] 配色使用 base.css 变量（`var(--c-xxx)`），没有硬编码颜色 - [ ] **禁止在 body 内联样式中使用深色背景（`#0f`/`#1a`/`#111`/`#222` 等深色值）** - [ ] **禁止 CSS fallback 使用深色值**，如 `var(--c-bg, #0f0f1e)` 必须改为 `var(--c-bg, #f8fafc)` 等亮色值 - [ ] **禁止深色背景配深色文字**，确保背景和文字的对比度足够 - [ ] 卡片/区块用底色+阴影，没有用线框 - [ ] 表格表头用 `--c-elevated` 深色底 - [ ] 表格主体用 `--c-surface` 白色底，偶数行用 `--c-surface-alt` - [ ] **🔴 强制校验：`<div>` 和 `</div>` 数量必须完全相等**（`grep -c '<div' file && grep -c '</div>' file`），不等则禁止部署。多余的闭合标签会导致页面白屏、样式错乱、内容消失 - [ ] **🔴 强制校验：只允许一套页面框架**（1个 scroll-progress、1个 toc-sidebar、1个 toc-toggle、1个 report-wrap、1个 page-body），重复会导致布局崩溃 - [ ] **🔴 禁止手写 `<footer>`**（第3次强调，子 agent 最容易犯这个错） - [ ] **🔴 禁止在 page-body 中间插入 `<script>` 标签**，所有图表初始化脚本必须写在 body 底部、main.js 之后 ## 模板文件 - `templates/base.css` — 设计系统样式（v6.1 底色层级） - `templates/index.html` — 首页模板（含 iframe viewer） - `scripts/main.js` — 交互系统（TOC、滚动进度、渐入动画、图表放大 Lightbox） - `deploy.py` — CLI 入口（参数解析与命令分发） - `lib/config.py` — 配置加载、四组部署目标常量、工具函数 - `lib/page.py` — 页面 HTML 生成与部署 - `lib/index.py` — 索引管理（重建/添加/扫描） - `lib/backup.py` — 备份恢复与页面模板更新 - `lib/local_deploy.py` — 本地报告部署（写入 dist 目录） - `lib/remote_deploy.py` — 远程报告部署（Cloudflare Pages via wrangler） - `lib/skill_cf_publish.py` — 技能发布到 CF Pages（预览/生产） - `lib/skill_clawhub_publish.py` — 技能发布到 ClawHub registry - `manifest.json` — 版本信息、文件清单、更新日志 - `upgrade.py` — 独立升级脚本（可选，agent 可直接按 SKILL.md 流程执行） ## 安全说明 ### 所需环境变量 - `CLOUDFLARE_API_TOKEN` — Cloudflare API Token，远程报告部署 + 技能 CF 发布共用，local 模式无需配置 ### 所需外部工具 - `python3` — 运行部署脚本 - `curl` — 升级流程下载远端文件 - `npx` / `wrangler` — 远程发布到 Cloudflare Pages - `openclaw` — AI 自动分类功能（`--ai` 模式，可选） - `clawhub` — ClawHub 发布 CLI（`skill-clawhub-publish` 命令） ### ClawHub 发布注意 - **⚠️ 发布前必须排除 `dist/` 和 `backups/` 目录**，否则超过 20MB 限制会失败 - `skill-clawhub-publish` 已内置自动临时移除逻辑，但需确认生效 ### 升级安全 - 升级从 `manifest.json` 的 `repository` 字段指定的远端下载文件 - 所有文件经过 SHA256 校验，校验失败会拒绝写入 - **建议：** 仅在信任远端来源时执行升级 ### 凭据管理 - 凭据通过环境变量或 TOOLS.md 配置，**不要将 token 提交到公共仓库** - 建议使用环境变量方式配置 CLOUDFLARE_API_TOKEN - 建议使用最小权限 token（仅需 Cloudflare Pages 编辑权限） ## 图表与可视化规范 > **图表放大功能**：main.js 已内置图表放大（Lightbox）功能，支持 chart-box、ECharts div、Mermaid 流程图。手机端按钮始终可见。ECharts 弹层会重新创建实例确保正确渲染。 报告页面内置三种图表引擎，按需选用： ### 1. Mermaid — 流程图/架构图/时序图 适用于：系统架构、流程说明、时序关系、状态机等。 ```html <div class="mermaid-wrap"><pre class="mermaid"> flowchart TD A["节点A"] --> B["节点B"] </pre></div> ``` **禁止事项：** - ❌ 不要在 `mermaid-wrap` 外再包额外的 `div`（如 `arch-diagram`），否则会导致 `</div>` 闭合错乱 - ❌ 不要在 mermaid 代码块前后加多余的闭合标签 **支持类型：** `flowchart`、`sequence`、`class`、`state`、`er`、`gantt`、`pie`、`mindmap`、`timeline` ### 2. ECharts — 数据可视化（推荐） 适用于：柱状图、折线图、饼图、散点图、雷达图、热力图、地图、桑基图等复杂数据可视化。 **HTML 结构（写在 page-body 内容区，使用 data-option 属性声明配置）：** ```html <div class="chart-box"> <div class="chart-box__title">图表标题</div> <div class="chart-box__canvas" id="chart-xxx" data-option='{"xAxis":{"type":"category","data":["A","B","C"]},"yAxis":{"type":"value"},"series":[{"type":"bar","data":[10,20,15]}]}'></div> <div class="chart-box__caption">数据来源说明</div> </div> ``` **⚠️ 关键规则：** - **使用 `data-option` 属性**：将 ECharts option 配置写成 JSON 字符串，放在 `chart-box__canvas` 元素的 `data-option` 属性中 - **不需要手写 `<script>` 标签**：`main.js` 会自动扫描所有 `chart-box__canvas[data-option]` 元素，自动调用 `echarts.init()` + `setOption()` - **每个图表的 `id` 必须唯一**（建议用 `chart-` 前缀 + 简短描述） - **`data-option` 的值必须是合法 JSON**（双引号包裹 key，字符串值用双引号） **data-option 内 JSON 转义规则：** - 如果 `data-option` 在双引号属性中（`data-option="..."`），内层 JSON 的双引号要写为 `&quot;` - 推荐用单引号包裹属性值（`data-option='...'`），内层 JSON 用标准双引号即可 - 如果 option 中有 HTML 模板字符串，需要先转义 **完整示例：** ```html <div class="chart-box"> <div class="chart-box__title">市场规模增长趋势</div> <div class="chart-box__canvas" id="chart-growth" data-option='{ "tooltip":{"trigger":"axis"}, "xAxis":{"type":"category","data":["2023","2024","2025","2026"]}, "yAxis":{"type":"value","name":"亿美元"}, "series":[{"type":"line","data":[3.2,5.1,7.2,8.5],"smooth":true,"areaStyle":{"opacity":0.15},"itemStyle":{"color":"#6366f1"}}] }'></div> <div class="chart-box__caption">数据来源：Fortune Business Insights</div> </div> ``` **配色原则：** 使用报告主题色 `#6366f1`（indigo）为主色，辅以 `#8b5cf6`、`#06b6d4`、`#10b981`、`#f59e0b`、`#ef4444` 等柔和色系。避免使用默认亮色。 **尺寸类：** - 默认高度 360px：`chart-box__canvas` - 小图 240px：`chart-box__canvas--sm` - 大图 480px：`chart-box__canvas--lg` **注意事项：** - 每个图表的 `id` 必须唯一（建议用 `chart-` 前缀 + 简短描述） - 响应式 resize 由 main.js 自动处理，无需手动监听 - 多个图表各自独立，各自写 `data-option` 即可 - ECharts CDN 由 deploy.py 按需自动注入到 `<head>` 中，不需要手动添加 `<script src="echarts">` ### 3. Chart.js — 轻量图表 适用于：简单的柱状图、折线图、饼图、环形图、面积图。比 ECharts 更轻量。 **HTML 结构（写在 page-body 内容区）：** ```html <div class="chart-container"> <canvas id="chart-xxx" height="300"></canvas> </div> <p class="chart-caption">图表说明</p> ``` **初始化脚本（必须写在 body 底部，ECharts 脚本之后）：** ```html <script> document.addEventListener('DOMContentLoaded', function() { new Chart(document.getElementById('chart-xxx'), { type: 'bar', data: { labels: ['A', 'B', 'C'], datasets: [{ label: '数据', data: [10, 20, 15], backgroundColor: '#6366f1' }] }, options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true } } } }); }); </script> ``` ### 图表优先原则（必须遵守） 生成报告时，凡是能用图表表达的内容，**必须优先使用图表渲染**，而非纯文字或表格堆砌。 **适用场景：** - 数据对比 → 柱状图/水平柱状图 - 趋势变化 → 折线图/面积图 - 占比分布 → 饼图/环形图 - 多维评估 → 雷达图 - 流程步骤 → 流程图/甘特图 - 系统关系 → 架构图/ER图 - 转化链路 → 漏斗图 - 状态流转 → 状态机图 - 时序交互 → 时序图 - 项目排期 → 甘特图 - 数据散布 → 散点图 **判断标准：** 如果一段内容包含 3 个以上数据点、多步骤流程、多组件关系，就必须用图表呈现。 --- ### 选型指南 | 场景 | 推荐引擎 | 理由 | |------|----------|------| | 流程/架构/时序 | Mermaid | 声明式语法，适合关系图 | | 复杂数据可视化 | ECharts | 交互丰富，图表类型全面 | | 简单统计图表 | Chart.js | 轻量，上手快 | | 需要动画效果 | ECharts | 内置丰富动画和交互 | | 多图表混排 | ECharts | 统一风格，配色一致 | ### 🔴 图表渲染完整性校验（每次生成后必须执行） **ECharts 使用声明式 `data-option` 属性，不需要手写 `<script>` 标签。** ```bash # 检查每个 chart-box__canvas 是否有 data-option 属性 CONTAINERS=$(grep -c 'chart-box__canvas' FILE) DATA_OPTS=$(grep -c 'data-option=' FILE) echo "图表容器: $CONTAINERS, data-option: $DATA_OPTS" if [ "$CONTAINERS" -gt 0 ] && [ "$DATA_OPTS" -eq 0 ]; then echo "❌ 致命错误：有图表容器但没有 data-option！图表不会渲染！" echo "必须在 chart-box__canvas 元素上添加 data-option='{json}' 属性" fi ``` **正确示例（声明式）：** ```html <!-- 内容区：图表容器 + data-option 属性，一步到位 --> <div class="chart-box"> <div class="chart-box__title">标题</div> <div class="chart-box__canvas" id="chart-xxx" data-option='{"xAxis":{"type":"category","data":["A","B"]},"series":[{"type":"bar","data":[10,20]}]}'></div> </div> <!-- main.js 自动扫描所有 chart-box__canvas[data-option] 并初始化，无需手写 <script> --> ``` ### ⚠️ 常见错误检查清单（每次生成后必须检查） | # | 问题 | 正确做法 | 后果 | |---|------|---------|------| | 1 | Mermaid 用 `<mermaid>` 标签 | 必须用 `<pre class="mermaid">` | 图表不渲染 | | 2 | `mermaid.initialize()` 在 CDN 加载前调用 | 必须在 `<script src="mermaid.min.js">` 之后调用 | ReferenceError | | 3 | Mermaid 外层 div 设置了 background 色 | 外层容器不设背景色，让 Mermaid 自身控制渲染 | 底色污染/白块 | | 4 | ECharts 容器 div 没设高度 | 必须 `style="width:100%;height:400px"` 或用 CSS 设固定高度 | 图表不渲染（高度为0） | | 5 | ECharts CDN 放在 body 底部但初始化用 DOMContentLoaded | CDN 放 head 同步加载（deploy.py 自动处理），初始化用 DOMContentLoaded | 加载时序可能出错 | | 5b | **有 chart 容器但没有 echarts.init 脚本** | **每个 chart-box__canvas 必须有对应的 echarts.init + setOption** | **图表不渲染（最常见错误）** | | 6 | 多个图表用了相同 id | 每个 ECharts 容器 id 必须唯一 | 只渲染第一个 | | 7 | 子 agent 输出完整 HTML 页面 | 子 agent 只输出 style + body 内容片段 | report-wrap/page-body 双重嵌套 | | 8 | style 标签残留到 page-body 内 | style 必须在 head 中，body 中不得有 style | 样式异常/白屏 | ### 脚本加载顺序（严格遵守） ``` <head> ├── echarts.min.js ← 同步加载（deploy.py 按需注入） └── chart.umd.min.js ← 同步加载（deploy.py 按需注入） <body> ├── page-body 内容区 ← 图表容器用 data-option 声明配置 ├── main.js ← 自动扫描 data-option 并初始化 ECharts └── mermaid.min.js + init ← 按需加载 </body> ``` **核心规则：** ECharts 图表使用 **声明式 `data-option` 属性**，不需要手写初始化脚本。`main.js` 自动处理所有初始化和 resize。Chart.js 仍使用 `data-chart` 属性（已有自动初始化支持）。Mermaid 使用 `<pre class="mermaid">` 标签。 --- ## 生成报告时的关键提醒 > **🔴 图表渲染——data-option 声明式（无需手写 script！）** > > ECharts 图表不需要手写 `<script>` 初始化代码。只需在 `chart-box__canvas` 元素上加 `data-option` 属性： > > ```html > <div class="chart-box__canvas" id="chart-xxx" data-option='{"xAxis":...,"series":...}'></div> > ``` > > `main.js` 会自动扫描所有 `chart-box__canvas[data-option]` 元素，自动调用 `echarts.init()` + `setOption()` + resize 监听。 > > **完整三件套变为：** > 1. **图表容器** + **`data-option` 属性**（内容区 HTML，一步到位） > 2. **ECharts CDN**（deploy.py 按需自动注入 `<head>`） > 3. ~~初始化脚本~~（不再需要！main.js 自动处理） ## 注意事项 - **本技能不包含任何 Cloudflare 凭据**，所有 token/account/project 信息由使用者自行配置 - 首页由 index.html 模板 + index.json 数据驱动，index.html 不应手动编辑 - 如果报告 HTML 是完整页面（含 `<html>` 标签），deploy.py 会自动提取 body 和 style - deploy.py 会自动给文件名加日期前缀 - **外部页面**需要额外创建独立 Cloudflare Pages 项目并部署，然后用 `add` 命令加入索引 - 首页的 iframe viewer 会自动识别外部 URL（`url` 字段以 http 开头且域名不同），用 iframe 内嵌打开 - 管理模式下支持拖拽页面到侧边栏分类进行移动、勾选删除，操作完成后生成组合指令 - 管理模式下按 ESC 退出管理模式，取消所有未提交的操作

path: MUSIC_PLAYER.md
content: # 音乐播放页面模板

使用 `music_player_builder.py` 从模板快速生成歌曲播放页面。

## 文件位置

- **模板：** `templates/music-player.html`
- **构建脚本：** `music_player_builder.py`

## 使用方法

### 基本用法

```bash
cd /root/.openclaw/workspace/skills/report-expert

python3 music_player_builder.py \
  --mp3 /path/to/song.mp3 \
  --cover /path/to/cover.jpg \
  --title "歌曲名" \
  --output /path/to/output.html
```

### 完整参数

| 参数 | 必填 | 说明 |
|------|------|------|
| `--mp3` | ✅ | MP3 文件路径 |
| `--cover` | ✅ | 封面图路径（jpg/png/webp） |
| `--title` | ✅ | 歌曲名 |
| `--subtitle` | ❌ | 副标题（如"AI Generated Music"） |
| `--lyrics` | ❌ | 歌词文件（JSON 或纯文本） |
| `--output` | ✅ | 输出 HTML 文件路径 |
| `--download-filename` | ❌ | 下载文件名，默认为"歌曲名.mp3" |
| `--duration` | ❌ | 歌曲时长（秒），纯文本歌词自动分配时间时使用 |

### 歌词格式

**JSON 格式（带时间信息）：**
```json
[
  { "time": 0.0, "text": "Intro", "isSection": true },
  { "time": 0.0, "text": "第一句歌词", "isSection": false },
  { "time": 5.2, "text": "第二句歌词", "isSection": false }
]
```

**纯文本格式（无时间信息，自动分配）：**
```
[Intro]
第一句歌词
第二句歌词

[Verse 1]
第三句歌词
第四句歌词
```

### 生成并部署

```bash
# 1. 生成播放页面
python3 music_player_builder.py \
  --mp3 /path/to/song.mp3 \
  --cover /path/to/cover.jpg \
  --title "歌曲名" \
  --lyrics /path/to/lyrics.txt \
  --output /root/space/rego/static-site/claw/other/song-player.html

# 2. 注册到索引
python3 deploy.py add song-player.html \
  --title "歌曲名 - 播放" \
  --desc "歌曲描述" \
  --category other \
  --url "https://www.rego.vip/claw/other/song-player.html"

# 3. 同步部署
python3 -c "from lib.config import sync_to_deploy; sync_to_deploy()"
```

### 模板变量

| 变量 | 说明 |
|------|------|
| `{{TITLE}}` | 歌曲名（页面标题、封面 alt、下载文件名） |
| `{{SUBTITLE}}` | 副标题 |
| `{{COVER_SRC}}` | 封面图（base64 data URI 或 URL） |
| `{{AUDIO_SRC}}` | 音频源（base64 data URI 或 URL） |
| `{{DOWNLOAD_FILENAME}}` | 下载时的文件名 |
| `{{LYRICS_JSON}}` | 歌词 JSON 数组 |

## 页面特性

- 左右布局：播放器 + 封面 | 歌词同步滚动
- 播放/暂停、进度条拖动、音量控制
- 点击歌词跳转播放位置
- 下载按钮（文件名为歌名.mp3）
- 深色紫蓝渐变背景，粉紫强调色
- 响应式：手机端自动上下布局
- 封面旋转动画


path: deploy.py
content: #!/usr/bin/env python3
"""传琪 · 报告部署系统 — 索引管理、首页生成、页面部署"""

import json, sys, shutil, re
from pathlib import Path
from datetime import date

BASE_DIR = Path(__file__).parent
DIST_DIR = BASE_DIR / "dist"

CATEGORIES = {
    "research": "深度研究",
    "analysis": "数据分析",
    "summary": "内容摘要",
    "comparison": "对比评测",
    "tutorial": "教程指南",
    "project": "项目作品",
    "other": "其他",
}

# ── 自适应配置：从 TOOLS.md 读取部署模式 ──
def _load_config():
    """Read REPORT_DEPLOY_MODE and related vars from TOOLS.md"""
    config = {"mode": "local", "local_dir": str(BASE_DIR), "local_url": "/", "site_name": "传琪"}
    tools = Path.home() / ".openclaw/workspace/TOOLS.md"
    if not tools.exists():
        return config
    text = tools.read_text("utf-8")
    def _get(key):
        m = re.search(rf'{key}=(.+)', text)
        if not m: return None
        v = m.group(1).strip()
        return v.strip().strip('`').strip() if v else None
    mode = _get("REPORT_DEPLOY_MODE") or "local"
    config["mode"] = mode
    if mode == "local":
        config["local_dir"] = _get("REPORT_LOCAL_DIR") or str(BASE_DIR)
        config["local_url"] = _get("REPORT_LOCAL_URL") or "/"
        config["site_name"] = _get("REPORT_SITE_NAME") or "传琪"
    elif mode == "remote":
        config["site_name"] = _get("CF_SITE_NAME") or "传琪"
        config["local_url"] = _get("CF_SITE_URL") or "/"
        config["local_dir"] = _get("CF_PROJECT_DIR") or str(BASE_DIR)
    return config

_CFG = _load_config()
INDEX_FILE = Path(_CFG["local_dir"]) / "index.json"
SITE_URL = _CFG["local_url"].rstrip("/")
SITE_NAME = _CFG["site_name"]

def load_index():
    if INDEX_FILE.exists():
        with open(INDEX_FILE, "r", encoding="utf-8") as f:
            return json.load(f)
    return {"site": {"name": SITE_NAME, "baseUrl": SITE_URL}, "pages": []}

def save_index(data):
    INDEX_FILE.parent.mkdir(parents=True, exist_ok=True)
    with open(INDEX_FILE, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)

def strip_emoji(text):
    """Remove emoji and special symbols from text"""
    import unicodedata
    return re.sub(r'[\U0001f300-\U0001f9ff\U00002702-\U000027b0\U00002600-\U000026ff\U0001fa00-\U0001fa6f\U0001fa70-\U0001faff\u200d\ufe0f]', '', text)

def add_ids(html):
    counter = {}
    def repl(m):
        tag = m.group(1).lower()
        text = re.sub(r'<[^>]+>', '', m.group(2)).strip()
        slug = re.sub(r'[^\w\u4e00-\u9fff]+', '-', text).strip('-').lower()
        if not slug: slug = 'section'
        counter[slug] = counter.get(slug, 0) + 1
        uid = slug if counter[slug] == 1 else f"{slug}-{counter[slug]}"
        return f'<{tag} id="{uid}">{m.group(2)}</{tag}>'
    return re.sub(r'<(h[23])>(.*?)</\1>', repl, html, flags=re.DOTALL)

def generate_page_html(page_info, base_url):
    cat = page_info["category"]
    cat_name = CATEGORIES.get(cat, "其他")
    body = add_ids(page_info["body"])
    page_style = page_info.get("style", "")
    style_tag = f"\n  <style>\n    {page_style}\n  </style>" if page_style else ""
    return f'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>{page_info["title"]}</title>
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Noto+Sans+SC:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
  <link rel="stylesheet" href="../styles/base.css">
{style_tag}
</head>
<body>
  <div class="scroll-progress"></div>
  <aside class="toc-sidebar">
    <div class="toc-sidebar__header">
      <div class="toc-sidebar__title">目录</div>
      <button class="toc-sidebar__close" aria-label="收起目录">✕</button>
    </div>
    <div class="toc-list"></div>
  </aside>
  <button class="toc-toggle" aria-label="展开目录">☰</button>
  <div class="report-wrap">
    <header class="report-header">
      <div class="report-header__breadcrumb">
        <a href="../" target="_top">🏠</a>
        <span>{cat_name}</span>
      </div>
      <div class="report-header__meta">
        <span class="report-header__tag">{cat_name}</span>
        <span class="report-header__date">{page_info["date"]}</span>
      </div>
      <h1 class="report-header__title">{page_info["title"]}</h1>
      {f'<p class="report-header__desc">{page_info["desc"]}</p>' if page_info.get("desc") else ""}
    </header>
    <div class="page-body" data-reveal>{body}</div>
  </div>
  <footer class="page-footer">
    <div class="page-footer__logo">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
    </div>
    <a href="../" class="page-footer__link" target="_top">{SITE_NAME}</a>
    <div class="page-footer__sep"></div>
    <span>报告与研究成果</span>
  </footer>
  <script src="../scripts/main.js?v=6"></script>
</body>
</html>'''

def copy_assets():
    """Ensure static assets exist in dist"""
    pass

def rebuild_index():
    data = load_index()
    copy_assets()
    tpl = BASE_DIR / "templates" / "index.html"
    if tpl.exists():
        html = tpl.read_text("utf-8")
        html = html.replace("{{SITE_BASE}}", ".")
        html = html.replace("{{SITE_NAME}}", SITE_NAME)
        (DIST_DIR / "index.html").write_text(html, "utf-8")
        (DIST_DIR / "index.json").write_text(json.dumps(data, ensure_ascii=False, indent=2), "utf-8")
    print(f"✅ 索引已刷新，共 {len(data['pages'])} 篇内容")

def deploy(category, html_file, title=None, desc=None):
    if category not in CATEGORIES:
        print(f"❌ 未知分类: {category}")
        print(f"   可选: {', '.join(CATEGORIES.keys())}")
        sys.exit(1)
    src = Path(html_file)
    if not src.exists():
        print(f"❌ 文件不存在: {html_file}")
        sys.exit(1)

    with open(src, "r", encoding="utf-8") as f:
        html = f.read()

    # Extract body content
    body_match = re.search(r'<body[^>]*>(.*)</body>', html, re.DOTALL)
    if body_match:
        body = body_match.group(1).strip()
    else:
        body = html
    body = strip_emoji(body)

    # Extract style
    style_match = re.search(r'<style>(.*?)</style>', html, re.DOTALL)
    page_style = style_match.group(1).strip() if style_match else ""

    # Remove wrapper divs if present
    for cls in ['report-wrap', 'page-body', 'wrap']:
        m = re.search(rf'<div class="{cls}"[^>]*>(.*)</div>\s*$', body, re.DOTALL)
        if m: body = m.group(1).strip()

    # Remove old headers/footers
    body = re.sub(r'<header[^>]*>.*?</header>', '', body, flags=re.DOTALL)
    body = re.sub(r'<footer[^>]*>.*?</footer>', '', body, flags=re.DOTALL)
    body = re.sub(r'<h1[^>]*>.*?</h1>', '', body, flags=re.DOTALL)

    if not title:
        title_match = re.search(r'<title>(.*?)</title>', html)
        title = title_match.group(1) if title_match else src.stem

    data = load_index()
    base_url = data["site"].get("baseUrl", SITE_URL)
    today = date.today().isoformat()
    filename = f"{today}-{src.stem}.html"

    page_info = {
        "title": title,
        "desc": desc or "",
        "date": today,
        "category": category,
        "body": body,
        "style": page_style,
    }

    page_html = generate_page_html(page_info, base_url)

    cat_dir = DIST_DIR / category
    cat_dir.mkdir(parents=True, exist_ok=True)
    out_path = cat_dir / filename
    with open(out_path, "w", encoding="utf-8") as f:
        f.write(page_html)

    # Update index
    page_url = f"{base_url}/{category}/{filename}"
    data["pages"].append({
        "filename": filename,
        "title": title,
        "desc": desc or "",
        "date": today,
        "category": category,
        "url": page_url,
    })
    save_index(data)

    print(f"✅ 部署成功")
    print(f"   分类: {CATEGORIES.get(category, category)} ({category})")
    print(f"   标题: {title}")
    print(f"   日期: {today}")
    print(f"   路径: /{category}/{filename}")
    print(f"   链接: {page_url}")

def add_to_index(filename, title, desc, category, url=None):
    """Add an external page (e.g. deployed elsewhere) to the index"""
    data = load_index()
    today = date.today().isoformat()
    entry = {
        "filename": filename,
        "title": title,
        "desc": desc or "",
        "date": today,
        "category": category,
    }
    if url:
        entry["url"] = url
    data["pages"].append(entry)
    save_index(data)
    print(f"✅ 已添加到索引: {title}")

def scan_and_rebuild():
    """Scan all HTML pages in dist/, extract metadata, build complete index.json.

    Reads title/date/desc from each page. If --ai flag is passed, also uses
    page content to auto-detect category and generate description.
    """
    import subprocess

    use_ai = "--ai" in sys.argv
    dist = DIST_DIR
    if not dist.exists():
        print(f"❌ dist 目录不存在: {dist}"); sys.exit(1)

    pages = []
    html_files = sorted(dist.rglob("*.html"))
    # Exclude index.html
    html_files = [f for f in html_files if f.name != "index.html"]

    if not html_files:
        print("⚠️ 未找到任何 HTML 页面"); return

    print(f"📡 扫描到 {len(html_files)} 个页面...")

    for fpath in html_files:
        rel = fpath.relative_to(dist)
        category = str(rel.parent)  # e.g. "research", "analysis"
        html = fpath.read_text("utf-8")

        # Extract title
        title_m = re.search(r'<title>(.*?)</title>', html)
        title = title_m.group(1).strip() if title_m else fpath.stem

        # Extract date from filename (2026-04-09-xxx.html)
        date_m = re.match(r'(\d{4}-\d{2}-\d{2})', fpath.stem)
        date_str = date_m.group(1) if date_m else date.today().isoformat()

        # Extract desc from meta or first paragraph
        desc = ""
        desc_m = re.search(r'<meta[^>]+name="description"[^>]+content="([^"]+)"', html)
        if not desc_m:
            desc_m = re.search(r'<p class="report-header__desc">(.*?)</p>', html, re.DOTALL)
        if desc_m:
            desc = re.sub(r'<[^>]+>', '', desc_m.group(1)).strip()

        # Extract body text for AI classification (first 2000 chars)
        body_text = ""
        body_m = re.search(r'<div class="page-body"[^>]*>(.*)</div>\s*<footer', html, re.DOTALL)
        if body_m:
            body_text = re.sub(r'<[^>]+>', '', body_m.group(1)).strip()[:2000]

        # Detect external pages (iframes or redirects)
        is_external = bool(re.search(r'<iframe|window\.location', html))
        page_url = f"{SITE_URL}/{rel}" if not is_external else None

        pages.append({
            "filename": rel.name,
            "title": title,
            "desc": desc,
            "date": date_str,
            "category": category,
            "url": page_url,
        })

        status = f"  ✅ {category}/{rel.name} — {title}"
        if use_ai and body_text:
            status += " (待 AI 分类)"
        print(status)

    # AI classification pass
    if use_ai:
        print(f"\n🤖 AI 分类中...")
        # Build a prompt with all pages
        cat_list = ", ".join(CATEGORIES.keys())
        for p in pages:
            fpath = dist / p["category"] / p["filename"]
            html = fpath.read_text("utf-8")
            body_m = re.search(r'<div class="page-body"[^>]*>(.*)</div>\s*<footer', html, re.DOTALL)
            body_text = re.sub(r'<[^>]+>', '', body_m.group(1)).strip()[:1500] if body_m else ""
            if not body_text:
                continue
            prompt = f"""根据以下内容，选择最合适的分类。
可选分类: {cat_list}
内容标题: {p['title']}
内容摘要(前1500字): {body_text}

只输出分类英文key，不要其他内容:"""
            try:
                result = subprocess.run(
                    ["openclaw", "ai", "--model", "zai/glm-5-turbo", "--prompt", prompt],
                    capture_output=True, text=True, timeout=30
                )
                ai_cat = result.stdout.strip().lower()
                if ai_cat in CATEGORIES:
                    old_cat = p["category"]
                    p["category"] = ai_cat
                    if old_cat != ai_cat:
                        print(f"  🔄 {p['filename']}: {old_cat} → {ai_cat}")
                # Also generate desc if missing
                if not p["desc"]:
                    desc_prompt = f"""用一句话(20-40字)描述以下内容的核心主题。
标题: {p['title']}
内容: {body_text[:800]}

只输出描述文字:"""
                    dr = subprocess.run(
                        ["openclaw", "ai", "--model", "zai/glm-5-turbo", "--prompt", desc_prompt],
                        capture_output=True, text=True, timeout=30
                    )
                    p["desc"] = dr.stdout.strip()[:100]
            except Exception as e:
                print(f"  ⚠️ {p['filename']}: AI 分类失败 ({e})")

    # Build categories from actual pages found
    cat_colors = {
        "research": {"c": "var(--c-amber)", "b": "var(--c-amber-light)"},
        "analysis": {"c": "var(--c-green)", "b": "var(--c-green-light)"},
        "project": {"c": "var(--c-accent)", "b": "var(--c-accent-light)"},
        "summary": {"c": "var(--c-purple)", "b": "var(--c-purple-light)"},
        "comparison": {"c": "var(--c-accent)", "b": "var(--c-accent-light)"},
        "tutorial": {"c": "var(--c-orange)", "b": "var(--c-orange-light)"},
        "other": {"c": "var(--c-text-muted)", "b": "var(--c-surface-alt)"},
    }
    cat_icons = {
        "research": "🔍", "analysis": "📊", "project": "🛠️",
        "summary": "📝", "comparison": "⚖️", "tutorial": "📖", "other": "📎",
    }
    cat_descs = {
        "research": "系统性调研与深度分析报告", "analysis": "数据驱动的分析洞察",
        "project": "开发项目与互动作品", "summary": "信息整理与要点提炼",
        "comparison": "产品、方案或工具的横向对比", "tutorial": "操作指南与最佳实践",
        "other": "未分类内容",
    }

    categories = {}
    for p in pages:
        cat = p["category"]
        if cat not in categories:
            categories[cat] = {
                "name": CATEGORIES.get(cat, cat),
                "icon": cat_icons.get(cat, "📄"),
                "description": cat_descs.get(cat, ""),
            }
            if cat in cat_colors:
                categories[cat]["color"] = cat_colors[cat]

    # Save
    data = {
        "site": {"name": SITE_NAME, "baseUrl": SITE_URL},
        "categories": categories,
        "pages": pages,
    }
    save_index(data)

    print(f"\n✅ 索引重建完成")
    print(f"   页面: {len(pages)} 篇")
    print(f"   分类: {', '.join(categories.keys())}")
    print(f"   文件: {INDEX_FILE}")

def update_pages():
    """Re-wrap all report pages with the latest template and styles.

    Scans dist/ for HTML pages (excluding index.html), extracts body content
    and style, then re-generates each page using generate_page_html.
    """
    dist = DIST_DIR
    if not dist.exists():
        print(f"❌ dist 目录不存在: {dist}"); sys.exit(1)

    html_files = sorted(dist.rglob("*.html"))
    html_files = [f for f in html_files if f.name != "index.html"]

    if not html_files:
        print("⚠️ 未找到任何 HTML 页面"); return

    print(f"🔄 更新 {len(html_files)} 个页面...")
    updated = 0

    for fpath in html_files:
        rel = fpath.relative_to(dist)
        category = str(rel.parent)
        html = fpath.read_text("utf-8")

        # Extract title
        title_m = re.search(r'<title>(.*?)</title>', html)
        title = title_m.group(1).strip() if title_m else fpath.stem

        # Extract date from filename
        date_m = re.match(r'(\d{4}-\d{2}-\d{2})', fpath.stem)
        date_str = date_m.group(1) if date_m else date.today().isoformat()

        # Extract body (from page-body div)
        body_m = re.search(r'<div class="page-body"[^>]*>(.*)</div>\s*<footer', html, re.DOTALL)
        if not body_m:
            print(f"  ⏭️  {rel} — 无法提取内容，跳过")
            continue
        body = body_m.group(1).strip()
        body = strip_emoji(body)
        body = re.sub(r'<h1[^>]*>.*?</h1>', '', body, flags=re.DOTALL)
        body = re.sub(r'<header[^>]*>.*?</header>', '', body, flags=re.DOTALL)
        body = re.sub(r'<footer[^>]*>.*?</footer>', '', body, flags=re.DOTALL)

        # Extract page-specific style (from <style> in <head>)
        style_m = re.search(r'<style>(.*?)</style>', html, re.DOTALL)
        page_style = style_m.group(1).strip() if style_m else ""

        # Extract desc from header
        desc = ""
        desc_m = re.search(r'<p class="report-header__desc">(.*?)</p>', html, re.DOTALL)
        if desc_m:
            desc = re.sub(r'<[^>]+>', '', desc_m.group(1)).strip()

        # Re-generate with latest template
        page_info = {
            "title": title,
            "desc": desc,
            "date": date_str,
            "category": category,
            "body": body,
            "style": page_style,
        }
        page_html = generate_page_html(page_info, SITE_URL)

        # Write back to same path
        fpath.write_text(page_html, "utf-8")
        updated += 1
        print(f"  ✅ {rel} — {title}")

    print(f"\n✅ 更新完成: {updated}/{len(html_files)} 个页面已重新生成")

if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("用法:")
        print("  python deploy.py deploy <category> <html_file> [--title T] [--desc D]")
        print("  python deploy.py add <filename> --title T --desc D --category C [--url U]")
        print("  python deploy.py rebuild_index")
        print("  python deploy.py scan [--ai]")
        print("  python deploy.py update")
        sys.exit(0)

    cmd = sys.argv[1]

    if cmd == "deploy":
        if len(sys.argv) < 4:
            print("❌ 用法: python deploy.py deploy <category> <html_file> [--title T] [--desc D]")
            sys.exit(1)
        category = sys.argv[2]
        html_file = sys.argv[3]
        title = desc = None
        i = 4
        while i < len(sys.argv):
            if sys.argv[i] == '--title' and i+1 < len(sys.argv): title = sys.argv[i+1]; i += 2
            elif sys.argv[i] == '--desc' and i+1 < len(sys.argv): desc = sys.argv[i+1]; i += 2
            else: i += 1
        deploy(category, html_file, title, desc)

    elif cmd == "add":
        if len(sys.argv) < 3:
            print("❌ 用法: python deploy.py add <filename> --title T --desc D --category C [--url U]")
            sys.exit(1)
        filename = sys.argv[2]
        title = desc = category = url = None
        i = 3
        while i < len(sys.argv):
            if sys.argv[i] == '--title' and i+1 < len(sys.argv): title = sys.argv[i+1]; i += 2
            elif sys.argv[i] == '--desc' and i+1 < len(sys.argv): desc = sys.argv[i+1]; i += 2
            elif sys.argv[i] == '--category' and i+1 < len(sys.argv): category = sys.argv[i+1]; i += 2
            elif sys.argv[i] == '--url' and i+1 < len(sys.argv): url = sys.argv[i+1]; i += 2
            else: i += 1
        if not title or not category:
            print("❌ --title 和 --category 是必须的")
            sys.exit(1)
        add_to_index(filename, title, desc, category, url)

    elif cmd == "rebuild_index":
        rebuild_index()

    elif cmd == "scan":
        scan_and_rebuild()

    elif cmd == "update":
        update_pages()

    else:
        print(f"❌ 未知命令: {cmd}")


path: index-body.html
content: <style>
.copy-block{display:flex;align-items:center;gap:var(--space-sm);background:#1e1b2e;border:1px solid rgba(139,92,246,.2);border-radius:var(--radius-md);padding:var(--space-md) var(--space-lg);cursor:pointer;transition:all .2s;box-shadow:var(--shadow-sm);}
  .copy-block:hover{border-color:rgba(139,92,246,.5);box-shadow:0 0 0 3px rgba(139,92,246,.1);}
  .copy-block.copied{border-color:rgba(34,197,94,.5);box-shadow:0 0 0 3px rgba(34,197,94,.1);}
  .copy-text{flex:1;font-family:var(--font-mono);font-size:var(--fs-sm);color:#e2dff0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
  .copy-block--multi .copy-text{white-space:pre;line-height:1.8;color:#c4bfe0;}
  .copy-btn{flex-shrink:0;padding:3px 12px;border-radius:var(--radius-sm);background:rgba(139,92,246,.15);color:#a78bfa;font-size:var(--fs-xs);font-weight:600;transition:all .2s;}
  .copy-block:hover .copy-btn{background:#a78bfa;color:#1e1b2e;}
  .copy-block.copied .copy-btn{background:rgba(34,197,94,.15);color:#4ade80;}
  .copy-block.copied .copy-btn::after{content:' 已复制';}
  .step-flow{display:flex;align-items:center;gap:0;flex-wrap:wrap;padding:var(--space-lg) 0;}
  .step-flow__item{display:flex;align-items:center;gap:var(--space-sm);background:var(--c-surface);border:1px solid var(--c-border-light);border-radius:var(--radius-lg);padding:var(--space-sm) var(--space-md);font-size:var(--fs-sm);color:var(--c-text-secondary);box-shadow:var(--shadow-sm);}
  .step-flow__num{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:50%;background:var(--c-accent);color:#fff;font-size:11px;font-weight:700;flex-shrink:0;}
  .step-flow__arrow{color:var(--c-border);padding:0 var(--space-xs);flex-shrink:0;}
  .usage-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:var(--space-lg);}
  .usage-card{background:var(--c-surface);border:1px solid var(--c-border-light);border-radius:var(--radius-lg);padding:var(--space-xl);box-shadow:var(--shadow-sm);transition:box-shadow .2s;}
  .usage-card:hover{box-shadow:var(--shadow-md);}
  .usage-card p{font-size:var(--fs-sm);color:var(--c-text-secondary);margin:0 0 var(--space-md);line-height:var(--lh-normal);}
  .version-timeline{display:flex;flex-direction:column;gap:0;border-left:2px solid var(--c-border-light);margin-left:var(--space-xs);padding-left:var(--space-lg);}
  .version-entry{position:relative;padding:var(--space-sm) 0;}
  .version-entry::before{content:'';position:absolute;left:calc(-1*var(--space-lg) - 5px);top:50%;transform:translateY(-50%);width:8px;height:8px;border-radius:50%;background:var(--c-accent);border:2px solid var(--c-surface);}
  .version-tag{display:inline-block;padding:1px 8px;border-radius:var(--radius-sm);background:var(--c-accent-light);color:var(--c-accent);font-size:var(--fs-xs);font-weight:600;font-family:var(--font-mono);margin-right:var(--space-sm);}
  .version-entry span:last-child{font-size:var(--fs-sm);color:var(--c-text-secondary);}
  /* Accordion capability */
  .cap-accordion{border:1px solid var(--c-border-light);border-radius:var(--radius-lg);overflow:hidden;margin-bottom:var(--space-sm);}
  .cap-accordion__header{display:flex;align-items:center;gap:var(--space-md);padding:var(--space-md) var(--space-lg);background:var(--c-surface);cursor:pointer;transition:background .2s;user-select:none;}
  .cap-accordion__header:hover{background:var(--c-accent-light);}
  .cap-accordion.open .cap-accordion__header{background:var(--c-accent-light);border-bottom:1px solid var(--c-border-light);}
  .cap-accordion__name{flex:1;font-weight:600;font-size:var(--fs-sm);color:var(--c-text);}
  .cap-accordion__arrow{transition:transform .2s;color:var(--c-text-muted);flex-shrink:0;}
  .cap-accordion.open .cap-accordion__arrow{transform:rotate(90deg);}
  .cap-accordion__body{max-height:0;overflow:hidden;transition:max-height .3s ease;}
  .cap-accordion.open .cap-accordion__body{max-height:600px;}
  .cap-accordion__content{padding:var(--space-lg);font-size:var(--fs-sm);color:var(--c-text-secondary);line-height:var(--lh-normal);}
  .cap-accordion__flow{display:flex;align-items:flex-start;gap:var(--space-sm);margin:var(--space-md) 0 0;padding:var(--space-md);background:var(--c-surface);border-radius:var(--radius-md);border:1px solid var(--c-border-light);}
  .cap-accordion__flow-node{padding:var(--space-xs) var(--space-md);background:var(--c-accent-light);border:1px solid rgba(99,102,241,.2);border-radius:var(--radius-md);font-size:var(--fs-xs);color:var(--c-accent);font-weight:500;white-space:nowrap;}
  .cap-accordion__flow-arrow{color:var(--c-border);padding-top:2px;flex-shrink:0;}
  .cap-accordion__flow-arrow svg{display:block;}
  /* Category flow */
  .cat-flow{display:flex;align-items:center;gap:0;flex-wrap:wrap;padding:var(--space-lg);background:var(--c-surface);border:1px solid var(--c-border-light);border-radius:var(--radius-lg);margin-bottom:var(--space-lg);}
  .cat-flow__node{padding:var(--space-xs) var(--space-md);border-radius:var(--radius-md);font-size:var(--fs-xs);font-weight:600;white-space:nowrap;}
  .cat-flow__node--input{background:var(--c-accent-light);color:var(--c-accent);border:1px solid rgba(99,102,241,.2);}
  .cat-flow__node--process{background:#fef3c7;color:#92400e;border:1px solid rgba(217,119,6,.2);}
  .cat-flow__node--output{background:#dcfce7;color:#166534;border:1px solid rgba(34,197,94,.2);}
  .cat-flow__arrow{color:var(--c-border);padding:0 6px;}
  .cat-flow__arrow svg{display:block;}
  .cat-examples{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:var(--space-sm);}
  .cat-example{display:flex;align-items:center;gap:var(--space-sm);padding:var(--space-sm) var(--space-md);background:var(--c-surface);border:1px solid var(--c-border-light);border-radius:var(--radius-md);font-size:var(--fs-xs);transition:all .2s;}
  .cat-example:hover{border-color:var(--c-accent-light);transform:translateY(-1px);box-shadow:var(--shadow-sm);}
  .cat-example__code{font-family:var(--font-mono);color:var(--c-accent);font-weight:600;}
  .cat-example__label{color:var(--c-text-secondary);}
  /* Hero & v1.5 feature cards */
  .hero-banner{position:relative;overflow:hidden;border-radius:var(--radius-xl);padding:var(--space-3xl) var(--space-2xl);margin-bottom:var(--space-2xl);background:linear-gradient(135deg,#1e1b2e 0%,#2d1b4e 40%,#1a2744 100%);border:1px solid rgba(139,92,246,.15);box-shadow:0 8px 32px rgba(0,0,0,.3);}
  .hero-banner::before{content:'';position:absolute;top:-50%;right:-30%;width:600px;height:600px;background:radial-gradient(circle,rgba(139,92,246,.08) 0%,transparent 70%);pointer-events:none;}
  .hero-banner__badge{display:inline-flex;align-items:center;gap:var(--space-sm);padding:4px 14px;border-radius:20px;background:rgba(139,92,246,.15);border:1px solid rgba(139,92,246,.3);color:#c4b5fd;font-size:var(--fs-xs);font-weight:600;font-family:var(--font-mono);margin-bottom:var(--space-lg);letter-spacing:.5px;}
  .hero-banner__badge .dot{width:6px;height:6px;border-radius:50%;background:#a78bfa;animation:pulse-dot 2s ease-in-out infinite;}
  @keyframes pulse-dot{0%,100%{opacity:.5;transform:scale(1);}50%{opacity:1;transform:scale(1.3);}}
  .hero-banner__title{font-size:2rem;font-weight:800;color:#fff;margin:0 0 var(--space-sm);line-height:1.2;}
  .hero-banner__title span{background:linear-gradient(135deg,#a78bfa,#60a5fa);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}
  .hero-banner__subtitle{font-size:var(--fs-md);color:rgba(255,255,255,.6);margin:0 0 var(--space-xl);max-width:560px;line-height:var(--lh-normal);}
  .hero-stats{display:flex;gap:var(--space-2xl);flex-wrap:wrap;}
  .hero-stat{display:flex;flex-direction:column;gap:2px;}
  .hero-stat__value{font-size:1.5rem;font-weight:800;color:#fff;font-family:var(--font-mono);}
  .hero-stat__label{font-size:var(--fs-xs);color:rgba(255,255,255,.45);}
  /* Feature showcase */
  .feature-showcase{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:var(--space-lg);margin-bottom:var(--space-2xl);}
  .feat-card{position:relative;padding:var(--space-xl);background:var(--c-surface);border:1px solid var(--c-border-light);border-radius:var(--radius-lg);overflow:hidden;transition:all .25s;}
  .feat-card:hover{border-color:rgba(139,92,246,.3);transform:translateY(-2px);box-shadow:0 8px 24px rgba(0,0,0,.2);}
  .feat-card__icon{width:40px;height:40px;border-radius:var(--radius-md);display:flex;align-items:center;justify-content:center;font-size:1.2rem;margin-bottom:var(--space-md);background:var(--c-accent-light);border:1px solid rgba(99,102,241,.15);}
  .feat-card__title{font-weight:700;font-size:var(--fs-sm);color:var(--c-text);margin-bottom:var(--space-sm);}
  .feat-card__desc{font-size:var(--fs-xs);color:var(--c-text-secondary);line-height:var(--lh-normal);margin:0;}
  .feat-card__badge{position:absolute;top:var(--space-md);right:var(--space-md);padding:2px 8px;border-radius:var(--radius-sm);background:linear-gradient(135deg,rgba(139,92,246,.2),rgba(96,165,250,.2));color:#a78bfa;font-size:10px;font-weight:700;font-family:var(--font-mono);}
  /* v1.5 changelog highlight */
  .changelog-highlight{position:relative;padding:var(--space-xl);border-radius:var(--radius-lg);background:linear-gradient(135deg,rgba(139,92,246,.06),rgba(96,165,250,.06));border:1px solid rgba(139,92,246,.15);margin-bottom:var(--space-2xl);}
  .changelog-highlight__header{display:flex;align-items:center;gap:var(--space-md);margin-bottom:var(--space-md);}
  .changelog-highlight__version{padding:4px 12px;border-radius:var(--radius-sm);background:linear-gradient(135deg,#7c3aed,#3b82f6);color:#fff;font-size:var(--fs-xs);font-weight:700;font-family:var(--font-mono);}
  .changelog-highlight__date{font-size:var(--fs-xs);color:var(--c-text-muted);}
  .changelog-highlight ul{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:var(--space-sm);}
  .changelog-highlight li{display:flex;align-items:flex-start;gap:var(--space-sm);font-size:var(--fs-sm);color:var(--c-text-secondary);line-height:var(--lh-normal);}
  .changelog-highlight li::before{content:'→';color:#a78bfa;font-weight:700;flex-shrink:0;margin-top:1px;}
</style>

<!-- Hero Banner -->
<div class="hero-banner">
  <div class="hero-banner__badge"><span class="dot"></span> v1.6.15 稳定版</div>
  <h1 class="hero-banner__title">报告专家 <span>Report Expert</span></h1>
  <p class="hero-banner__subtitle">为 AI Agent 设计的专业报告生成与站点部署技能。一句话生成精美报告页面，自动部署上线。</p>
  <div class="hero-stats">
    <div class="hero-stat"><span class="hero-stat__value">3</span><span class="hero-stat__label">图表引擎</span></div>
    <div class="hero-stat"><span class="hero-stat__value">4</span><span class="hero-stat__label">部署目标</span></div>
    <div class="hero-stat"><span class="hero-stat__value">7</span><span class="hero-stat__label">内容分类</span></div>
    <div class="hero-stat"><span class="hero-stat__value">v6.1</span><span class="hero-stat__label">设计系统</span></div>
  </div>
</div>

<!-- v1.6.15 New Features -->
<div class="changelog-highlight">
  <div class="changelog-highlight__header">
    <span class="changelog-highlight__version">v1.6.15</span>
    <span class="changelog-highlight__date">2026-04-13</span>
  </div>
  <ul>
    <li><strong>多引擎图表系统</strong> — Mermaid 流程图 + ECharts 数据可视化 + Chart.js，三引擎协同</li>
    <li><strong>图表点击放大</strong> — 滚轮缩放、鼠标/触屏拖拽、工具栏放大/缩小/重置</li>
    <li><strong>部署前强制校验</strong> — div 平衡检测、单框架验证、禁止手写 footer、script 位置检查</li>
    <li><strong>设计系统 v6.1</strong> — mermaid-wrap 等比缩放、chart-box 放大镜按钮自动注入</li>
    <li><strong>自动目录 + 滚动进度</strong> — 侧栏 TOC 自动生成，阅读进度实时追踪</li>
    <li>Mermaid 优先横向布局、深色背景修复、pre 底色污染清除</li>
    <li>TOC 侧栏 padding 优化，内容区与侧栏保持合理间距</li>
  </ul>
</div>

<!-- Feature Cards -->
<div class="feature-showcase">
  <div class="feat-card">
    <span class="feat-card__badge">NEW</span>
    <div class="feat-card__icon">📊</div>
    <div class="feat-card__title">多引擎图表系统</div>
    <p class="feat-card__desc">Mermaid 绘制流程图、ECharts 渲染复杂数据可视化、Chart.js 生成轻量图表。三引擎自动缩放限高，点击任意图表即可放大查看细节。</p>
  </div>
  <div class="feat-card">
    <span class="feat-card__badge">NEW</span>
    <div class="feat-card__icon">🔍</div>
    <div class="feat-card__title">图表交互放大</div>
    <p class="feat-card__desc">点击图表弹出全屏弹层，支持滚轮缩放、鼠标和触屏拖拽、工具栏快速放大/缩小/重置。Mermaid SVG 自动修复 transform，高度自适应。</p>
  </div>
  <div class="feat-card">
    <span class="feat-card__badge">NEW</span>
    <div class="feat-card__icon">🛡️</div>
    <div class="feat-card__title">部署前强制校验</div>
    <p class="feat-card__desc">部署前自动检测 div 标签平衡、确保单框架结构、禁止手写 footer、验证 script 位置。不合格页面拒绝部署，保障线上质量。</p>
  </div>
  <div class="feat-card">
    <span class="feat-card__badge">v6.1</span>
    <div class="feat-card__icon">🎨</div>
    <div class="feat-card__title">设计系统升级</div>
    <p class="feat-card__desc">底色层级配色、SVG 图标体系、响应式栅格布局。mermaid-wrap 等比缩放、chart-box 放大镜按钮自动注入，视觉体验全面升级。</p>
  </div>
  <div class="feat-card">
    <div class="feat-card__icon">📑</div>
    <div class="feat-card__title">自动目录与进度</div>
    <p class="feat-card__desc">根据页面标题自动生成侧栏目录导航，支持移动端收起展开。顶部滚动进度条实时显示阅读位置，长文档阅读体验流畅。</p>
  </div>
  <div class="feat-card">
    <div class="feat-card__icon">🚀</div>
    <div class="feat-card__title">四目标部署系统</div>
    <p class="feat-card__desc">本地报告、远程 Cloudflare Pages、技能 CF 发布、ClawHub 发布——四个独立目标统一管理，一套配置覆盖所有场景。</p>
  </div>
</div>

<div class="section" id="section-install">
        <h2 id="安装与设置">1. 安装与设置</h2>

        <h3 id="安装技能">1.1 安装技能</h3>
        <p>技能支持两种安装渠道：</p>

        <div class="cap-accordion" onclick="this.classList.toggle('open')" style="margin-bottom:var(--space-md);">
          <div class="cap-accordion__header">
            <span class="cap-accordion__name">ClawHub 安装（推荐）</span>
            <span class="cap-accordion__arrow"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg></span>
          </div>
          <div class="cap-accordion__body">
            <div class="cap-accordion__content">
              <p style="margin-bottom:var(--space-md);">通过 ClawHub CLI 安装，自动管理版本和安全扫描。升级使用 <code>clawhub update report-expert</code>。</p>
              <div class="copy-block" onclick="navigator.clipboard.writeText(this.querySelector('.copy-text').textContent);this.classList.add('copied');setTimeout(()=>this.classList.remove('copied'),1500);">
                <div class="copy-text">clawhub install report-expert</div>
                <span class="copy-btn">复制</span>
              </div>
            </div>
          </div>
        </div>

        <div class="cap-accordion" onclick="this.classList.toggle('open')" style="margin-bottom:var(--space-md);">
          <div class="cap-accordion__header">
            <span class="cap-accordion__name">CF Pages 安装</span>
            <span class="cap-accordion__arrow"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg></span>
          </div>
          <div class="cap-accordion__body">
            <div class="cap-accordion__content">
              <p style="margin-bottom:var(--space-md);">复制以下内容发送给 OpenClaw 即可自动安装。包含 <code>upgrade.py</code> 自更新脚本，升级时运行 <code>python3 upgrade.py</code>。</p>
              <div class="copy-block" onclick="navigator.clipboard.writeText(this.querySelector('.copy-text').textContent);this.classList.add('copied');setTimeout(()=>this.classList.remove('copied'),1500);">
                <div class="copy-text" data-tpl="帮我从这个地址安装技能 {origin}/manifest.json"></div>
                <span class="copy-btn">复制</span>
              </div>
              <p style="font-size:var(--fs-sm);color:var(--c-text-muted);margin-top:var(--space-sm);">Agent 会读取 manifest.json 获取文件清单，自动下载到 skills/ 目录并生效。</p>
            </div>
          </div>
        </div>
        <script>(function(){document.querySelectorAll('[data-tpl]').forEach(function(el){el.textContent=el.getAttribute('data-tpl').replace('{origin}',location.origin)})})()</script>

        <h3 id="配置部署参数">1.2 配置部署参数</h3>
        <p>在 Agent 工作区的 <code>TOOLS.md</code> 中添加配置。技能有四个独立部署目标：</p>
        <table class="config-table">
          <thead><tr><th>配置组</th><th>变量</th><th>说明</th><th>示例</th></tr></thead>
          <tbody>
            <tr><td rowspan="3">本地报告</td><td><code>REPORT_LOCAL_DIR</code></td><td>本地站点根目录</td><td><code>/home/user/my-site</code></td></tr>
            <tr><td><code>REPORT_LOCAL_URL</code></td><td>站点访问地址</td><td><code>https://example.com</code></td></tr>
            <tr><td><code>REPORT_SITE_NAME</code></td><td>站点显示名称</td><td><code>我的报告站</code></td></tr>
            <tr><td>远程报告</td><td><code>REPORT_CF_PROJECT</code></td><td>报告站 CF Pages 项目名</td><td><code>my-reports</code></td></tr>
            <tr><td>技能 CF</td><td><code>SKILL_CF_PROJECT</code></td><td>技能站 CF Pages 项目名</td><td><code>report-expert-skill</code></td></tr>
            <tr><td rowspan="2">技能 ClawHub</td><td><code>SKILL_CLAWHUB_SLUG</code></td><td>ClawHub 技能标识</td><td><code>report-expert</code></td></tr>
            <tr><td><code>SKILL_CLAWHUB_NAME</code></td><td>ClawHub 显示名</td><td><code>报告专家</code></td></tr>
            <tr><td>通用</td><td><code>CLOUDFLARE_API_TOKEN</code></td><td>CF API Token（CF 操作需要）</td><td><code>cfut_xxx</code></td></tr>
          </tbody>
        </table>

        <h3 id="快速配置示例">1.3 快速配置示例</h3>

        <h4 id="本地报告模式">1.3.1 本地报告模式</h4>
        <p style="font-size:var(--fs-sm);color:var(--c-text-secondary);">文件直接写入本地目录，适合自建站点。复制以下配置粘贴到 <code>TOOLS.md</code>：</p>
        <div class="copy-block copy-block--multi" onclick="navigator.clipboard.writeText(this.querySelector('.copy-text').textContent);this.classList.add('copied');setTimeout(()=>this.classList.remove('copied'),1500);">
          <div class="copy-text">REPORT_LOCAL_DIR=/home/user/my-site
REPORT_LOCAL_URL=https://example.com
REPORT_SITE_NAME=我的报告站</div>
          <span class="copy-btn">复制</span>
        </div>

        <h4 id="远程报告模式">1.3.2 远程报告模式</h4>
        <p style="font-size:var(--fs-sm);color:var(--c-text-secondary);">报告部署到 Cloudflare Pages。需要先创建 CF Pages 项目并获取 API Token：</p>
        <div class="step-flow">
          <div class="step-flow__item"><span class="step-flow__num">1</span><span>登录 <a href="https://dash.cloudflare.com" style="color:var(--c-accent)">Dashboard</a></span></div>
          <div class="step-flow__arrow"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></div>
          <div class="step-flow__item"><span class="step-flow__num">2</span><span>头像 → API Tokens → Create Token</span></div>
          <div class="step-flow__arrow"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></div>
          <div class="step-flow__item"><span class="step-flow__num">3</span><span>Pages → <strong>Edit</strong> → 创建 Token</span></div>
        </div>
        <div class="copy-block copy-block--multi" onclick="navigator.clipboard.writeText(this.querySelector('.copy-text').textContent);this.classList.add('copied');setTimeout(()=>this.classList.remove('copied'),1500);">
          <div class="copy-text">REPORT_LOCAL_DIR=/home/user/my-site
REPORT_LOCAL_URL=https://example.com
REPORT_SITE_NAME=我的报告站
REPORT_CF_PROJECT=my-reports
CLOUDFLARE_API_TOKEN=cfut_你的Token</div>
          <span class="copy-btn">复制</span>
        </div>
        <p style="font-size:var(--fs-xs);color:var(--c-text-muted);margin-top:var(--space-xs);">将 <code>cfut_你的Token</code> 替换为实际 Token 值。</p>

        <h4 id="完整配置（四目标）">1.3.3 完整配置（四目标）</h4>
        <p style="font-size:var(--fs-sm);color:var(--c-text-secondary);">同时启用本地报告、远程报告、技能 CF 发布和 ClawHub 发布：</p>
        <div class="copy-block copy-block--multi" onclick="navigator.clipboard.writeText(this.querySelector('.copy-text').textContent);this.classList.add('copied');setTimeout(()=>this.classList.remove('copied'),1500);">
          <div class="copy-text"># 本地报告
REPORT_LOCAL_DIR=/home/user/my-site
REPORT_LOCAL_URL=https://example.com
REPORT_SITE_NAME=我的报告站

# 远程报告
REPORT_CF_PROJECT=my-reports

# 技能发布
SKILL_CF_PROJECT=report-expert-skill
SKILL_CLAWHUB_SLUG=report-expert
SKILL_CLAWHUB_NAME=报告专家

# 通用
CLOUDFLARE_API_TOKEN=cfut_你的Token</div>
          <span class="copy-btn">复制</span>
        </div>
      </div>

      <div class="section" id="section-usage">
        <h2 id="使用方式">2. 使用方式</h2>
        <p style="color:var(--c-text-secondary);font-size:var(--fs-sm);margin-bottom:var(--space-lg);">以下所有操作均通过自然语言对话完成，Agent 会自动调用技能执行。</p>
        <div class="usage-grid">
          <div class="usage-card">
            <p>Agent 自动调研、生成内容、转为 HTML、部署到站点并更新索引。你只需描述需求，整个流程自动完成。</p>
            <div class="copy-block" onclick="navigator.clipboard.writeText(this.querySelector('.copy-text').textContent);this.classList.add('copied');setTimeout(()=>this.classList.remove('copied'),1500);">
              <div class="copy-text">帮我写一份 AI Agent 市场调研报告</div>
              <span class="copy-btn">复制</span>
            </div>
          </div>
          <div class="usage-card">
            <p>支持直接部署 HTML 文件，或将 Markdown 自动转换后部署。可选择分类、指定标题和描述。</p>
            <div class="copy-block" onclick="navigator.clipboard.writeText(this.querySelector('.copy-text').textContent);this.classList.add('copied');setTimeout(()=>this.classList.remove('copied'),1500);">
              <div class="copy-text">把这个 HTML 文件部署到研究报告分类</div>
              <span class="copy-btn">复制</span>
            </div>
          </div>
          <div class="usage-card">
            <p>扫描全站页面，提取标题、日期、描述等信息，自动识别分类并重建完整索引。</p>
            <div class="copy-block" onclick="navigator.clipboard.writeText(this.querySelector('.copy-text').textContent);this.classList.add('copied');setTimeout(()=>this.classList.remove('copied'),1500);">
              <div class="copy-text">整理一下报告</div>
              <span class="copy-btn">复制</span>
            </div>
          </div>
          <div class="usage-card">
            <p>将独立部署的页面（游戏、工具等）添加到站点索引，支持站内 iframe 内嵌查看。</p>
            <div class="copy-block" onclick="navigator.clipboard.writeText(this.querySelector('.copy-text').textContent);this.classList.add('copied');setTimeout(()=>this.classList.remove('copied'),1500);">
              <div class="copy-text">把这个游戏链接加到站点里</div>
              <span class="copy-btn">复制</span>
            </div>
          </div>
          <div class="usage-card">
            <p>支持发布技能到 Cloudflare Pages（预览+生产）和 ClawHub。每次自动更新版本号、文件哈希和介绍页。</p>
            <div class="copy-block" onclick="navigator.clipboard.writeText(this.querySelector('.copy-text').textContent);this.classList.add('copied');setTimeout(()=>this.classList.remove('copied'),1500);">
              <div class="copy-text">发布报告专家技能</div>
              <span class="copy-btn">复制</span>
            </div>
          </div>
        </div>
      </div>

      <div class="section" id="section-detail">
        <h2 id="技能详细介绍">3. 技能详细介绍</h2>

        <h3 id="核心能力">3.1 核心能力</h3>
        <p style="color:var(--c-text-muted);font-size:var(--fs-xs);margin-bottom:var(--space-md);">点击展开查看工作流程</p>

        <div class="cap-accordion" onclick="this.classList.toggle('open')">
          <div class="cap-accordion__header">
            <span class="cap-accordion__name">设计系统 v6.1</span>
            <span class="cap-accordion__arrow"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg></span>
          </div>
          <div class="cap-accordion__body">
            <div class="cap-accordion__content">
              <p style="margin-bottom:var(--space-md);">内置专业配色方案、SVG 图标体系、响应式栅格布局。v6.1 新增 mermaid-wrap 等比缩放、chart-box 放大镜按钮自动注入。生成的报告页面视觉统一，无需额外样式调整。</p>
              <div class="cap-accordion__flow">
                <span class="cap-accordion__flow-node">base.css 变量</span>
                <span class="cap-accordion__flow-arrow"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></span>
                <span class="cap-accordion__flow-node">报告模板</span>
                <span class="cap-accordion__flow-arrow"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></span>
                <span class="cap-accordion__flow-node">完整 HTML</span>
              </div>
            </div>
          </div>
        </div>

        <div class="cap-accordion" onclick="this.classList.toggle('open')">
          <div class="cap-accordion__header">
            <span class="cap-accordion__name">多引擎图表系统</span>
            <span class="cap-accordion__arrow"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg></span>
          </div>
          <div class="cap-accordion__body">
            <div class="cap-accordion__content">
              <p style="margin-bottom:var(--space-md);">三大图表引擎协同工作：Mermaid 绘制流程图和时序图，ECharts 渲染复杂交互式数据可视化，Chart.js 生成轻量级图表。所有图表自动缩放限高 80vh，点击即可放大查看。</p>
              <div class="cap-accordion__flow">
                <span class="cap-accordion__flow-node">Mermaid</span>
                <span class="cap-accordion__flow-arrow"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></span>
                <span class="cap-accordion__flow-node">ECharts</span>
                <span class="cap-accordion__flow-arrow"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></span>
                <span class="cap-accordion__flow-node">Chart.js</span>
                <span class="cap-accordion__flow-arrow"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></span>
                <span class="cap-accordion__flow-node">自动渲染</span>
              </div>
            </div>
          </div>
        </div>

        <div class="cap-accordion" onclick="this.classList.toggle('open')">
          <div class="cap-accordion__header">
            <span class="cap-accordion__name">图表点击放大</span>
            <span class="cap-accordion__arrow"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg></span>
          </div>
          <div class="cap-accordion__body">
            <div class="cap-accordion__content">
              <p style="margin-bottom:var(--space-md);">任意图表点击即弹出全屏弹层，支持滚轮缩放、鼠标和触屏拖拽平移、工具栏快速放大/缩小/重置。Mermaid SVG 自动修复 transform 和高度自适应。</p>
              <div class="cap-accordion__flow">
                <span class="cap-accordion__flow-node">点击图表</span>
                <span class="cap-accordion__flow-arrow"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></span>
                <span class="cap-accordion__flow-node">全屏弹层</span>
                <span class="cap-accordion__flow-arrow"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></span>
                <span class="cap-accordion__flow-node">缩放+拖拽</span>
                <span class="cap-accordion__flow-arrow"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></span>
                <span class="cap-accordion__flow-node">ESC/点外部关闭</span>
              </div>
            </div>
          </div>
        </div>

        <div class="cap-accordion" onclick="this.classList.toggle('open')">
          <div class="cap-accordion__header">
            <span class="cap-accordion__name">智能页面生成</span>
            <span class="cap-accordion__arrow"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg></span>
          </div>
          <div class="cap-accordion__body">
            <div class="cap-accordion__content">
              <p style="margin-bottom:var(--space-md);">接受 Markdown 或 HTML 输入，自动提取内容并套用模板生成完整页面，包含大纲导航、滚动进度等交互组件。</p>
              <div class="cap-accordion__flow">
                <span class="cap-accordion__flow-node">Markdown/HTML</span>
                <span class="cap-accordion__flow-arrow"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></span>
                <span class="cap-accordion__flow-node">内容提取</span>
                <span class="cap-accordion__flow-arrow"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></span>
                <span class="cap-accordion__flow-node">模板渲染</span>
                <span class="cap-accordion__flow-arrow"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></span>
                <span class="cap-accordion__flow-node">完整 HTML</span>
              </div>
            </div>
          </div>
        </div>

        <div class="cap-accordion" onclick="this.classList.toggle('open')">
          <div class="cap-accordion__header">
            <span class="cap-accordion__name">索引管理</span>
            <span class="cap-accordion__arrow"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg></span>
          </div>
          <div class="cap-accordion__body">
            <div class="cap-accordion__content">
              <p style="margin-bottom:var(--space-md);">自动扫描站点下所有页面，提取标题、日期、描述、分类等信息，重建索引数据并刷新首页。</p>
              <div class="cap-accordion__flow">
                <span class="cap-accordion__flow-node">扫描 dist/</span>
                <span class="cap-accordion__flow-arrow"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></span>
                <span class="cap-accordion__flow-node">提取信息</span>
                <span class="cap-accordion__flow-arrow"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></span>
                <span class="cap-accordion__flow-node">识别分类</span>
                <span class="cap-accordion__flow-arrow"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></span>
                <span class="cap-accordion__flow-node">生成 index.json</span>
                <span class="cap-accordion__flow-arrow"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></span>
                <span class="cap-accordion__flow-node">刷新首页</span>
              </div>
            </div>
          </div>
        </div>

        <div class="cap-accordion" onclick="this.classList.toggle('open')">
          <div class="cap-accordion__header">
            <span class="cap-accordion__name">内嵌查看器</span>
            <span class="cap-accordion__arrow"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg></span>
          </div>
          <div class="cap-accordion__body">
            <div class="cap-accordion__content">
              <p style="margin-bottom:var(--space-md);">外部页面可在站内通过 iframe 打开查看，保持导航体验一致。支持 hash 路由和 postMessage 通信。</p>
              <div class="cap-accordion__flow">
                <span class="cap-accordion__flow-node">点击外部链接</span>
                <span class="cap-accordion__flow-arrow"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></span>
                <span class="cap-accordion__flow-node">写入 #v=hash</span>
                <span class="cap-accordion__flow-arrow"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></span>
                <span class="cap-accordion__flow-node">打开 iframe</span>
                <span class="cap-accordion__flow-arrow"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></span>
                <span class="cap-accordion__flow-node">postMessage 返回</span>
              </div>
            </div>
          </div>
        </div>

        <div class="cap-accordion" onclick="this.classList.toggle('open')">
          <div class="cap-accordion__header">
            <span class="cap-accordion__name">四目标部署系统</span>
            <span class="cap-accordion__arrow"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg></span>
          </div>
          <div class="cap-accordion__body">
            <div class="cap-accordion__content">
              <p style="margin-bottom:var(--space-md);">四个独立部署目标，统一配置管理。报告可部署到本地目录或 Cloudflare Pages，技能可发布到 CF Pages 或 ClawHub。</p>
              <div class="cap-accordion__flow">
                <span class="cap-accordion__flow-node">本地报告</span>
                <span class="cap-accordion__flow-arrow"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></span>
                <span class="cap-accordion__flow-node">远程报告</span>
                <span class="cap-accordion__flow-arrow"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></span>
                <span class="cap-accordion__flow-node">技能 CF</span>
                <span class="cap-accordion__flow-arrow"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></span>
                <span class="cap-accordion__flow-node">ClawHub</span>
              </div>
            </div>
          </div>
        </div>

        <div class="cap-accordion" onclick="this.classList.toggle('open')">
          <div class="cap-accordion__header">
            <span class="cap-accordion__name">版本管理与发布</span>
            <span class="cap-accordion__arrow"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg></span>
          </div>
          <div class="cap-accordion__body">
            <div class="cap-accordion__content">
              <p style="margin-bottom:var(--space-md);">支持两种升级方式和多个发布目标。ClawHub 渠道使用 <code>clawhub update</code> 升级，CF Pages 渠道使用内置 <code>upgrade.py</code> 升级。发布到 CF Pages（预览+生产）和 ClawHub 时自动更新版本号、文件哈希和介绍页。</p>
              <div class="cap-accordion__flow">
                <span class="cap-accordion__flow-node">版本号+1</span>
                <span class="cap-accordion__flow-arrow"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></span>
                <span class="cap-accordion__flow-node">更新哈希</span>
                <span class="cap-accordion__flow-arrow"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></span>
                <span class="cap-accordion__flow-node">生成介绍页</span>
                <span class="cap-accordion__flow-arrow"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></span>
                <span class="cap-accordion__flow-node">部署/发布</span>
              </div>
            </div>
          </div>
        </div>

        <h3 id="内容分类体系">3.2 内容分类体系</h3>
        <p style="color:var(--c-text-secondary);font-size:var(--fs-sm);margin-bottom:var(--space-md);">分类不是固定枚举，而是通过扫描页面内容自动识别。Agent 读取每个页面的 HTML，根据标题和正文语义判断所属分类，该分类决定页面在索引首页中归入哪个分组。</p>
        <div class="cat-flow">
          <span class="cat-flow__node cat-flow__node--input">页面 HTML</span>
          <span class="cat-flow__arrow"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></span>
          <span class="cat-flow__node cat-flow__node--process">语义分析</span>
          <span class="cat-flow__arrow"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></span>
          <span class="cat-flow__node cat-flow__node--process">匹配分类</span>
          <span class="cat-flow__arrow"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></span>
          <span class="cat-flow__node cat-flow__node--output">索引分组</span>
        </div>
        <p style="color:var(--c-text-muted);font-size:var(--fs-xs);margin-bottom:var(--space-sm);">内置分类标识：</p>
        <div class="cat-examples">
          <div class="cat-example"><span class="cat-example__code">research</span><span class="cat-example__label">深度研究</span></div>
          <div class="cat-example"><span class="cat-example__code">analysis</span><span class="cat-example__label">数据分析</span></div>
          <div class="cat-example"><span class="cat-example__code">project</span><span class="cat-example__label">项目作品</span></div>
          <div class="cat-example"><span class="cat-example__code">summary</span><span class="cat-example__label">内容摘要</span></div>
          <div class="cat-example"><span class="cat-example__code">comparison</span><span class="cat-example__label">对比评测</span></div>
          <div class="cat-example"><span class="cat-example__code">tutorial</span><span class="cat-example__label">教程指南</span></div>
          <div class="cat-example"><span class="cat-example__code">other</span><span class="cat-example__label">其他</span></div>
        </div>

        <h3 id="文件结构">3.3 技能文件结构</h3>
        <div class="copy-block copy-block--multi" onclick="navigator.clipboard.writeText(this.querySelector('.copy-text').textContent);this.classList.add('copied');setTimeout(()=>this.classList.remove('copied'),1500);" style="font-size:var(--fs-sm);">
          <div class="copy-text" style="white-space:pre;line-height:1.7;">report-expert/
├── SKILL.md            技能定义（Agent 自动读取）
├── deploy.py           CLI 入口（参数解析与命令分发）
├── upgrade.py          自动升级工具
├── manifest.json       版本清单（发布用）
├── lib/
│   ├── config.py       统一配置管理（四组部署目标）
│   ├── page.py         HTML 模板生成
│   ├── local_deploy.py 本地报告部署（写文件到 dist/）
│   ├── remote_deploy.py远程报告部署（Cloudflare Pages）
│   ├── skill_cf_publish.py    技能发布到 CF Pages
│   ├── skill_clawhub_publish.py 技能发布到 ClawHub
│   ├── index.py        索引管理（重建/添加/扫描）
│   └── backup.py       备份恢复与页面模板更新
├── templates/
│   ├── base.css        全局样式
│   └── index.html      首页模板
└── scripts/
    └── main.js         首页交互脚本</div>
          <span class="copy-btn">复制</span>
        </div>

        <h3 id="更新日志">3.4 版本更新记录</h3>
        <div class="version-timeline">
          <div class="version-entry"><span class="version-tag">v1.6.15</span><span>多引擎图表系统（Mermaid + ECharts + Chart.js）、图表点击放大、部署前强制校验、设计系统 v6.1、自动目录 + 滚动进度</span></div>
          <div class="version-entry"><span class="version-tag">v1.6.15</span><span>四目标部署系统：本地报告、远程报告、技能CF发布、技能ClawHub发布独立管理</span></div>
          <div class="version-entry"><span class="version-tag">v1.6.15</span><span>统一页头页脚注入、iframe hash 路由、非报告页面隔离</span></div>
          <div class="version-entry"><span class="version-tag">v1.6.15</span><span>iframe 内嵌查看器、报告页自动刷新</span></div>
          <div class="version-entry"><span class="version-tag">v1.6.15</span><span>自适应部署配置、项目分类支持</span></div>
        </div>

path: lib/__init__.py
content: # lib package


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)