Skill: jackyhwei/odd-articles (version 1.0.0)

--- name: odd-article-skills description: 内容生产和分发统一管线。素材收集→出稿→排版→封面→朋友圈文案→多平台转换→一键分发。涵盖公众号写作、微贴图轮播图、博客园文案、微博文案、品牌视频、Chrome CDP 自动发布。 --- # OddMeta文章生成技能 Article Generation Skill > 本技能默认使用 oddmeta 品牌色作为示例。你可以在 `references/` 目录下修改为自己的品牌色，或在 `local/SKILL.local.md` 中覆盖路径和个人设定。 一条龙：素材收集 → 写文章 → 排版 → 多平台内容 → 一键分发。 --- ## 环境变量配置 本技能所使用到的环境变量在 `local/.env` 中配置。 支持的环境变量如下： - `OUTPUT_DIR`：输出目录，用于放置收集到的素材、生成的文章、排版预览、封面图、微贴图轮播图等文件。 - `ARCHIVE_DIR`：存档目录，用于归档历史文档文件。 - `BRAND_NAME`：品牌名称，用于在生成的文章中显示品牌名称。 - `BRAND_LOGO_DARK`：品牌logo（深色），用于在生成的文章中显示品牌logo。 - `BRAND_LOGO_LIGHT`：品牌logo（浅色），用于在生成的文章中显示品牌logo。 - `WECHAT_ID`：微信公众号ID，用于发布文章。 - `WECHAT_SLOGON`：微信公众号标语，用于在文章中显示。 - `WECHAT_APPID`：微信公众号AppID，用于发布文章时的认证。 - `WECHAT_APPSECRET`：微信公众号AppSecret，用于发布文章时的认证。 - `CNBLOGS_TOKEN`：博客园Token，用于发布博客园文章时的认证。 - `MD_FORMATTER_DIR`：Markdown格式化工具目录，用于格式化Markdown文章。 - `BAOYU_WECHAT_SKILL_DIR`: 奥德元微信技能目录，用于发布文章。 - `MD_TO_WECHAT_SCRIPT`: Markdown文章转微信文章脚本，用于将Markdown文章转换为微信文章。 ### 输出目录 `OUTPUT_DIR` 示例 ``` [输出目录]/ ├── drafts/ │ └── current.json # 当前素材列表 ├── [文章标题].md # 产出的文章 ├── [文章标题]_preview.html # 排版预览 ├── [文章标题]_cover.html # 封面图 └── [主题]-chartlet.html # 微贴图轮播图 ``` #### 存档目录 `ARCHIVE_DIR` 示例 ``` [存档目录]/ ├── 20260321/ # 按日期归档（YYYYMMDD） │ ├── Fish-Audio-S2-Tutorial-v2/ # 文章标题目录 │ │ ├── Fish-Audio-S2-Tutorial-v2.md │ │ ├── Fish-Audio-S2-Tutorial-v2_preview.html │ │ ├── Fish-Audio-S2-Tutorial-v2_cover.html │ │ └── manifest.json │ └── OpenClaw-Memory-Deep-Article/ # 另一篇文章 │ ├── OpenClaw-Memory-Deep-Article.md │ ├── OpenClaw-Memory-Deep-Article_preview.html │ └── manifest.json └── 20260320/ └── ... ``` --- ## 触发词 ### 素材收集（Path A） | 触发词 | 说明 | |-------|------| | `/story` | 查看当前素材状态 | | "看看素材" | 查看已记录的素材 | | "出稿" | 生成文章 + 排版 + 封面图 | | "清空素材" | 清空当前素材 | | "记一笔：xxx" | 手动添加素材 | | "素材+1：xxx" | 手动添加素材 | | "写个朋友圈" | 根据素材/文章生成朋友圈文案 | ### 内容生成（Path B） | 触发词 | 说明 | |-------|------| | `/chartlet` + 微信链接 | 微信文章转微贴图轮播图 | | "转微贴图" + 微信链接 | 同上 | | "做成微贴图" + 微信链接 | 同上 | | "转微博" + 微信链接 | 生成微博文案 | | "转博客园" + 微信链接 | 生成博客园文案 | | "做视频" + 微信链接 | 触发品牌视频管线 | | "做视频画布" + 任意素材 | 生成可录制的 Prezi 风格视频画布（微信链接/md/pdf/html/文本均可） | | "录屏画布" + 任意素材 | 同上 | | "手账视频" + 任意素材 | 同上 | | "多平台分发" + 微信链接 | 一次生成所有平台内容 | | "转微贴图并发布" + 微信链接 | 生成 + 自动触发分发 | ### 文章阅读 | 触发词 | 说明 | |-------|------| | `/read-gzh` + 微信链接 | 抓取并总结公众号文章 | | "帮我读一下这篇公众号" | 同上 | | "总结一下这篇文章" | 同上 | ### 排版与配图 | 触发词 | 说明 | |-------|------| | "排版" | 用 oddmeta 主题排版 Markdown → 公众号 HTML | | "做头图" / "封面图" | 生成公众号头图 HTML（浏览器下载 PNG） | | "做竖版封面" / "竖版头图" | 从公众号封面 → 生成 3:4 竖版封面（1080×1440），适合微贴图/视频号 | | "做配图" / "准备配图" | 生成文章配图 HTML（浏览器下载 PNG） | | "排版+配图" / "全套排版" | 排版 + 头图 + 配图一起生成 | ### 分发 | 触发词 | 说明 | |-------|------| | `/distribute` | 读取 manifest 一键发布 | | "发布到博客园" | 将 Markdown 文件发布到博客园（→ [详细说明](references/publishing_to_cnblogs.md)） | | "一键发布" | 全平台发布 | | "全平台发布" | 同上 | | "发布到微博" | 单平台发布 | | "发布到微贴图" | 单平台发布 | | "发布到博客园" | 单平台发布 | ### 存档 | 触发词 | 说明 | |-------|------| | "/archive" | 将 OUTPUT_DIR 下的历史文档归档到 ARCHIVE_DIR | | "存档" | 同上 | | "归档" | 同上 | --- ### 博客园发布（独立工具） 不依赖 manifest，可直接发布 Markdown 文件到博客园。具体请参考：`references/publishing_to_cnblogs.md` --- ### 存档功能（独立工具） 将 `OUTPUT_DIR` 目录下的历史文档文件（.md, .html, .png, manifest.json）移动到 `ARCHIVE_DIR` 目录下，按日期归档。具体请参考：`references/archive_outputs.md` --- ## 两条输入路径 ### Path A：日常素材收集 → 出稿 ``` 边干活边记录 → 说"出稿" → 写文章 → 排版 → 封面图 → 朋友圈文案 → manifest ``` 适用场景：日常和 cc 协作时，自动积累素材，攒够了一键出稿。 ### Path B：微信链接 → 多平台内容 ``` 微信链接 → 抓取文章 → 分析结构 → 生成微贴图/博客园/微博/视频 → manifest → 分发 ``` 适用场景：已有公众号文章，一键转为多平台内容并发布。 --- ## Path A 流程：素材收集 → 出稿 ### 自动记录（默认开启） cc 在对话中**主动识别有料瞬间**并自动记录，无需手动触发。 **识别信号：** | 类型 | 识别信号 | 示例 | |-----|---------|------| | 踩坑翻车 | 预期≠结果、报错、折腾半天 | "试了三种方案都不行" | | 意外发现 | "没想到"、"原来可以"、意外有效 | "居然这样就解决了" | | 迭代打磨 | 改了多版、从复杂到简洁 | "200行改成20行还能跑" | | 搞笑时刻 | 对话金句、AI抽风、神奇bug | "它认真地给我写了一堆错的" | | 突破时刻 | 卡了很久终于通 | "困扰一周的bug终于找到了" | | 方法沉淀 | 可复用的技巧、心得 | "以后遇到这种情况就这么办" | **自动记录时**：不打断对话，段落结尾标记 `（✓ 素材+1）` ### 手动记录 用户说"记一笔：xxx"或"素材+1：xxx"时记录。 ### current.json 格式 ```json { "topic": "主题（可选，出稿时自动提取）", "materials": [ { "time": "2026-01-30 14:30", "content": "素材内容", "type": "搞笑时刻", "context": "可选的上下文备注", "auto": true } ], "created": "2026-01-30" } ``` ### 出稿步骤 #### STEP 1: 读取素材** 1. **读取素材** — 读取输出目录下 `drafts/current.json` 2. **分析提炼** — 提炼主题和故事线 #### STEP 2: 确定写作框架 1. **判断内容类型 → 选择写作框架**： | 内容类型 | 判断信号 | 使用框架 | 参考文件 | |---------|---------|---------|---------| | **教程类** | 教人安装/使用/配置工具、Skill 介绍、技术实战、"怎么做 xxx" | 六段式教程框架 | `references/framework_tutorial_articles.md` | | **深度长文** | 行业分析、人物故事、趋势判断、观点输出、"为什么 xxx" | 四幕式深度框架 | `references/framework_in_depth_articles.md` | | **其他** | 描述、分享、建议、建议、建议、建议 | 纯文本 | `references/frameworks.md` | #### STEP 3: 写作 ``` 读取: `references/writing-guide.md` 读取: `playbook.md`（如果存在，按 confidence 分级执行） 读取: `history.yaml`（最近 3 篇的 dimensions + closing_type 字段） 读取: `references/exemplars/index.yaml`（如果存在） ``` 1. **写文章** — 按对应框架写文章 2. **保存** — 保存 Markdown 文件，文件名格式为 `{{date}}-{{title}}.md`，`{{title}}` 为文章标题的拼音，保存到 `OUTPUT_DIR` 目录下。 3. **排版** — 调用排版工具生成 HTML 预览（oddmeta 主题） 4. **头图 + 配图** — 生成可下载的 HTML 文件（→ 读 `references/cover_template.md`） - **竖版封面（可选）**：用户说"做竖版封面"时，从已生成的公众号头图 HTML 转换 → 读 `references/cover_vertical_spec.md` 5. **朋友圈文案** — 生成朋友圈推广文案（→ 读 `references/platform_copy.md`） #### STEP 4: 推广文案 1. **manifest** — 生成 manifest.json，供 `/distribute` 使用。wechat 部分必须包含： - `wechat.markdown`：文章 Markdown 路径 - `wechat.html`：排版后的 `_preview.html` 路径 - `wechat.cover_image`：封面 PNG 路径（用户需先从浏览器下载） - `wechat.title`：文章标题 - `wechat.author`：作者名（默认 `曹操同学`） - `wechat.digest`：文章摘要（120 字内） - `wechat.images`：配图 PNG 路径列表（如有） 2. **询问** — 是否存档并清空当前素材 ### 排版命令 ```bash cd "$MD_FORMATTER_DIR" python3 md2wechat.py [文章路径] --theme [主题] ``` > `$MD_FORMATTER_DIR` 需在 `local/.env` 或环境变量中配置。 推荐主题：`default`（oddmeta 品牌色，默认）、`chinese`（中国风）、`apple`（极简优雅） 推荐字号：`medium`（15px 默认）、`large`（16px 长文推荐） **oddmeta 主题说明**：基于 chinese 主题，使用 oddmeta 品牌色（墨绿 #1A3328 + 鱼红 #C44536 + 宣纸底 #F2EDE3） **执行排版命令报错说明**：请参考 `references/running_python_script_guide.md` --- ## Path B 流程：微信链接 → 多平台内容 ### 第 1 步：抓取文章 使用 Python 抓取脚本（微信有反爬验证，WebFetch 会被拦）： ```bash python3 "${SKILL_DIR}/scripts/wechat_download.py" "<URL>" --json ``` 超时 30 秒。失败则提示用户手动复制文章正文。 如果用户只是说"帮我读一下这篇公众号"（`/read-gzh` 触发），执行抓取后直接生成结构化总结，不进入后续内容生成流程。总结格式： ```markdown # 文章总结 ## 基本信息（标题/作者/类型/配图数） ## 核心观点（3条） ## 关键信息 ## 金句摘录 ## 图片内容（下载并识别配图中的文字） ## 思考/迭代点 ``` ### 第 2 步：分析文章结构 提取：标题、副标题/金句、核心概念、关键数据、步骤/流程、亮点/特色、方法论/金句、行动召唤。 ### 第 3 步：拆分为卡片 8-10 张卡片，遵循chartlet阅读节奏（→ 读 `references/generate_chartlet.md`）： | 位置 | 卡片类型 | 内容 | |------|---------|------| | 第 1 张 | 封面 | 大标题 + hook + 迷你视觉元素 | | 第 2 张 | 先看结果 | 成品展示 + 核心数据 | | 第 3-4 张 | 概念解释 | 核心概念拆解 | | 第 5-7 张 | 流程/实战 | 步骤、对比、流程图 | | 第 8 张 | 亮点/特色 | 产品/作品亮点卡片 | | 第 9 张 | 方法论 | 一句话金句提炼 | | 第 10 张 | 行动召唤 | 链接 + 社区引导 | ### 第 4 步：生成图片 HTML 输出路径：文章同目录下 `[简短主题]-chartlet.html`，未指定目录放 `/tmp/`。浏览器自动打开预览。 最后一张行动召唤页必须包含：微信号 `[你的微信号]`（强调色大字）、备注关键词、核心链接。 > 在 `local/SKILL.local.md` 中配置你的实际微信号。 **📚 重要：生成前必读范例** 参考 `references/examples-chartlet/example-chartlet.html` 的质量标准： ✅ **卡片设计要求** - 纯信息图设计，无文章截图 - 像素风/游戏化界面展示（适用时） - 流程图、卡片网格、编号列表等丰富视觉元素 - 品牌色克制使用（墨绿85% + 鱼红5%） ✅ **文案质量要求** - 真人分享感，有真实场景和个人感受 - 口语化表达："玩疯了"、"上头了"、"然后我就..." - 298-350字 + 8-12个标签 生成的内容应达到范例的专业水准。 ### 第 5 步：生成微贴图发布文案 **根据内容类型选择风格：** - **个人 IP 风格**（真人分享、产品开发、踩坑记录） - → 读 `local/SKILL.local.md` 中指定的个人品牌风格文件（如有） - 流水账式真实感 + 具体时间细节 + 口语化表达 - 300-350字，8-12个标签 - 人设：在 `local/SKILL.local.md` 中自定义 - **oddmeta风格**（方法论总结、深度分析） - → 读 `references/platform_copy.md` 的微贴图部分 - 结构化拆解 + 干货密度高 - 适合转载公众号文章 ### 第 6 步：生成博客园发布文案 → 读 `references/platform_copy.md` 的博客园部分。 ### 第 7 步：生成播客脚本 → 读 `references/platform_copy.md` 的播客部分。 ### 第 7.5 步：生成微博文案 → 读 `references/platform_copy.md` 的微博部分。 ### 第 8 步：AI 语音生成 使用 Fish Audio TTS 将播客脚本转为 MP3（→ 读 `references/tts_config.md`）。 文件命名：`[播客标题].mp3` + `[播客标题]-播客脚本.txt` ### 第 8.5 步：输出 manifest.json 所有内容生成完毕后，自动输出 manifest.json 到输出目录。格式： ```json { "version": "1.0", "created": "<ISO时间戳>", "source": "<微信链接>", "title": "<文章标题>", "outputs": { "chartlet": { "html": "...", "copy": { "title": "...", "body": "...", "tags": [...] } }, "cnblogs": { "copy": { "body": "...", "circles": [...] } }, "xiaoyuzhou": { "audio": "...", "script": "...", "copy": { "title": "...", "description": "...", "show_notes": "..." } }, "video_canvas": { "html": "...", "teleprompter_md": "...", "cover_html": "..." }, "weibo": { "copy": { "title": "...", "body": "...", "tags": [...] } } } } ``` 如果用户说"转微贴图并发布"，生成 manifest 后自动执行 `/distribute`。 ### 第 9 步：品牌视频生成（可选） 仅当用户提到"视频"、"抖音"、"视频号"或"品牌视频"时执行： **A. Remotion 品牌片头片尾** ```bash cd "$REMOTION_DIR" npx remotion render src/index.ts Intro --output /tmp/brand-intro.mp4 npx remotion render src/index.ts Outro --output /tmp/brand-outro.mp4 ``` > `$REMOTION_DIR` 需在 `local/.env` 或环境变量中配置。 **B. AI 视频 Prompt** — 为 Seedance 2.0 或 Google Veo 生成 4 段视频 prompt **C. ffmpeg 拼接指令** — 生成拼接命令供用户手动执行 ### 第 9B 步：视频画布生成（可选） 仅当用户说"做视频画布"、"录屏画布"、"手账视频"时执行。**接受任意素材输入**：微信链接、Markdown 文件、PDF、HTML 文件、纯文本、用户口述内容均可。 1. **获取内容** — 根据输入类型自动处理： - 微信链接：调用 `scripts/wechat_download.py` 抓取 - 文件路径（md/pdf/html/txt）：直接读取 - 用户粘贴的文本：直接使用 2. **分析结构** — 提取标题、核心数据、痛点、步骤、原理、对比、金句、亮点 3. **拆分为 9 张卡片** — 读取 `references/video_canvas_template.md` 获取完整 CSS+JS 模板和卡片规范 4. **生成 9 段提词器脚本** — 口语化，每段 80-150 字，含 `[提示]` cue 标记 5. **输出提词器脚本 md** — `[简短主题]-提词器脚本.md`，用户可直接编辑 6. **组装 HTML** — CSS 框架 + HTML 骨架 + 填充内容 + JS 框架（SCRIPTS 与 md 一致） 7. **输出文件** — `[简短主题]-视频画布.html`，保存到文章同目录或 `/tmp/` 8. **生成封面图** — `[简短主题]-封面.html`，手账风格 + 人像圆框，浏览器下载 PNG 9. **提示用户** — 先检查提词器脚本 md，再在浏览器中打开 HTML 录制。16:9 固定比例，各平台直接上传 ### 第 10 步：用户微调 告知用户所有产出物路径，提示可调整，输入 `/distribute` 可一键发布。 **公众号同步提示**：封面 PNG 从浏览器下载后，直接 `/distribute --platforms wechat` 即可同步到草稿箱（API 模式，无需打开 Chrome）。 **一次性产出五样东西，不需要额外要求：** 1. 微贴图图片 HTML（含一键下载工具栏） 2. 微贴图发布文案（标题 + 正文 + 标签） 3. 博客园发布文案（正文 + 圈子标签） 4. 微博发布文案（录制脚本 + AI 语音 MP3） 5. manifest.json（供 `/distribute` 一键发布） **第 9B 步可选追加（说"视频画布"时）：** 6. 视频画布 HTML（含录制 + 提词器 + 美颜，16:9 固定） 7. 提词器脚本 md（可编辑，修改后说"更新提词器"同步到 HTML） 8. 封面图 HTML（手账风格 + 人像圆框，浏览器下载 PNG） --- ## 分发流程（/distribute） 读取 manifest.json，通过 Chrome CDP 自动化发布到各平台（→ 读 `references/distribute-platforms.md`）。 ### 用法 ```bash # 全平台发布 npx -y bun "${SKILL_DIR}/scripts/distribute/distribute.ts" --manifest /path/to/manifest.json # 选择平台 npx -y bun "${SKILL_DIR}/scripts/distribute/distribute.ts" --manifest /path/to/manifest.json --platforms chartlet,weibo # 预览模式（不提交，只预填内容） npx -y bun "${SKILL_DIR}/scripts/distribute/distribute.ts" --manifest /path/to/manifest.json --platforms chartlet --preview ``` ### 平台缩写 | 缩写 | 平台 | 状态 | |------|------|------| | `wechat` | 公众号 | 可用 | | `chartlet` | 贴图 | 可用 | | `cnblogs` | 博客园 | 可用 | | `weibo` | 微博 | 实验性 | | `douyin` | 抖音 | 实验性 | | `shipinhao` | 视频号 | 待开发 | ### 执行顺序 公众号 → 微贴图 → 博客园 → 微博 → 抖音 → 视频号 → 微博（顺序执行，避免 Chrome 端口冲突） ### 四级降级 | 级别 | 模式 | 触发条件 | |------|------|---------| | L0 | API 直推 | 公众号 API 直接推草稿箱，无需 Chrome | | L1 | 自动发布 | CDP 完全自动化 | | L2 | 辅助发布 | 登录态失效/选择器失效/`--preview` | | L3 | 手动模式 | CDP 连接失败 | 公众号优先 L0（API），凭证缺失或失败时自动降级 L1（CDP）。 --- ## 品牌设计规范 **两套品牌色体系：** - **oddmeta**：专业内容品牌（公众号、深度文章、方法论） - **OPEN365**：真人IP品牌（微贴图、微博、博客园、日常分享） > **单一真相源**：在 `local/SKILL.local.md` 中指定你的品牌色文档路径。 > 如果色值冲突，以品牌文档为准。以下色板作为默认示例。 ### oddmeta 色板（墨绿体系） **比例法则**：墨绿 85% : 鱼红 5% : 其余 10% | 名称 | 色值 | 用途 | |------|------|------| | 墨绿主色 | `#1A3328` | 暗底卡片背景 | | 宣纸底 | `#F2EDE3` | 浅底卡片背景 | | 鱼红 | `#C44536` | 强调色、数字、标签（仅点睛） | | 半透白 | `rgba(255,255,255,0.5)` | 暗底上的品牌名 | | 半透墨绿 | `rgba(26,51,40,0.4)` | 浅底上的品牌名 | | 苔灰 | `#7A8C80` | 次要文字 | | 深墨 | `#0F1F18` | 更深背景 | | 淡青 | `#D4DDD7` | 分割线、边框 | ### OPEN365 色板（桃粉体系） **比例法则**：桃气粉 15% : 奶油黄 40% : 暮光紫 10% : 灰色 35% | 名称 | 色值 | 用途 | |------|------|------| | 桃气粉 | `#FF6B9D` | 主强调色、标题、关键数据 | | 奶油黄底 | `#FFF9E6` | 浅底背景、卡片底色 | | 暮光紫 | `#9D7BA8` | 辅助色、次要信息、品牌名 | | 温灰 | `#6B6B6B` | 正文文字 | | 浅灰底 | `#F5F5F5` | 现代感背景 | | 深夜蓝 | `#2D3047` | 暗底背景（少用） | ### 品牌选择规则 | 内容类型 | 使用品牌 | 原因 | |---------|---------|------| | 公众号深度文章 | oddmeta | 专业、权威、内容品牌 | | 行业分析报告 | oddmeta | 冷静客观 | | 微贴图真人分享 | oddmeta | 温暖、真实、真人IP | | 博客园日常动态 | oddmeta | 活泼、亲和 | | 微博日常动态 | oddmeta | 真实过程展示 | | 产品开发记录 | oddmeta | 真实过程展示 | | B端产品介绍 | oddmeta | 专业可信赖 | **双品牌联动**：同一篇内容，公众号用oddmeta色，微贴图转发用奥德元色 ### 字体 ```css font-family: -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif; ``` ### 品牌角标 每页左上角 `oddmeta` logo + 文字，暗底页用 `.light`，浅底页用 `.dark`。 ### 页码 右下角 `1/N`，暗底页 `rgba(255,255,255,0.2)`，浅底页 `rgba(26,51,40,0.2)`。 --- ## 内容改写原则 微信 → 微贴图不是照搬，需适配： | 维度 | 微信 | 微贴图 | |------|------|--------| | 篇幅 | 2000-3000 字 | 每页 50-80 字 | | 结构 | 线性阅读 | 卡片式跳读 | | 语气 | 技术向、深度 | 简洁、直观、有冲击力 | | 视觉 | 文字为主 | 视觉为主、文字点缀 | 改写要点：标题要炸、数字要大、一页一个点、视觉替代文字、保留核心链接。 --- ## 完整模板参考 首次生成微贴图图片时，参考 `references/examples-chartlet/exampe.html` 范例文件获取完整 CSS + JS。 > 如果有额外的本地模板参考，在 `local/SKILL.local.md` 中指定路径，格式为 `extra_examples: ["references/examples-chartlet/exampe-chartlet.html"]`。 生成新内容时复用范例文件的 CSS + JS 部分，只替换卡片内容。 --- ## Script Directory **Agent Execution**: Determine this SKILL.md directory as `SKILL_DIR`, then use `${SKILL_DIR}/scripts/<name>`. | Script | Purpose | |--------|---------| | `scripts/distribute/distribute.ts` | 分发主编排器 | | `scripts/distribute/cdp-utils.ts` | 共享 CDP 工具 | | `scripts/distribute/platforms/*.ts` | 各平台发布模块 | | `scripts/archive_outputs.py` | 存档历史文档（Python）（按日期归档）（可选）（默认不执行） | | `scripts/md2wechat.py` | 将Markdown转换为微信文章格式（Python，根据范例） | | `scripts/publish_to_cnblogs.py` | 博客园发布（Python） | | `scripts/wechat_download.py` | 微信文章抓取（Python，模拟微信 UA） | --- ## Reference 文件索引 cc 按需读取，不要一次性加载所有 reference。 | 场景 | 读取文件 | |------|---------| | 存档历史文档 | `references/archive_outputs.md` — 存档历史文档（按日期归档）（可选）（默认不执行） | | 生成头图/配图 | `references/cover_template.md` — oddmeta 风格排版规范（头图 + 配图 + 视觉组件） | | 横版→竖版封面 | `references/cover_vertical_spec.md` — 公众号封面转竖版的 CSS 转换规范 | | 生成微贴图卡片 | `references/generate_chartlet.md` — 微贴图轮播图设计规范 | | 生成各平台文案 | `references/platform_copy.md` — 微贴图/微博/博客园/朋友圈文案规范 | | 生成视频画布 | `references/video_canvas_template.md` — 手账拼贴视频画布模板（CSS+JS+卡片规范） | | 出稿写深度长文 | `references/framework_in_depth_articles.md` — 人设 + 写作规范 + 格式（四幕式，8000-12000 字） | | 出稿写教程类文章 | `references/framework_tutorial_articles.md` — 六段式教程框架（先看结果→概念→操作→实战→拿走即用，2000-4000 字） | | 出稿其它类型文章 | `references/frameworks.md` — 人设 + 写作规范 + 格式（四幕式）| | 生成播客音频 | `references/tts_config.md` — Fish Audio 配置 + 生成脚本 | | 分发到各平台 | `references/distribute_platforms.md` — 平台配置 + manifest 格式 + 降级策略 | | 生成微贴图轮播图 | `references/xiaohongshu-format.md` — HTML 模板 + 视觉组件库 | --- ## 故障处理 | 问题 | 处理 | |------|------| | 运行Python报错 | 优先参考 `references/running_python_script_guide.md` | | 微信抓取失败 | 提示用户手动复制文章正文 | | 文章太短（<500字） | 压缩为 5-6 张卡片 | | 文章太长（>5000字） | 精选核心，控制 10 张以内 | | 导出图片模糊 | 检查 SCALE=2，浏览器缩放 100% | | manifest 不存在 | 提示先运行内容生成 | | Chrome 启动失败 | 降级 L3（手动模式） | | TTS 402 余额不足 | 提示去 fish.audio 充值 | | TTS 生成失败 | 只输出脚本文本，提示手动录制 | | 代码块在公众号编辑器中无换行 | 修改 md2wechat.py 脚本，用 `<br/>` 替换换行符 |

path: README.md
content: # Article Auto Generate Skills

Claude Code Skill for end-to-end content production and distribution.

```
素材收集 → 写文章 → 排版 → 封面图 → 多平台内容 → 一键分发
```

## 功能

- **Path A**: 日常收集素材 → 生成文章 → 排版 → 封面图 → 社交媒体文案
- **Path B**: 微信文章URL → 自动生成微贴图、博客园、播客、视频画布 → 一键分发

### 支持平台

| 平台 | 状态 |
|------|------|
| 微信公众号 | 可用 |
| 微贴图 | 可用 |
| 博客园 | 可用 |
| 微博 | 可用 |
| 抖音 | 实验性 |

## 安装

1. 克隆到你的 Claude Code skills 目录：

```bash
git clone https://github.com/oddmeta/odd-article-skills.git ~/.claude/skills/odd-article-skills
```

2. 复制环境变量配置：

```bash
mkdir -p ~/.claude/skills/odd-article-skills/local
cp ~/.claude/skills/odd-article-skills/.env.example ~/.claude/skills/odd-article-skills/local/.env
```

3. 编辑 `local/.env` 填入你的凭证

## 配置

### 技能所需的环境变量

| 变量 | 说明 |
|------|------|
| `OUTPUT_DIR` | 输出目录，用于放置收集到的素材、生成的文章、排版预览、封面图、微贴图轮播图等文件。 |
| `ARCHIVE_DIR` | 存档目录，用于归档历史文档文件。 |
| `BRAND_NAME` | 品牌名称，用于在生成的文章中显示品牌名称。|
| `BRAND_LOGO_DARK` | 品牌logo（深色），用于在生成的文章中显示品牌logo。|
| `BRAND_LOGO_LIGHT` | 品牌logo（浅色），用于在生成的文章中显示品牌logo。|
| `WECHAT_ID` | 微信公众号ID，用于发布文章。|
| `WECHAT_SLOGON` | 微信公众号标语，用于在文章中显示。|
| `WECHAT_APPID` | 微信公众号AppID，用于发布文章时的认证。|
| `WECHAT_APPSECRET` | 微信公众号AppSecret，用于发布文章时的认证。|
| `CNBLOGS_TOKEN` | 博客园Token，用于发布博客园文章时的认证。|
| `MD_FORMATTER_DIR` | Markdown格式化工具目录，用于格式化Markdown文章。|
| `BAOYU_WECHAT_SKILL_DIR` | 奥德元微信技能目录，用于发布文章。|
| `MD_TO_WECHAT_SCRIPT` | Markdown文章转微信文章脚本，用于将Markdown文章转换为微信文章。|
| `FISH_API_KEY` | Fish Audio TTS API key |

## 使用

在 Claude Code、OpenCode、OpenClaw 等工具 中使用这些触发词：

| 触发词 | 功能 |
|-------|------|
| "出稿" | 从素材生成文章 + 排版 + 封面图 |
| "排版" | Markdown → 微信公众号 HTML |
| "做头图" | 生成公众号头图 |
| `/chartlet` + 微信链接 | 微信文章转微贴图轮播图 |
| "转微博" + 微信链接 | 生成微博文案 |
| "转播客" + 微信链接 | 生成播客脚本 + AI 语音 |
| "做视频画布" + 内容 | 生成可录制的视频画布 |
| `/distribute` | 一键发布到所有平台 |

## 项目结构

```
odd-article-skills/
├── SKILL.md                                # 主要技能定义
├── .env.example                            # 环境变量示例
├── local/                                  # 本地配置（不提交到Git）
│   ├── SKILL.local.md                      # 个人设置覆盖
│   └── .env                                # API密钥
├── references/                             # 模板和样式指南
│   ├── cover_template.md                   # 封面图设计规范
│   ├── frameworks.md                       # 文章框架类型
│   ├── platform_copy.md                    # 平台文案指南
│   ├── tts_config.md                       # Fish Audio TTS配置
│   └── ...
└── scripts/
    ├── wechat_download.py                  # 微信文章抓取
    ├── md2wechat.py                        # 微信文章排版
    └── distribute/                         # 多平台发布器
```

## 许可证

MIT

path: scripts/archive_outputs.py
content: #!/usr/bin/env python3
"""
存档工具
功能：将 `OUTPUT_DIR` 目录下的历史文档文件（.md, .html, .png, manifest.json）移动到 `ARCHIVE_DIR` 目录下

使用方式：
    python archive_outputs.py
    python archive_outputs.py --output-dir /path/to/output --archive-dir /path/to/archive
"""

import argparse
import sys
import os
import shutil
from pathlib import Path
from datetime import datetime

def setup_encoding():
    """解决 Windows 下的编码问题"""
    if sys.platform == 'win32':
        try:
            sys.stdout.reconfigure(encoding='utf-8') # type: ignore
            sys.stderr.reconfigure(encoding='utf-8') # type: ignore
        except Exception:
            pass
    try:
        import io
        if sys.stdout.encoding != 'utf-8':
            sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
    except Exception:
        pass

def get_env_var(var_name: str, default: str = "") -> str:
    """从环境变量或 .env 文件中获取配置"""
    value = os.environ.get(var_name)
    if value:
        return value
    
    # 尝试从 .env 文件读取
    env_files = ['.env', 'local/.env']
    for env_file in env_files:
        env_path = Path(__file__).parent.parent / env_file
        if env_path.exists():
            with open(env_path, 'r', encoding='utf-8') as f:
                for line in f:
                    line = line.strip()
                    if line.startswith(f'{var_name}='):
                        return line.split('=', 1)[1].strip()
    
    return default

def normalize_path(path_str: str) -> Path:
    """规范化路径，处理 Windows 和 Unix 路径格式"""
    path_str = path_str.replace('\\', '/')
    if path_str.startswith('~'):
        path_str = str(Path.home()) + path_str[1:]
    return Path(path_str)

def archive_outputs(output_dir: Path, archive_dir: Path, dry_run: bool = False) -> bool:
    """将 `OUTPUT_DIR` 下的历史文档文件移动到 `ARCHIVE_DIR`"""
    
    # 检查输出目录是否存在
    if not output_dir.exists():
        print(f"[ERROR] 输出目录不存在: {output_dir}")
        return False
    
    # 查找需要归档的文件
    extensions = ['.md', '.html', '.png']
    files_to_archive = []
    
    for ext in extensions:
        files_to_archive.extend(output_dir.glob(f'*{ext}'))
    
    # 添加 manifest.json 文件
    manifest_file = output_dir / 'manifest.json'
    if manifest_file.exists():
        files_to_archive.append(manifest_file)
    
    if not files_to_archive:
        print("[INFO] 没有找到需要归档的文件 (.md, .html, .png, manifest.json)")
        return True
    
    # 从 .md 文件名中提取文章标题
    article_title = None
    for file_path in files_to_archive:
        if file_path.suffix == '.md':
            article_title = file_path.stem
            break
    
    if not article_title:
        print("[ERROR] 未找到 .md 文件，无法确定文章标题")
        return False
    
    # 创建归档目录（ARCHIVE_DIR/当前月份/文章标题）
    month_year = datetime.now().strftime('%Y-%m')
    archive_subdir = archive_dir / month_year / article_title
    
    if archive_subdir.exists():
        print(f"[WARNING] 归档目录已存在: {archive_subdir}")
    else:
        if dry_run:
            print(f"[DRY RUN] 将创建目录: {archive_subdir}")
        else:
            archive_subdir.mkdir(parents=True, exist_ok=True)
            print(f"[INFO] 创建归档目录: {archive_subdir}")
    
    # 移动文件
    moved_count = 0
    for file_path in files_to_archive:
        dest_path = archive_subdir / file_path.name
        
        if dest_path.exists():
            print(f"[WARNING] 目标文件已存在，跳过: {dest_path}")
            continue
        
        if dry_run:
            print(f"[DRY RUN] 将移动: {file_path} -> {dest_path}")
        else:
            try:
                if file_path.name == 'manifest.json':
                    dest_path = archive_subdir / f'{article_title}.manifest.json'
                    shutil.move(str(file_path), str(dest_path))
                else:
                    shutil.move(str(file_path), str(dest_path))
                print(f"[OK] 已移动: {file_path.name}")
                moved_count += 1
            except Exception as e:
                print(f"[ERROR] 移动失败 {file_path.name}: {e}")
    
    if dry_run:
        print(f"\n[DRY RUN] 总计将移动 {len(files_to_archive)} 个文件")
    else:
        print(f"\n[OK] 归档完成！共移动 {moved_count} 个文件到: {archive_subdir}")
    
    return True

def main():
    setup_encoding()
    
    parser = argparse.ArgumentParser(description='存档工具 - 归档历史文档文件')
    parser.add_argument('--output-dir', '-o', help='输出目录（默认从环境变量 `OUTPUT_DIR` 读取）')
    parser.add_argument('--archive-dir', '-a', help='存档目录（默认从环境变量 `ARCHIVE_DIR` 读取）')
    parser.add_argument('--dry-run', '-n', action='store_true', help='预览模式，不实际移动文件')
    
    args = parser.parse_args()
    
    # 获取配置
    output_dir_str = args.output_dir or get_env_var('OUTPUT_DIR')
    archive_dir_str = args.archive_dir or get_env_var('ARCHIVE_DIR')

    output_dir = normalize_path(output_dir_str)
    archive_dir = normalize_path(archive_dir_str)

    if not output_dir:
        print("[ERROR] 未指定输出目录，请设置 `OUTPUT_DIR` 环境变量或使用 --output-dir 参数")
        sys.exit(1)
    
    if not archive_dir:
        print("[ERROR] 未指定存档目录，请设置 `ARCHIVE_DIR` 环境变量或使用 --archive-dir 参数")
        sys.exit(1)
        
    print(f"[INFO] 输出目录: {output_dir}")
    print(f"[INFO] 存档目录: {archive_dir}")
    print(f"[INFO] 模式: {'预览（不实际移动）' if args.dry_run else '执行'}")
    print("-" * 50)
    
    success = archive_outputs(output_dir, archive_dir, args.dry_run)
    sys.exit(0 if success else 1)

if __name__ == "__main__":
    main()

path: scripts/generate_img.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Generate Image for your article

@Time    : 2023/08/09 10:00:00
@File    : generate_img.py
@Software: odd_article_skills
@Desc    : 调用 ModelScope 图片生成 API 生成图片，
            并将生成的图片保存为 result_image.jpg
"""

import requests
import time
import json
from PIL import Image
from io import BytesIO

base_url = 'https://api-inference.modelscope.cn/'
[REDACTED_SECRET]"
model_name = "Qwen/Qwen-Image"

common_headers = {
    "Authorization": f"Bearer {api_key}",
    "Content-Type": "application/json",
}

def generate_image(title: str, content: str, output_file: str = "result_image.jpg"):
    response = requests.post(
        f"{base_url}v1/images/generations",
        headers={**common_headers, "X-ModelScope-Async-Mode": "true"},
        data=json.dumps({
            "model": model_name,
            "prompt": f"{title}\n{content}"
        }, ensure_ascii=False).encode('utf-8')
    )
    
    print(f"Response status: {response.status_code}")
    print(f"Response text: {response.text[:500]}")


    response.raise_for_status()
    task_id = response.json()["task_id"]

    while True:
        result = requests.get(
            f"{base_url}v1/tasks/{task_id}",
            headers={**common_headers, "X-ModelScope-Task-Type": "image_generation"},
        )
        result.raise_for_status()
        data = result.json()

        if data["task_status"] == "SUCCEED":
            image = Image.open(BytesIO(requests.get(data["output_images"][0]).content))
            image.save(output_file)
            print(f"图片已保存到: {output_file}")
            break
        elif data["task_status"] == "FAILED":
            print("Image Generation Failed.")
            break

        time.sleep(5)

if __name__ == "__main__":
    generate_image("A golden cat", "A golden cat")

path: scripts/md2wechat.py
content: #!/usr/bin/env python3
"""
Markdown 转 HTML 工具 (基于 baoyu-markdown-to-html 设计)

功能：将 Markdown 格式转换为微信公众号优化的 HTML 格式
- 支持多种主题（default, grace, simple）
- 支持 frontmatter 元数据
- 支持表格、代码块、脚注、警告框
- ASCII 架构图横向滚动
- 移动端适配
"""

import argparse
import json
import markdown
import re
import sys
from datetime import datetime
from pathlib import Path

# 经典主题（默认）
THEME_DEFAULT = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{title}</title>
    <style>
        body {{
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
            font-size: 16px;
            line-height: 1.8;
            color: #333333;
            max-width: 800px;
            margin: 0 auto;
            padding: 20px;
            background: #ffffff;
        }}
        h1 {{
            font-size: 24px;
            font-weight: 600;
            margin: 24px 0 16px;
            color: #1a1a1a;
            text-align: center;
            border-bottom: 2px solid #007aff;
            padding-bottom: 12px;
        }}
        h2 {{
            font-size: 20px;
            font-weight: 600;
            margin: 24px 0 12px;
            color: #ffffff;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            padding: 8px 16px;
            border-radius: 4px;
        }}
        h3 {{
            font-size: 18px;
            font-weight: 600;
            margin: 20px 0 10px;
            color: #333333;
        }}
        p {{
            margin: 16px 0;
            line-height: 1.8;
            color: #333333;
        }}
        ul, ol {{
            padding-left: 24px;
            margin: 16px 0;
        }}
        li {{
            margin: 8px 0;
            line-height: 1.7;
        }}
        strong {{
            font-weight: 600;
            color: #1a1a1a;
        }}
        hr {{
            border: none;
            border-top: 1px solid #eeeeee;
            margin: 24px 0;
        }}
        blockquote {{
            border-left: 4px solid #007aff;
            padding: 12px 16px;
            margin: 16px 0;
            background: #f0f7ff;
            color: #333333;
        }}
        pre {{
            background: #f6f8fa;
            padding: 16px;
            border-radius: 4px;
            overflow-x: auto;
            overflow-y: hidden;
            white-space: pre;
            -webkit-overflow-scrolling: touch;
            margin: 16px 0;
            font-family: "Menlo", "Monaco", "Consolas", monospace;
            font-size: 13px;
            line-height: 1.5;
        }}
        code {{
            font-family: "Menlo", "Monaco", "Consolas", monospace;
            font-size: 13px;
            background: #f6f8fa;
            padding: 2px 6px;
            border-radius: 4px;
            color: #d73a49;
        }}
        pre code {{
            background: none;
            padding: 0;
            color: inherit;
        }}
        img {{
            max-width: 100%;
            height: auto;
            display: block;
            margin: 16px auto;
            border-radius: 4px;
        }}
        a {{
            color: #007aff;
            text-decoration: none;
        }}
        .table-wrapper {{
            overflow-x: auto;
            margin: 16px 0;
        }}
        table {{
            width: 100%;
            border-collapse: collapse;
            font-size: 14px;
        }}
        th, td {{
            border: 1px solid #eeeeee;
            padding: 10px 12px;
            text-align: left;
        }}
        th {{
            background: #f6f8fa;
            font-weight: 600;
        }}
        /* 警告框 */
        .alert {{
            padding: 12px 16px;
            margin: 16px 0;
            border-radius: 4px;
        }}
        .alert-note {{
            background: #e7f3ff;
            border-left: 4px solid #007aff;
        }}
        .alert-warning {{
            background: #fff8e6;
            border-left: 4px solid #ff9500;
        }}
        .alert-tip {{
            background: #e6fff2;
            border-left: 4px solid #34c759;
        }}
        .alert-important {{
            background: #fff2f2;
            border-left: 4px solid #ff3b30;
        }}
        .author {{
            color: #999999;
            font-size: 14px;
            text-align: center;
            margin-top: 32px;
            padding-top: 16px;
            border-top: 1px solid #eeeeee;
        }}
    </style>
</head>
<body>
{content}
<div class="author">{author}</div>
</body>
</html>"""

# 简洁主题
THEME_SIMPLE = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{title}</title>
    <style>
        body {{
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            font-size: 16px;
            line-height: 1.9;
            color: #2c3e50;
            max-width: 720px;
            margin: 0 auto;
            padding: 32px 20px;
            background: #fafafa;
        }}
        h1 {{
            font-size: 26px;
            font-weight: 700;
            margin: 0 0 24px;
            color: #1a1a1a;
        }}
        h2 {{
            font-size: 20px;
            font-weight: 600;
            margin: 32px 0 16px;
            color: #1a1a1a;
        }}
        p {{
            margin: 16px 0;
            color: #4a4a4a;
        }}
        ul, ol {{
            padding-left: 20px;
            margin: 16px 0;
        }}
        pre {{
            background: #282c34;
            color: #abb2bf;
            padding: 16px;
            border-radius: 8px;
            overflow-x: auto;
            white-space: pre;
            margin: 20px 0;
        }}
        blockquote {{
            border-left: 3px solid #3498db;
            padding: 8px 16px;
            margin: 16px 0;
            color: #666;
        }}
        .author {{
            color: #999;
            font-size: 14px;
            text-align: center;
            margin-top: 40px;
            padding-top: 20px;
        }}
    </style>
</head>
<body>
{content}
<div class="author">{author}</div>
</body>
</html>"""

# 优雅主题
THEME_GRACE = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{title}</title>
    <style>
        body {{
            font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
            font-size: 16px;
            line-height: 2.0;
            color: #444;
            max-width: 760px;
            margin: 0 auto;
            padding: 24px;
            background: linear-gradient(135deg, #667eea08 0%, #764ba208 100%);
        }}
        .card {{
            background: #ffffff;
            border-radius: 16px;
            padding: 32px;
            box-shadow: 0 4px 20px rgba(0,0,0,0.08);
            margin: 16px 0;
        }}
        h1 {{
            font-size: 24px;
            font-weight: 600;
            margin: 0 0 24px;
            text-align: center;
            color: #1a1a1a;
        }}
        h2 {{
            font-size: 18px;
            font-weight: 600;
            margin: 24px 0 12px;
            color: #5e6ad2;
            text-shadow: 0 1px 2px rgba(0,0,0,0.1);
        }}
        p {{
            margin: 14px 0;
            color: #555;
        }}
        pre {{
            background: #f8f9fa;
            padding: 16px;
            border-radius: 12px;
            overflow-x: auto;
            white-space: pre;
            margin: 16px 0;
            border: 1px solid #eee;
        }}
        blockquote {{
            background: linear-gradient(135deg, #f5f7fa 0%, #e4e8f0 100%);
            border-radius: 12px;
            padding: 16px 20px;
            margin: 16px 0;
            border: none;
            box-shadow: inset 0 2px 4px rgba(0,0,0,0.05);
        }}
        .author {{
            color: #999;
            font-size: 14px;
            text-align: center;
            margin-top: 32px;
        }}
    </style>
</head>
<body>
<div class="card">
{content}
</div>
<div class="author">{author}</div>
</body>
</html>"""

# oddmeta 主题（墨绿 + 鱼红 + 宣纸底）
THEME_ODD = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{title}</title>
    <style>
        body {{
            font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
            font-size: 16px;
            line-height: 1.9;
            color: #333333;
            max-width: 720px;
            margin: 0 auto;
            padding: 24px 20px;
            background: #F2EDE3;
        }}
        h1 {{
            font-size: 24px;
            font-weight: 700;
            margin: 24px 0 20px;
            text-align: center;
            color: #1A3328;
        }}
        h2 {{
            font-size: 18px;
            font-weight: 600;
            margin: 28px 0 14px;
            color: #1A3328;
            border-left: 4px solid #C44536;
            padding-left: 12px;
        }}
        h3 {{
            font-size: 16px;
            font-weight: 600;
            margin: 20px 0 10px;
            color: #1A3328;
        }}
        p {{
            margin: 14px 0;
            color: #333333;
        }}
        ul, ol {{
            padding-left: 24px;
            margin: 14px 0;
        }}
        li {{
            margin: 8px 0;
            line-height: 1.7;
        }}
        strong {{
            font-weight: 600;
            color: #C44536;
        }}
        hr {{
            border: none;
            border-top: 1px solid #D4DDD7;
            margin: 28px 0;
        }}
        blockquote {{
            border-left: 3px solid #C44536;
            padding: 12px 16px;
            margin: 16px 0;
            background: #ffffff;
            color: #555555;
            border-radius: 0 4px 4px 0;
        }}
        pre {{
            background: #1A3328;
            color: #D4DDD7;
            padding: 16px;
            border-radius: 6px;
            overflow-x: auto;
            overflow-y: hidden;
            white-space: pre;
            -webkit-overflow-scrolling: touch;
            margin: 16px 0;
            font-family: "Menlo", "Monaco", "Consolas", monospace;
            font-size: 13px;
            line-height: 1.5;
        }}
        code {{
            font-family: "Menlo", "Monaco", "Consolas", monospace;
            font-size: 13px;
            background: #1A3328;
            color: #D4DDD7;
            padding: 2px 6px;
            border-radius: 4px;
        }}
        pre code {{
            background: none;
            padding: 0;
            color: inherit;
        }}
        img {{
            max-width: 100%;
            height: auto;
            display: block;
            margin: 16px auto;
            border-radius: 4px;
        }}
        a {{
            color: #C44536;
            text-decoration: none;
        }}
        a:hover {{
            text-decoration: underline;
        }}
        .table-wrapper {{
            overflow-x: auto;
            margin: 16px 0;
        }}
        table {{
            width: 100%;
            border-collapse: collapse;
            font-size: 14px;
        }}
        th, td {{
            border: 1px solid #D4DDD7;
            padding: 10px 12px;
            text-align: left;
        }}
        th {{
            background: #1A3328;
            color: #ffffff;
            font-weight: 600;
        }}
        tr:nth-child(even) {{
            background: #ffffff;
        }}
        tr:nth-child(odd) {{
            background: #F2EDE3;
        }}
        .author {{
            color: #7A8C80;
            font-size: 14px;
            text-align: center;
            margin-top: 40px;
            padding-top: 20px;
            border-top: 1px solid #D4DDD7;
        }}
    </style>
</head>
<body>
{content}
<div class="author">{author}</div>
</body>
</html>"""

THEMES = {
    'default': THEME_DEFAULT,
    'simple': THEME_SIMPLE,
    'grace': THEME_GRACE,
    'odd': THEME_ODD
}

def parse_frontmatter(content: str) -> tuple[dict, str]:
    """解析 YAML frontmatter"""
    pattern = r'^---\n(.*?)\n---\n(.*)$'
    match = re.match(pattern, content, re.DOTALL)
    
    if match:
        frontmatter = match.group(1)
        body = match.group(2)
        metadata = {}
        for line in frontmatter.strip().split('\n'):
            if ':' in line:
                key, value = line.split(':', 1)
                metadata[key.strip()] = value.strip()
        return metadata, body
    return {}, content

def extract_title(content: str) -> str:
    """从内容中提取标题"""
    # 尝试 H1
    match = re.match(r'^# (.+)$', content.strip(), re.MULTILINE)
    if match:
        return match.group(1)
    # 尝试 H2
    match = re.match(r'^## (.+)$', content.strip(), re.MULTILINE)
    if match:
        return match.group(1)
    return "Article"

def convert_alerts(html: str) -> str:
    """转换警告框"""
    html = re.sub(
        r'> \[!NOTE\](.*?)',
        r'<div class="alert alert-note"><strong>NOTE:</strong>\1',
        html, flags=re.DOTALL
    )
    html = re.sub(
        r'> \[!WARNING\](.*?)',
        r'<div class="alert alert-warning"><strong>WARNING:</strong>\1',
        html, flags=re.DOTALL
    )
    html = re.sub(
        r'> \[!TIP\](.*?)',
        r'<div class="alert alert-tip"><strong>TIP:</strong>\1',
        html, flags=re.DOTALL
    )
    html = re.sub(
        r'> \[!IMPORTANT\](.*?)',
        r'<div class="alert alert-important"><strong>IMPORTANT:</strong>\1',
        html, flags=re.DOTALL
    )
    # 关闭标签
    html = re.sub(r'(</div>\n?)', r'\1</div>', html)
    return html

def inline_styles_for_wechat(html: str, theme: str = 'default') -> str:
    """将CSS样式转换为内联样式，支持微信公众号"""
    if theme != 'odd':
        return html
    
    # h2样式：左侧边框+墨绿+鱼红强调
    html = re.sub(
        r'<h2([^>]*)>(.*?)</h2>',
        r'<h2\1 style="font-size:18px;font-weight:600;margin:28px 0 14px;color:#1A3328;border-left:4px solid #C44536;padding-left:12px;">\2</h2>',
        html, flags=re.DOTALL
    )
    
    # h3样式
    html = re.sub(
        r'<h3([^>]*)>(.*?)</h3>',
        r'<h3\1 style="font-size:16px;font-weight:600;margin:20px 0 10px;color:#1A3328;">\2</h3>',
        html, flags=re.DOTALL
    )
    
    # 微信公众号对列表支持有限，移除可能导致问题的样式
    
    # strong样式：鱼红强调
    html = re.sub(
        r'<strong>(.*?)</strong>',
        r'<strong style="font-weight:600;color:#C44536;">\1</strong>',
        html, flags=re.DOTALL
    )
    
    # 处理p标签内的列表项（markdown解析可能把列表项解析成p包裹原始文本）
    # 简单替换：所有"- "开头的内容替换为"• "
    html = re.sub(r'<p>\n?\s*-\s+', '<p>• ', html)
    # 处理换行后的列表项
    html = re.sub(r'\n-\s+', '\n• ', html)
    # 处理 <p>• </p><p>xxx</p> 或 <p>• </p>\n<p>xxx</p> 这种被分成两行的情况
    html = re.sub(r'<p>• \s*</p>\s*<p>', '<p>• ', html)
    html = re.sub(r'</p>\n<p>• ', '</p><p>• ', html)
    # 处理div内的连续p标签合并（markdown解析器将列表项解析成两个p的问题）
    html = re.sub(r'(<div[^>]*>)\s*<p>•\s*</p>\s*<p>', r'\1<p>• ', html, flags=re.DOTALL)
    
    # 处理GFM任务列表 [x] 和 [ ]
    html = re.sub(r'\[x\]', '✅', html, flags=re.IGNORECASE)
    html = re.sub(r'\[ \]', '⬜', html)
    
    # 最终修复：合并被分成两行的列表项 <p>• </p><p>xxx</p>
    # 匹配 bullet在第一个p，内容在第二个p，中间可能有换行
    html = re.sub(r'<p>• (\s*)</p>(\n\s*)<p>', r'<p>• ', html)
    html = re.sub(r'<p>• (\s*)</p>\s*<p>', r'<p>• ', html)
    
    # 微信公众号列表支持差，将li转换为p加符号的形式
    html = re.sub(r'<ul>(.*?)</ul>', r'<div>\1</div>', html, flags=re.DOTALL)
    html = re.sub(r'<ol>(.*?)</ol>', r'<div>\1</div>', html, flags=re.DOTALL)
    # 转换li为p，使用中文顿号
    html = re.sub(r'<li>(.*?)</li>', r'<p>• \1</p>', html, flags=re.DOTALL)
    
    # pre样式：墨绿底，使用更可靠的换行方式
    html = re.sub(
        r'<pre([^>]*)>(.*?)</pre>',
        lambda m: '<pre' + m.group(1) + ' style="background:#1A3328;color:#D4DDD7;padding:16px;border-radius:6px;overflow-x:auto;margin:16px;font-family:Menlo,Monaco,Consolas,monospace;font-size:13px;line-height:1.6;display:block;">' + m.group(2).replace('\n', '<br/>') + '</pre>',
        html, flags=re.DOTALL
    )
    
    # code样式（包含在pre内的需要特殊处理）
    html = re.sub(
        r'<code([^>]*)>(.*?)</code>',
        r'<code\1 style="font-family:Menlo,Monaco,Consolas,monospace;font-size:13px;background:#1A3328;color:#D4DDD7;padding:2px 6px;border-radius:4px;white-space:pre-wrap;word-break:break-all;">\2</code>',
        html, flags=re.DOTALL
    )
    
    # blockquote样式
    html = re.sub(
        r'<blockquote([^>]*)>(.*?)</blockquote>',
        r'<blockquote\1 style="border-left:3px solid #C44536;padding:12px 16px;margin:16px 0;background:#ffffff;color:#555555;border-radius:0 4px 4px 0;">\2</blockquote>',
        html, flags=re.DOTALL
    )
    
    # hr样式
    html = re.sub(
        r'<hr\s*/?>',
        r'<hr style="border:none;border-top:1px solid #D4DDD7;margin:28px 0;" />',
        html
    )
    
    # 清理thead及其样式
    html = re.sub(r'<thead[^>]*>(\s*)<tr>', r'<tr>', html)
    html = re.sub(r'</tr>(\s*)</thead>', r'</tr>', html)
    html = re.sub(r'<thead>(\s*)', r'', html)
    html = re.sub(r'(\s*)</thead>', r'', html)
    
    # table样式
    html = re.sub(
        r'<table([^>]*)>(.*?)</table>',
        r'<table\1 style="width:100%;border-collapse:collapse;font-size:14px;">\2</table>',
        html, flags=re.DOTALL
    )
    # 清理重复的style属性
    html = re.sub(r'(style="[^"]*")\s+\1', r'\1', html)
    
    # th样式（先处理没有属性的th，再处理有属性的）
    html = re.sub(
        r'<th>\s*(.*?)\s*</th>',
        r'<th style="background:#1A3328;color:#ffffff;font-weight:600;padding:10px 12px;text-align:left;border:1px solid #D4DDD7;">\1</th>',
        html, flags=re.DOTALL
    )
    html = re.sub(
        r'<th([^>]*?)>\s*(.*?)\s*</th>',
        r'<th\1 style="background:#1A3328;color:#ffffff;font-weight:600;padding:10px 12px;text-align:left;border:1px solid #D4DDD7;">\2</th>',
        html, flags=re.DOTALL
    )
    # 再次清理重复的style
    html = re.sub(r'(style="[^"]*")\s+\1', r'\1', html)
    
    # td样式
    html = re.sub(
        r'<td>\s*(.*?)\s*</td>',
        r'<td style="padding:10px 12px;text-align:left;border:1px solid #D4DDD7;">\1</td>',
        html, flags=re.DOTALL
    )
    html = re.sub(
        r'<td([^>]*?)>\s*(.*?)\s*</td>',
        r'<td\1 style="padding:10px 12px;text-align:left;border:1px solid #D4DDD7;">\2</td>',
        html, flags=re.DOTALL
    )
    # 再次清理重复的style
    html = re.sub(r'(style="[^"]*")\s+\1', r'\1', html)
    
    return html

def convert_markdown_to_html(markdown_content: str, theme: str = 'default', 
                              title: str = '', author: str = '', 
                              keep_title: bool = False) -> str:
    """将 Markdown 转换为 HTML"""
    # 解析 frontmatter
    metadata, body = parse_frontmatter(markdown_content)
    
    # 获取元数据
    title = title or metadata.get('title', '') or extract_title(body)
    author = author or metadata.get('author', metadata.get('description', ''))
    
    # 是否保留标题
    if not keep_title:
        body = re.sub(r'^# .+\n', '', body, count=1, flags=re.MULTILINE)
    
    # 转换为 HTML
    md = markdown.Markdown(
        extensions=['tables', 'fenced_code', 'footnotes'],
        extension_configs={
            'fenced_code': {'lang_prefix': ''},
        }
    )
    html_content = md.convert(body)
    
    # 转换警告框
    html_content = convert_alerts(html_content)
    
    # odd主题：转换为内联样式（微信公众号支持）
    if theme == 'odd':
        html_content = inline_styles_for_wechat(html_content, theme)
    
    # 选择主题
    template = THEMES.get(theme, THEMES['default'])
    full_html = template.format(title=title, content=html_content, author=author)
    
    return full_html, {'title': title, 'author': author}

def setup_encoding():
    """解决 Windows 下的编码问题"""
    if sys.platform == 'win32':
        try:
            sys.stdout.reconfigure(encoding='utf-8')
            sys.stderr.reconfigure(encoding='utf-8')
        except Exception:
            pass
    import io
    if sys.stdout.encoding != 'utf-8':
        sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')

def main():
    setup_encoding()
    parser = argparse.ArgumentParser(description='Markdown 转 HTML 工具')
    parser.add_argument('input', nargs='?', default=None, help='输入 Markdown 文件路径或内容')
    parser.add_argument('-o', '--output', default=None, help='输出 HTML 文件路径')
    parser.add_argument('--theme', default='default', choices=['default', 'simple', 'grace', 'odd'],
                        help='主题风格 (default/simple/grace)')
    parser.add_argument('-t', '--title', default='', help='文章标题')
    parser.add_argument('-a', '--author', default='', help='作者名称')
    parser.add_argument('--keep-title', action='store_true', help='保留标题')
    parser.add_argument('--stdin', action='store_true', help='从标准输入读取')
    parser.add_argument('--json', action='store_true', help='输出 JSON 格式')
    
    args = parser.parse_args()
    
    # 读取输入：优先 stdin，其次文件，最后内容
    if args.stdin or (args.input is None and not Path(args.input or '').exists()):
        markdown_content = sys.stdin.read()
    elif args.input and Path(args.input).exists():
        markdown_content = Path(args.input).read_text(encoding='utf-8')
    else:
        markdown_content = args.input or ''
    
    if not markdown_content:
        print("[ERROR] No content provided")
        sys.exit(1)
    
    # 转换为 HTML
    html, metadata = convert_markdown_to_html(
        markdown_content, args.theme, args.title, args.author, args.keep_title
    )
    
    # 输出
    if args.json:
        result = {
            'title': metadata['title'],
            'author': metadata['author'],
            'htmlPath': args.output or '',
            'theme': args.theme
        }
        print(json.dumps(result, ensure_ascii=False, indent=2))
    elif args.output:
        Path(args.output).write_text(html, encoding='utf-8')
        print(f"[OK] Saved: {args.output}")
        if args.json:
            print(json.dumps({'htmlPath': args.output}, ensure_ascii=False))
    else:
        print(html)

if __name__ == "__main__":
    main()


path: scripts/publish_to_cnblogs.py
content: #!/usr/bin/env python3
"""
博客园发布工具
功能：将 Markdown 文件发布到博客园

使用方式：
    python publish_to_cnblogs.py <markdown文件路径> --token <你的PAT> --title <标题>
    python publish_to_cnblogs.py <markdown文件路径> --token <你的PAT>  # 自动从文件提取标题

常见问题：
    - 如果遇到 "No such file or directory" 错误，尝试使用绝对路径
    - Windows 用户建议使用: python G:\\path\\to\\publish_to_cnblogs.py
    - 或先 cd 到脚本所在目录再执行
"""

import argparse
import sys
import re
import os
from pathlib import Path

# 尝试导入 requests，如果失败给出友好提示
try:
    import requests
except ImportError:
    print("[ERROR] 请先安装依赖: pip install requests")
    sys.exit(1)

CNBLOGS_API = "https://i.cnblogs.com/openapi/v1/posts"

def setup_encoding():
    """解决 Windows 下的编码问题"""
    if sys.platform == 'win32':
        try:
            sys.stdout.reconfigure(encoding='utf-8')
            sys.stderr.reconfigure(encoding='utf-8')
        except Exception:
            pass
    try:
        import io
        if sys.stdout.encoding != 'utf-8':
            sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
    except Exception:
        pass

def normalize_path(path_str: str) -> Path:
    """规范化路径，处理 Windows 和 Unix 路径格式"""
    # 转换反斜杠为正斜杠
    path_str = path_str.replace('\\', '/')
    # 处理 ~ 家目录
    if path_str.startswith('~'):
        path_str = str(Path.home()) + path_str[1:]
    return Path(path_str)

def extract_title_from_markdown(content: str) -> str:
    """从 Markdown 内容中提取标题"""
    match = re.match(r'^#\s+(.+)$', content.strip(), re.MULTILINE)
    if match:
        return match.group(1).strip()
    return ""

def extract_description_from_markdown(content: str) -> str:
    """从 Markdown 内容中提取描述（摘要）"""
    lines = content.strip().split('\n')
    description = []
    in_code_block = False
    
    for line in lines[1:20]:
        if line.strip().startswith('```'):
            in_code_block = not in_code_block
            continue
        if in_code_block:
            continue
        if line.strip().startswith('#'):
            break
        if line.strip():
            description.append(line.strip())
        if len(description) >= 3:
            break
    
    desc = ' '.join(description)
    return desc[:200] if len(desc) > 200 else desc

def publish_to_cnblogs(title: str, body: str, token: str, description: str = "", post_type: str = "BlogPost") -> bool:
    """发布文章到博客园"""
    headers = {
        "Authorization": f"Bearer {token}",
        "Authorization-Type": "pat",
        "Content-Type": "application/json"
    }
    
    data = {
        "title": title,
        "body": body,
        "description": description,
        "postType": post_type,
        "postFormat": "Markdown"
    }
    
    try:
        response = requests.post(CNBLOGS_API, headers=headers, json=data, timeout=30)
        result = response.json()
        
        if response.status_code == 200 or response.status_code == 201:
            post_id = result.get('id', result.get('postId', 'unknown'))
            print(f"[OK] Published successfully! Post ID: {post_id}")
            print(f"[INFO] Response: {result}")
            return True
        else:
            print(f"[ERROR] Failed: {result}")
            return False
    except Exception as e:
        print(f"[ERROR] Exception: {e}")
        return False

def main():
    setup_encoding()
    
    parser = argparse.ArgumentParser(description='博客园 Markdown 发布工具')
    parser.add_argument('input', help='Markdown 文件路径')
    parser.add_argument('--token', '-t', required=True, help='博客园 PAT (Personal Access Token)')
    parser.add_argument('--title', '-T', default='', help='文章标题（默认从文件提取）')
    parser.add_argument('--description', '-d', default='', help='文章摘要')
    parser.add_argument('--post-type', default='BlogPost', choices=['BlogPost', 'Article'], help='文章类型')
    parser.add_argument('--preview', '-p', action='store_true', help='仅预览，不发布')
    
    args = parser.parse_args()
    
    # 规范化输入路径
    input_path = normalize_path(args.input)
    if not input_path.exists():
        print(f"[ERROR] File not found: {args.input}")
        print(f"[INFO] 尝试使用绝对路径，例如: python G:\\\\path\\\\to\\\\publish_to_cnblogs.py")
        sys.exit(1)
    
    content = input_path.read_text(encoding='utf-8')
    
    title = args.title or extract_title_from_markdown(content)
    if not title:
        print("[ERROR] Cannot extract title. Please provide with --title")
        sys.exit(1)
    
    description = args.description or extract_description_from_markdown(content)
    
    print(f"[INFO] Title: {title}")
    print(f"[INFO] Description: {description[:50]}..." if len(description) > 50 else f"[INFO] Description: {description}")
    
    if args.preview:
        print("\n" + "=" * 50)
        print("PREVIEW MODE - No actual publish")
        print("=" * 50)
        print(f"\nTitle: {title}\n")
        print(f"Body (first 500 chars):\n{content[:500]}...")
        return
    
    success = publish_to_cnblogs(title, content, args.token, description, args.post_type)
    sys.exit(0 if success else 1)

if __name__ == "__main__":
    main()

path: scripts/wechat_download.py
content: #!/usr/bin/env python3
"""
微信文章抓取脚本
WeChat Article Fetcher

用于从微信公众平台抓取文章内容。
"""

import argparse
import json
import re
import sys
import urllib.request
import urllib.error
from html import unescape
from datetime import datetime


def get_wechat_headers():
    """获取微信请求头，模拟浏览器访问"""
    return {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
        'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
        'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
        'Accept-Encoding': 'gzip, deflate, br',
    }


def extract_article_id(url: str) -> str:
    """从URL中提取文章ID"""
    # 匹配各种微信文章URL格式
    patterns = [
        r'/(appmsg.*?)\?',  # /appmsg/v4?
        r'/s\?.*?(mid=\w+)',  # /s?mid=
        r'/s\?.*?(sn=\w+)',  # /s?sn=
    ]
    
    for pattern in patterns:
        match = re.search(pattern, url)
        if match:
            return match.group(1)
    
    # 尝试直接从完整URL获取
    if 'mp.weixin.qq.com' in url:
        # 从URL中提取关键参数
        match = re.search(r'mid=(\w+)', url)
        if match:
            return match.group(1)
        match = re.search(r'sn=(\w+)', url)
        if match:
            return match.group(1)
    
    return ''


def fetch_article(url: str) -> dict:
    """抓取微信文章"""
    headers = get_wechat_headers()
    request = urllib.request.Request(url, headers=headers)
    
    try:
        with urllib.request.urlopen(request, timeout=30) as response:
            html = response.read().decode('utf-8')
    except urllib.error.HTTPError as e:
        return {
            'success': False,
            'error': f'HTTP Error: {e.code}',
            'message': '微信文章抓取失败，请尝试手动复制文章正文'
        }
    except Exception as e:
        return {
            'success': False,
            'error': str(e),
            'message': '网络错误，请检查网络连接'
        }
    
    # 解析HTML
    article = parse_html(html, url)
    article['fetched_at'] = datetime.utcnow().isoformat() + 'Z'
    
    return {
        'success': True,
        'article': article
    }


def parse_html(html: str, url: str) -> dict:
    """解析微信文章HTML"""
    article = {
        'url': url,
        'title': '',
        'author': '',
        'content': '',
        'content_html': '',
        'publish_date': '',
        'source': '',
        'cover_image': '',
        'images': []
    }
    
    # 提取标题
    title_match = re.search(r'<title>([^<]+)</title>', html)
    if title_match:
        article['title'] = unescape(title_match.group(1))
    
    # 提取作者
    author_match = re.search(r'["\']author["\']\s*:\s*["\']([^"\']+)["\']', html)
    if author_match:
        article['author'] = author_match.group(1)
    
    # 提取发布时间
    date_match = re.search(r'["\']pub_date["\']\s*:\s*["\']([^"\']+)["\']', html)
    if date_match:
        article['publish_date'] = date_match.group(1)
    
    # 提取封面图
    cover_match = re.search(r'["\']cover["\']\s*:\s*["\']([^"\']+\.(?:jpg|png|webp))["\']', html, re.I)
    if cover_match:
        article['cover_image'] = cover_match.group(1)
    
    # 提取正文内容 - 尝试多种方式
    content_match = re.search(r'id="js_content"[^>]*>([\s\S]*?)</div>', html)
    if content_match:
        content_html = content_match.group(1)
        article['content_html'] = content_html
        
        # 提取纯文本
        content_text = re.sub(r'<[^>]+>', '', content_html)
        content_text = unescape(content_text)
        content_text = re.sub(r'\s+', ' ', content_text).strip()
        article['content'] = content_text
        
        # 提取所有图片
        img_matches = re.findall(r'<img[^>]+src="([^"]+)"', content_html)
        article['images'] = [img for img in img_matches if 'mmbiz' in img or 'qpic' in img]
    
    # 提取摘要
    digest_match = re.search(r'["\']digest["\']\s*:\s*["\']([^"\']*)["\']', html)
    if digest_match:
        article['digest'] = digest_match.group(1)
    
    return article


def main():
    parser = argparse.ArgumentParser(description='抓取微信文章')
    parser.add_argument('url', help='微信文章URL')
    parser.add_argument('--json', action='store_true', help='输出JSON格式')
    parser.add_argument('--output', '-o', help='输出文件路径')
    
    args = parser.parse_args()
    
    result = fetch_article(args.url)
    
    if args.json:
        output = json.dumps(result, indent=2, ensure_ascii=False)
        if args.output:
            with open(args.output, 'w', encoding='utf-8') as f:
                f.write(output)
            print(f"已保存到: {args.output}")
        else:
            print(output)
    else:
        if result['success']:
            article = result['article']
            print(f"标题: {article['title']}")
            print(f"作者: {article['author']}")
            print(f"发布日期: {article['publish_date']}")
            print(f"字数: {len(article['content'])}")
            print(f"图片数: {len(article['images'])}")
            print(f"\n内容预览:\n{article['content'][:500]}...")
        else:
            print(f"错误: {result.get('message', result.get('error', '未知错误'))}")
            return 1
    
    return 0 if result['success'] else 1


if __name__ == '__main__':
    sys.exit(main())

path: scripts/distribute/cdp-utils.ts
content: /**
 * Shared CDP utilities for distribute skill.
 * Based on baoyu-post-to-wechat/scripts/cdp.ts and baoyu-post-to-x/scripts/x-utils.ts.
 */

import { spawn, type ChildProcess } from 'node:child_process';
import fs from 'node:fs';
import { mkdir } from 'node:fs/promises';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';

// ─── Sleep ───

export function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

/** Random delay between min and max ms (anti-detection) */
export function randomDelay(min = 200, max = 800): Promise<void> {
  return sleep(min + Math.random() * (max - min));
}

// ─── Port ───

export async function getFreePort(): Promise<number> {
  return new Promise((resolve, reject) => {
    const server = net.createServer();
    server.unref();
    server.on('error', reject);
    server.listen(0, '127.0.0.1', () => {
      const address = server.address();
      if (!address || typeof address === 'string') {
        server.close(() => reject(new Error('Unable to allocate a free TCP port.')));
        return;
      }
      const port = address.port;
      server.close((err) => {
        if (err) reject(err);
        else resolve(port);
      });
    });
  });
}

// ─── Chrome Discovery ───

export function findChromeExecutable(): string | undefined {
  const override = process.env.DISTRIBUTE_CHROME_PATH?.trim();
  if (override && fs.existsSync(override)) return override;

  const candidates: string[] = [];
  switch (process.platform) {
    case 'darwin':
      candidates.push(
        '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
        '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
        '/Applications/Chromium.app/Contents/MacOS/Chromium',
      );
      break;
    case 'win32':
      candidates.push(
        'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
        'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
      );
      break;
    default:
      candidates.push('/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser');
      break;
  }

  for (const p of candidates) {
    if (fs.existsSync(p)) return p;
  }
  return undefined;
}

// ─── Profile ───

export function getProfileDir(platform: string): string {
  const base = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
  return path.join(base, `${platform}-browser-profile`);
}

// ─── JSON Fetch ───

async function fetchJson<T = unknown>(url: string): Promise<T> {
  const res = await fetch(url, { redirect: 'follow' });
  if (!res.ok) throw new Error(`Request failed: ${res.status} ${res.statusText}`);
  return (await res.json()) as T;
}

async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise<string> {
  const start = Date.now();
  let lastError: unknown = null;

  while (Date.now() - start < timeoutMs) {
    try {
      const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`);
      if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl;
      lastError = new Error('Missing webSocketDebuggerUrl');
    } catch (error) {
      lastError = error;
    }
    await sleep(200);
  }

  throw new Error(`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
}

// ─── CDP Connection ───

type PendingRequest = {
  resolve: (value: unknown) => void;
  reject: (error: Error) => void;
  timer: ReturnType<typeof setTimeout> | null;
};

export class CdpConnection {
  private ws: WebSocket;
  private nextId = 0;
  private pending = new Map<number, PendingRequest>();
  private eventHandlers = new Map<string, Set<(params: unknown) => void>>();
  private defaultTimeoutMs: number;

  private constructor(ws: WebSocket, options?: { defaultTimeoutMs?: number }) {
    this.ws = ws;
    this.defaultTimeoutMs = options?.defaultTimeoutMs ?? 15_000;

    this.ws.addEventListener('message', (event) => {
      try {
        const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer);
        const msg = JSON.parse(data) as {
          id?: number; method?: string; params?: unknown;
          result?: unknown; error?: { message?: string };
        };

        if (msg.method) {
          const handlers = this.eventHandlers.get(msg.method);
          if (handlers) handlers.forEach((h) => h(msg.params));
        }

        if (msg.id) {
          const pending = this.pending.get(msg.id);
          if (pending) {
            this.pending.delete(msg.id);
            if (pending.timer) clearTimeout(pending.timer);
            if (msg.error?.message) pending.reject(new Error(msg.error.message));
            else pending.resolve(msg.result);
          }
        }
      } catch {}
    });

    this.ws.addEventListener('close', () => {
      for (const [, pending] of this.pending.entries()) {
        if (pending.timer) clearTimeout(pending.timer);
        pending.reject(new Error('CDP connection closed.'));
      }
      this.pending.clear();
    });
  }

  static async connect(url: string, timeoutMs: number, options?: { defaultTimeoutMs?: number }): Promise<CdpConnection> {
    const ws = new WebSocket(url);
    await new Promise<void>((resolve, reject) => {
      const timer = setTimeout(() => reject(new Error('CDP connection timeout.')), timeoutMs);
      ws.addEventListener('open', () => { clearTimeout(timer); resolve(); });
      ws.addEventListener('error', () => { clearTimeout(timer); reject(new Error('CDP connection failed.')); });
    });
    return new CdpConnection(ws, options);
  }

  on(method: string, handler: (params: unknown) => void): void {
    if (!this.eventHandlers.has(method)) this.eventHandlers.set(method, new Set());
    this.eventHandlers.get(method)!.add(handler);
  }

  async send<T = unknown>(
    method: string,
    params?: Record<string, unknown>,
    options?: { sessionId?: string; timeoutMs?: number },
  ): Promise<T> {
    const id = ++this.nextId;
    const message: Record<string, unknown> = { id, method };
    if (params) message.params = params;
    if (options?.sessionId) message.sessionId = options.sessionId;

    const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;

    const result = await new Promise<unknown>((resolve, reject) => {
      const timer = timeoutMs > 0
        ? setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, timeoutMs)
        : null;
      this.pending.set(id, { resolve, reject, timer });
      this.ws.send(JSON.stringify(message));
    });

    return result as T;
  }

  close(): void {
    try { this.ws.close(); } catch {}
  }
}

// ─── Chrome Session ───

export interface ChromeSession {
  cdp: CdpConnection;
  sessionId: string;
  targetId: string;
}

export interface LaunchResult {
  cdp: CdpConnection;
  chrome: ChildProcess;
}

async function checkExistingPort(): Promise<number | null> {
  const ports = [9222, 9223, 9224, 9225];
  for (const port of ports) {
    try {
      const res = await fetch(`http://127.0.0.1:${port}/json/version`, { method: 'HEAD' });
      console.log(`  Check port ${port}: ${res.ok}`);
      if (res.ok) return port;
    } catch (e) {
      console.log(`  Port ${port} not accessible: ${e}`);
    }
  }
  return null;
}

async function launchChromeWithRetry(url: string, platform: string, maxRetries = 3): Promise<LaunchResult> {
  let lastError: Error | null = null;
  
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await launchChrome(url, platform);
    } catch (err) {
      lastError = err instanceof Error ? err : new Error(String(err));
      console.log(`  Attempt ${i + 1} failed: ${lastError.message}`);
      
      if (i < maxRetries - 1) {
        await sleep(2000);
      }
    }
  }
  
  throw lastError;
}

export async function launchChrome(url: string, platform: string): Promise<LaunchResult> {
  const profile = getProfileDir(platform);
  await mkdir(profile, { recursive: true });

  const isWindows = process.platform === 'win32';
  
  let existingPort = process.env.CDP_PORT ? parseInt(process.env.CDP_PORT) : null;
  if (!existingPort) {
    existingPort = await checkExistingPort();
  }
  
  let port = existingPort || await getFreePort();
  let chrome: ChildProcess | undefined;
  
  const chromePath = findChromeExecutable();
  if (!chromePath) throw new Error('Chrome not found. Set DISTRIBUTE_CHROME_PATH env var.');
  
  if (!existingPort) {
    console.log(`[distribute][${platform}] Launching Chrome (profile: ${profile}, port: ${port})`);
    
    const chromeArgs = [
      `--remote-debugging-port=${port}`,
      `--user-data-dir=${profile}`,
      '--no-first-run',
      '--no-default-browser-check',
      '--disable-blink-features=AutomationControlled',
      url,
    ];
    
    console.log(`  Command: ${chromePath} ${chromeArgs.join(' ')}`);
    
    chrome = spawn(chromePath, chromeArgs, { 
      stdio: isWindows ? 'pipe' : 'ignore',
      detached: false,
      windowsHide: false
    });
    
    if (isWindows && chrome.stdout) {
      chrome.stdout.on('data', (data) => console.log(`  Chrome stdout: ${data}`));
      chrome.stderr.on('data', (data) => console.log(`  Chrome stderr: ${data}`));
    }

    await sleep(5000);
  } else {
    console.log(`[distribute][${platform}] Using existing Chrome on port ${port}`);
    chrome = spawn(chromePath, [`--new-window`, url], { stdio: 'ignore' });
    await sleep(2000);
  }

  // Check if chrome process is running
  if (chrome && isWindows) {
    try {
      execSync(`wmic process where "name='chrome.exe'" get processid`, { stdio: 'pipe' });
      console.log('  Chrome process is running');
    } catch {}
  }

  const wsUrl = await waitForChromeDebugPort(port, 30_000);
  const cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 20_000 });

  return { cdp, chrome };
}

export async function getPageSession(cdp: CdpConnection, urlPattern: string): Promise<ChromeSession> {
  const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
  const pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes(urlPattern));

  if (!pageTarget) throw new Error(`Page not found: ${urlPattern}`);

  const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', {
    targetId: pageTarget.targetId,
    flatten: true,
  });

  await cdp.send('Page.enable', {}, { sessionId });
  await cdp.send('Runtime.enable', {}, { sessionId });
  await cdp.send('DOM.enable', {}, { sessionId });

  return { cdp, sessionId, targetId: pageTarget.targetId };
}

// ─── Interaction Helpers ───

export async function clickElement(session: ChromeSession, selector: string): Promise<void> {
  const posResult = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
    expression: `
      (function() {
        const el = document.querySelector('${selector.replace(/'/g, "\\'")}');
        if (!el) return 'null';
        el.scrollIntoView({ block: 'center' });
        const rect = el.getBoundingClientRect();
        return JSON.stringify({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 });
      })()
    `,
    returnByValue: true,
  }, { sessionId: session.sessionId });

  if (posResult.result.value === 'null') throw new Error(`Element not found: ${selector}`);
  const pos = JSON.parse(posResult.result.value);

  await session.cdp.send('Input.dispatchMouseEvent', {
    type: 'mousePressed', x: pos.x, y: pos.y, button: 'left', clickCount: 1,
  }, { sessionId: session.sessionId });
  await randomDelay(30, 80);
  await session.cdp.send('Input.dispatchMouseEvent', {
    type: 'mouseReleased', x: pos.x, y: pos.y, button: 'left', clickCount: 1,
  }, { sessionId: session.sessionId });
}

export async function typeText(session: ChromeSession, text: string): Promise<void> {
  const lines = text.split('\n');
  for (let i = 0; i < lines.length; i++) {
    if (lines[i].length > 0) {
      await session.cdp.send('Input.insertText', { text: lines[i] }, { sessionId: session.sessionId });
    }
    if (i < lines.length - 1) {
      await session.cdp.send('Input.dispatchKeyEvent', {
        type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13,
      }, { sessionId: session.sessionId });
      await session.cdp.send('Input.dispatchKeyEvent', {
        type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13,
      }, { sessionId: session.sessionId });
    }
    await randomDelay(20, 60);
  }
}

export async function pasteFromClipboard(session: ChromeSession): Promise<void> {
  const modifiers = process.platform === 'darwin' ? 4 : 2; // Meta for Mac, Ctrl for others
  await session.cdp.send('Input.dispatchKeyEvent', {
    type: 'keyDown', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86,
  }, { sessionId: session.sessionId });
  await session.cdp.send('Input.dispatchKeyEvent', {
    type: 'keyUp', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86,
  }, { sessionId: session.sessionId });
}

export async function evaluate<T = unknown>(session: ChromeSession, expression: string): Promise<T> {
  const result = await session.cdp.send<{ result: { value: T } }>('Runtime.evaluate', {
    expression,
    returnByValue: true,
  }, { sessionId: session.sessionId });
  return result.result.value;
}

export async function waitForSelector(session: ChromeSession, selector: string, timeoutMs = 10_000): Promise<boolean> {
  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    const found = await evaluate<boolean>(session, `!!document.querySelector('${selector.replace(/'/g, "\\'")}')`);
    if (found) return true;
    await sleep(500);
  }
  return false;
}

export async function uploadFile(session: ChromeSession, selector: string, filePath: string): Promise<void> {
  const { root } = await session.cdp.send<{ root: { nodeId: number } }>('DOM.getDocument', {}, { sessionId: session.sessionId });
  const { nodeId } = await session.cdp.send<{ nodeId: number }>('DOM.querySelector', {
    nodeId: root.nodeId,
    selector,
  }, { sessionId: session.sessionId });

  if (!nodeId) throw new Error(`Upload input not found: ${selector}`);

  await session.cdp.send('DOM.setFileInputFiles', {
    nodeId,
    files: [filePath],
  }, { sessionId: session.sessionId });
}

// ─── Manifest Types ───

export interface Manifest {
  version: string;
  created: string;
  source: string;
  title: string;
  outputs: {
    xiaohongshu?: {
      html: string;
      images_dir?: string;
      copy: { title: string; body: string; tags: string[] };
    };
    jike?: {
      copy: { body: string; circles: string[] };
    };
    xiaoyuzhou?: {
      audio: string;
      script: string;
      copy: { title: string; description: string; show_notes: string };
    };
    wechat?: {
      markdown: string;
      images?: string[];
      html?: string;
      title?: string;
      author?: string;
      digest?: string;
      cover_image?: string;
    };
    video?: {
      intro?: string;
      outro?: string;
      prompts?: string;
    };
    douyin?: {
      video: string;
      copy: { title: string; description: string; tags: string[] };
    };
    weibo?: {
      copy: { title: string; body: string; tags: string[] };
    };
    chartlet?: {
      images: string[];
      title?: string;
      author?: string;
    };
  };
}

export type PlatformId = 'wechat' | 'xhs' | 'jike' | 'xiaoyuzhou' | 'douyin' | 'shipinhao' | 'weibo' | 'chartlet';

export interface PublishResult {
  platform: PlatformId;
  status: 'success' | 'assisted' | 'manual' | 'skipped' | 'error';
  message: string;
  url?: string;
}

export function loadManifest(manifestPath: string): Manifest {
  if (!fs.existsSync(manifestPath)) {
    throw new Error(`manifest.json not found: ${manifestPath}`);
  }
  return JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
}


path: scripts/distribute/distribute.ts
content: #!/usr/bin/env bun
/**
 * distribute.ts — Main orchestrator for multi-platform content distribution.
 *
 * Usage:
 *   npx -y bun distribute.ts --manifest /path/to/manifest.json [--platforms xhs,jike] [--preview]
 */

import { parseArgs } from 'node:util';
import { execSync } from 'node:child_process';
import { loadManifest, sleep, type PlatformId, type PublishResult } from './cdp-utils.ts';
import { publishToWechat } from './platforms/wechat.ts';
import { publishToXiaohongshu } from './platforms/xiaohongshu.ts';
import { publishToJike } from './platforms/jike.ts';
import { publishToXiaoyuzhou } from './platforms/xiaoyuzhou.ts';
import { publishToDouyin } from './platforms/douyin.ts';
import { publishToShipinhao } from './platforms/shipinhao.ts';
import { publishToWeibo } from './platforms/weibo.ts';
import { publishChartlet } from './platforms/chartlet.ts';

// ─── Parse Args ───

const { values } = parseArgs({
  options: {
    manifest: { type: 'string', short: 'm' },
    platforms: { type: 'string', short: 'p' },
    preview: { type: 'boolean', default: false },
  },
  strict: false,
});

if (!values.manifest) {
  console.error('Usage: distribute.ts --manifest <path> [--platforms chartlet,xhs,jike] [--preview]');
  process.exit(1);
}

// ─── Platform Registry ───

const PLATFORM_ORDER: PlatformId[] = ['wechat', 'chartlet', 'xhs', 'jike', 'xiaoyuzhou', 'douyin', 'shipinhao', 'weibo'];

const PLATFORM_NAMES: Record<PlatformId, string> = {
  wechat: '公众号',
  chartlet: '微贴图',
  xhs: '小红书',
  jike: '即刻',
  xiaoyuzhou: '小宇宙',
  douyin: '抖音',
  shipinhao: '视频号',
  weibo: '微博',
};

const PLATFORM_HANDLERS: Record<PlatformId, (manifest: ReturnType<typeof loadManifest>, preview: boolean) => Promise<PublishResult>> = {
  wechat: publishToWechat,
  chartlet: publishChartlet,
  xhs: publishToXiaohongshu,
  jike: publishToJike,
  xiaoyuzhou: publishToXiaoyuzhou,
  douyin: publishToDouyin,
  shipinhao: publishToShipinhao,
  weibo: publishToWeibo,
};

// ─── Main ───

async function main() {
  const manifest = loadManifest(values.manifest!);
  const preview = values.preview ?? false;

  // Determine which platforms to publish
  let selectedPlatforms: PlatformId[];
  if (values.platforms) {
    selectedPlatforms = values.platforms.split(',').map((p) => p.trim() as PlatformId);
    // Validate
    for (const p of selectedPlatforms) {
      if (!PLATFORM_ORDER.includes(p)) {
        console.error(`Unknown platform: ${p}. Available: ${PLATFORM_ORDER.join(', ')}`);
        process.exit(1);
      }
    }
  } else {
    // Auto-detect from manifest
    selectedPlatforms = PLATFORM_ORDER.filter((p) => {
      switch (p) {
        case 'wechat': return !!manifest.outputs.wechat;
        case 'chartlet': return !!manifest.outputs.chartlet;
        case 'xhs': return !!manifest.outputs.xiaohongshu;
        case 'jike': return !!manifest.outputs.jike;
        case 'xiaoyuzhou': return !!manifest.outputs.xiaoyuzhou;
        case 'douyin': return !!manifest.outputs.douyin;
        case 'shipinhao': return false; // Not yet supported
        case 'weibo': return !!manifest.outputs.weibo;
        default: return false;
      }
    });
  }

  if (selectedPlatforms.length === 0) {
    console.log('No platforms to publish to. Check manifest.json outputs.');
    process.exit(0);
  }

  // Show plan
  console.log(`\n📋 Distribution Plan${preview ? ' (PREVIEW MODE)' : ''}`);
  console.log(`   Source: ${manifest.title}`);
  console.log(`   Platforms: ${selectedPlatforms.map((p) => PLATFORM_NAMES[p]).join(' → ')}\n`);

  // Execute sequentially
  const results: PublishResult[] = [];

  for (const platform of selectedPlatforms) {
    console.log(`\n${'─'.repeat(50)}`);
    console.log(`▶ ${PLATFORM_NAMES[platform]}`);

    try {
      const result = await PLATFORM_HANDLERS[platform](manifest, preview);
      results.push(result);

      const icon = result.status === 'success' ? '✅' :
                   result.status === 'assisted' ? '🔵' :
                   result.status === 'manual' ? '📋' :
                   result.status === 'skipped' ? '⏭️' : '❌';
      console.log(`${icon} ${PLATFORM_NAMES[platform]}: ${result.message}`);
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      results.push({ platform, status: 'error', message });
      console.log(`❌ ${PLATFORM_NAMES[platform]}: ${message}`);
    }

    // Kill Chrome between platforms to avoid port/profile conflicts
    try { execSync('pkill -f "remote-debugging-port" 2>/dev/null', { stdio: 'ignore' }); } catch {}
    await sleep(3000);
  }

  // Summary
  console.log(`\n${'═'.repeat(50)}`);
  console.log('📊 Distribution Summary\n');

  for (const r of results) {
    const icon = r.status === 'success' ? '✅' :
                 r.status === 'assisted' ? '🔵' :
                 r.status === 'manual' ? '📋' :
                 r.status === 'skipped' ? '⏭️' : '❌';
    console.log(`  ${icon} ${PLATFORM_NAMES[r.platform]}: ${r.message}${r.url ? ` → ${r.url}` : ''}`);
  }

  const successCount = results.filter((r) => r.status === 'success' || r.status === 'assisted').length;
  console.log(`\n  ${successCount}/${results.length} platforms published successfully.`);
}

main().catch((err) => {
  console.error('Fatal error:', err);
  process.exit(1);
});


path: scripts/distribute/wechat-api.ts
content: /**
 * WeChat Official Account API client.
 * Pushes articles directly to drafts via the WeChat MP API,
 * bypassing Chrome CDP automation entirely.
 */

import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { Manifest } from './cdp-utils.ts';

// ─── Constants ───

const WECHAT_API_BASE = 'https://api.weixin.qq.com';
// Bun's TLS stack rejects WeChat API certs on POST multipart uploads;
// this option bypasses certificate verification for these API calls.
export const BUN_FETCH_OPTS = { tls: { rejectUnauthorized: false } } as const;
const MAX_RETRIES = 2;
const RETRY_DELAY_MS = 3000;
const CONFIG_DIR = path.join(os.homedir(), '.config', 'wechat-api');
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
const TOKEN_CACHE_FILE = path.join(CONFIG_DIR, 'token-cache.json');
const TOKEN_LIFETIME_MS = 2 * 60 * 60 * 1000; // 2 hours
const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; // refresh 5 min early

const MD_TO_WECHAT_SCRIPT = process.env.MD_TO_WECHAT_SCRIPT || path.join(import.meta.dirname, '../../baoyu-post-to-wechat/scripts/md-to-wechat.ts');

// ─── Types ───

interface Credentials {
  appId: string;
  appSecret: string;
}

interface TokenCache {
  accessToken: string;
  expiresAt: number; // epoch ms
}

interface MdToWechatOutput {
  title: string;
  author?: string;
  summary?: string;
  htmlPath: string;
  contentImages: Array<{
    placeholder: string;
    localPath: string;
    originalPath: string;
  }>;
}

// ─── Retry helper ───

export async function fetchWithRetry(url: string, init?: RequestInit & { tls?: { rejectUnauthorized: boolean } }): Promise<Response> {
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
    try {
      return await fetch(url, init);
    } catch (err) {
      if (attempt < MAX_RETRIES) {
        const msg = err instanceof Error ? err.message : String(err);
        console.log(`  [wechat-api] Request failed (${msg}), retrying in ${RETRY_DELAY_MS / 1000}s... (${attempt + 1}/${MAX_RETRIES})`);
        await new Promise(r => setTimeout(r, RETRY_DELAY_MS));
      } else {
        throw err;
      }
    }
  }
  throw new Error('unreachable');
}

// ─── Credentials ───

export function loadCredentials(): Credentials | null {
  const appId = process.env.WECHAT_APPID?.trim();
  const appSecret = process.env.WECHAT_APPSECRET?.trim();
  if (appId && appSecret) {
    return { appId, appSecret };
  }

  if (fs.existsSync(CONFIG_FILE)) {
    try {
      const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
      if (config.appId && config.appSecret) {
        return { appId: config.appId, appSecret: config.appSecret };
      }
    } catch {}
  }

  return null;
}

// ─── Access Token ───

function readTokenCache(): TokenCache | null {
  if (!fs.existsSync(TOKEN_CACHE_FILE)) return null;
  try {
    const cache = JSON.parse(fs.readFileSync(TOKEN_CACHE_FILE, 'utf-8')) as TokenCache;
    if (cache.accessToken && cache.expiresAt > Date.now() + TOKEN_REFRESH_BUFFER_MS) {
      return cache;
    }
  } catch {}
  return null;
}

function writeTokenCache([REDACTED_SECRET], expiresIn: number): void {
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
  const cache: TokenCache = {
    accessToken: token,
    expiresAt: Date.now() + expiresIn * 1000,
  };
  fs.writeFileSync(TOKEN_CACHE_FILE, JSON.stringify(cache, null, 2));
}

export async function getAccessToken(creds: Credentials): Promise<string> {
  const cached = readTokenCache();
  if (cached) return cached.accessToken;

  const url = `${WECHAT_API_BASE}/cgi-bin/token?grant_type=client_credential&appid=${encodeURIComponent(creds.appId)}&[REDACTED_SECRET])}`;
  const res = await fetchWithRetry(url, { ...BUN_FETCH_OPTS });
  if (!res.ok) throw new Error(`Failed to get access_token: HTTP ${res.status}`);

  const data = await res.json() as { access_token?: string; expires_in?: number; errcode?: number; errmsg?: string };
  if (data.errcode && data.errcode !== 0) {
    throw new Error(`WeChat API error ${data.errcode}: ${data.errmsg}`);
  }
  if (!data.access_token || !data.expires_in) {
    throw new Error('Invalid access_token response');
  }

  writeTokenCache(data.access_token, data.expires_in);
  console.log('  [wechat-api] Access token obtained');
  return data.access_token;
}

// ─── Image Upload ───

export async function uploadContentImage([REDACTED_SECRET], imagePath: string): Promise<string> {
  const fileData = fs.readFileSync(imagePath);
  const ext = path.extname(imagePath).slice(1) || 'png';
  const mimeType = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`;
  const fileName = path.basename(imagePath);

  const form = new FormData();
  form.append('media', new Blob([fileData], { type: mimeType }), fileName);

  const url = `${WECHAT_API_BASE}/cgi-bin/media/uploadimg?access_token=${token}`;
  const res = await fetchWithRetry(url, { method: 'POST', body: form, ...BUN_FETCH_OPTS });
  if (!res.ok) throw new Error(`uploadimg failed: HTTP ${res.status}`);

  const data = await res.json() as { url?: string; errcode?: number; errmsg?: string };
  if (data.errcode && data.errcode !== 0) {
    throw new Error(`uploadimg error ${data.errcode}: ${data.errmsg}`);
  }
  if (!data.url) throw new Error('uploadimg: no URL returned');

  return data.url;
}

export async function uploadCoverImage([REDACTED_SECRET], imagePath: string): Promise<string> {
  const fileData = fs.readFileSync(imagePath);
  const ext = path.extname(imagePath).slice(1) || 'png';
  const mimeType = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`;
  const fileName = path.basename(imagePath);

  const form = new FormData();
  form.append('media', new Blob([fileData], { type: mimeType }), fileName);

  const url = `${WECHAT_API_BASE}/cgi-bin/material/add_material?access_token=${token}&type=image`;
  const res = await fetchWithRetry(url, { method: 'POST', body: form, ...BUN_FETCH_OPTS });
  if (!res.ok) throw new Error(`add_material failed: HTTP ${res.status}`);

  const data = await res.json() as { media_id?: string; errcode?: number; errmsg?: string };
  if (data.errcode && data.errcode !== 0) {
    throw new Error(`add_material error ${data.errcode}: ${data.errmsg}`);
  }
  if (!data.media_id) throw new Error('add_material: no media_id returned');

  return data.media_id;
}

// ─── Markdown Conversion ───

export function convertMarkdown(markdownPath: string): MdToWechatOutput {
  console.log('  [wechat-api] Converting markdown to HTML...');

  const scriptDir = path.dirname(MD_TO_WECHAT_SCRIPT);
  const result = spawnSync('npx', ['-y', 'bun', MD_TO_WECHAT_SCRIPT, markdownPath, '--theme', 'grace'], {
    cwd: scriptDir,
    timeout: 60_000,
    encoding: 'utf-8',
  });

  if (result.status !== 0) {
    const stderr = result.stderr?.trim() || '';
    throw new Error(`md-to-wechat failed (exit ${result.status})${stderr ? `: ${stderr}` : ''}`);
  }

  const stdout = result.stdout?.trim();
  if (!stdout) throw new Error('md-to-wechat produced no output');

  // The script outputs JSON to stdout
  const output = JSON.parse(stdout) as MdToWechatOutput;
  if (!output.htmlPath || !fs.existsSync(output.htmlPath)) {
    throw new Error(`HTML file not found: ${output.htmlPath}`);
  }

  return output;
}

// ─── HTML Processing ───

export function extractArticleContent(htmlPath: string, inlineCss = false): { content: string; styles: string } {
  const html = fs.readFileSync(htmlPath, 'utf-8');

  // Extract <style> blocks
  const styleMatches = html.match(/<style[^>]*>([\s\S]*?)<\/style>/gi) || [];
  const styles = styleMatches.map(s => {
    const inner = s.replace(/<\/?style[^>]*>/gi, '');
    return inner.trim();
  }).join('\n');

  // Priority: #output → .content → <body>

  // 1. Extract #output content (md-to-wechat output)
  const outputMatch = html.match(/<div[^>]*id=["']output["'][^>]*>([\s\S]*?)<\/div>\s*(?:<\/body>|<script|$)/i);
  if (outputMatch) {
    const content = stripTipElements(outputMatch[1].trim());
    return inlineCss ? { content: inlineCssStyles(content, styles), styles: '' } : { content, styles };
  }

  // 2. Extract .container div (md2wechat_formatter _preview.html uses .container)
  const containerMatch = html.match(/<div[^>]*class=["'][^"']*\bcontainer\b[^"']*["'][^>]*>([\s\S]*?)<\/div>\s*<\/body>/i);
  if (containerMatch) {
    const content = stripTipElements(containerMatch[1].trim());
    return inlineCss ? { content: inlineCssStyles(content, styles), styles: '' } : { content, styles };
  }

  // 2b. Extract .content container (md2wechat_formatter _preview.html)
  const contentMatch = html.match(/<div[^>]*class=["'][^"']*\bcontent\b[^"']*["'][^>]*>([\s\S]*?)<\/div>\s*(?:<\/body>|<script|$)/i);
  if (contentMatch) {
    const content = stripTipElements(contentMatch[1].trim());
    return inlineCss ? { content: inlineCssStyles(content, styles), styles: '' } : { content, styles };
  }

  // 3. Fallback: extract <body> content
  const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
  if (bodyMatch) {
    const content = stripTipElements(bodyMatch[1].trim());
    return inlineCss ? { content: inlineCssStyles(content, styles), styles: '' } : { content, styles };
  }

  throw new Error('Could not extract article content from HTML');
}

/** Remove .tip elements (e.g. "全选复制粘贴到公众号编辑器" hint bars) */
function stripTipElements(html: string): string {
  return html.replace(/<div[^>]*class=["'][^"']*\btip\b[^"']*["'][^>]*>[\s\S]*?<\/div>/gi, '').trim();
}

interface CssRule {
  selector: string;
  declarations: Map<string, string>;
}

function parseCssRules(css: string): CssRule[] {
  const rules: CssRule[] = [];
  const ruleBlocks = css.match(/[^{}]+\{[^{}]*\}/g) || [];
  for (const block of ruleBlocks) {
    const colonIdx = block.indexOf('{');
    if (colonIdx === -1) continue;
    const selector = block.slice(0, colonIdx).trim();
    const declarations = block.slice(colonIdx + 1, block.lastIndexOf('}')).trim();
    const declMap = new Map<string, string>();
    for (const decl of declarations.split(';')) {
      const colon = decl.indexOf(':');
      if (colon === -1) continue;
      const prop = decl.slice(0, colon).trim().toLowerCase();
      const value = decl.slice(colon + 1).trim();
      if (prop && value) declMap.set(prop, value);
    }
    if (declMap.size > 0) rules.push({ selector, declarations: declMap });
  }
  return rules;
}

function cssSelectorMatches(selector: string, tagName: string, classes: string[], id: string): boolean {
  const parts = selector.trim().split(/\s+/);
  let lastPart = parts[parts.length - 1];
  if (lastPart === '*') return true;
  const tagMatch = lastPart.match(/^([a-zA-Z][a-zA-Z0-9]*)/);
  if (tagMatch && tagMatch[1].toLowerCase() !== tagName.toLowerCase()) return false;
  const classMatches = [...lastPart.matchAll(/\.([a-zA-Z_-][a-zA-Z0-9_-]*)/g)].map(m => m[1]);
  for (const cls of classMatches) {
    if (!classes.includes(cls)) return false;
  }
  const idMatch = lastPart.match(/#([a-zA-Z_-][a-zA-Z0-9_-]*)/);
  if (idMatch && idMatch[1] !== id) return false;
  return true;
}

function inlineCss(html: string, rules: CssRule[]): string {
  return html.replace(/<([a-zA-Z][a-zA-Z0-9]*)\b([^>]*)>/g, (_match, tag, attrs) => {
    const existingStyleMatch = attrs.match(/style=["']([^"']*)["']/);
    // If element already has inline styles, preserve them (md-to-wechat already inlined)
    if (existingStyleMatch) {
      return `<${tag}${attrs}>`;
    }
    
    const classMatch = attrs.match(/class=["']([^"']*)["']/);
    const idMatch = attrs.match(/id=["']([^"']*)["']/);
    const classes = classMatch ? classMatch[1].split(/\s+/).filter(Boolean) : [];
    const id = idMatch ? idMatch[1] : '';
    const matchedStyles: string[] = [];
    for (const rule of rules) {
      if (cssSelectorMatches(rule.selector, tag, classes, id)) {
        for (const [prop, value] of rule.declarations) {
          matchedStyles.push(`${prop}:${value}`);
        }
      }
    }
    const newAttrs = attrs.replace(/\s*style=["'][^"']*["']/g, '');
    if (matchedStyles.length > 0) {
      return `<${tag}${newAttrs} style="${matchedStyles.join(';')}">`;
    }
    return `<${tag}${newAttrs}>`;
  });
}

export function inlineCssStyles(html: string, css: string): string {
  if (!css) return html;
  const rules = parseCssRules(css);
  return inlineCss(html, rules);
}

export function processHtmlWithImages(
  content: string,
  styles: string,
  imageMap: Map<string, string>,
): string {
  let processed = content;

  for (const [placeholder, cdnUrl] of imageMap) {
    processed = processed.replaceAll(
      placeholder,
      `<img src="${cdnUrl}" style="max-width: 100%; height: auto;" />`,
    );
  }

  if (styles) {
    processed = inlineCssStyles(processed, styles);
  }
  return processed;
}

// ─── Draft Creation ───

export async function createDraft(
  [REDACTED_SECRET],
  article: {
    title: string;
    content: string;
    author?: string;
    digest?: string;
    thumb_media_id: string;
  },
): Promise<{ media_id: string }> {
  const url = `${WECHAT_API_BASE}/cgi-bin/draft/add?access_token=${token}`;

  const body = {
    articles: [
      {
        title: article.title,
        author: article.author || '',
        digest: article.digest || '',
        content: article.content,
        thumb_media_id: article.thumb_media_id,
        content_source_url: '',
        need_open_comment: 0,
        only_fans_can_comment: 0,
      },
    ],
  };

  const res = await fetchWithRetry(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
    ...BUN_FETCH_OPTS,
  });

  if (!res.ok) throw new Error(`draft/add failed: HTTP ${res.status}`);

  const data = await res.json() as { media_id?: string; errcode?: number; errmsg?: string };
  if (data.errcode && data.errcode !== 0) {
    throw new Error(`draft/add error ${data.errcode}: ${data.errmsg}`);
  }
  if (!data.media_id) throw new Error('draft/add: no media_id returned');

  return { media_id: data.media_id };
}

// ─── Main Orchestrator ───

export async function publishViaApi(manifest: Manifest): Promise<{ mediaId: string }> {
  const wechatData = manifest.outputs.wechat;
  if (!wechatData?.markdown) throw new Error('No wechat markdown in manifest');

  // 1. Load credentials
  const creds = loadCredentials();
  if (!creds) throw new Error('No WeChat API credentials configured');

  console.log('  [wechat-api] Starting API publish flow...');

  // 2. Get access token
  const token = await getAccessToken(creds);

  // 3. Convert markdown to HTML (unless pre-rendered HTML provided)
  let title: string;
  let author: string | undefined;
  let digest: string | undefined;
  let htmlContent: string;
  let styles = '';
  let contentImages: MdToWechatOutput['contentImages'] = [];

  if (wechatData.html && fs.existsSync(wechatData.html)) {
    // Use pre-rendered HTML — inline all CSS into element styles
    console.log('  [wechat-api] Using pre-rendered HTML');
    const extracted = extractArticleContent(wechatData.html, true);
    htmlContent = extracted.content;
    styles = extracted.styles;
    title = wechatData.title || manifest.title;
    author = wechatData.author;
    digest = wechatData.digest;
  } else {
    // Convert markdown
    const mdOutput = convertMarkdown(wechatData.markdown);
    const extracted = extractArticleContent(mdOutput.htmlPath, true);
    htmlContent = extracted.content;
    styles = extracted.styles;
    title = wechatData.title || mdOutput.title || manifest.title;
    author = wechatData.author || mdOutput.author;
    digest = wechatData.digest || mdOutput.summary;
    contentImages = mdOutput.contentImages || [];
  }

  // 4. Upload content images and build placeholder→URL map
  const imageMap = new Map<string, string>();
  if (contentImages.length > 0) {
    console.log(`  [wechat-api] Uploading ${contentImages.length} content image(s)...`);
    for (const img of contentImages) {
      if (!fs.existsSync(img.localPath)) {
        console.warn(`  [wechat-api] Image not found, skipping: ${img.localPath}`);
        continue;
      }
      try {
        const cdnUrl = await uploadContentImage(token, img.localPath);
        imageMap.set(img.placeholder, cdnUrl);
        console.log(`  [wechat-api]   ✓ ${path.basename(img.localPath)}`);
      } catch (err) {
        console.warn(`  [wechat-api]   ✗ ${path.basename(img.localPath)}: ${err instanceof Error ? err.message : err}`);
      }
    }
  }

  // 5. Process HTML with uploaded image URLs
  const finalContent = processHtmlWithImages(htmlContent, styles, imageMap);

  // 6. Upload cover image (required for draft)
  let thumbMediaId: string;
  const coverPath = wechatData.cover_image;

  if (coverPath && fs.existsSync(coverPath)) {
    console.log('  [wechat-api] Uploading cover image...');
    thumbMediaId = await uploadCoverImage(token, coverPath);
  } else if (wechatData.images && wechatData.images.length > 0 && fs.existsSync(wechatData.images[0])) {
    // Fallback: use first content image as cover
    console.log('  [wechat-api] No cover image specified, using first content image...');
    thumbMediaId = await uploadCoverImage(token, wechatData.images[0]);
  } else if (contentImages.length > 0 && fs.existsSync(contentImages[0].localPath)) {
    console.log('  [wechat-api] No cover image specified, using first article image...');
    thumbMediaId = await uploadCoverImage(token, contentImages[0].localPath);
  } else {
    throw new Error('No cover image available. Provide cover_image in manifest or ensure article has images.');
  }

  // 7. Create draft
  console.log('  [wechat-api] Creating draft...');
  const draft = await createDraft(token, {
    title,
    content: finalContent,
    author,
    digest,
    thumb_media_id: thumbMediaId,
  });

  console.log(`  [wechat-api] Draft created successfully (media_id: ${draft.media_id})`);
  return { mediaId: draft.media_id };
}


path: scripts/distribute/platforms/chartlet.ts
content: /**
 * WeChat Chartlet (图片消息/贴图) publisher.
 *
 * Chartlet = 长图形式的图片消息，适合微贴图轮播图等内容
 *
 * Degradation strategy:
 *   L0: WeChat API → push directly to drafts (no browser needed)
 *   L1: CDP automation → Chrome-based
 *   L3: Manual → output file path for copy-paste
 */

import fs from 'node:fs';
import path from 'node:path';
import type { Manifest, PublishResult } from '../cdp-utils.ts';
import { loadCredentials, getAccessToken, uploadContentImage, fetchWithRetry, BUN_FETCH_OPTS } from '../wechat-api.ts';

const WECHAT_API_BASE = 'https://api.weixin.qq.com';

export async function publishChartlet(manifest: Manifest, preview: boolean): Promise<PublishResult> {
  const chartletData = manifest.outputs.chartlet;
  if (!chartletData) {
    return { platform: 'chartlet', status: 'skipped', message: 'No chartlet content in manifest' };
  }

  if (!chartletData.images || chartletData.images.length === 0) {
    return { platform: 'chartlet', status: 'manual', message: 'No images provided for chartlet' };
  }

  const existingImages: string[] = [];
  for (const img of chartletData.images) {
    if (fs.existsSync(img)) {
      existingImages.push(img);
    }
  }

  if (existingImages.length === 0) {
    return { platform: 'chartlet', status: 'manual', message: 'No valid image files found' };
  }

  // L0: Try WeChat API direct push
  if (!preview) {
    try {
      const result = await publishChartletViaApi(manifest, existingImages);
      return {
        platform: 'chartlet',
        status: 'success',
        message: `Chartlet pushed to drafts via API (media_id: ${result.mediaId})`,
      };
    } catch (err) {
      const reason = err instanceof Error ? err.message : String(err);
      console.log(`  [chartlet] API mode failed, falling back: ${reason}`);
    }
  }

  // L1/L3: Manual fallback - output image paths for user to upload manually
  return {
    platform: 'chartlet',
    status: preview ? 'manual' : 'manual',
    message: `Please upload these images to WeChat manually:\n${existingImages.map(p => `  - ${path.basename(p)}`).join('\n')}`,
  };
}

interface ChartletDraftResult {
  mediaId: string;
}

async function publishChartletViaApi(manifest: Manifest, imagePaths: string[]): Promise<ChartletDraftResult> {
  const chartletData = manifest.outputs.chartlet!;
  
  // 1. Load credentials
  const creds = loadCredentials();
  if (!creds) throw new Error('No WeChat API credentials configured');

  console.log('  [chartlet] Starting API publish flow...');

  // 2. Get access token
  const token = await getAccessToken(creds);

  // 3. Upload images and build content with img tags
  console.log(`  [chartlet] Uploading ${imagePaths.length} image(s)...`);
  const uploadedUrls: string[] = [];

  for (const imgPath of imagePaths) {
    try {
      const url = await uploadContentImage(token, imgPath);
      uploadedUrls.push(url);
      console.log(`  [chartlet]   ✓ ${path.basename(imgPath)}`);
    } catch (err) {
      console.warn(`  [chartlet]   ✗ ${path.basename(imgPath)}: ${err instanceof Error ? err.message : err}`);
    }
  }

  if (uploadedUrls.length === 0) {
    throw new Error('Failed to upload any images');
  }

  // 4. Build content - embed images as single column
  const title = chartletData.title || manifest.title;
  const author = chartletData.author || 'oddmeta';
  
  // For chartlet, we create a simple HTML with images stacked
  const content = buildChartletContent(uploadedUrls);

  // 5. Create draft with chartlet content
  console.log('  [chartlet] Creating chartlet draft...');
  const draft = await createChartletDraft(token, {
    title,
    author,
    content,
  });

  console.log(`  [chartlet] Draft created successfully (media_id: ${draft.media_id})`);
  return { mediaId: draft.media_id };
}

function buildChartletContent(imageUrls: string[]): string {
  // Build HTML content with images stacked vertically
  // Each image takes full width
  const imagesHtml = imageUrls
    .map(url => `<img src="${url}" style="width:100%;display:block;" />`)
    .join('');
  
  return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body>
${imagesHtml}
</body>
</html>`;
}

async function createChartletDraft(
  [REDACTED_SECRET],
  article: {
    title: string;
    author: string;
    content: string;
  },
): Promise<{ media_id: string }> {
  const url = `${WECHAT_API_BASE}/cgi-bin/draft/add?access_token=${token}`;

  // For chartlet, we use the same draft API but with image-based content
  // The content_source_url is required but can be empty
  const body = {
    articles: [
      {
        title: article.title,
        author: article.author,
        digest: '',
        content: article.content,
        content_source_url: '',
        thumb_media_id: '', // Chartlet doesn't need cover
        need_open_comment: 0,
        only_fans_can_comment: 0,
      },
    ],
  };

  const res = await fetchWithRetry(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
    ...BUN_FETCH_OPTS,
  });

  if (!res.ok) throw new Error(`draft/add failed: HTTP ${res.status}`);

  const data = await res.json() as { media_id?: string; errcode?: number; errmsg?: string };
  if (data.errcode && data.errcode !== 0) {
    throw new Error(`draft/add error ${data.errcode}: ${data.errmsg}`);
  }
  if (!data.media_id) throw new Error('draft/add: no media_id returned');

  return { media_id: data.media_id };
}


path: scripts/distribute/platforms/douyin.ts
content: /**
 * Douyin (抖音) publisher via Chrome CDP.
 * Opens creator.douyin.com, uploads video, fills description.
 * EXPERIMENTAL: Douyin has aggressive anti-automation.
 */

import fs from 'node:fs';
import {
  launchChrome, getPageSession, clickElement, typeText, evaluate,
  waitForSelector, uploadFile, sleep, randomDelay,
  type Manifest, type PublishResult, type ChromeSession,
} from '../cdp-utils.ts';

const DOUYIN_URL = 'https://creator.douyin.com/creator-micro/content/upload';

const SELECTORS = {
  videoUpload: 'input[type="file"][accept*="video"]',
  titleInput: 'input[placeholder*="标题"], input[class*="title"]',
  descriptionEditor: '[contenteditable="true"], textarea[placeholder*="描述"], div[class*="editor"]',
  tagInput: 'input[placeholder*="话题"], input[class*="topic"]',
  publishBtn: 'button:has-text("发布"), button[class*="publish"]',
  loginIndicator: 'img[class*="avatar"]',
};

export async function publishToDouyin(manifest: Manifest, preview: boolean): Promise<PublishResult> {
  const douyinData = manifest.outputs.douyin;
  if (!douyinData) {
    return { platform: 'douyin', status: 'skipped', message: 'No Douyin content in manifest' };
  }

  if (!fs.existsSync(douyinData.video)) {
    return {
      platform: 'douyin',
      status: 'manual',
      message: `Video file not found: ${douyinData.video}. Upload manually.`,
    };
  }

  let launchResult;
  try {
    launchResult = await launchChrome(DOUYIN_URL, 'douyin');
  } catch (err) {
    return {
      platform: 'douyin',
      status: 'manual',
      message: `Chrome launch failed. Upload ${douyinData.video} to Douyin manually.`,
    };
  }

  const { cdp, chrome } = launchResult;

  try {
    await sleep(5000); // Douyin loads slowly

    let session: ChromeSession;
    try {
      session = await getPageSession(cdp, 'douyin.com');
    } catch {
      return {
        platform: 'douyin',
        status: 'assisted',
        message: 'Page opened. Please log in to Douyin creator, then retry.',
      };
    }

    // Check login
    const currentUrl = await evaluate<string>(session, 'window.location.href');
    if (currentUrl.includes('login')) {
      return {
        platform: 'douyin',
        status: 'assisted',
        message: 'Login required. Please scan QR to log in to Douyin, then run /distribute again.',
      };
    }

    // Upload video
    const hasUpload = await waitForSelector(session, SELECTORS.videoUpload, 8_000);
    if (hasUpload) {
      await uploadFile(session, SELECTORS.videoUpload, douyinData.video);
      console.log(`  Video uploaded: ${douyinData.video}`);
      await sleep(10000); // Video processing takes time
    }

    // Fill title
    const hasTitle = await waitForSelector(session, SELECTORS.titleInput, 5_000);
    if (hasTitle) {
      await clickElement(session, SELECTORS.titleInput);
      await randomDelay(300, 600);
      await typeText(session, douyinData.copy.title);
    }

    await randomDelay(300, 600);

    // Fill description
    const hasDesc = await waitForSelector(session, SELECTORS.descriptionEditor, 5_000);
    if (hasDesc) {
      await clickElement(session, SELECTORS.descriptionEditor);
      await randomDelay();
      await typeText(session, douyinData.copy.description);
    }

    // Add tags
    for (const tag of douyinData.copy.tags) {
      const hasTag = await waitForSelector(session, SELECTORS.tagInput, 3_000);
      if (hasTag) {
        await clickElement(session, SELECTORS.tagInput);
        await randomDelay(100, 300);
        await typeText(session, tag.replace(/^#/, ''));
        await sleep(500);
        await session.cdp.send('Input.dispatchKeyEvent', {
          type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13,
        }, { sessionId: session.sessionId });
        await session.cdp.send('Input.dispatchKeyEvent', {
          type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13,
        }, { sessionId: session.sessionId });
        await randomDelay(300, 600);
      }
    }

    if (!preview) {
      const hasPublish = await waitForSelector(session, SELECTORS.publishBtn, 5_000);
      if (hasPublish) {
        await randomDelay(500, 1000);
        await clickElement(session, SELECTORS.publishBtn);
        await sleep(5000);
        return { platform: 'douyin', status: 'success', message: 'Published to Douyin' };
      }
      return { platform: 'douyin', status: 'assisted', message: 'Content filled, publish manually.' };
    }

    return { platform: 'douyin', status: 'assisted', message: 'Content pre-filled in Douyin editor.' };
  } catch (err) {
    return {
      platform: 'douyin',
      status: 'manual',
      message: `CDP error (Douyin anti-automation likely): ${err instanceof Error ? err.message : String(err)}`,
    };
  } finally {
    cdp.close();
  }
}


path: scripts/distribute/platforms/jike.ts
content: /**
 * Jike (即刻) publisher via Chrome CDP.
 * Opens web.okjike.com, fills in post content.
 */

import {
  launchChrome, getPageSession, clickElement, typeText, evaluate,
  waitForSelector, sleep, randomDelay,
  type Manifest, type PublishResult, type ChromeSession,
} from '../cdp-utils.ts';

const JIKE_URL = 'https://web.okjike.com/';

const SELECTORS = {
  composeBtn: 'button[class*="compose"], a[href*="compose"], div[class*="ComposeButton"]',
  contentEditor: '[contenteditable="true"], textarea[placeholder*="分享"], .ql-editor',
  publishBtn: 'button[class*="submit"], button[class*="publish"], button:has-text("发布")',
  loginIndicator: 'img[class*="avatar"], div[class*="Avatar"]',
};

export async function publishToJike(manifest: Manifest, preview: boolean): Promise<PublishResult> {
  const jikeData = manifest.outputs.jike;
  if (!jikeData) {
    return { platform: 'jike', status: 'skipped', message: 'No Jike content in manifest' };
  }

  let launchResult;
  try {
    launchResult = await launchChrome(JIKE_URL, 'jike');
  } catch (err) {
    return {
      platform: 'jike',
      status: 'manual',
      message: `Chrome launch failed. Copy your Jike post manually:\n${jikeData.copy.body.substring(0, 100)}...`,
    };
  }

  const { cdp, chrome } = launchResult;

  try {
    await sleep(4000); // Jike loads slowly

    let session: ChromeSession;
    try {
      session = await getPageSession(cdp, 'okjike.com');
    } catch {
      return {
        platform: 'jike',
        status: 'assisted',
        message: 'Page opened. Please log in to Jike, then retry.',
      };
    }

    // Check login
    const isLoggedIn = await evaluate<boolean>(session, `!!document.querySelector('${SELECTORS.loginIndicator}')`);
    if (!isLoggedIn) {
      const currentUrl = await evaluate<string>(session, 'window.location.href');
      if (currentUrl.includes('login')) {
        return {
          platform: 'jike',
          status: 'assisted',
          message: 'Login required. Please log in to Jike, then run /distribute again.',
        };
      }
    }

    // Click compose button to open editor
    const hasCompose = await waitForSelector(session, SELECTORS.composeBtn, 5_000);
    if (hasCompose) {
      await clickElement(session, SELECTORS.composeBtn);
      await sleep(1500);
    }

    // Fill content
    const hasEditor = await waitForSelector(session, SELECTORS.contentEditor, 5_000);
    if (hasEditor) {
      await clickElement(session, SELECTORS.contentEditor);
      await randomDelay();
      await typeText(session, jikeData.copy.body);
      console.log(`  Content filled (${jikeData.copy.body.length} chars)`);
    } else {
      return {
        platform: 'jike',
        status: 'assisted',
        message: 'Editor not found. Jike is open, paste manually.',
      };
    }

    if (!preview) {
      const hasPublish = await waitForSelector(session, SELECTORS.publishBtn, 5_000);
      if (hasPublish) {
        await randomDelay(500, 1000);
        await clickElement(session, SELECTORS.publishBtn);
        await sleep(3000);
        return { platform: 'jike', status: 'success', message: 'Published to Jike' };
      }
      return { platform: 'jike', status: 'assisted', message: 'Content filled, publish button not found. Please publish manually.' };
    }

    return {
      platform: 'jike',
      status: 'assisted',
      message: `Content pre-filled in Jike editor. Circles: ${jikeData.copy.circles.join(', ')}`,
    };
  } catch (err) {
    return {
      platform: 'jike',
      status: 'manual',
      message: `CDP error: ${err instanceof Error ? err.message : String(err)}`,
    };
  } finally {
    cdp.close();
  }
}


path: scripts/distribute/platforms/shipinhao.ts
content: /**
 * Shipinhao (视频号) publisher.
 * Currently NOT supported - no web creator backend.
 * Falls back to manual mode with file paths.
 */

import type { Manifest, PublishResult } from '../cdp-utils.ts';

export async function publishToShipinhao(manifest: Manifest, _preview: boolean): Promise<PublishResult> {
  // 视频号 currently has no web-based creator platform.
  // Future: consider mobile-based automation or API integration.

  const videoData = manifest.outputs.video;
  if (!videoData) {
    return { platform: 'shipinhao', status: 'skipped', message: 'No video content in manifest' };
  }

  const paths: string[] = [];
  if (videoData.intro) paths.push(`Intro: ${videoData.intro}`);
  if (videoData.outro) paths.push(`Outro: ${videoData.outro}`);
  if (videoData.prompts) paths.push(`Prompts: ${videoData.prompts}`);

  return {
    platform: 'shipinhao',
    status: 'manual',
    message: `视频号 has no web creator backend. Upload via mobile app.\n  ${paths.join('\n  ')}`,
  };
}


path: scripts/distribute/platforms/wechat.ts
content: /**
 * WeChat Official Account (公众号) publisher.
 *
 * Degradation strategy:
 *   L0: WeChat API → push directly to drafts (no browser needed)
 *   L1: CDP automation → Chrome-based (existing logic)
 *   L3: Manual → output file path for copy-paste
 */

import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import type { Manifest, PublishResult } from '../cdp-utils.ts';
import { publishViaApi } from '../wechat-api.ts';

const BAOYU_WECHAT_SKILL_DIR = process.env.BAOYU_WECHAT_SKILL_DIR || '';

export async function publishToWechat(manifest: Manifest, preview: boolean): Promise<PublishResult> {
  const wechatData = manifest.outputs.wechat;
  if (!wechatData) {
    return { platform: 'wechat', status: 'skipped', message: 'No WeChat content in manifest' };
  }

  if (!wechatData.markdown || !fs.existsSync(wechatData.markdown)) {
    return { platform: 'wechat', status: 'manual', message: `Markdown file not found: ${wechatData.markdown}` };
  }

  // L0: Try WeChat API direct push (skip in preview mode — user wants the editor)
  if (!preview) {
    try {
      const result = await publishViaApi(manifest);
      return {
        platform: 'wechat',
        status: 'success',
        message: `Article pushed to drafts via API (media_id: ${result.mediaId})`,
      };
    } catch (err) {
      const reason = err instanceof Error ? err.message : String(err);
      console.log(`  [wechat] API mode failed, falling back to CDP: ${reason}`);
    }
  }

  // L1: CDP automation fallback
  const scriptPath = path.join(BAOYU_WECHAT_SKILL_DIR, 'scripts', 'wechat-article.ts');
  if (!fs.existsSync(scriptPath)) {
    return { platform: 'wechat', status: 'manual', message: `baoyu-post-to-wechat script not found: ${scriptPath}` };
  }

  const args = [scriptPath, wechatData.markdown, '--theme', 'grace'];
  if (!preview) {
    args.push('--submit');
  }

  console.log(`  Running: npx -y bun ${args.join(' ')}`);

  const result = spawnSync('npx', ['-y', 'bun', ...args], {
    stdio: 'inherit',
    timeout: 120_000,
  });

  if (result.status === 0) {
    return {
      platform: 'wechat',
      status: preview ? 'assisted' : 'success',
      message: preview ? 'Article opened in WeChat editor' : 'Article published to WeChat',
    };
  }

  // L3: Manual fallback
  return {
    platform: 'wechat',
    status: 'manual',
    message: `Script failed (exit ${result.status}). Markdown file: ${wechatData.markdown}`,
  };
}


path: scripts/distribute/platforms/weibo.ts
content: /**
 * Weibo (微博) publisher via Chrome CDP.
 * Opens weibo.com/compose, fills in post content.
 */

import {
  launchChrome, getPageSession, clickElement, typeText, evaluate,
  waitForSelector, sleep, randomDelay,
  type Manifest, type PublishResult, type ChromeSession,
} from '../cdp-utils.ts';

const WEIBO_URL = 'https://weibo.com';
const FALLBACK_CDP_PORT = 9222;

const SELECTORS = {
  composeArea: '[contenteditable="true"], textarea.W_input, div.W_editor, div[node-type="text"], div.placeholder',
  publishBtn: 'a[node-type="publish"], button:has-text("发布"), div[node-type="publish"], a.W_btn_a[node-type="publish"]',
  loginIndicator: 'a[href*="setting"], div[class*="user-card"], img[class*="avatar"], div[class*="user-name"]',
  titleInput: 'input[name="title"], input[placeholder*="标题"]',
  navCompose: 'a[href*="compose"], a[href*="/publish"], div:has-text("发布微博")',
  weiboHome: 'div[node-type="feed_list"], div[class*="Feed"]',
  sendBtn: 'a.W_btn_a[node-type="submit"], a[node-type="submit"]',
};

export async function publishToWeibo(manifest: Manifest, preview: boolean): Promise<PublishResult> {
  const weiboData = manifest.outputs.weibo;
  if (!weiboData) {
    return { platform: 'weibo', status: 'skipped', message: 'No Weibo content in manifest' };
  }

  let launchResult;
  try {
    launchResult = await launchChrome(WEIBO_URL, 'weibo');
  } catch (err) {
    const errMsg = err instanceof Error ? err.message : String(err);
    console.error(`  [weibo] Launch error: ${errMsg}`);
    return {
      platform: 'weibo',
      status: 'manual',
      message: `Chrome launch failed: ${errMsg}\n\nCopy your Weibo post manually:\n${weiboData.copy.body.substring(0, 100)}...`,
    };
  }

  const { cdp, chrome } = launchResult;

  try {
    await sleep(4000);

    let session: ChromeSession;
    try {
      session = await getPageSession(cdp, 'weibo.com');
    } catch {
      return {
        platform: 'weibo',
        status: 'assisted',
        message: 'Page opened. Please log in to Weibo, then retry.',
      };
    }

    // Check if logged in - look for user avatar or settings
    const isLoggedIn = await evaluate<boolean>(session, `
      !!(
        document.querySelector('a[href*="setting"]') ||
        document.querySelector('div[class*="user-card"]') ||
        document.querySelector('img[class*="avatar"]')
      )
    `);
    
    if (!isLoggedIn) {
      const currentUrl = await evaluate<string>(session, 'window.location.href');
      if (currentUrl.includes('login') || currentUrl.includes('passport')) {
        return {
          platform: 'weibo',
          status: 'assisted',
          message: 'Login required. Please log in to Weibo, then run /distribute again.',
        };
      }
    }

    // Try to find and click compose button on the home page
    const composeBtnSelectors = [
      'a[href*="/compose"]',
      'a[href*="compose"]', 
      'div[node-type="publish"]',
      'a[node-type="publish"]',
      'div:has-text("发布微博")',
      'a:has-text("发微博")',
      'span:has-text("发微博")'
    ];
    
    console.log('  Looking for compose button...');
    for (const sel of composeBtnSelectors) {
      const hasBtn = await evaluate<boolean>(session, `!!document.querySelector('${sel.replace(/'/g, "\\'")}')`);
      if (hasBtn) {
        console.log(`  Found: ${sel}, clicking...`);
        await clickElement(session, sel);
        await sleep(3000);
        break;
      }
    }
    
    // Debug: get all textareas and editable elements
    const inputs = await evaluate<any[]>(session, `
      Array.from(document.querySelectorAll('textarea, [contenteditable="true"], input')).map(el => ({
        tag: el.tagName,
        id: el.id,
        class: el.className?.substring(0, 50),
        placeholder: el.getAttribute('placeholder')?.substring(0, 30)
      }))
    `);
    console.log(`  Found ${inputs.length} input elements:`, inputs.slice(0, 5));
    
    // Try to find compose area
    const hasEditor = await waitForSelector(session, SELECTORS.composeArea, 10_000);
    if (hasEditor) {
      await clickElement(session, SELECTORS.composeArea);
      await randomDelay();
      
      // Build content: title (if any) + body + tags
      const fullContent = weiboData.copy.body + '\n\n' + weiboData.copy.tags.map(t => '#' + t).join(' ');
      await typeText(session, fullContent);
      console.log(`  Content filled (${fullContent.length} chars)`);
    } else {
      // Try alternative: look for any textarea or editable area
      const hasTextArea = await waitForSelector(session, 'textarea, [contenteditable="true"]', 5_000);
      if (hasTextArea) {
        await clickElement(session, 'textarea, [contenteditable="true"]');
        await randomDelay();
        const fullContent = weiboData.copy.body + '\n\n' + weiboData.copy.tags.map(t => '#' + t).join(' ');
        await typeText(session, fullContent);
        console.log(`  Content filled (${fullContent.length} chars)`);
      } else {
        return {
          platform: 'weibo',
          status: 'assisted',
          message: 'Editor not found. Weibo is open, please paste content manually.',
        };
      }
    }

    if (!preview) {
      await sleep(1500);
      
      // Look for publish button
      const publishSelectors = [
        'a[node-type="publish"]',
        'a.W_btn_a[node-type="publish"]',
        'button:has-text("发布")',
        'a:has-text("发布")'
      ];
      
      for (const sel of publishSelectors) {
        const hasBtn = await waitForSelector(session, sel, 2_000);
        if (hasBtn) {
          await randomDelay(500, 1000);
          await clickElement(session, sel);
          await sleep(3000);
          
          const finalUrl = await evaluate<string>(session, 'window.location.href');
          if (finalUrl.includes('weibo.com/u/') || finalUrl.includes('detail') || finalUrl.includes('status')) {
            return { platform: 'weibo', status: 'success', message: 'Published to Weibo' };
          }
          return { platform: 'weibo', status: 'success', message: 'Published to Weibo (check manually)' };
        }
      }
      
      // Try Ctrl+Enter shortcut
      await randomDelay(500, 1000);
      await session.cdp.send('Input.dispatchKeyEvent', {
        type: 'keyDown', key: 'Control', code: 'ControlLeft', modifiers: 0x4,
      }, { sessionId: session.sessionId });
      await session.cdp.send('Input.dispatchKeyEvent', {
        type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13,
      }, { sessionId: session.sessionId });
      await sleep(2000);
      
      return { 
        platform: 'weibo', 
        status: 'assisted', 
        message: 'Content filled, please click publish manually.' 
      };
    }

    return {
      platform: 'weibo',
      status: 'assisted',
      message: `Content pre-filled. Tags: ${weiboData.copy.tags.join(', ')}`,
    };
  } catch (err) {
    return {
      platform: 'weibo',
      status: 'manual',
      message: `CDP error: ${err instanceof Error ? err.message : String(err)}`,
    };
  } finally {
    cdp.close();
  }
}

path: scripts/distribute/platforms/xiaohongshu.ts
content: /**
 * Xiaohongshu (小红书) publisher via Chrome CDP.
 * Opens creator.xiaohongshu.com, uploads images, fills in copy.
 */

import fs from 'node:fs';
import path from 'node:path';
import {
  launchChrome, getPageSession, clickElement, typeText, evaluate,
  waitForSelector, uploadFile, sleep, randomDelay,
  type Manifest, type PublishResult, type ChromeSession,
} from '../cdp-utils.ts';

const CREATOR_URL = 'https://creator.xiaohongshu.com/publish/publish';

// Selectors (extracted for easy update when UI changes)
const SELECTORS = {
  uploadInput: 'input[type="file"]',
  titleInput: '.titleInput input, input[placeholder*="标题"], .c-input_inner[placeholder*="标题"]',
  contentEditor: '.ql-editor, [contenteditable="true"].content, div[data-placeholder*="正文"]',
  tagInput: '.tag-input input, input[placeholder*="话题"]',
  publishBtn: 'button.publishBtn, button.css-k0vba7, button[class*="publish"]',
  loginIndicator: '.avatar, .user-avatar, img[class*="avatar"]',
};

async function findAndUploadImages(session: ChromeSession, imagesDir: string): Promise<boolean> {
  const imageFiles = fs.readdirSync(imagesDir)
    .filter((f) => /\.(png|jpg|jpeg|webp)$/i.test(f))
    .sort()
    .map((f) => path.join(imagesDir, f));

  if (imageFiles.length === 0) return false;

  // Wait for upload area
  const hasUpload = await waitForSelector(session, SELECTORS.uploadInput, 10_000);
  if (!hasUpload) return false;

  // Upload all images at once via file input
  const { root } = await session.cdp.send<{ root: { nodeId: number } }>('DOM.getDocument', {}, { sessionId: session.sessionId });
  const { nodeId } = await session.cdp.send<{ nodeId: number }>('DOM.querySelector', {
    nodeId: root.nodeId,
    selector: SELECTORS.uploadInput,
  }, { sessionId: session.sessionId });

  if (!nodeId) return false;

  await session.cdp.send('DOM.setFileInputFiles', {
    nodeId,
    files: imageFiles,
  }, { sessionId: session.sessionId });

  console.log(`  Uploaded ${imageFiles.length} images`);
  await sleep(3000); // Wait for upload processing

  return true;
}

async function fillContent(session: ChromeSession, title: string, body: string, tags: string[]): Promise<void> {
  // Fill title
  const hasTitle = await waitForSelector(session, SELECTORS.titleInput, 5_000);
  if (hasTitle) {
    await clickElement(session, SELECTORS.titleInput);
    await randomDelay();
    await typeText(session, title);
    console.log(`  Title filled: ${title.substring(0, 30)}...`);
  }

  await randomDelay(300, 600);

  // Fill body content
  const hasContent = await waitForSelector(session, SELECTORS.contentEditor, 5_000);
  if (hasContent) {
    await clickElement(session, SELECTORS.contentEditor);
    await randomDelay();
    await typeText(session, body);
    console.log(`  Content filled (${body.length} chars)`);
  }

  await randomDelay(300, 600);

  // Add tags
  for (const tag of tags) {
    const cleanTag = tag.replace(/^#/, '');
    const hasTagInput = await waitForSelector(session, SELECTORS.tagInput, 3_000);
    if (hasTagInput) {
      await clickElement(session, SELECTORS.tagInput);
      await randomDelay(100, 300);
      await typeText(session, cleanTag);
      await randomDelay(500, 1000);
      // Press Enter to confirm tag
      await session.cdp.send('Input.dispatchKeyEvent', {
        type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13,
      }, { sessionId: session.sessionId });
      await session.cdp.send('Input.dispatchKeyEvent', {
        type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13,
      }, { sessionId: session.sessionId });
      await randomDelay(300, 600);
    }
  }
}

export async function publishToXiaohongshu(manifest: Manifest, preview: boolean): Promise<PublishResult> {
  const xhsData = manifest.outputs.xiaohongshu;
  if (!xhsData) {
    return { platform: 'xhs', status: 'skipped', message: 'No Xiaohongshu content in manifest' };
  }

  let launchResult;
  try {
    launchResult = await launchChrome(CREATOR_URL, 'xiaohongshu');
  } catch (err) {
    return {
      platform: 'xhs',
      status: 'manual',
      message: `Chrome launch failed: ${err instanceof Error ? err.message : String(err)}. Copy: ${xhsData.copy.title}`,
    };
  }

  const { cdp, chrome } = launchResult;

  try {
    await sleep(8000); // Wait for page load + potential redirects

    // Retry session acquisition (page may still be navigating)
    let session: ChromeSession | null = null;
    for (let attempt = 0; attempt < 3; attempt++) {
      try {
        session = await getPageSession(cdp, 'xiaohongshu.com');
        break;
      } catch {
        await sleep(3000);
      }
    }
    if (!session) {
      return {
        platform: 'xhs',
        status: 'assisted',
        message: 'Page opened. Please log in to Xiaohongshu creator platform, then retry.',
      };
    }

    const isLoggedIn = await evaluate<boolean>(session, `!!document.querySelector('${SELECTORS.loginIndicator}')`);
    if (!isLoggedIn) {
      // Check if we're on login page
      const currentUrl = await evaluate<string>(session, 'window.location.href');
      if (currentUrl.includes('login')) {
        return {
          platform: 'xhs',
          status: 'assisted',
          message: 'Login required. Please log in to Xiaohongshu, then run /distribute again.',
        };
      }
    }

    // Upload images if available
    if (xhsData.images_dir && fs.existsSync(xhsData.images_dir)) {
      await findAndUploadImages(session, xhsData.images_dir);
    }

    // Fill content
    await fillContent(session, xhsData.copy.title, xhsData.copy.body, xhsData.copy.tags);

    if (!preview) {
      // Click publish
      const hasPublish = await waitForSelector(session, SELECTORS.publishBtn, 5_000);
      if (hasPublish) {
        await randomDelay(500, 1000);
        await clickElement(session, SELECTORS.publishBtn);
        await sleep(3000);
        return { platform: 'xhs', status: 'success', message: 'Published to Xiaohongshu' };
      }
      return { platform: 'xhs', status: 'assisted', message: 'Content filled, publish button not found. Please publish manually.' };
    }

    return { platform: 'xhs', status: 'assisted', message: 'Content pre-filled in Xiaohongshu editor. Review and publish manually.' };
  } catch (err) {
    return {
      platform: 'xhs',
      status: 'manual',
      message: `CDP error: ${err instanceof Error ? err.message : String(err)}`,
    };
  } finally {
    cdp.close();
    // Don't kill Chrome - let user review
  }
}


path: scripts/distribute/platforms/xiaoyuzhou.ts
content: /**
 * Xiaoyuzhou (小宇宙) publisher via Chrome CDP.
 * Opens podcasters.xiaoyuzhoufm.com, uploads audio, fills show notes.
 */

import fs from 'node:fs';
import {
  launchChrome, getPageSession, clickElement, typeText, evaluate,
  waitForSelector, uploadFile, sleep, randomDelay,
  type Manifest, type PublishResult, type ChromeSession,
} from '../cdp-utils.ts';

const XIAOYUZHOU_URL = 'https://podcasters.xiaoyuzhoufm.com/';

const SELECTORS = {
  newEpisodeBtn: 'a[href*="episode/new"], button:has-text("新建节目"), a:has-text("新建")',
  audioUpload: 'input[type="file"][accept*="audio"], input[type="file"][accept*="mp3"]',
  titleInput: 'input[placeholder*="标题"], input[name="title"]',
  descriptionEditor: 'textarea[placeholder*="简介"], [contenteditable="true"], textarea[name="description"]',
  showNotesEditor: 'textarea[placeholder*="文稿"], textarea[name="shownotes"]',
  publishBtn: 'button:has-text("发布"), button[type="submit"]',
  loginIndicator: 'img[class*="avatar"], div[class*="Avatar"]',
};

export async function publishToXiaoyuzhou(manifest: Manifest, preview: boolean): Promise<PublishResult> {
  const xyData = manifest.outputs.xiaoyuzhou;
  if (!xyData) {
    return { platform: 'xiaoyuzhou', status: 'skipped', message: 'No Xiaoyuzhou content in manifest' };
  }

  if (!fs.existsSync(xyData.audio)) {
    return {
      platform: 'xiaoyuzhou',
      status: 'manual',
      message: `Audio file not found: ${xyData.audio}. Upload manually.`,
    };
  }

  let launchResult;
  try {
    launchResult = await launchChrome(XIAOYUZHOU_URL, 'xiaoyuzhou');
  } catch (err) {
    return {
      platform: 'xiaoyuzhou',
      status: 'manual',
      message: `Chrome launch failed. Upload ${xyData.audio} to Xiaoyuzhou manually.`,
    };
  }

  const { cdp, chrome } = launchResult;

  try {
    await sleep(8000); // Wait for page load + potential redirects

    let session: ChromeSession | null = null;
    for (let attempt = 0; attempt < 3; attempt++) {
      try {
        session = await getPageSession(cdp, 'xiaoyuzhoufm.com');
        break;
      } catch {
        await sleep(3000);
      }
    }
    if (!session) {
      return {
        platform: 'xiaoyuzhou',
        status: 'assisted',
        message: 'Page opened. Please log in to Xiaoyuzhou, then retry.',
      };
    }

    // Navigate to new episode
    const hasNewBtn = await waitForSelector(session, SELECTORS.newEpisodeBtn, 5_000);
    if (hasNewBtn) {
      await clickElement(session, SELECTORS.newEpisodeBtn);
      await sleep(2000);
    }

    // Upload audio
    const hasAudioUpload = await waitForSelector(session, SELECTORS.audioUpload, 5_000);
    if (hasAudioUpload) {
      await uploadFile(session, SELECTORS.audioUpload, xyData.audio);
      console.log(`  Audio uploaded: ${xyData.audio}`);
      await sleep(5000); // Wait for audio processing
    }

    // Fill title
    const hasTitle = await waitForSelector(session, SELECTORS.titleInput, 5_000);
    if (hasTitle) {
      await clickElement(session, SELECTORS.titleInput);
      await randomDelay();
      await typeText(session, xyData.copy.title);
      console.log(`  Title: ${xyData.copy.title}`);
    }

    await randomDelay(300, 600);

    // Fill description
    const hasDesc = await waitForSelector(session, SELECTORS.descriptionEditor, 3_000);
    if (hasDesc) {
      await clickElement(session, SELECTORS.descriptionEditor);
      await randomDelay();
      await typeText(session, xyData.copy.description);
    }

    // Fill show notes
    const hasNotes = await waitForSelector(session, SELECTORS.showNotesEditor, 3_000);
    if (hasNotes) {
      await clickElement(session, SELECTORS.showNotesEditor);
      await randomDelay();
      await typeText(session, xyData.copy.show_notes);
      console.log(`  Show notes filled`);
    }

    if (!preview) {
      const hasPublish = await waitForSelector(session, SELECTORS.publishBtn, 5_000);
      if (hasPublish) {
        await randomDelay(500, 1000);
        await clickElement(session, SELECTORS.publishBtn);
        await sleep(5000);
        return { platform: 'xiaoyuzhou', status: 'success', message: 'Episode published to Xiaoyuzhou' };
      }
      return { platform: 'xiaoyuzhou', status: 'assisted', message: 'Content filled, publish button not found. Publish manually.' };
    }

    return {
      platform: 'xiaoyuzhou',
      status: 'assisted',
      message: 'Episode pre-filled in Xiaoyuzhou editor. Review and publish manually.',
    };
  } catch (err) {
    return {
      platform: 'xiaoyuzhou',
      status: 'manual',
      message: `CDP error: ${err instanceof Error ? err.message : String(err)}`,
    };
  } finally {
    cdp.close();
  }
}


path: references/archive_outputs.md
content: # 存档工具

> 将环境变量 `OUTPUT_DIR` 目录下的历史文档文件（*.md, *.html, *.png, manifest.json）移动到环境变量 `ARCHIVE_DIR` 目录下，按月份和标题归档

---

## 环境变量

在 `local/.env` 中配置环境变量：`OUTPUT_DIR`, `ARCHIVE_DIR`

---

## 使用方式

### 基本用法

```bash
python3 "${SKILL_DIR}/scripts/archive_outputs.py"
```

### Windows 示例

```bash
# 使用默认配置
python "scripts/archive_outputs.py"

# 预览模式（不实际移动）
python "scripts/archive_outputs.py" --dry-run

# 指定目录
python "scripts/archive_outputs.py" --output-dir OUTPUT_DIR --archive-dir ARCHIVE_DIR
```

---

## 参数说明

| 参数 | 简写 | 必填 | 说明 |
|------|------|------|------|
| `--output-dir` | `-o` | 否 | 指定输出目录（默认从环境变量 `OUTPUT_DIR` 读取） |
| `--archive-dir` | `-a` | 否 | 指定存档目录（默认从环境变量 `ARCHIVE_DIR` 读取） |
| `--dry-run` | `-n` | 否 | 预览模式，不实际移动文件 |

---

## 归档规则

1. **文件目录**：若 `output-dir` 未指定，则默认从环境变量`OUTPUT_DIR`读取。若上述目录不存在，结束存档，并提示未发现需要归档的文件
2. **归档目录**：若 `archive-dir` 未指定，则默认从环境变量`ARCHIVE_DIR`读取，若环境变量`ARCHIVE_DIR`未指定，则默认使用 `archives` 目录; 上述目录若不存在，则创建
3. **文件类型**：自动查找 `output-dir` 下的 `.md`、`.html`、`.png` 文件，以及 `manifest.json` 文件
4. **归档目录结构**：在 `archive-dir` 下创建以当前月份命名的子目录（格式：YYYY-MM，如 2026-03），并根据文章标题创建子目录
5. **文件移动**：将找到的文件移动到步骤2创建的子目录中
6. **冲突处理**：如果目标文件已存在，则跳过移动

### 归档目录结构示例

```
[存档目录]/
├── 2026-03                  # 2026年3月份的归档
│   ├── Fish-Audio-S2-Tutorial-v2/    # 文章标题目录
│   │   ├── Fish-Audio-S2-Tutorial-v2.md
│   │   ├── Fish-Audio-S2-Tutorial-v2_preview.html
│   │   ├── Fish-Audio-S2-Tutorial-v2_cover.html
│   │   └── manifest.json
│   └── OpenClaw-Memory-Deep-Article/  # 另一篇文章
│       ├── OpenClaw-Memory-Deep-Article.md
│       ├── OpenClaw-Memory-Deep-Article_preview.html
│       └── manifest.json
├── 2026-04                  # 2026年3月份的归档
│   └── ...
└── 2026-05    └── ...
```bash
python archive_outputs.py
```

**输出**：
```
[INFO] 输出目录: g:/AAA/odd-outputs
[INFO] 存档目录: g:/AAA/odd-archives
[INFO] 模式: 执行
--------------------------------------------------
[INFO] 创建归档目录: g:/AAA/odd-archives/2026-03
[OK] 已移动: Fish-Audio-S2-Tutorial-v2.md
[OK] 已移动: Fish-Audio-S2-Tutorial-v2_preview.html
[OK] 已移动: Fish-Audio-S2-Tutorial-v2_cover.html
[OK] 已移动: manifest.json

[OK] 归档完成！共移动 4 个文件到: g:/AAA/odd-archives/2026-03
```


### 示例 2：预览模式

```bash
python archive_outputs.py --dry-run
```

**输出**：
```
[INFO] 输出目录: g:/AAA/odd-outputs
[INFO] 存档目录: g:/AAA/odd-archives
[INFO] 模式: 预览（不实际移动）
--------------------------------------------------
[DRY RUN] 将创建目录: g:/AAA/odd-archives/2026-03
[DRY RUN] 将移动: g:/AAA/odd-outputs/Fish-Audio-S2-Tutorial-v2.md -> g:/AAA/odd-archives/2026-03/Fish-Audio-S2-Tutorial-v2.md
[DRY RUN] 将移动: g:/AAA/odd-outputs/Fish-Audio-S2-Tutorial-v2_preview.html -> g:/AAA/odd-archives/2026-03/Fish-Audio-S2-Tutorial-v2_preview.html
[DRY RUN] 将移动: g:/AAA/odd-outputs/Fish-Audio-S2-Tutorial-v2_cover.html -> g:/AAA/odd-archives/2026-03/Fish-Audio-S2-Tutorial-v2_cover.html
[DRY RUN] 将移动: g:/AAA/odd-outputs/manifest.json -> g:/AAA/odd-archives/2026-03/Fish-Audio-S2-Tutorial-v2_manifest.json

[DRY RUN] 总计将移动 4 个文件
```


### 示例 3：指定目录

```bash
python archive_outputs.py --output-dir /tmp/outputs --archive-dir /tmp/archives
```

---

## 常见问题

### "未指定输出目录"

**原因**：`OUTPUT_DIR` 环境变量未设置

**解决方案**：
1. 在 `local/.env` 中设置 `OUTPUT_DIR`
2. 或使用 `--output-dir` 参数指定

### "未指定存档目录"

**原因**：`ARCHIVE_DIR` 环境变量未设置

**解决方案**：
1. 在 `local/.env` 中设置 `ARCHIVE_DIR`
2. 或使用 `--archive-dir` 参数指定

### "没有找到需要归档的文件"

**原因**：`OUTPUT_DIR` 下没有 `.md`、`.html`、`.png`、`manifest.json` 文件

**解决方案**：
1. 检查 `OUTPUT_DIR` 路径是否正确
2. 确认目录下是否有需要归档的文件
3. 使用 `--dry-run` 预览模式查看会找到哪些文件

### "目标文件已存在，跳过"

**原因**：`ARCHIVE_DIR` 下已存在同名文件

**解决方案**：
1. 手动检查并删除或重命名已存在的文件
2. 或修改源文件名后再归档

---

## 相关文件

- 脚本位置：`scripts/archive_outputs.py`
- 配置文件：`local/.env`
- 环境变量：`OUTPUT_DIR`、`ARCHIVE_DIR`

---

## 触发词

在对话中使用以下触发词可调用存档功能：

| 触发词 | 说明 |
|-------|------|
| `/archive` | 将 `OUTPUT_DIR` 下的历史文档归档到 `ARCHIVE_DIR` |
| "存档" | 同上 |
| "归档" | 同上 |

path: references/cover_template.md
content: # oddmeta 风格排版规范

> 公众号头图、文章配图、排版的统一规范。
> 所有图片产出为 HTML 文件，浏览器打开后点击下载按钮导出 PNG。

---

## 环境变量配置

本技能所使用到的环境变量在 `local/.env` 中配置。

支持的环境变量如下：

- `OUTPUT_DIR`：输出目录，用于放置收集到的素材、生成的文章、排版预览、封面图、微贴图轮播图等文件。
- `ARCHIVE_DIR`：存档目录，用于归档历史文档文件。
- `BRAND_NAME`：品牌名称，用于在生成的文章中显示品牌名称。
- `BRAND_LOGO_DARK`：品牌logo（深色），用于在生成的文章中显示品牌logo。
- `BRAND_LOGO_LIGHT`：品牌logo（浅色），用于在生成的文章中显示品牌logo。
- `WECHAT_ID`：微信公众号ID，用于发布文章。
- `WECHAT_SLOGON`：微信公众号标语，用于在文章中显示。
- `WECHAT_APPID`：微信公众号AppID，用于发布文章时的认证。
- `WECHAT_APPSECRET`：微信公众号AppSecret，用于发布文章时的认证。
- `CNBLOGS_TOKEN`：博客园Token，用于发布博客园文章时的认证。
- `MD_FORMATTER_DIR`：Markdown格式化工具目录，用于格式化Markdown文章。
- `BAOYU_WECHAT_SKILL_DIR`: 奥德元微信技能目录，用于发布文章。
- `MD_TO_WECHAT_SCRIPT`: Markdown文章转微信文章脚本，用于将Markdown文章转换为微信文章。

---

## 一、排版（Markdown → 公众号 HTML）

使用 md-formatter 排版工具，oddmeta 主题：

```bash
cd "$MD_FORMATTER_DIR"
python3 md2wechat.py [文章路径] --theme default -o [输出HTML路径]
```

产出 `_preview.html`，浏览器打开 → 全选复制 → 粘贴到公众号编辑器。

**执行排版命令报错说明**：请参考 `references/running_python_script_guide.md`

---

## 二、公众号头图（HTML 可下载）

### 尺寸

- 主头图：`900 x 383 px`（2.35:1），2x 导出 `1800 x 766`
- 次头图：从主图中心裁出 `200 x 200` 正方形

### 设计规范

- **背景**：墨绿暗底 `#1A3328`，带微弱径向渐变纹理
- **主标题**：不超过 15 字，宣纸白 `#F2EDE3`，72px，font-weight 900，居中
- **副标题**：20px，`rgba(255,255,255,0.5)`，主标题下方
- **产品名/关键词**：鱼红 `#C44536`，带鱼红边框框住，居中
- **品牌角标**：左上角 Brand SVG + 文字
- **底部标签**：右下角，小字标签（工具名、特性），`rgba(255,255,255,0.35)`
- **装饰元素**：左右两侧可放低透明度（0.12）的主题相关 SVG 图标

### HTML 结构

```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<style>
  body { background: #111; display: flex; flex-direction: column; align-items: center; padding: 70px 20px 80px; }
  .cover { width: 900px; height: 383px; position: relative; overflow: hidden; border-radius: 8px; }
  .bg-dark { position: absolute; inset: 0; background: #1A3328; }
  .content { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 2; }
  /* 工具栏同微贴图模板 */
</style>
</head>
<body>
  <div class="toolbar">
    <button onclick="downloadCover('main')">下载头图 (900x383)</button>
    <button onclick="downloadCover('secondary')">下载次图 (200x200)</button>
  </div>
  <div class="cover" id="cover-main">
    <div class="bg-dark"></div>
    <!-- 品牌角标 -->
    <!-- 主标题 + 副标题 + 产品名 -->
    <!-- 底部标签 -->
  </div>
  <!-- html2canvas + FileSaver 下载脚本 -->
</body>
</html>
```

下载脚本：用 `html2canvas` 渲染 cover div，SCALE=2，主图直接导出，次图从中心裁正方形。

---

## 三、文章配图（HTML 可下载）

### 尺寸

- 宽度固定 `800px`，高度按内容 `380-500px`
- 导出 2x（`1600px` 宽）

### 设计规范

- **暗底页和浅底页交替出现**
- 暗底：`#1A3328` 背景，宣纸白文字
- 浅底：`#F2EDE3` 背景，墨绿文字
- 每张图左上角品牌角标，右下角页码 `N/N`
- 每张图顶部有 section-tag 标签（英文大写，letter-spacing: 2px）
- 鱼红仅用于：强调数字、标签分类名、关键箭头

### 常用配图类型

| 类型 | 适用场景 | 布局 |
|------|---------|------|
| 流程图 | 展示 Pipeline / 工作流 | 横向箭头连接的卡片行 |
| 对比图 | A vs B | 左右双栏，暗/浅对比 |
| 链路图 | 从输入到产出的完整路径 | 横向大卡片 + 箭头 |
| 网格卡片 | 分类展示多个项目 | 3列 grid，墨绿小卡片 on 浅底 |
| 星级评分 | 适配度 / 推荐度 | 列表行，左侧星级右侧说明 |

### HTML 结构

```html
<body>
  <div class="toolbar">
    <button onclick="downloadAll()">全部下载 (ZIP)</button>
    <button onclick="downloadOne()">下载当前</button>
  </div>

  <div class="slide-label">配图 N · 放在「xxx」之后</div>
  <div class="slide" style="height:420px;">
    <div class="bg-dark"></div>  <!-- 或 bg-light -->
    <div class="brand light"></div>  <!-- 或 brand dark -->
    <div class="page-num light">1/N</div>
    <div class="content">
      <div class="section-tag on-dark">ENGLISH TAG</div>
      <div class="title-dark" style="font-size:22px;">中文标题</div>
      <!-- 具体内容 -->
    </div>
  </div>

  <!-- html2canvas + JSZip + FileSaver -->
</body>
```

下载脚本用 `html2canvas` 渲染每个 `.slide`，打包为 ZIP。每张图命名 `配图-序号-描述.png`。

### 配图命名和位置标注

每张 `.slide` 上方必须有 `.slide-label` 标注：
```
配图 N · 放在「文章中的哪个小节」之后
```

---

## 四、视觉组件速查

### 品牌角标 Brand SVG

使用环境变量 `BRAND_LOGO_DARK` 或者 `BRAND_LOGO_LIGHT` 地址的 SVG 图标作为 Brand SVG，适用于公众号头图、文章配图、微贴图等。

- 暗底版：使用 `BRAND_LOGO_DARK` 作为 Brand SVG
- 浅底版：使用 `BRAND_LOGO_LIGHT` 作为 Brand SVG

暗底版示例：
```html
<div class="brand">
<svg id="a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 0.3 0.3">
<defs><style>.c{fill:#000;}.c,.d{stroke-width:0px;}.d{fill:#fff;}</style></defs>
<circle class="c" cx=".18" cy=".18" r=".11"/>
<circle class="d" cx=".18" cy=".18" r=".09"/>
<circle class="c" cx=".18" cy=".18" r=".06"/>
<circle class="d" cx=".18" cy=".18" r=".04"/>
<polygon class="d" points=".19 .23 .2 .26 .23 .27 .22 .29 .25 .31 .3 .24 .31 .15 .27 .18 .21 .18 .19 .23"/>
<path class="c" d="M.26.24l.03-.04h.01v.08h-.02v-.05l-.02.03h0l-.02-.03v.05h-.02v-.08h.01l.03.04Z"/>
</svg>
  ODDMETA
</div>
```

浅底版示例：
```html
<div class="brand">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 0.3 0.3">
<defs><style>.c{fill:#000;}.c,.d{stroke-width:0px;}.d{fill:#fff;}</style></defs>
<circle class="c" cx=".18" cy=".18" r=".11"/>
<circle class="d" cx=".18" cy=".18" r=".09"/>
<circle class="c" cx=".18" cy=".18" r=".06"/>
<circle class="d" cx=".18" cy=".18" r=".04"/>
<polygon class="d" points=".19 .23 .2 .26 .23 .27 .22 .29 .25 .31 .3 .24 .31 .15 .27 .18 .21 .18 .19 .23"/>
<path class="c" d="M.26.24l.03-.04h.01v.08h-.02v-.05l-.02.03h0l-.02-.03v.05h-.02v-.08h.01l.03.04Z"/>
</svg>
  ODDMETA
</div>
```

### Section Tag

```html
<div style="display:inline-block;font-size:11px;font-weight:700;letter-spacing:2px;padding:4px 12px;border-radius:4px;background:rgba(255,255,255,0.08);color:rgba(255,255,255,0.5);">PIPELINE</div>
```

### 流程卡片

```html
<div style="width:110px;height:80px;border-radius:10px;background:rgba(196,69,54,0.15);display:flex;flex-direction:column;align-items:center;justify-content:center;border:1.5px solid rgba(196,69,54,0.3);">
  <div style="font-size:13px;font-weight:800;color:#F2EDE3;">标题</div>
  <div style="font-size:10px;color:rgba(255,255,255,0.4);margin-top:4px;">说明文字</div>
</div>
```

箭头：`<div style="color:rgba(255,255,255,0.25);font-size:20px;padding:0 8px;">→</div>`

### 大数字

```html
<div style="font-size:64px;font-weight:900;color:#C44536;">22</div>
<div style="font-size:22px;font-weight:800;color:#F2EDE3;">个 Skill</div>
```

### 信息行

```html
<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px;">
  <div style="width:6px;height:6px;border-radius:50%;background:#C44536;flex-shrink:0;"></div>
  <div style="font-size:13px;color:rgba(255,255,255,0.8);">内容文字</div>
</div>
```

---

## 五、色板速查

| 名称 | 色值 | 用途 |
|------|------|------|
| 墨绿主色 | `#1A3328` | 暗底背景 |
| 宣纸底 | `#F2EDE3` | 浅底背景、暗底上的主文字 |
| 鱼红 | `#C44536` | 强调色（仅点睛：数字、箭头、标签名、边框） |
| 半透白 | `rgba(255,255,255,0.5)` | 暗底上的品牌名 |
| 半透墨绿 | `rgba(26,51,40,0.4)` | 浅底上的品牌名 |
| 苔灰 | `#7A8C80` | 次要文字 |
| 深墨 | `#0F1F18` | 更深背景 |

**比例法则**：墨绿 85% : 鱼红 5% : 其余 10%

---

## 六、CDN 依赖

所有下载功能用这三个库：

```html
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/file-saver@2.0.5/dist/FileSaver.min.js"></script>
```

---

## 七、下载函数模板（必读）

生成头图 HTML 时，必须使用以下正确的下载函数：

```javascript
function downloadCover(type) {
  const cover = document.getElementById('cover-main');
  html2canvas(cover, { scale: 2, backgroundColor: '#1A3328' }).then(canvas => {
    if (type === 'secondary') {
      const size = 200;
      const sx = (canvas.width - size) / 2;
      const sy = (canvas.height - size) / 2;
      const tempCanvas = document.createElement('canvas');
      tempCanvas.width = size;
      tempCanvas.height = size;
      const ctx = tempCanvas.getContext('2d');
      ctx.drawImage(canvas, sx, sy, size, size, 0, 0, size, size);
      // 正确：将 tempCanvas 转换为 Blob，然后保存
      tempCanvas.toBlob(blob => saveAs(blob, 'cover-200.png'));
    } else {
      canvas.toBlob(blob => saveAs(blob, 'cover.png'));
    }
  });
}
```

**常见错误**：
```javascript
// ❌ 错误：saveAs 不支持 Data URL，只支持 Blob
saveAs(tempCanvas.toDataURL('image/png'), 'cover-200.png');

// ❌ 错误：tempCanvas 没有 toBlob 方法（需要用 canvas 元素）
const tempCanvas = document.createElement('canvas');
// ... 绘制代码 ...
tempCanvas.toBlob(...); // 正确

// ✅ 正确：将 canvas 转换为 Blob，然后保存
tempCanvas.toBlob(blob => saveAs(blob, 'cover-200.png'));
```

path: references/cover_vertical_spec.md
content: # 公众号封面 → 竖版封面转换规范

> 从已有的公众号头图 HTML（900×383 横版）生成 3:4 竖版封面（1080×1440），适合微贴图/视频号。
> 内容完全不变（标题、图片、标签），只换比例 + 适配竖版布局。

## 环境变量配置

本技能所使用到的环境变量在 `local/.env` 中配置。

支持的环境变量如下：

- `OUTPUT_DIR`：输出目录，用于放置收集到的素材、生成的文章、排版预览、封面图、微贴图轮播图等文件。
- `ARCHIVE_DIR`：存档目录，用于归档历史文档文件。
- `BRAND_NAME`：品牌名称，用于在生成的文章中显示品牌名称。
- `BRAND_LOGO_DARK`：品牌logo（深色），用于在生成的文章中显示品牌logo。
- `BRAND_LOGO_LIGHT`：品牌logo（浅色），用于在生成的文章中显示品牌logo。
- `WECHAT_ID`：微信公众号ID，用于发布文章。
- `WECHAT_SLOGON`：微信公众号标语，用于在文章中显示。
- `WECHAT_APPID`：微信公众号AppID，用于发布文章时的认证。
- `WECHAT_APPSECRET`：微信公众号AppSecret，用于发布文章时的认证。
- `CNBLOGS_TOKEN`：博客园Token，用于发布博客园文章时的认证。
- `MD_FORMATTER_DIR`：Markdown格式化工具目录，用于格式化Markdown文章。
- `BAOYU_WECHAT_SKILL_DIR`: 奥德元微信技能目录，用于发布文章。
- `MD_TO_WECHAT_SCRIPT`: Markdown文章转微信文章脚本，用于将Markdown文章转换为微信文章。

---

## 操作流程

1. **复制**原公众号封面 HTML → `[主题]_cover_vertical.html`
2. **应用下方 CSS 转换表**
3. 浏览器打开，确认效果，下载 PNG

---

## CSS 转换表

### 尺寸

| 选择器 | 横版（原） | 竖版（改） |
|--------|-----------|-----------|
| `.cover` | `width: 900px; height: 383px` | `width: 1080px; height: 1440px` |

### 背景图

| 选择器 | 横版（原） | 竖版（改） |
|--------|-----------|-----------|
| `.bg-img img` | `object-position: center 40%` | `object-position: center center` |

### 渐变遮罩

横版用左→右渐变（文字在左），竖版改为底→顶渐变（文字在底部）。

| 选择器 | 横版（原） | 竖版（改） |
|--------|-----------|-----------|
| `.overlay` | `linear-gradient(90deg, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.7) 45%, rgba(0,0,0,0.15) 75%, rgba(0,0,0,0.05) 100%)` | `linear-gradient(to top, rgba(0,0,0,0.92) 0%, rgba(0,0,0,0.8) 35%, rgba(0,0,0,0.3) 55%, rgba(0,0,0,0.05) 80%, rgba(0,0,0,0.15) 100%)` |
| `.overlay-bottom` | `height: 80px` | `height: 200px` |

### 内容布局

竖版文字区移到底部。

| 选择器 | 横版（原） | 竖版（改） |
|--------|-----------|-----------|
| `.content` | `justify-content: center` | `justify-content: flex-end` |
| `.content` | `padding: 0 0 0 55px` | `padding: 0 60px 180px 60px` |

### 字号放大

竖版画布更大，字号需等比放大，确保视觉冲击力。

| 元素 | 横版 | 竖版 |
|------|------|------|
| `.tag` font-size | 11px | 18px |
| `.tag` letter-spacing | 2px | 4px |
| `.tag` padding | 4px 12px | 6px 16px |
| `.tag` margin-bottom | 14px | 20px |
| `.title` font-size | 46px | **136px** |
| `.title` | — | 添加 `white-space: nowrap`（防止换行） |
| `.title` max-width | 450px | 900px |
| `.subtitle` font-size | 16px | 44px |
| `.stat-num` font-size | 32px | 56px |
| `.stat-unit` font-size | 13px | 20px |
| `.stat-sep` font-size | 20px | 26px |

### 品牌角标放大

| 元素 | 横版 | 竖版 |
|------|------|------|
| `.brand` position | `top: 16px; left: 20px` | `top: 36px; left: 45px` |
| `.brand svg` | `width: 20px; height: 20px` | `width: 56px; height: 56px` |
| `.brand-text` font-size | 11px | 32px |

### 底部标签放大

| 元素 | 横版 | 竖版 |
|------|------|------|
| `.bottom-tags` position | `bottom: 14px; left: 20px` | `bottom: 36px; left: 45px` |
| `.btag` font-size | 10px | 13px |
| `.btag` padding | 3px 8px | 5px 12px |

### 工具栏更新

| 元素 | 横版（原） | 竖版（改） |
|------|-----------|-----------|
| 主按钮文案 | `下载头图 (900x383)` | `下载竖版封面 (1080×1440)` |
| 下载文件名 | `xxx-cover-900x383.png` | `xxx-vertical-cover-1080x1440.png` |

---

## 注意事项

- **内容不改**：标题、副标题、数据、标签、背景图、品牌 SVG 全部保持原样
- **标题不换行**：竖版标题加 `white-space: nowrap`，确保一行显示
- **渐变方向**：横版是左→右（文字左侧），竖版是底→顶（文字底部），这是最关键的布局区别
- **底部留白**：`.content` 底部 padding 180px，给 bottom-tags 留空间


path: references/frameworks.md
content: # 写作框架库

## 你的任务

根据选题和客户风格，生成 5 套差异化写作框架供用户选择。每套框架是一个完整的文章骨架——不是写文章本身，而是告诉写作步骤"每一段写什么、怎么写"。

## 9 套框架类型

### 框架 A: 教程类文章框架

请参考 [教程类型](framework_tutorial_articles.md)

### 框架 B: 深度长文框架

请参考 [深度长文框架](framework_in_depth_articles.md)

### 框架 C: 痛点型

适合：解决问题、提供方案的选题。干货型账号首选。

```
结构：
1. 前言（痛点共鸣）
   - 写作主题的技术所对应的应用领域，背景，技术现状，以及存在的问题。
   - 写作要点
      - 用故事性的场景切入
      - 直接描述目标读者正在经历的痛点场景
      - 让读者产生共鸣
      - 埋下伏笔
      - 用"你是不是也..."或具体场景切入
      - 制造紧迫感：这个问题不解决会怎样
      - 控制在 200-300 字

2. 痛点放大（H2）
   - 用数据或案例说明这个问题有多普遍
   - 分析为什么大多数人的做法是错的
   - 金句落点：一句话总结错误认知

3. 解决方案（H2）
   - 核心方法/工具/思路（不超过 3 个要点）
   - 每个要点配一个具体案例或操作步骤
   - 金句落点：一句话总结方法论

4. 实操验证（H2，可选）
   - 用一个完整案例走一遍解决流程
   - 或用前后对比展示效果

5. 结尾（行动引导）
   - 总结核心观点（一句话）
   - CTA：引导留言分享自己的痛点、或转发给同样有这个问题的朋友
```

### 框架 D: 故事型

适合：人物、事件、趋势类选题。故事型/情绪型账号首选。

```
结构：
1. 开头（悬念钩子）
   - 抛出一个反直觉的结果或意外的场景
   - "谁也没想到..."、"所有人都以为...结果..."
   - 不要在开头剧透结论

2. 背景铺垫（H2）
   - 交代故事的时间、人物、起因
   - 控制在 200 字以内，快速过渡
   - 金句落点：一句话定义这个故事的核心矛盾

3. 转折与高潮（H2）
   - 事件的关键转折点
   - 用细节还原场景（对话、数字、画面）
   - 这是全文最花笔墨的地方

4. 深度解读（H2）
   - 从故事上升到规律/趋势/洞察
   - 这个故事对读者意味着什么
   - 金句落点：一句话总结你从这个故事中看到的本质

5. 结尾（情绪共振）
   - 回扣开头的悬念
   - CTA：引导读者分享"你身边有没有类似的故事"
```

### 框架 E: 清单型

适合：盘点、推荐、方法论类选题。干货型/测评型账号首选。

```
结构：
1. 开头（价值承诺）
   - 直接告诉读者"看完这篇你能得到什么"
   - 用数字锚定预期："5 个方法"、"3 个工具"、"7 个坑"
   - 简短说明为什么你有资格推荐（经验/测试/调研）

2. 清单项 1-N（每项一个 H2）
   - 每项结构统一：名称 → 一句话说明 → 具体案例或使用场景 → 适用人群
   - 项与项之间用不同长度，避免机械感
   - 每 2-3 项穿插一个金句或个人吐槽，打破节奏
   - 建议 5-7 项，不超过 10 项

3. 结尾（总结 + 彩蛋）
   - 一张表格或一句话总结所有清单项
   - 加一个"隐藏推荐"或"个人最爱"作为彩蛋
   - CTA：引导留言补充"你还知道哪些"
```

### 框架 F: 对比型

适合：选择、决策、两个方案/观点的讨论。测评型/干货型账号首选。

```
结构：
1. 开头（选择困境）
   - 描述读者面临的"选 A 还是选 B"困境
   - 说明为什么这个选择很重要/很容易选错

2. A 方案深度分析（H2）
   - 优势（2-3 点，每点配案例）
   - 劣势（1-2 点，诚实说）
   - 最适合什么场景/什么人
   - 金句落点：一句话定义 A 的核心价值

3. B 方案深度分析（H2）
   - 同样的结构，与 A 形成对照
   - 金句落点：一句话定义 B 的核心价值

4. 对比总结（H2）
   - 用表格对比关键维度（3-5 个维度）
   - 明确给出"如果你是 X 情况选 A，如果是 Y 情况选 B"的结论
   - 不要和稀泥说"各有优劣"——读者要的是明确建议

5. 结尾（个人选择）
   - 说清楚"如果是我，我选X"以及为什么
   - CTA：引导投票或留言"你选哪个"
```

### 框架 G: 热点解读型

适合：新闻、事件、行业动态的深度解读。热点型账号首选。

```
结构：
1. 开头（事件速览）
   - 2-3 句话说清楚发生了什么（5W1H 精简版）
   - 不要复制新闻原文，用自己的话重述
   - 用一个判断句结尾："这件事比表面看起来复杂得多"

2. 表面信息（H2）
   - 大多数人看到的：媒体怎么报道的、网友怎么评论的
   - 简要梳理主流观点
   - 金句落点：指出主流观点的盲区

3. 深层分析（H2）
   - 你看到了什么别人没看到的
   - 这件事背后的利益链/技术逻辑/行业趋势
   - 用 1-2 个类比或历史事件做对照
   - 金句落点：一句话总结你的核心判断

4. 影响预判（H2）
   - 短期：接下来会怎样
   - 长期：对行业/普通人意味着什么
   - 说清楚不确定性："如果 X 发生，则 Y；如果不发生，则 Z"

5. 结尾（读者行动建议）
   - 普通读者应该怎么应对/关注什么
   - CTA：引导关注后续进展、或留言分享看法
```

### 框架 H: 纯观点型

适合：表达立场、反驳主流观点、输出价值判断。情绪型/热点型账号首选。与热点解读型的区别：不需要从一个新闻事件出发，直接从一个论点开刀。

```
结构：
1. 开头（亮刀子）
   - 第一段就把核心观点甩出来，不铺垫
   - 观点要够尖锐，让读者要么点头要么皱眉
   - "我越来越觉得 X 是一个巨大的谎言"、"所有人都在说 X，但没人提 Y"

2. 为什么我这么想（H2）
   - 触发这个观点的具体经历或观察（不是抽象推理）
   - 1-2 个你亲眼见过/亲身经历的案例
   - 金句落点：把观察提炼为一句判断

3. 主流观点哪里错了（H2）
   - 列出主流说法，逐条反驳
   - 不要稻草人——引用真实的对立观点，给对方最强的版本，然后说明为什么你仍然不同意
   - 承认对方有道理的部分（"他们说得没错，但..."）
   - 金句落点：一句话总结分歧的本质

4. 如果我是对的，意味着什么（H2）
   - 推演这个观点的后果——对读者、对行业、对普通人
   - 提供一个可验证的预测或判断标准（"如果半年后 X 发生，说明我说对了"）

5. 结尾（留余地但不软）
   - 不要总结，不要"综上所述"
   - 承认自己可能是错的，但说清楚在什么条件下你会改变看法
   - CTA：引导读者表态——"你同意吗？评论区见"
```

### 框架 I: 复盘/经验型

适合：个人实践总结、项目复盘、阶段性回顾。干货型/故事型账号首选。与故事型的区别：主角是"我"，重点是可迁移的经验而不是戏剧性。

```
结构：
1. 开头（结果先行）
   - 先说结果/数据：做了什么、多久、成果如何
   - 不要谦虚也不要吹，直接上数字
   - "做了 3 个月，踩了 X 个坑，最后 Y 还行，Z 彻底失败"

2. 背景和动机（H2）
   - 为什么做这件事、起点是什么状态
   - 控制在 150 字以内，快速过渡——读者来看经验的，不是来听你的人生故事
   - 金句落点：一句话说清你当时的认知水平（"那时候我以为 X，现在觉得自己太天真"）

3. 做对了什么（H2）
   - 2-3 个关键决策，每个配上具体数据或结果
   - 说清楚"为什么当时选了这个方向"——决策过程比结果更有价值
   - 金句落点：提炼为可迁移的原则

4. 做错了什么（H2）
   - 这是全文最值钱的部分——读者来就是为了避坑
   - 每个错误：当时怎么想的 → 实际发生了什么 → 现在回头看应该怎么做
   - 不要轻描淡写——具体到金额、时间、情绪代价
   - 金句落点：一句话总结这个坑的本质

5. 结尾（如果重来）
   - "如果重新来过，我会..."——给出 2-3 条具体建议
   - 不要泛泛而谈，要具体到"第一周先做 X，不要碰 Y"
   - CTA：引导读者分享自己的复盘或提问
```

## 输出格式

对每个选题，从 9 套框架中选出最匹配的 5 套，每套包含：

```
### 框架 X: {类型名}（推荐指数：⭐⭐⭐⭐⭐）

**开头策略**：{1-2 句话说明开头怎么写}

**段落大纲**：
1. {H2 标题} — {这段写什么，2-3 句话}
2. {H2 标题} — {这段写什么}
3. ...

**金句预埋**：
- {第 X 段结尾}："{建议的金句方向}"
- {第 X 段结尾}："{建议的金句方向}"

**结尾引导**：{CTA 策略，1 句话}

**推荐理由**：{为什么这个选题适合用这套框架}
```

## 推荐指数规则

根据选题特征和客户 content_style 匹配度打星：
- ⭐⭐⭐⭐⭐ 最佳匹配
- ⭐⭐⭐⭐ 适合
- ⭐⭐⭐ 可以用但不是最优
- ⭐⭐ 勉强
- ⭐ 不建议

content_style 对应关系：
- 干货型 → 优先推荐：痛点型、清单型、复盘型
- 故事型 → 优先推荐：故事型、热点解读型、复盘型
- 情绪型 → 优先推荐：故事型、痛点型、纯观点型
- 热点型 → 优先推荐：热点解读型、对比型、纯观点型
- 测评型 → 优先推荐：对比型、清单型


path: references/framework_in_depth_articles.md
content: # 深度长文写作框架和规范

> 基于奥德元公众号的写作风格，提炼出的写作方法论。
> 目标：每篇 8000-12000 字，具备奥德元自身写作风格和 10w+ 传播潜力的深度长文。

---

## 人设：会讲故事的极客朋友

一个硬核、极客、技术专家，但说人话的技术玩家，同时是一个善于用故事讲道理的叙事者。

**核心特质：**
- 真的在用这些工具，不是云评测
- 懂技术原理，但不掉书袋
- 深入详解技术原理和实现细节
- 会踩坑，也会分享怎么爬出来
- 有观点，敢下判断
- 博览群书，东西方哲学和历史、流行文化、商业案例信手拈来
- 能把复杂技术话题讲成引人入胜的故事

---

## 语言风格

### 基础要求
- **硬核但通俗**：用大白话讲明白技术的事
- **有逻辑**：用逻辑结构，避免无逻辑
- **信息密度高**：每句话都有信息量，不灌水
- **真实感**：会说"折腾了两小时""丑哭了"，不端着
- **有态度**：敢说"这个设计有问题""这才是正确的用法"

### 进阶要求（10w+ 级别）

**句子节奏——长短交替：**
- 长句铺展（40-60 字）建立信息量和节奏感
- 短句收束（15 字以内）制造冲击和判断感
- 示例：长句描述背景 → 短句下判断"毫不意外。"

**排比句制造气势：**
```
从电脑网页，打到手机终端；
从"千人千面"，打到"流量推荐"；
从中产消费升级，打到下沉低价优先。
```
三段以上的排比，最后一段要有"升级"——从情绪升到物理/化学/哲学层面。

**破折号（——）是秘密武器：**

用在关键判断、揭示、转折之前，制造停顿感和戏剧张力。
```
这一人事安排等于公开宣告——微软要向"旧战场"告别了。
```

**口语与书面的配比：**

- 书面/分析语言：70%（战略分析、行业判断、数据解读）
- 口语/网络语言：25%（人物描写、类比、吐槽）
- 圈子黑话：5%（精准打击目标受众）

---

## 标题技术

**核心原则：制造认知冲突，同时暗示叙事弧线。**

### 六种标题模式

| 模式 | 示例 | 原理 |
|------|------|------|
| 简单易懂 | "有手就行" | 照着本文介绍，必须能快速理解并跑通功能流程 |
| 代码示例 | "少费话，上干货" | Talk is cheap, show me the code |
| 反转/颠覆 | "人类还配不上贾维斯" | 预期是"AI 配不上人类"，翻转制造好奇 |
| 对仗/并列 | "旧战场和新革命" | 两个概念并列，暗示张力和选择 |
| 进程/弧线 | "从仰望，到猛攻" | 浓缩整篇文章的核心变化 |
| 文学化用 | "xx已过万重山" | 借用经典诗句/名言改造 |
| 好奇缺口 | "改变技术世界的一张表" | 故意模糊，逼人点进来看"什么表" |
| 人物+判断 | "[人名]的主战场" | 用知名人物引流，叠加态度判断 |

### 副标题

- 字数极短（10 字以内）
- 功能：补充信息 or 制造反差（如副标题"不是 xxx"解构了正标题的严肃感）
- 格式上是对仗/反转的点睛之笔

### 禁忌

- 不用问号标题（"xxx 到底行不行？"）
- 不用惊叹号
- 不用"震惊""竟然"等营销号词
- 不堆砌 emoji

---

## 框架结构

```
一、前言（故事先行，700 字不出论点）
二、方案介绍
三、安装/使用（分步骤+代码块+配图标记）
四、使用场景与案例
五、升华/收束（未来展望）
六、注意事项
七、参考资料（若有参考资料）
八、广而告之

```

## 各部分详解

### 一、前言

写作主题的技术所对应的应用领域，背景，技术现状，以及存在的问题。

**写作要点**：
- 400-800 字
- 场景描写要生动
- 埋下伏笔
- 让读者产生共鸣

### 二、方案介绍

**写作要点**：
- 800-1000 字
- 对官方资料进行汇总输出
- 对比同类型技术方案的SWOT分析
- 突出本方案解决了已有方案中存在的什么问题

### 三、安装/使用（分步骤+代码块+配图标记）

文章的主体部分，提出核心观点，若为某一技术方案，应包括从0开始完整复现应该技术方案的步骤和流程。若引用原文中的代码，请务必标注代码块语言。

**写作要点**：
- 3000-6000 字
- 开发、部署、虚拟环境的创建
- 依赖组件的安装
- 复现完整流程
- 测试例的实现及测试结果
- 观点清晰、论证严密
- 多角度论证
- 案例 + 数据支撑

### 四、使用场景与案例

通过应用场景案例或转折深化论述。

**写作要点**：
- 2000-3000 字
- 本技术方案真实使用场景和案例
- 对比分析
- 经验教训总结

### 五、个人看法

个人对本技术方案的评价与总结 + 升华 + CTA。以及对此方案的未来展望。

**写作要点**：
- 500-700 字
- 呼应序言
- 明确行动指引
- 开放式结尾

### 六、注意事项

**写作要点**：
- 2000-3000 字
- 技术方案在复现、实施、使用过程中可能会碰到的一些常见的问题，以及可能的解决方案。

## 七、参考资料（若有参考资料）

若存在参考资料，请务必标注，并标识来源地址。

参考资料格式示例：

```markdown
- [1] **OddTTS项目**: https://github.com/oddmeta/oddtts
- [2] **小落同学**: https://www.oddmeta.net/project-xl
```

### 八、广而告之

固定内容如下：

```html
<div style="text-align: center;">
<span>关注我的公众号：奥德元</span>
<br>
<br>
<span style="font-size: 1.2em; font-weight: bold; color: red;">一起学习AI，一起追赶时代！</span>
<br>
<br>
<span>新建了一个AI技术交流群，欢迎大家一起加入讨论。</span>
<br>
<span>扫码加入AI技术交流群（微信）</span>
<br>
<span>若需联系作者，请加微信：</span><span style="font-size: 1.2em; font-weight: bold; color: blue;"><b>oddmeta</b></span>
</div>
```


## 人设风格

### oddmeta 风格特点

- **深刻**：挖掘本质，不停留表面
- **尖锐**：观点鲜明，敢于下判断
- **接地气**：用生活化的语言解释复杂概念
- **真实**：不装，承认局限

### 写作技巧

1. **少用形容词，多用动词和名词**
2. **少用"一些"、"某些"，多用具体数据**
3. **段落要短，每段一个观点**
4. **金句要单列，加粗强调**

## 诚实标注

AI 参与写作时必须标注：

```markdown
**AI参与写作**：OpenClaw
**主导 & 审校**：[曹操同学]
```

## 格式规范

- 每段不超过 3 行（移动端阅读友好）
- 关键数据和观点加粗
- 引用来源`参考资料`中脚注，并添加引用来源链接
- 案例用引用区块展示

---

更多参考：
- `framework_tutorial_articles.md` - 教程类文章框架
- `frameworks.md` - 框架类型
- `platform_copy.md` - 各平台文案规范       

path: references/framework_tutorial_articles.md
content: # 教程类文章框架

本文件定义了教程类文章的标准化写作框架，适用于 4000-8000 字的技术教程。

## 框架结构

```
→ 一、前言
→ 二、方案介绍
→ 三、安装/使用（分步骤+代码块+配图标记）
→ 四、使用场景与案例（分阶段+表格展示+人机协作）
→ 五、拿走即用（快速安装命令+使用方式表格）
→ 六、注意事项
→ 七、广而告之
```

## 各部分详解

### 一、前言

写作主题的技术所对应的应用领域，背景，技术现状，以及存在的问题。

**写作要点**：
- 用故事性的场景切入
- 埋下伏笔
- 让读者产生共鸣
- 控制在 200-300 字

### 二、方案介绍

用表格 + 一句话定义的方式介绍本方案的核心概念。

| 概念 | 定义 |
|------|------|
| 概念A | 一句话解释 |
| 概念B | 一句话解释 |

**写作要点**：
- 挑选 3-5 个核心概念
- 用通俗的语言解释
- 对官方资料进行汇总输出
- 对比同类型技术方案的SWOT分析
- 突出本方案解决了已有方案中存在的什么问题


展示最终效果，包含：
- 成品截图/效果展示
- 在线体验链接
- 核心数据（性能提升、功能数量等）
- 素材中若有图片，下载并引用素材配图

### 三、安装/使用

分步骤讲解，包含：
- 代码块（带语言标记）
- 配图标记（提示在什么位置插入图片）
- 命令行示例

**写作要点**：
- 步骤要清晰编号
- 代码块要完整可运行
- 关键步骤要有配图标记
- 控制在 600-800 字

### 四、使用场景与案例

通过应用场景案例或转折深化论述。

- 阶段划分
- 表格展示关键配置
- 人机协作说明（AI 负责什么，人类负责什么）

**写作要点**：
- 真实项目案例
- 展示完整流程
- 体现人机协作
- 控制在 800-1000 字

### 五、拿走即用

总结部分，包含：
- 一行命令安装
- 使用方式速查表
- 相关资源链接

升华 + CTA，包含：
- 价值总结
- 行动召唤
- 互动问题

**写作要点**：
- 简洁明了
- 方便复制
- 控制在 200-300 字

### 六、注意事项

**写作要点**：
- 500-3000 字
- 技术方案在复现、实施、使用过程中可能会碰到的一些常见的问题，以及可能的解决方案。
- 所有的参考资料都要引用，引用时请在代码块中引用，并在文本中添加引用来源及其链接。

### 七、广而告之

添加一段固定内容：

```markdown
<div style="text-align: center;">
<span>关注我的公众号：奥德元</span>
<br>
<br>
<span style="font-size: 1.2em; font-weight: bold; color: red;">一起学习AI，一起追赶时代！</span>
<br>
<br>
<span>新建了一个AI技术交流群，欢迎大家一起加入讨论。</span>
<br>
<span>扫码加入AI技术交流群（微信）</span>
<br>
<span>若需联系作者，请加微信：</span><span style="font-size: 1.2em; font-weight: bold; color: blue;"><b>oddmeta</b></span>
</div>
```

## 八、参考资料（若有参考资料）

若存在参考资料，请务必标注，并标识来源地址。

参考资料格式示例：

```markdown
- [1] **OddTTS项目**: https://github.com/oddmeta/oddtts
- [2] **小落同学**: https://www.oddmeta.net/project-xl
```

## 格式规范

- 标题层级：h1 → h2 → h3
- 代码块：标注语言
- 表格：使用 Markdown 表格
- 图片：用配图标记说明
- 引用：使用 > 区块引用

## 人机协作标注

在文章中适当位置标注：

```markdown
**本文由 AI 辅助调研和撰写**
**人类主导 + AI 协作**
```

---

更多参考：
- `framework_in_depth_articles.md` - 深度长文框架
- `frameworks.md` - 框架类型
- `platform_copy.md` - 各平台文案规范

path: references/generate_chartlet.md
content: # 微贴图设计规范

一键把微信公众号文章转成微贴图（微信公众号贴图） + 发布文案。HTML 模板 + 视觉组件部分

---

## 环境变量

本技能所使用到的环境变量在 `local/.env` 中配置。

支持的环境变量如下：

- `OUTPUT_DIR`：输出目录，用于放置收集到的素材、生成的文章、排版预览、封面图、微贴图轮播图等文件。
- `ARCHIVE_DIR`：存档目录，用于归档历史文档文件。
- `BRAND_NAME`：品牌名称，用于在生成的文章中显示品牌名称。
- `BRAND_LOGO_DARK`：品牌logo（深色），用于在生成的文章中显示品牌logo。
- `BRAND_LOGO_LIGHT`：品牌logo（浅色），用于在生成的文章中显示品牌logo。
- `WECHAT_ID`：微信公众号ID，用于发布文章。
- `WECHAT_SLOGON`：微信公众号标语，用于在文章中显示。
- `WECHAT_APPID`：微信公众号AppID，用于发布文章时的认证。
- `WECHAT_APPSECRET`：微信公众号AppSecret，用于发布文章时的认证。
- `MD_FORMATTER_DIR`：Markdown格式化工具目录，用于格式化Markdown文章。

---

## 尺寸

- 显示尺寸：`720 x 540 px`
- 导出尺寸：`1440 x 1080 px`（2x 缩放）
- 比例：4:3（公众号配图标准）

---

## 卡片设计原则

- 每张卡只讲 **1 个重点**
- 信息密度适中，留白充足
- 暗底页和浅底页 **交替出现**
- 文字要大、要粗、要少
- 数据可视化优先（数字大、表格清晰、SVG 图示）

---

## 页面骨架

```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>公众号贴图图片 - [主题]</title>
<style>
  /* === 基础 === */
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body {
    background: #111;
    display: flex; flex-direction: column; align-items: center;
    padding: 30px 20px 80px;
    padding-top: 70px; /* 为工具栏留空 */
    font-family: -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif;
  }

  .brand { position: absolute; top: 20px; left: 20px; display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 700; letter-spacing: 1px; }
  .brand svg { width: 20px; height: 20px; }
  .brand.dark { color: rgba(255,255,255,0.5); }
  .brand.light { color: rgba(26,51,40,0.4); }

  /* === 卡片 === */
  .slide {
    width: 540px; height: 720px;
    position: relative; overflow: hidden;
    border-radius: 8px; margin-bottom: 40px;
    box-shadow: 0 4px 24px rgba(0,0,0,0.5);
  }

  /* === 暗底 === */
  .bg-dark {
    position: absolute; inset: 0; background: #1A3328;
  }
  .bg-dark::after {
    content: ''; position: absolute; inset: 0;
    background:
      radial-gradient(circle at 80% 15%, rgba(255,255,255,0.03) 0%, transparent 50%),
      radial-gradient(circle at 20% 85%, rgba(0,0,0,0.08) 0%, transparent 50%);
  }

  /* === 浅底 === */
  .bg-light { position: absolute; inset: 0; background: #F2EDE3; }

  /* === 内容层 === */
  .content {
    position: absolute; inset: 0; padding: 50px 40px;
    display: flex; flex-direction: column; z-index: 2;
  }

  /* === 下载工具栏 === */
  .toolbar {
    position: fixed; top: 0; left: 0; right: 0; z-index: 100;
    background: rgba(17,17,17,0.95); backdrop-filter: blur(10px);
    border-bottom: 1px solid rgba(255,255,255,0.1);
    padding: 12px 20px; display: flex; align-items: center;
    justify-content: center; gap: 12px;
  }
  .toolbar .btn {
    background: #1A3328; color: #F2EDE3; border: none;
    padding: 10px 24px; border-radius: 8px; font-size: 14px;
    font-weight: 600; cursor: pointer;
  }
  .toolbar .btn.accent { background: #C44536; }
</style>
</head>
<body>

<!-- 工具栏 -->
<div class="toolbar">
  <button class="btn accent" onclick="downloadAll()">全部下载 (ZIP)</button>
  <button class="btn" onclick="downloadOne()">下载当前</button>
  <span class="progress" id="progress"></span>
</div>

<!-- 第 1 张 · 封面 -->
<div class="slide">...</div>

<!-- 第 2-N 张 · 内容 -->
<div class="slide">...</div>

<!-- CDN + 下载脚本 -->
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/file-saver@2.0.5/dist/FileSaver.min.js"></script>
<script>
const SCALE = 2;
async function renderSlide(s) {
  return html2canvas(s, { scale: SCALE, useCORS: true, backgroundColor: null, width: 540, height: 720, logging: false });
}
function canvasToBlob(c) { return new Promise(r => c.toBlob(r, 'image/png')); }

async function downloadAll() {
  const slides = document.querySelectorAll('.slide');
  const progress = document.getElementById('progress');
  const zip = new JSZip();
  for (let i = 0; i < slides.length; i++) {
    progress.textContent = `渲染第 ${i+1}/${slides.length} 张...`;
    const canvas = await renderSlide(slides[i]);
    zip.file(`公众号贴图-${String(i+1).padStart(2,'0')}.png`, await canvasToBlob(canvas));
  }
  progress.textContent = '打包中...';
  saveAs(await zip.generateAsync({type:'blob'}), '公众号贴图-全部.zip');
  progress.textContent = `${slides.length} 张已下载`;
}

async function downloadOne() {
  const slides = document.querySelectorAll('.slide');
  const vc = window.innerHeight / 2;
  let idx = 0, min = Infinity;
  slides.forEach((s,i) => {
    const d = Math.abs(s.getBoundingClientRect().top + 360 - vc);
    if (d < min) { min = d; idx = i; }
  });
  const canvas = await renderSlide(slides[idx]);
  saveAs(await canvasToBlob(canvas), `公众号贴图-${String(idx+1).padStart(2,'0')}.png`);
}
</script>
</body>
</html>
```

---

### 品牌角标 Brand SVG

每页左上角必须有品牌角标。使用环境变量 `BRAND_LOGO_DARK` 或者 `BRAND_LOGO_LIGHT` 地址的 SVG 图标作为 Brand SVG。

- 暗底版：使用 `BRAND_LOGO_DARK` 作为 Brand SVG。
- 浅底版：使用 `BRAND_LOGO_LIGHT` 作为 Brand SVG。

**注意：品牌角标必须将环境变量的内容原封不动的插入到 HTML 中，不能有任何修改。**

---

## 常用视觉组件

### 信息行（暗底）

```html
<div style="display:flex;align-items:center;padding:12px 16px;border-radius:8px;margin-bottom:8px;background:rgba(255,255,255,0.06);">
  <div style="font-size:13px;font-weight:700;min-width:70px;color:#C44536;">标签</div>
  <div style="font-size:13px;color:rgba(255,255,255,0.7);flex:1;">内容文字</div>
</div>
```

### 信息行（浅底）

同上，`background: rgba(26,51,40,0.06)`，文字色 `color: #555`。

### 步骤序号

```html
<div style="display:flex;gap:14px;margin-bottom:14px;">
  <div style="width:28px;height:28px;border-radius:50%;background:#C44536;color:#F2EDE3;font-size:14px;font-weight:700;display:flex;align-items:center;justify-content:center;">1</div>
  <div style="font-size:14px;line-height:1.7;color:rgba(255,255,255,0.8);">
    <strong style="color:#F2EDE3;">步骤标题</strong><br>步骤说明
  </div>
</div>
```

### 大数字

```html
<div style="display:flex;align-items:baseline;gap:14px;">
  <div style="font-size:64px;font-weight:900;color:#C44536;">22</div>
  <div style="font-size:22px;font-weight:800;color:#F2EDE3;">个 Skill</div>
</div>
```

### 2x2 卡片网格

```html
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
  <div style="background:rgba(255,255,255,0.05);border-radius:10px;padding:14px;">
    <div style="font-size:20px;margin-bottom:6px;">emoji</div>
    <div style="font-size:13px;color:#F2EDE3;font-weight:700;">标题</div>
    <div style="font-size:10px;color:rgba(255,255,255,0.4);line-height:1.6;">说明文字</div>
  </div>
  <!-- 重复 3 个 -->
</div>
```

### 引用块

```html
<div style="border-left:3px solid #C44536;padding:12px 16px;margin:12px 0;background:rgba(255,255,255,0.04);border-radius:0 8px 8px 0;">
  <p style="font-size:14px;color:rgba(255,255,255,0.6);line-height:1.8;font-style:italic;">"引用文字"</p>
</div>
```

### CTA 链接框

```html
<div style="border:1px solid rgba(255,255,255,0.2);border-radius:10px;padding:16px 20px;text-align:center;margin-top:12px;">
  <div style="font-size:11px;color:rgba(255,255,255,0.3);margin-bottom:6px;letter-spacing:2px;">标签</div>
  <div style="font-size:13px;color:#C44536;font-weight:600;word-break:break-all;">链接文字</div>
</div>
```

### 代码预览框（模拟终端）

```html
<div style="background:#1A3328;border-radius:10px;padding:16px;font-family:'SF Mono','Menlo',monospace;">
  <div style="display:flex;gap:6px;margin-bottom:10px;">
    <div style="width:8px;height:8px;border-radius:50%;background:rgba(255,255,255,0.15);"></div>
    <div style="width:8px;height:8px;border-radius:50%;background:rgba(255,255,255,0.15);"></div>
    <div style="width:8px;height:8px;border-radius:50%;background:rgba(255,255,255,0.15);"></div>
    <span style="font-size:9px;color:rgba(255,255,255,0.3);margin-left:6px;">文件名.md</span>
  </div>
  <div style="font-size:10px;line-height:1.8;color:rgba(255,255,255,0.4);">代码内容</div>
</div>
```

### 流程图（转化类）

```html
<div style="display:flex;align-items:center;justify-content:center;gap:12px;">
  <div style="background:rgba(255,255,255,0.1);border-radius:8px;padding:12px 16px;text-align:center;">
    <div style="font-size:24px;">emoji</div>
    <div style="font-size:12px;color:rgba(255,255,255,0.7);margin-top:4px;">步骤1</div>
  </div>
  <div style="color:rgba(255,255,255,0.3);font-size:18px;">→</div>
  <div style="background:rgba(196,69,54,0.2);border:1px solid rgba(196,69,54,0.3);border-radius:8px;padding:12px 16px;text-align:center;">
    <div style="font-size:24px;">emoji</div>
    <div style="font-size:12px;color:#C44536;margin-top:4px;font-weight:600;">最终结果</div>
  </div>
</div>
```

### SVG 数据可视化

对于有数据、对比、地图、流程等内容，用 inline SVG 绘制可视化图表。SVG 渲染清晰度高，适合 html2canvas 导出。


path: references/platform_copy.md
content: # 平台文案规范

本文档定义了各社交媒体平台的文案规范。

## 微贴图

### 封面文案

- **字数**：20 字以内
- **风格**：口语化、有冲击力
- **结构**：关键词 + 痛点/效果

### 正文文案

- **字数**：300-350 字
- **结构**：
  1. 开头：Hook（痛点/悬念）
  2. 中间：核心内容（3-4 个要点）
  3. 结尾：CTA + 标签

### 标签

- 8-12 个标签
- 包含领域关键词
- 热门标签穿插

### 示例

```
开头：
兄弟们！我终于搞定了这个难题！

中间：
1. 第一点...
2. 第二点...
3. 第三点...

结尾：
收藏起来慢慢看～ 👇
#标签1 #标签2 #标签3
```

---

## 微博/博客园/朋友圈

### 风格特点

- **简洁**：每条不超过 280 字
- **真实**：第一人称日常感
- **互动**：留问题引导评论

### 结构

```
[正文内容]
↓

[互动问题]
```

### 圈子标签

- 选择 1-2 个相关圈子
- 圈子名用 # 包围

---

## 播客

### 脚本结构

1. **开场**（1-2 分钟）
   - 打招呼
   - 本期主题介绍
   - 预告亮点

2. **正文**（15-30 分钟）
   - 逻辑清晰
   - 适当留白
   - 口语化表达

3. **结尾**（1-2 分钟）
   - 总结要点
   - CTA（关注、评论）
   - 下期预告

### 语音要求

- 语速：每分钟 150-180 字
- 停顿：关键点停顿
- 情感：自然起伏

---

## 朋友圈

### 文案结构

```
[开头 Hook]：引起注意
[核心内容]：1-2 句话
[行动CTA]：引导互动

[配图]：1-9 张
```

### 风格

- 真实分享感
- 简短精炼
- 适度 emoji

---

## 微博

### 风格特点
- **简洁**：每条不超过 2000 字（普通用户），长微博可达万字
- **话题**：带 #话题标签
- **互动**：转发、评论、点赞

### 结构
```
[正文内容]

#话题1 #话题2 #话题3
```

### 注意事项
- 微博内容审核较严，避免敏感词
- 热门话题更容易获得曝光
- 图片/视频可增加互动

---

## 各平台对比

| 维度 | 微贴图 | 微博 | 博客园 | 朋友圈 | 微博 |
|------|--------|------|------|--------|------|
| 篇幅 | 300字 | 280字 | 15-30分钟 | 100字 | 2000字 |
| 风格 | 种草感 | 日常感 | 深度感 | 分享感 | 话题感 |
| 视觉 | 图片为主 | 文字为主 | 声音为主 | 图片为主 | 混合 |
| 互动 | 收藏 | 评论 | 关注 | 点赞 | 转发 |

---

## 核心原则

1. **适配平台**：不是照搬，要转换
2. **真实感**：不做作，有个人特色
3. **有价值**：要么有用，要么有趣
4. **有行动**：明确 CTA

path: references/publishing_to_cnblogs.md
content: # 博客园发布工具

> 不依赖 manifest，可直接发布 Markdown 文件到博客园

---

### 环境变量支持

在 `local/.env` 中读取 `CNBLOGS_TOKEN`，避免每次输入 token：

## 使用方式

### 基本用法

```bash
python3 "${SKILL_DIR}/scripts/publish_to_cnblogs.py" <markdown文件> --token ${CNBLOGS_TOKEN} [--title <标题>] [--preview]
```

### Windows 示例

```bash
# 推荐方式：使用绝对路径
python ${SKILL_DIR}/scripts/publish_to_cnblogs.py article.md --token ${CNBLOGS_TOKEN}

# 或先切换到脚本目录
cd "${SKILL_DIR}/scripts"
python publish_to_cnblogs.py ../article.md --token ${CNBLOGS_TOKEN}
```

---

## 获取 PAT

1. 登录博客园 → https://i.cnblogs.com/
2. 点击账号设置 → Access Tokens
3. 创建新 Token（建议设置过期时间）

---

## 参数说明

| 参数 | 简写 | 必填 | 说明 |
|------|------|------|------|
| `<input>` | - | 是 | Markdown 文件路径 |
| `--token` | `-t` | 是 | 博客园 PAT（Personal Access Token） |
| `--title` | `-T` | 否 | 文章标题（默认从 `# 标题` 提取） |
| `--description` | `-d` | 否 | 文章摘要（自动提取前几段） |
| `--post-type` | - | 否 | 文章类型，默认 `BlogPost`，可选 `Article` |
| `--preview` | `-p` | 否 | 仅预览，不实际发布 |

---

## 功能特性

### 自动提取信息

- **标题**：自动从 Markdown 文件的第一级标题提取（`# 标题`）
- **摘要**：自动提取文章前几段作为摘要（最多 200 字）
- **内容格式**：自动设置为 Markdown 格式

### 编码处理

脚本已内置 UTF-8 编码处理，解决 Windows 下的中文显示问题。

---

## 常见问题

### "No such file or directory"

**原因**：Windows 路径格式问题

**解决方案**：
1. 使用绝对路径，如 `/path/to/article.md`
2. 或先切换到脚本所在目录，使用相对路径

### 编码错误

**解决方案**：脚本已内置 UTF-8 编码处理，如仍有问题，请确保：
- Markdown 文件使用 UTF-8 编码保存
- 终端支持 UTF-8 显示

### Token 从环境变量读取

在 `local/.env` 中设置 `CNBLOGS_TOKEN`，脚本会自动读取：

```bash
# local/.env
CNBLOGS_TOKEN=your_token_here
```

---

## 完整示例

### 示例 1：基本发布

```bash
python publish_to_cnblogs.py article.md --token ${CNBLOGS_TOKEN}
```

### 示例 2：指定标题

```bash
python publish_to_cnblogs.py article.md --token ${CNBLOGS_TOKEN} --title "我的第一篇博客"
```

### 示例 3：预览模式

```bash
python publish_to_cnblogs.py article.md --token ${CNBLOGS_TOKEN} --preview
```

### 示例 4：指定文章类型

```bash
python publish_to_cnblogs.py article.md --token ${CNBLOGS_TOKEN} --post-type Article
```

---

## 返回结果

### 成功

[OK] Published successfully! Post ID: 12345678 [INFO] Response: {"id": "12345678", "postId": "12345678", ...}


### 失败

[ERROR] Failed: {"error": "Invalid token"}


---

## 相关文件

- 脚本位置：`scripts/publish_to_cnblogs.py`
- 配置文件：`local/.env`
- 环境变量：`CNBLOGS_TOKEN`


path: references/running_python_script_guide.md
content: # 运行python脚本失败应对方法

Windows 环境下，运行python脚本失败，提示 `No such file or directory`。
这是因为Windows环境下bash默认使用的是Windows路径，而python脚本中使用的是Unix路径，导致的。

---

## 应对方法

创建一个run_python.py，并以subprocess.run运行该python脚本。

---

## 示例

```python
import sys
import subprocess

cmd = [
    'md2wechat.py',
    '2026-03-25-超轻量级中文TTS模型推荐.md',
    '-o', '2026-03-25-超轻量级中文TTS模型推荐_preview.html',
    '--theme', 'default',
    '--keep-title'
]

result = subprocess.run([sys.executable] + cmd, capture_output=True, text=True)
result = subprocess.run([sys.executable] + cmd, capture_output=True, text=True, encoding='utf-8', errors='ignore')
print(result.stdout)
print(result.stderr)
```




path: references/video_canvas_template.md
content: # 视频画布模板参考（手账拼贴风格）

> cc 生成视频画布 HTML 时读取此文件。模板包含完整的 CSS、HTML 骨架和 JS 框架。
> cc 根据文章内容填充 9 张卡片内容 + 9 段提词器脚本，其余部分原样复用。

---

## 环境变量

本技能所使用到的环境变量在 `local/.env` 中配置。

支持的环境变量如下：

- `OUTPUT_DIR`：输出目录，用于放置收集到的素材、生成的文章、排版预览、封面图、微贴图轮播图等文件。
- `ARCHIVE_DIR`：存档目录，用于归档历史文档文件。
- `BRAND_NAME`：品牌名称，用于在生成的文章中显示品牌名称。
- `BRAND_LOGO_DARK`：品牌logo（深色），用于在生成的文章中显示品牌logo。
- `BRAND_LOGO_LIGHT`：品牌logo（浅色），用于在生成的文章中显示品牌logo。
- `WECHAT_ID`：微信公众号ID，用于发布文章。
- `WECHAT_SLOGON`：微信公众号标语，用于在文章中显示。
- `WECHAT_APPID`：微信公众号AppID，用于发布文章时的认证。
- `WECHAT_APPSECRET`：微信公众号AppSecret，用于发布文章时的认证。
- `CNBLOGS_TOKEN`：博客园Token，用于发布博客园文章时的认证。
- `MD_FORMATTER_DIR`：Markdown格式化工具目录，用于格式化Markdown文章。
- `BAOYU_WECHAT_SKILL_DIR`: 奥德元微信技能目录，用于发布文章。
- `MD_TO_WECHAT_SCRIPT`: Markdown文章转微信文章脚本，用于将Markdown文章转换为微信文章。

---

## 输入素材类型

cc 接受任意素材输入，自动识别并提取内容：

| 输入类型 | 处理方式 |
|---------|---------|
| 微信链接 | 调用 `scripts/wechat_download.py` 抓取 |
| Markdown 文件 | 直接读取 |
| PDF 文件 | 读取并提取文本 |
| HTML 文件 | 读取并提取正文 |
| 纯文本 | 直接使用 |
| 用户口述/粘贴内容 | 直接使用 |

提取内容后，分析结构 → 拆分为 9 张卡片 + 9 段提词器脚本。

---

## 关键技术陷阱

**必须遵守，否则 HTML 会崩溃：**

1. **`</script>` 拆分**：模板字符串或 innerHTML 中出现 `</script>` 会终止外层 script 块。必须拆开写：
   ```js
   // ❌ 错误
   doc.body.innerHTML = '<script>...</script>';
   // ✅ 正确
   doc.body.innerHTML = '<scr' + 'ipt>...</scr' + 'ipt>';
   ```

2. **let 变量 TDZ**：所有 `let` 变量声明必须在 `goOverview()` 调用之前，否则触发 Temporal Dead Zone 错误。

3. **瘦脸 scale**：使用 `scale(-(1.2*slimFactor), 1.2)` 实现镜像+瘦脸。不要用 `scale(-slimFactor, 1)`，会导致摄像头画面不填满圆框。

4. **`</style>` 安全**：如果 CSS 中有内联模板，同样注意 `</style>` 标签不要意外闭合。

---

## 完整 HTML 模板

### 文档头

```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>oddmeta #{{NUMBER}} - {{TITLE}}</title>
    <style>
        /* === 完整 CSS 见下方 === */
    </style>
</head>
<body>
    <!-- === 完整 HTML 骨架见下方 === -->
    <script>
        // === 完整 JS 见下方 ===
    </script>
</body>
</html>
```

---

## CSS 框架（~320行）

```css
@import url('https://fonts.googleapis.com/css2?family=Caveat:wght@400;600;700&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #E8E3D9; cursor: grab; }
body.dragging { cursor: grabbing; }
body.dragging .world { transition: none !important; }

/* 方格纸底 */
.world {
    position: absolute; width: 3600px; height: 2000px;
    background: #F2EDE3;
    background-image:
        linear-gradient(rgba(26,51,40,0.05) 1px, transparent 1px),
        linear-gradient(90deg, rgba(26,51,40,0.05) 1px, transparent 1px);
    background-size: 24px 24px;
    transform-origin: 0 0;
    transition: transform 0.7s cubic-bezier(0.25, 0.1, 0.25, 1);
    will-change: transform;
}

/* 卡片通用 */
.card {
    position: absolute; cursor: pointer;
    transition: box-shadow 0.3s, filter 0.3s;
}
.card:hover { filter: brightness(1.02); box-shadow: 8px 8px 0 rgba(26,51,40,0.15) !important; }
.card.active { z-index: 50 !important; }

/* 胶带 */
.tape { position: absolute; height: 26px; z-index: 10; pointer-events: none; }
.tape-tl { top: -13px; left: 28px; transform: rotate(-4deg); }
.tape-tr { top: -13px; right: 28px; transform: rotate(3deg); }
.tape-bl { bottom: -13px; left: 36px; transform: rotate(3deg); }
.tape-br { bottom: -13px; right: 28px; transform: rotate(-2deg); }
.tape-w80 { width: 80px; } .tape-w100 { width: 100px; }
.tape-red { background: rgba(196,69,54,0.13); }
.tape-green { background: rgba(26,51,40,0.09); }

/* 便签 */
.sticky {
    position: absolute; padding: 10px 14px; font-family: 'Caveat', cursive; font-size: 16px;
    line-height: 1.4; z-index: 10; box-shadow: 2px 2px 5px rgba(0,0,0,0.07); pointer-events: none;
}
.sticky-y { background: #FFF8DC; color: #1A3328; }
.sticky-p { background: #FFE4E1; color: #C44536; }
.sticky-g { background: #E8EDEA; color: #1A3328; }

/* 连线 */
.connector { position: absolute; pointer-events: none; z-index: 5; }
.connector path { fill: none; stroke: #1A3328; stroke-width: 2; stroke-dasharray: 8 6; opacity: 0.2; }

/* === c1: 标题卡（墨绿底） === */
.c1 { left:80px;top:200px;width:500px;height:460px;background:#1A3328;padding:44px;display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;transform:rotate(-1.2deg);box-shadow:6px 6px 0 rgba(26,51,40,0.15);z-index:10; }
.c1 .logo { font-family:'Caveat',cursive;font-size:22px;color:#7A8C80;margin-bottom:20px; }
.c1 h1 { font-size:44px;color:#F2EDE3;line-height:1.35;font-weight:900;margin-bottom:16px; }
.c1 h1 em { font-style:normal;display:inline-block;background:#C44536;padding:2px 10px;transform:rotate(-1deg); }
.c1 .sub { font-family:'Caveat',cursive;font-size:22px;color:#7A8C80; }

/* === c2: 数据卡（白底） === */
.c2 { left:680px;top:80px;width:380px;height:420px;background:white;padding:40px;display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;transform:rotate(1deg);border:2px solid #1A3328;box-shadow:5px 5px 0 rgba(26,51,40,0.1);z-index:10; }
.c2 .num { font-family:'Caveat',cursive;font-size:120px;font-weight:700;color:#C44536;line-height:1; }
.c2 .lbl { font-size:26px;color:#1A3328;font-weight:800;margin-bottom:12px; }
.c2 .det { font-size:15px;color:#7A8C80;line-height:2; }
.c2 .stamp { margin-top:16px;padding:6px 18px;border:2px solid #C44536;border-radius:3px;font-family:'Caveat',cursive;font-size:20px;color:#C44536;transform:rotate(-4deg); }

/* === c3: 痛点/问题卡（白底+条纹） === */
.c3 { left:680px;top:540px;width:440px;height:400px;background:white;padding:36px;display:flex;flex-direction:column;justify-content:center;transform:rotate(-0.5deg);border:2px solid #1A3328;box-shadow:5px 5px 0 rgba(26,51,40,0.1);z-index:10; }
.c3 h2 { font-size:26px;color:#1A3328;font-weight:900;margin-bottom:20px; }
.c3 .strip { background:#FFE4E1;padding:12px 16px;margin-bottom:8px;border-left:4px solid #C44536;font-size:15px;color:#333;line-height:1.5; }
.c3 .strip:nth-child(odd) { transform:rotate(0.3deg); }
.c3 .strip:nth-child(even) { transform:rotate(-0.3deg); }
.c3 .punchline { margin-top:16px;padding:16px;background:#1A3328;color:#F2EDE3;border-radius:3px;font-size:18px;font-weight:800;line-height:1.5;text-align:center;transform:rotate(0.5deg); }
.c3 .punchline span { color:#C44536; }

/* === c4: 步骤卡（墨绿底+ticket） === */
.c4 { left:1200px;top:140px;width:460px;height:460px;background:#1A3328;padding:40px;display:flex;flex-direction:column;justify-content:center;transform:rotate(0.7deg);box-shadow:5px 5px 0 rgba(26,51,40,0.2);z-index:10; }
.c4 h2 { font-family:'Caveat',cursive;font-size:34px;color:#F2EDE3;font-weight:700;margin-bottom:24px; }
.c4 .ticket { background:white;padding:16px 20px;margin-bottom:12px;border-left:5px solid #C44536;display:flex;align-items:flex-start;gap:14px; }
.c4 .ticket:nth-child(odd) { transform:rotate(-0.3deg); }
.c4 .ticket:nth-child(even) { transform:rotate(0.4deg); }
.c4 .tnum { font-family:'Caveat',cursive;font-size:30px;font-weight:700;color:#C44536;min-width:28px;line-height:1; }
.c4 .tbody h3 { font-size:17px;color:#1A3328;font-weight:700;margin-bottom:2px; }
.c4 .tbody p { font-size:12px;color:#7A8C80;line-height:1.4; }
.c4 .foot { text-align:center;color:#7A8C80;font-size:14px;margin-top:12px; }

/* === c5: 架构卡（白底+流程框） === */
.c5 { left:1220px;top:660px;width:480px;height:400px;background:white;padding:36px;display:flex;flex-direction:column;justify-content:center;transform:rotate(-0.6deg);border:2px solid #1A3328;box-shadow:5px 5px 0 rgba(26,51,40,0.1);z-index:10; }
.c5 h2 { font-size:26px;color:#1A3328;font-weight:900;margin-bottom:20px; }
.c5 .abox { background:#F2EDE3;border:1.5px solid #1A3328;padding:14px 18px;margin-bottom:6px;position:relative; }
.c5 .abox h3 { font-size:16px;color:#1A3328;font-weight:800;margin-bottom:3px; }
.c5 .abox p { font-size:12px;color:#7A8C80;line-height:1.5; }
.c5 .abox .mt { position:absolute;top:-7px;right:14px;width:56px;height:16px;transform:rotate(2deg); }
.c5 .abox .mt.red { background:rgba(196,69,54,0.15); }
.c5 .abox .mt.grn { background:rgba(26,51,40,0.1); }
.c5 .aconn { text-align:center;font-family:'Caveat',cursive;font-size:18px;color:#7A8C80;padding:1px 0; }

/* === c6: 数据/对比卡（白底+小票） === */
.c6 { left:1780px;top:100px;width:400px;height:440px;background:white;padding:36px;display:flex;flex-direction:column;justify-content:center;transform:rotate(0.5deg);border:2px solid #1A3328;box-shadow:5px 5px 0 rgba(26,51,40,0.1);z-index:10; }
.c6 h2 { font-size:26px;color:#1A3328;font-weight:900;margin-bottom:20px; }
.c6 .receipt { background:#F2EDE3;padding:18px;border:1px dashed rgba(26,51,40,0.3);font-family:monospace; }
.c6 .rrow { display:flex;justify-content:space-between;padding:7px 0;font-size:14px;color:#333;border-bottom:1px dotted rgba(26,51,40,0.15); }
.c6 .rrow .v { font-weight:700;color:#1A3328; } .c6 .rrow .v.r { color:#C44536; }
.c6 .rtotal { display:flex;justify-content:space-between;padding:10px 0 0;font-size:16px;font-weight:800;color:#C44536; }
.c6 .fnote { margin-top:14px;font-family:'Caveat',cursive;font-size:17px;color:#7A8C80;text-align:center;transform:rotate(-1deg); }

/* === c7: 金句卡（墨绿底+大引号） === */
.c7 { left:1800px;top:600px;width:440px;height:400px;background:#1A3328;padding:48px;display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;transform:rotate(-0.8deg);box-shadow:5px 5px 0 rgba(26,51,40,0.2);z-index:10; }
.c7 .qm { font-family:'Caveat',cursive;font-size:72px;color:#C44536;line-height:0.5;margin-bottom:20px; }
.c7 .qt { font-size:30px;color:#F2EDE3;font-weight:900;line-height:1.6;margin-bottom:20px; }
.c7 .qt em { font-style:normal;display:inline-block;background:#C44536;padding:2px 8px; }
.c7 .qs { font-family:'Caveat',cursive;font-size:19px;color:#7A8C80;line-height:1.8; }

/* === c8: 闭环/特色卡（白底+标签流） === */
.c8 { left:2340px;top:180px;width:500px;height:400px;background:white;padding:36px;display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;transform:rotate(0.4deg);border:2px solid #1A3328;box-shadow:5px 5px 0 rgba(26,51,40,0.1);z-index:10; }
.c8 h2 { font-size:26px;color:#1A3328;font-weight:900;margin-bottom:24px; }
.c8 .trk { display:flex;align-items:center;gap:6px;margin-bottom:10px; }
.c8 .tag { padding:10px 16px;border-radius:5px;font-size:14px;font-weight:700;border:2px solid #1A3328;background:white;color:#1A3328; }
.c8 .tag.hot { background:#C44536;border-color:#C44536;color:white;transform:rotate(-1deg); }
.c8 .tag.em { font-size:17px;padding:8px 10px; }
.c8 .arr { font-family:'Caveat',cursive;font-size:20px;color:#7A8C80; }
.c8 .ins { margin-top:16px;font-size:15px;color:#7A8C80;line-height:1.8; }

/* === c9: CTA卡（墨绿底+品牌） === */
.c9 { left:2380px;top:640px;width:380px;height:380px;background:#1A3328;padding:44px;display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;transform:rotate(-0.6deg);box-shadow:5px 5px 0 rgba(26,51,40,0.2);z-index:10; }
.c9 .fish { font-size:52px;margin-bottom:12px; }
.c9 .brand { font-family:'Caveat',cursive;font-size:40px;color:#F2EDE3;font-weight:700;margin-bottom:6px; }
.c9 .sl { font-size:15px;color:#7A8C80;line-height:1.7;margin-bottom:20px; }
.c9 .cta { display:inline-block;background:#C44536;color:white;padding:12px 32px;font-size:16px;font-weight:700;border-radius:3px;transform:rotate(-1.5deg);box-shadow:3px 3px 0 rgba(0,0,0,0.12); }

/* === 导航栏 === */
.nav-bar {
    position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
    display: flex; gap: 6px; z-index: 300;
    background: rgba(26,51,40,0.9); padding: 8px 16px; border-radius: 28px;
    backdrop-filter: blur(12px);
}
.nav-dot {
    width: 32px; height: 32px; border-radius: 50%; border: 2px solid rgba(242,237,227,0.3);
    background: transparent; color: #F2EDE3; font-family: 'Caveat', cursive;
    font-size: 15px; font-weight: 700; cursor: pointer;
    display: flex; align-items: center; justify-content: center; transition: all 0.3s;
}
.nav-dot:hover { border-color: #F2EDE3; background: rgba(242,237,227,0.1); }
.nav-dot.active { background: #C44536; border-color: #C44536; }
.nav-overview {
    width: 32px; height: 32px; border-radius: 50%; border: 2px solid rgba(242,237,227,0.3);
    background: transparent; color: #F2EDE3; font-size: 14px; cursor: pointer;
    display: flex; align-items: center; justify-content: center; transition: all 0.3s; margin-right: 8px;
}
.nav-overview:hover { border-color: #F2EDE3; }
.nav-overview.active { background: rgba(242,237,227,0.15); border-color: #F2EDE3; }

/* === 摄像头（可拖拽） === */
.webcam-wrap {
    position: fixed; bottom: 80px; right: 32px; width: 160px; height: 160px;
    border-radius: 50%; overflow: hidden; border: 3px solid #1A3328;
    box-shadow: 4px 4px 0 rgba(26,51,40,0.15); z-index: 200; background: #1A3328; transform: rotate(-2deg);
    cursor: grab; user-select: none;
}
.webcam-wrap.dragging { cursor: grabbing; }
.webcam-wrap video { width: 100%; height: 100%; object-fit: cover; transform: scaleX(-1); }
.webcam-wrap.off { display: none; }

/* === 工具栏（左下角） === */
.toolbar {
    position: fixed; bottom: 24px; left: 24px;
    display: flex; gap: 6px; z-index: 300;
    background: rgba(255,255,255,0.95); padding: 6px 10px; border-radius: 14px;
    box-shadow: 0 2px 12px rgba(0,0,0,0.1); border: 1px solid rgba(26,51,40,0.1);
}
.tb-btn {
    width: 44px; height: 44px; border-radius: 10px; border: none; cursor: pointer;
    display: flex; align-items: center; justify-content: center; font-size: 18px;
    background: transparent; transition: all 0.2s;
}
.tb-btn:hover { background: rgba(26,51,40,0.06); }
.tb-btn.active { background: #1A3328; color: white; }
.tb-rec {
    background: #C44536 !important; color: white; font-size: 14px; font-weight: 700;
    width: auto; padding: 0 18px; gap: 6px; border-radius: 22px;
}
.tb-rec:hover { background: #a33828 !important; }
.tb-rec .dot { width: 10px; height: 10px; border-radius: 50%; background: white; }

/* === 录制状态（保留导航栏和页码） === */
body.recording .hint,
body.recording .cam-toggle { display: none !important; }

.rec-indicator {
    position: fixed; top: 20px; left: 20px; z-index: 400;
    display: none; align-items: center; gap: 8px;
    background: rgba(196,69,54,0.9); color: white;
    padding: 8px 18px; border-radius: 24px; font-size: 14px; font-weight: 700;
    cursor: pointer; backdrop-filter: blur(8px);
    animation: rec-pulse 1.5s ease-in-out infinite;
}
.rec-indicator .rec-dot { width: 10px; height: 10px; border-radius: 50%; background: white; }
body.recording .rec-indicator { display: flex; }
@keyframes rec-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
.rec-timer { font-family: 'Caveat', cursive; font-size: 18px; font-variant-numeric: tabular-nums; }

/* 页码 / 提示 */
.page-num { position: fixed; top: 20px; right: 28px; font-family: 'Caveat', cursive; font-size: 22px; color: #7A8C80; z-index: 300; }
.hint { position: fixed; top: 20px; left: 28px; font-size: 13px; color: #7A8C80; z-index: 300; transition: opacity 0.5s; }
.hint.hide { opacity: 0; pointer-events: none; }
.cam-toggle {
    position: fixed; bottom: 80px; right: 200px;
    background: rgba(26,51,40,0.85); color: #F2EDE3; border: none;
    padding: 8px 14px; border-radius: 20px; font-size: 12px; cursor: pointer; z-index: 200;
}
.cam-toggle:hover { background: #1A3328; }
.cam-toggle.on { background: #C44536; }

/* === 设置面板 === */
.modal-overlay {
    position: fixed; inset: 0; background: rgba(0,0,0,0.4);
    z-index: 500; display: none; align-items: center; justify-content: center;
    backdrop-filter: blur(4px);
}
.modal-overlay.show { display: flex; }
.modal {
    background: white; border-radius: 16px; padding: 32px;
    width: 480px; max-width: 90vw; box-shadow: 0 20px 60px rgba(0,0,0,0.2);
    position: relative;
}
.modal h2 { font-size: 24px; font-weight: 900; color: #1A3328; margin-bottom: 24px; }
.modal .close-btn {
    position: absolute; top: 16px; right: 16px; width: 36px; height: 36px;
    border-radius: 50%; border: none; background: #f0f0f0; cursor: pointer;
    font-size: 18px; display: flex; align-items: center; justify-content: center;
}
.modal .close-btn:hover { background: #e0e0e0; }
.modal .section-label { font-size: 13px; color: #999; margin-bottom: 12px; }
.modal .start-rec-btn {
    width: 100%; padding: 14px; border-radius: 12px; border: none;
    background: #C44536; color: white; font-size: 16px; font-weight: 700;
    cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 8px;
}
.modal .start-rec-btn:hover { background: #a33828; }
.modal .info-box {
    background: #f8f8f8; border-radius: 10px; padding: 14px 16px;
    margin-bottom: 20px; font-size: 13px; color: #666; line-height: 1.6;
}
.modal .info-box strong { color: #1A3328; }

/* === 美颜面板 === */
.beauty-panel {
    position: fixed; bottom: 80px; left: 24px;
    background: rgba(255,255,255,0.97); border-radius: 14px;
    padding: 16px 20px; z-index: 350; width: 220px;
    box-shadow: 0 4px 20px rgba(0,0,0,0.12); border: 1px solid rgba(26,51,40,0.1);
    display: none;
}
.beauty-panel.show { display: block; }
.bp-title { font-size: 14px; font-weight: 700; color: #1A3328; margin-bottom: 12px; }
.bp-row { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
.bp-row:last-child { margin-bottom: 0; }
.bp-label { font-size: 12px; color: #666; min-width: 32px; }
.bp-row input[type=range] { flex: 1; accent-color: #1A3328; }
body.recording .beauty-panel { display: none !important; }

/* === 网站演示层 === */
.web-layer {
    position: fixed; inset: 0; z-index: 100;
    display: none; flex-direction: column;
    background: #E8E3D9;
}
.web-layer.show { display: flex; }
.web-bar {
    height: 40px; background: rgba(26,51,40,0.95); display: flex; align-items: center;
    padding: 0 12px; gap: 8px; flex-shrink: 0;
}
body.recording .web-bar { display: none; }
.web-bar-url {
    flex: 1; font-size: 13px; color: #F2EDE3; font-family: monospace;
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.web-bar-btn {
    background: none; border: 1px solid rgba(242,237,227,0.3); color: #F2EDE3;
    border-radius: 6px; padding: 4px 10px; font-size: 12px; cursor: pointer;
}
.web-bar-btn:hover { background: rgba(242,237,227,0.1); }
.web-iframe {
    flex: 1; border: none; width: 100%; background: white;
}

/* 网站管理弹窗 */
.web-modal-overlay {
    position: fixed; inset: 0; background: rgba(0,0,0,0.4);
    z-index: 500; display: none; align-items: center; justify-content: center;
    backdrop-filter: blur(4px);
}
.web-modal-overlay.show { display: flex; }
.web-modal {
    background: white; border-radius: 16px; padding: 32px;
    width: 520px; max-width: 90vw; box-shadow: 0 20px 60px rgba(0,0,0,0.2);
    position: relative; max-height: 80vh; display: flex; flex-direction: column;
}
.web-modal h2 { font-size: 24px; font-weight: 900; color: #1A3328; margin-bottom: 20px; }
.web-modal .close-btn {
    position: absolute; top: 16px; right: 16px; width: 36px; height: 36px;
    border-radius: 50%; border: none; background: #f0f0f0; cursor: pointer;
    font-size: 18px; display: flex; align-items: center; justify-content: center;
}
.web-modal .close-btn:hover { background: #e0e0e0; }
.web-url-input-row {
    display: flex; gap: 8px; margin-bottom: 16px;
}
.web-url-input {
    flex: 1; padding: 10px 14px; border: 2px solid #ddd; border-radius: 10px;
    font-size: 14px; outline: none;
}
.web-url-input:focus { border-color: #1A3328; }
.web-url-add-btn {
    padding: 10px 20px; border-radius: 10px; border: none;
    background: #1A3328; color: white; font-size: 14px; font-weight: 700;
    cursor: pointer; white-space: nowrap;
}
.web-url-add-btn:hover { background: #2a4a3a; }
.web-url-list {
    flex: 1; overflow-y: auto; min-height: 60px;
}
.web-url-item {
    display: flex; align-items: center; gap: 10px; padding: 10px 12px;
    border-radius: 8px; margin-bottom: 6px; background: #f8f8f8;
}
.web-url-item:hover { background: #f0f0f0; }
.web-url-item .idx {
    width: 24px; height: 24px; border-radius: 50%; background: #1A3328; color: white;
    font-size: 12px; font-weight: 700; display: flex; align-items: center; justify-content: center;
    flex-shrink: 0;
}
.web-url-item .url-text {
    flex: 1; font-size: 13px; color: #333; word-break: break-all;
}
.web-url-item .go-btn {
    padding: 4px 12px; border-radius: 6px; border: 1px solid #1A3328;
    background: white; color: #1A3328; font-size: 12px; cursor: pointer;
}
.web-url-item .go-btn:hover { background: #1A3328; color: white; }
.web-url-item .del-btn {
    padding: 4px 8px; border-radius: 6px; border: 1px solid #ddd;
    background: white; color: #999; font-size: 12px; cursor: pointer;
}
.web-url-item .del-btn:hover { background: #C44536; color: white; border-color: #C44536; }
.web-url-empty {
    text-align: center; color: #999; font-size: 13px; padding: 24px 0;
}

/* 导航栏网站分隔 + 网站导航点 */
.nav-web-sep {
    width: 1px; height: 20px; background: rgba(242,237,227,0.2);
    margin: 6px 4px; display: none;
}
.nav-web-sep.show { display: block; }
.nav-dot-web {
    width: 32px; height: 32px; border-radius: 50%; border: 2px solid rgba(242,237,227,0.3);
    background: transparent; color: #F2EDE3; font-size: 14px; cursor: pointer;
    display: flex; align-items: center; justify-content: center; transition: all 0.3s;
}
.nav-dot-web:hover { border-color: #F2EDE3; background: rgba(242,237,227,0.1); }
.nav-dot-web.active { background: #C44536; border-color: #C44536; }

/* === 实时字幕 === */
.subtitle-bar {
    position: fixed; bottom: 72px; left: 50%; transform: translateX(-50%);
    z-index: 250; pointer-events: none;
    max-width: 70vw; text-align: center;
    display: none;
}
.subtitle-bar.show { display: block; }
.subtitle-text {
    display: inline-block; background: rgba(0,0,0,0.7); color: white;
    padding: 10px 24px; border-radius: 8px; font-size: 22px; font-weight: 600;
    line-height: 1.6; backdrop-filter: blur(4px);
    max-width: 100%; word-break: break-word;
}
.subtitle-text:empty { display: none; }
```

---

## HTML 骨架

```html
<body>
    <div class="page-num" id="pageNum">全景</div>
    <div class="hint" id="hint">点击卡片放大 · ← → 翻页 · ESC 回全景</div>

    <!-- 摄像头 -->
    <div class="webcam-wrap off" id="webcam"><video id="camVideo" autoplay playsinline muted></video></div>
    <button class="cam-toggle" id="camBtn">📷</button>

    <!-- 录制指示器 -->
    <div class="rec-indicator" id="recIndicator" title="点击停止录制">
        <div class="rec-dot"></div>
        <span>REC</span>
        <span class="rec-timer" id="recTimer">00:00</span>
    </div>

    <!-- 实时字幕 -->
    <div class="subtitle-bar" id="subtitleBar">
        <span class="subtitle-text" id="subtitleText"></span>
    </div>

    <!-- 网站演示层 -->
    <div class="web-layer" id="webLayer">
        <div class="web-bar">
            <span class="web-bar-url" id="webBarUrl">—</span>
            <button class="web-bar-btn" id="webBarBack" title="返回画布">✕ 退出</button>
        </div>
        <iframe class="web-iframe" id="webIframe" sandbox="allow-scripts allow-same-origin allow-forms allow-popups" allowfullscreen></iframe>
    </div>

    <!-- 网站管理弹窗 -->
    <div class="web-modal-overlay" id="webModal">
        <div class="web-modal">
            <button class="close-btn" id="closeWebModal">&times;</button>
            <h2>网站演示</h2>
            <div class="web-url-input-row">
                <input class="web-url-input" id="webUrlInput" type="url" placeholder="输入网址，如 https://oddmeta.net">
                <button class="web-url-add-btn" id="webUrlAddBtn">添加</button>
            </div>
            <div class="web-url-list" id="webUrlList">
                <div class="web-url-empty">还没有添加网站</div>
            </div>
        </div>
    </div>

    <!-- 工具栏 -->
    <div class="toolbar">
        <button class="tb-btn" id="btnTeleprompter" title="提词器">📋</button>
        <button class="tb-btn" id="btnCamToggle" title="摄像头开关">📷</button>
        <button class="tb-btn" id="btnBeauty" title="美颜">✨</button>
        <button class="tb-btn" id="btnSubtitle" title="实时字幕">💬</button>
        <button class="tb-btn" id="btnWebsite" title="网站演示">🌐</button>
        <button class="tb-btn tb-rec" id="btnRecord" title="开始录制">
            <div class="dot"></div>
            <span>录制</span>
        </button>
    </div>

    <!-- 美颜面板 -->
    <div class="beauty-panel" id="beautyPanel">
        <div class="bp-title">美颜</div>
        <div class="bp-row"><span class="bp-label">美白</span><input type="range" min="0" max="100" value="25" id="sliderWhiten"></div>
        <div class="bp-row"><span class="bp-label">瘦脸</span><input type="range" min="0" max="100" value="12" id="sliderSlim"></div>
        <div class="bp-row"><span class="bp-label">美牙</span><input type="range" min="0" max="100" value="20" id="sliderTeeth"></div>
        <div class="bp-row"><span class="bp-label">磨皮</span><input type="range" min="0" max="100" value="10" id="sliderSmooth"></div>
    </div>

    <!-- 录制确认面板（16:9 固定） -->
    <div class="modal-overlay" id="settingsModal">
        <div class="modal">
            <button class="close-btn" id="closeSettings">&times;</button>
            <h2>开始录制</h2>
            <div class="info-box">
                <strong>16:9 全屏录制</strong><br>
                点击开始后选择当前标签页。录完自动下载 webm 文件。<br>
                各平台（视频号/微贴图/抖音）都支持 16:9 横屏上传。
            </div>
            <button class="start-rec-btn" id="startRecBtn">
                <div style="width:10px;height:10px;border-radius:50%;background:white;"></div>
                开始录制
            </button>
        </div>
    </div>

    <!-- 导航栏 -->
    <div class="nav-bar">
        <button class="nav-overview active" id="navOverview" title="全景">⊞</button>
        <!-- cc 生成 9 个导航点 -->
        <button class="nav-dot" data-idx="0">1</button>
        <button class="nav-dot" data-idx="1">2</button>
        <button class="nav-dot" data-idx="2">3</button>
        <button class="nav-dot" data-idx="3">4</button>
        <button class="nav-dot" data-idx="4">5</button>
        <button class="nav-dot" data-idx="5">6</button>
        <button class="nav-dot" data-idx="6">7</button>
        <button class="nav-dot" data-idx="7">8</button>
        <button class="nav-dot" data-idx="8">9</button>
        <div class="nav-web-sep" id="navWebSep"></div>
        <!-- 动态网站导航点由 JS 插入此处 -->
    </div>

    <!-- 画布 -->
    <div class="world" id="world">
        <!-- 连接线（固定，不用改） -->
        <div class="connector" style="left:540px;top:400px;"><svg width="180" height="60"><path d="M0,30 C40,10 80,50 160,20"/></svg></div>
        <div class="connector" style="left:820px;top:480px;"><svg width="60" height="100"><path d="M30,0 C10,30 50,70 30,100"/></svg></div>
        <div class="connector" style="left:1050px;top:280px;"><svg width="180" height="60"><path d="M0,40 C50,10 100,50 170,20"/></svg></div>
        <div class="connector" style="left:1370px;top:580px;"><svg width="60" height="120"><path d="M30,0 C50,40 10,80 30,110"/></svg></div>
        <div class="connector" style="left:1640px;top:300px;"><svg width="180" height="60"><path d="M0,30 C50,5 100,55 160,25"/></svg></div>
        <div class="connector" style="left:1860px;top:520px;"><svg width="60" height="120"><path d="M30,0 C10,40 50,80 30,110"/></svg></div>
        <div class="connector" style="left:2200px;top:350px;"><svg width="180" height="60"><path d="M0,35 C50,5 100,55 160,20"/></svg></div>
        <div class="connector" style="left:2540px;top:560px;"><svg width="60" height="120"><path d="M30,0 C50,40 10,80 30,110"/></svg></div>

        <!-- ========== 9 张卡片（cc 根据文章内容填充） ========== -->
        <!-- 下方是每张卡片的 HTML 结构示例 -->
    </div>
</body>
```

---

## 9 张卡片内容规范

cc 根据文章内容选择每张卡片的内容。以下是每种卡片的 HTML 结构和适用场景。

### c1 — 标题卡（墨绿底，大字，红色强调）

**适用**：主标题 + hook，必须有冲击力

```html
<div class="card c1" data-idx="0">
    <div class="tape tape-tl tape-w100 tape-green"></div>
    <div class="tape tape-tr tape-w80 tape-red"></div>
    <div class="logo">oddmeta · #{{NUMBER}}</div>
    <h1>{{标题第一行}}<br>{{标题第二行}}<br><em>{{红色强调词}}</em></h1>
    <div class="sub">{{副标题/数据摘要}}</div>
    <div class="sticky sticky-y" style="bottom:-18px;right:-8px;transform:rotate(4deg);font-size:15px;">{{便签文字}} →</div>
</div>
```

### c2 — 数据卡（白底，大数字，印章）

**适用**：核心数据展示，用一个大数字震撼

```html
<div class="card c2" data-idx="1">
    <div class="tape tape-tl tape-w80 tape-green"></div>
    <div class="tape tape-br tape-w80 tape-red"></div>
    <div class="num">{{核心数字}}</div>
    <div class="lbl">{{单位/标签}}</div>
    <div class="det">{{补充数据}}<br>{{补充数据}}</div>
    <div class="stamp">{{印章文字}}</div>
    <div class="sticky sticky-p" style="bottom:-14px;left:-8px;font-size:13px;transform:rotate(-3deg);">{{便签}}</div>
</div>
```

### c3 — 痛点/问题卡（白底，条纹列表，金句底栏）

**适用**：为什么？痛点列举 + 一句话总结

```html
<div class="card c3" data-idx="2">
    <div class="tape tape-tl tape-w100 tape-red"></div>
    <div class="tape tape-tr tape-w80 tape-green"></div>
    <h2>{{问题标题}}</h2>
    <div class="strip">✗ {{痛点1}}</div>
    <div class="strip">✗ {{痛点2}}</div>
    <div class="strip">✗ {{痛点3}}</div>
    <div class="punchline">{{总结}}<br>是 <span>「{{核心洞察}}」</span> 的不够</div>
    <div class="sticky sticky-g" style="bottom:-12px;right:-6px;font-size:13px;transform:rotate(3deg);">{{便签}}</div>
</div>
```

### c4 — 步骤卡（墨绿底，ticket列表）

**适用**：怎么做？操作流程，3-4步

```html
<div class="card c4" data-idx="3">
    <div class="tape tape-tl tape-w80" style="background:rgba(242,237,227,0.08);"></div>
    <h2>{{步骤标题}}</h2>
    <div class="ticket"><span class="tnum">1</span><div class="tbody"><h3>{{步骤1标题}}</h3><p>{{步骤1描述}}</p></div></div>
    <div class="ticket"><span class="tnum">2</span><div class="tbody"><h3>{{步骤2标题}}</h3><p>{{步骤2描述}}</p></div></div>
    <div class="ticket"><span class="tnum">3</span><div class="tbody"><h3>{{步骤3标题}}</h3><p>{{步骤3描述}}</p></div></div>
    <div class="foot" style="color:#7A8C80;">{{底部小字}}</div>
    <div class="sticky sticky-y" style="bottom:-16px;right:-6px;font-size:13px;transform:rotate(3deg);">{{便签}}</div>
</div>
```

### c5 — 架构卡（白底，流程框+胶带）

**适用**：技术原理、架构拆解，2-3个流程框

```html
<div class="card c5" data-idx="4">
    <div class="tape tape-tl tape-w100 tape-green"></div>
    <div class="tape tape-br tape-w80 tape-red"></div>
    <h2>{{架构标题}}</h2>
    <div class="abox"><div class="mt red"></div><h3>① {{层级1}}</h3><p>{{描述}}</p></div>
    <div class="aconn">↓</div>
    <div class="abox"><div class="mt grn"></div><h3>② {{层级2}}</h3><p>{{描述}}</p></div>
    <div class="aconn">↓</div>
    <div class="abox"><div class="mt red"></div><h3>③ {{层级3}}</h3><p>{{描述}}</p></div>
    <div class="sticky sticky-y" style="bottom:-14px;right:-6px;font-size:12px;transform:rotate(3deg);">{{便签}}</div>
</div>
```

### c6 — 数据/对比卡（白底，小票 receipt）

**适用**：算账、成本对比、数据拆解

```html
<div class="card c6" data-idx="5">
    <div class="tape tape-tl tape-w80 tape-red"></div>
    <h2>{{对比标题}} 🧾</h2>
    <div class="receipt">
        <div class="rrow"><span>{{项目1}}</span><span class="v r">{{数据1}}</span></div>
        <div class="rrow"><span>{{项目2}}</span><span class="v">{{数据2}}</span></div>
        <div class="rrow"><span>{{项目3}}</span><span class="v">{{数据3}}</span></div>
        <div class="rtotal"><span>TOTAL</span><span>{{总结}}</span></div>
    </div>
    <div class="fnote">{{脚注}}</div>
</div>
```

### c7 — 金句卡（墨绿底，大引号）

**适用**：核心金句、洞察、类比

```html
<div class="card c7" data-idx="6">
    <div class="tape tape-tl tape-w80" style="background:rgba(242,237,227,0.08);"></div>
    <div class="tape tape-br tape-w80 tape-red"></div>
    <div class="qm">"</div>
    <div class="qt">{{金句上半}}<br><em>{{红色强调部分}}</em></div>
    <div class="qs">{{金句解读/类比}}</div>
</div>
```

### c8 — 闭环/特色卡（白底，标签流）

**适用**：亮点展示、结果闭环、特色功能

```html
<div class="card c8" data-idx="7">
    <div class="tape tape-tl tape-w100 tape-green"></div>
    <div class="tape tape-tr tape-w80 tape-red"></div>
    <h2>{{闭环标题}}</h2>
    <div class="trk"><div class="tag em">{{emoji}}</div><span class="arr">→</span><div class="tag hot">{{核心动作}}</div><span class="arr">→</span><div class="tag">{{结果}}</div></div>
    <div class="trk"><div class="tag em">{{emoji}}</div><span class="arr">→</span><div class="tag hot">{{核心动作}}</div><span class="arr">→</span><div class="tag">{{结果}}</div></div>
    <div class="trk"><div class="tag em">{{emoji}}</div><span class="arr">→</span><div class="tag hot">{{核心动作}}</div><span class="arr">→</span><div class="tag">{{结果}}</div></div>
    <div class="ins">{{一句话总结}}</div>
</div>
```

### c9 — CTA卡（墨绿底，品牌）

**适用**：固定结尾，关注 oddmeta。内容基本不变，只改预告。

```html
<div class="card c9" data-idx="8">
    <div class="tape tape-tl tape-w80" style="background:rgba(242,237,227,0.08);"></div>
    <div class="tape tape-br tape-w80 tape-red"></div>
    <div class="fish">🐟</div>
    <div class="brand">ODDMETA</div>
    <div class="sl">造东西的人<br>记录造东西的故事</div>
    <div class="cta">关注 奥德元</div>
    <div class="sticky sticky-y" style="bottom:-14px;left:-8px;font-size:13px;transform:rotate(-4deg);">{{下期预告}}</div>
</div>
```

---

## 提词器脚本规范

cc 为每张卡片生成一段口播文案，存入 `SCRIPTS` 数组。格式要求：

- 每段对应一张卡片（共 9 段）
- 用 `\n` 换行
- `[提示]` 标记会渲染为灰色小字（给主播看的 cue，不念出来）
- 口语化，像跟朋友说话，不要播音腔
- 每段 30-60 秒的量（约 80-150 字）

**示例格式**：

```js
const SCRIPTS = [
    `{{第1张卡片的口播文案}}\n\n[看镜头，停一拍]`,
    `{{第2张卡片的口播文案}}\n\n[让数字沉一下]`,
    `{{第3张卡片的口播文案}}\n\n[语气坚定]`,
    `{{第4张卡片的口播文案}}\n\n[轻松语气]`,
    `{{第5张卡片的口播文案}}\n\n[节奏稍慢，讲清楚]`,
    `{{第6张卡片的口播文案}}\n\n[强调重点数字]`,
    `{{第7张卡片的口播文案}}\n\n[让金句落地]`,
    `{{第8张卡片的口播文案}}\n\n[语气从平到燃]`,
    `{{第9张卡片的口播文案}}\n\n[看镜头，干脆收]`
];
```

---

## JS 框架（~520行）

**重要：以下 JS 原样使用，cc 只需替换 `SCRIPTS` 数组和 `title` 中的编号/标题。**

```js
// ========================
// 提词器脚本（cc 替换此数组）
// ========================
const SCRIPTS = [
    // cc 生成 9 段口播文案
];

// ========================
// 画布导航
// ========================
const world = document.getElementById('world');
const cards = document.querySelectorAll('.card');
const dots = document.querySelectorAll('.nav-dot');
const navOverview = document.getElementById('navOverview');
const pageNum = document.getElementById('pageNum');
const hint = document.getElementById('hint');

let currentIdx = -1;

// 网站演示状态（提前声明避免 TDZ）
let webMode = false;
let currentWebIdx = -1;
const WEB_URLS = [];
const webLayer = document.getElementById('webLayer');
const webIframe = document.getElementById('webIframe');
const webBarUrl = document.getElementById('webBarUrl');
const webModal = document.getElementById('webModal');
const webUrlInput = document.getElementById('webUrlInput');
const webUrlList = document.getElementById('webUrlList');
const navWebSep = document.getElementById('navWebSep');
const navBar = document.querySelector('.nav-bar');

// 提前声明（避免 TDZ 错误）
let prompterWin = null;
let prompterDoc = null;
let prompterScrolling = false;
let prompterScrollTimer = null;

const vw = window.innerWidth, vh = window.innerHeight;
const worldW = 2900, worldH = 1100;
const overviewScale = Math.min(vw / worldW, vh / worldH) * 0.85;
const overviewX = (vw - worldW * overviewScale) / 2;
const overviewY = (vh - worldH * overviewScale) / 2;

let worldTx = overviewX, worldTy = overviewY, worldScale = overviewScale;

function setWorldTransform(tx, ty, s, animate) {
    worldTx = tx; worldTy = ty; worldScale = s;
    if (animate) world.style.transition = 'transform 0.7s cubic-bezier(0.25, 0.1, 0.25, 1)';
    world.style.transform = `translate(${tx}px, ${ty}px) scale(${s})`;
}

function goOverview() {
    if (webMode) exitWebMode(true);
    currentIdx = -1;
    setWorldTransform(overviewX, overviewY, overviewScale, true);
    cards.forEach(c => c.classList.remove('active'));
    dots.forEach(d => d.classList.remove('active'));
    clearWebNavActive();
    navOverview.classList.add('active');
    pageNum.textContent = '全景';
    updateTeleprompter();
}

function goCard(idx) {
    if (idx < 0 || idx >= cards.length) return;
    if (webMode) exitWebMode(true);
    currentIdx = idx;
    const card = cards[idx];
    const cl = parseFloat(card.style.left || card.offsetLeft);
    const ct = parseFloat(card.style.top || card.offsetTop);
    const cw = card.offsetWidth, ch = card.offsetHeight;
    const scale = Math.min(vw * 0.75 / cw, vh * 0.75 / ch);
    const tx = vw / 2 - (cl + cw / 2) * scale;
    const ty = vh / 2 - (ct + ch / 2) * scale;
    setWorldTransform(tx, ty, scale, true);
    cards.forEach(c => c.classList.remove('active'));
    card.classList.add('active');
    dots.forEach(d => d.classList.remove('active'));
    clearWebNavActive();
    dots[idx].classList.add('active');
    navOverview.classList.remove('active');
    pageNum.textContent = (idx + 1) + ' / ' + cards.length;
    updateTeleprompter();
}

cards.forEach(card => {
    card.addEventListener('click', () => {
        const idx = parseInt(card.dataset.idx);
        currentIdx === idx ? goOverview() : goCard(idx);
    });
});
dots.forEach(dot => dot.addEventListener('click', () => goCard(parseInt(dot.dataset.idx))));
navOverview.addEventListener('click', goOverview);

goOverview();

document.addEventListener('keydown', e => {
    if (e.key === 'Escape') { goOverview(); return; }
    if (e.key === 'ArrowRight' || e.key === ' ') {
        e.preventDefault();
        if (webMode) {
            if (currentWebIdx < WEB_URLS.length - 1) goWebsite(currentWebIdx + 1);
            else goOverview();
        } else if (currentIdx === cards.length - 1 && WEB_URLS.length > 0) {
            goWebsite(0);
        } else {
            currentIdx < cards.length - 1 ? goCard(currentIdx + 1) : goOverview();
        }
    }
    if (e.key === 'ArrowLeft') {
        e.preventDefault();
        if (webMode) {
            if (currentWebIdx > 0) goWebsite(currentWebIdx - 1);
            else goCard(cards.length - 1);
        } else {
            currentIdx > 0 ? goCard(currentIdx - 1) : currentIdx === 0 ? goOverview() : (WEB_URLS.length > 0 ? goWebsite(WEB_URLS.length - 1) : goCard(cards.length - 1));
        }
    }
});

setTimeout(() => hint.classList.add('hide'), 5000);

// ========================
// 画布拖拽
// ========================
let isDragging = false, dragStartX, dragStartY, dragStartTx, dragStartTy;
let dragMoved = false;

document.addEventListener('mousedown', e => {
    if (e.target.closest('.card, .toolbar, .nav-bar, .nav-dot, .nav-overview, .modal-overlay, .web-modal-overlay, .beauty-panel, .webcam-wrap, .cam-toggle, .rec-indicator, .web-layer, button')) return;
    isDragging = true;
    dragMoved = false;
    dragStartX = e.clientX;
    dragStartY = e.clientY;
    dragStartTx = worldTx;
    dragStartTy = worldTy;
    document.body.classList.add('dragging');
    e.preventDefault();
});

document.addEventListener('mousemove', e => {
    if (!isDragging) return;
    const dx = e.clientX - dragStartX;
    const dy = e.clientY - dragStartY;
    if (Math.abs(dx) > 3 || Math.abs(dy) > 3) dragMoved = true;
    worldTx = dragStartTx + dx;
    worldTy = dragStartTy + dy;
    world.style.transform = `translate(${worldTx}px, ${worldTy}px) scale(${worldScale})`;
});

document.addEventListener('mouseup', () => {
    if (!isDragging) return;
    isDragging = false;
    document.body.classList.remove('dragging');
});

// ========================
// 摄像头
// ========================
const webcam = document.getElementById('webcam');
const camVideo = document.getElementById('camVideo');
const camBtn = document.getElementById('camBtn');
const btnCamToggle = document.getElementById('btnCamToggle');
let camOn = false;

async function startCam() {
    try {
        camVideo.srcObject = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
        camOn = true; camBtn.classList.add('on'); camBtn.textContent = '📷 ON';
        btnCamToggle.classList.add('active');
        webcam.classList.remove('off');
    } catch(e) { webcam.classList.add('off'); }
}
function stopCam() {
    if (camVideo.srcObject) camVideo.srcObject.getTracks().forEach(t => t.stop());
    camOn = false; camBtn.classList.remove('on'); camBtn.textContent = '📷';
    btnCamToggle.classList.remove('active');
    webcam.classList.add('off');
}
camBtn.addEventListener('click', () => camOn ? stopCam() : startCam());
btnCamToggle.addEventListener('click', () => camOn ? stopCam() : startCam());

startCam();

// 摄像头拖拽
(function initWebcamDrag() {
    let dragging = false, offsetX, offsetY;
    webcam.addEventListener('mousedown', e => {
        dragging = true;
        const rect = webcam.getBoundingClientRect();
        offsetX = e.clientX - rect.left;
        offsetY = e.clientY - rect.top;
        webcam.classList.add('dragging');
        e.preventDefault();
        e.stopPropagation();
    });
    document.addEventListener('mousemove', e => {
        if (!dragging) return;
        webcam.style.left = (e.clientX - offsetX) + 'px';
        webcam.style.top = (e.clientY - offsetY) + 'px';
        webcam.style.right = 'auto';
        webcam.style.bottom = 'auto';
    });
    document.addEventListener('mouseup', () => {
        if (!dragging) return;
        dragging = false;
        webcam.classList.remove('dragging');
    });
})();

// ========================
// 提词器（Document PiP + 降级弹窗）
// ========================
const TELEPROMPTER_CSS = `
    * { margin:0; padding:0; box-sizing:border-box; }
    html, body { height:100%; }
    body { font-family:-apple-system,sans-serif; background:rgba(250,250,250,0.96); color:#333; display:flex; flex-direction:column; }
    .header { padding:12px 16px; background:white; border-bottom:1px solid #eee; display:flex; align-items:center; justify-content:space-between; }
    .header h2 { font-size:14px; color:#333; display:flex; align-items:center; gap:6px; }
    .page-label { font-size:13px; font-weight:700; color:#1A3328; }
    .controls { padding:10px 16px; background:white; border-bottom:1px solid #eee; }
    .ctrl-row { display:flex; align-items:center; gap:10px; margin-bottom:6px; }
    .ctrl-row:last-child { margin-bottom:0; }
    .ctrl-label { font-size:12px; color:#666; min-width:50px; }
    .ctrl-row input[type=range] { flex:1; accent-color:#1A3328; }
    .play-btn { width:30px; height:30px; border-radius:50%; border:2px solid #1A3328; background:white; cursor:pointer; display:flex; align-items:center; justify-content:center; font-size:12px; }
    .play-btn:hover { background:#1A3328; color:white; }
    .script-area { padding:16px; flex:1; overflow-y:auto; }
    .script-text { font-size:20px; line-height:2.0; color:#222; white-space:pre-wrap; font-weight:500; }
    .script-text .cue { color:#999; font-size:14px; font-weight:400; }
    .note { padding:8px 16px; font-size:11px; color:#999; border-top:1px solid #eee; text-align:center; }
`;

const TELEPROMPTER_HTML = `
    <div class="header">
        <h2>📋 提词器</h2>
        <span class="page-label" id="pLabel">全景</span>
    </div>
    <div class="controls">
        <div class="ctrl-row">
            <button class="play-btn" id="playBtn">▶</button>
            <span class="ctrl-label">滚动</span>
            <input type="range" id="speedSlider" min="0" max="100" value="30">
        </div>
        <div class="ctrl-row">
            <span class="ctrl-label" style="margin-left:40px;">透明度</span>
            <input type="range" id="opacitySlider" min="20" max="100" value="85">
        </div>
    </div>
    <div class="script-area" id="scriptArea">
        <div class="script-text" id="scriptText">点击卡片开始...</div>
    </div>
    <div class="note">始终置顶 · 不会出现在录制中</div>
`;

async function openTeleprompter() {
    if (prompterWin && !prompterWin.closed) { prompterWin.focus(); return; }
    try {
        if ('documentPictureInPicture' in window) {
            prompterWin = await documentPictureInPicture.requestWindow({ width: 380, height: 480 });
            prompterDoc = prompterWin.document;
        } else { throw new Error('fallback'); }
    } catch(e) {
        prompterWin = window.open('', 'teleprompter', 'width=380,height=480,top=50,left=50');
        prompterDoc = prompterWin.document;
    }
    const style = prompterDoc.createElement('style');
    style.textContent = TELEPROMPTER_CSS;
    prompterDoc.head.appendChild(style);
    prompterDoc.body.innerHTML = TELEPROMPTER_HTML;
    setupTeleprompterEvents();
    updateTeleprompter();
}

function setupTeleprompterEvents() {
    if (!prompterDoc) return;
    const opacitySlider = prompterDoc.getElementById('opacitySlider');
    const playBtn = prompterDoc.getElementById('playBtn');
    const speedSlider = prompterDoc.getElementById('speedSlider');
    const scriptArea = prompterDoc.getElementById('scriptArea');
    if (opacitySlider) {
        opacitySlider.addEventListener('input', () => {
            prompterDoc.body.style.opacity = opacitySlider.value / 100;
        });
        prompterDoc.body.style.opacity = opacitySlider.value / 100;
    }
    if (playBtn) {
        playBtn.addEventListener('click', () => {
            prompterScrolling = !prompterScrolling;
            playBtn.textContent = prompterScrolling ? '⏸' : '▶';
            if (prompterScrolling) {
                prompterScrollTimer = setInterval(() => {
                    if (scriptArea) scriptArea.scrollTop += (parseInt(speedSlider.value) / 50);
                }, 16);
            } else { clearInterval(prompterScrollTimer); }
        });
    }
}

function updateTeleprompter() {
    if (!prompterDoc) return;
    try {
        let label, rawText;
        if (webMode) {
            label = `网站 ${currentWebIdx + 1} / ${WEB_URLS.length}`;
            rawText = `网站演示中\n\n${WEB_URLS[currentWebIdx] || ''}\n\n[自由演示，按 → 下一页，ESC 回全景]`;
        } else if (currentIdx === -1) {
            label = '全景';
            rawText = '点击卡片进入对应页面\n提词器会自动同步';
        } else {
            label = `第 ${currentIdx + 1} / ${cards.length}`;
            rawText = SCRIPTS[currentIdx] || '';
        }
        const html = rawText.replace(/\[([^\]]+)\]/g, '<span class="cue">[$1]</span>');
        const textEl = prompterDoc.getElementById('scriptText');
        const labelEl = prompterDoc.getElementById('pLabel');
        const areaEl = prompterDoc.getElementById('scriptArea');
        if (textEl) textEl.innerHTML = html;
        if (labelEl) labelEl.textContent = label;
        if (areaEl) areaEl.scrollTop = 0;
        if (prompterScrolling) {
            prompterScrolling = false;
            const btn = prompterDoc.getElementById('playBtn');
            if (btn) btn.textContent = '▶';
            clearInterval(prompterScrollTimer);
        }
    } catch(e) {}
}

document.getElementById('btnTeleprompter').addEventListener('click', openTeleprompter);

// ========================
// 网站演示
// ========================
function goWebsite(idx) {
    if (idx < 0 || idx >= WEB_URLS.length) return;
    webMode = true;
    currentWebIdx = idx;
    currentIdx = -1;
    world.style.display = 'none';
    webLayer.classList.add('show');
    webIframe.src = WEB_URLS[idx];
    webBarUrl.textContent = WEB_URLS[idx];
    cards.forEach(c => c.classList.remove('active'));
    dots.forEach(d => d.classList.remove('active'));
    navOverview.classList.remove('active');
    clearWebNavActive();
    const webDots = navBar.querySelectorAll('.nav-dot-web');
    if (webDots[idx]) webDots[idx].classList.add('active');
    pageNum.textContent = `🌐 ${idx + 1} / ${WEB_URLS.length}`;
    updateTeleprompter();
}

function exitWebMode(silent) {
    webMode = false;
    currentWebIdx = -1;
    webLayer.classList.remove('show');
    webIframe.src = 'about:blank';
    world.style.display = '';
    clearWebNavActive();
}

function clearWebNavActive() {
    navBar.querySelectorAll('.nav-dot-web').forEach(d => d.classList.remove('active'));
}

function addWebUrl(url) {
    url = url.trim();
    if (!url) return;
    if (!/^https?:\/\//i.test(url)) url = 'https://' + url;
    WEB_URLS.push(url);
    renderWebUrlList();
    renderWebNavDots();
}

function removeWebUrl(idx) {
    WEB_URLS.splice(idx, 1);
    renderWebUrlList();
    renderWebNavDots();
    if (webMode && currentWebIdx >= WEB_URLS.length) goOverview();
}

function renderWebUrlList() {
    if (WEB_URLS.length === 0) {
        webUrlList.innerHTML = '<div class="web-url-empty">还没有添加网站</div>';
        return;
    }
    webUrlList.innerHTML = WEB_URLS.map((u, i) => `
        <div class="web-url-item">
            <span class="idx">${i + 1}</span>
            <span class="url-text">${u}</span>
            <button class="go-btn" data-go="${i}">前往</button>
            <button class="del-btn" data-del="${i}">✕</button>
        </div>
    `).join('');
    webUrlList.querySelectorAll('.go-btn').forEach(btn => {
        btn.addEventListener('click', () => {
            webModal.classList.remove('show');
            goWebsite(parseInt(btn.dataset.go));
        });
    });
    webUrlList.querySelectorAll('.del-btn').forEach(btn => {
        btn.addEventListener('click', () => removeWebUrl(parseInt(btn.dataset.del)));
    });
}

function renderWebNavDots() {
    navBar.querySelectorAll('.nav-dot-web').forEach(d => d.remove());
    navWebSep.classList.toggle('show', WEB_URLS.length > 0);
    WEB_URLS.forEach((u, i) => {
        const btn = document.createElement('button');
        btn.className = 'nav-dot-web';
        btn.dataset.webIdx = i;
        btn.textContent = '🌐';
        btn.title = u;
        btn.addEventListener('click', () => goWebsite(i));
        navBar.appendChild(btn);
    });
}

document.getElementById('btnWebsite').addEventListener('click', () => webModal.classList.add('show'));
document.getElementById('closeWebModal').addEventListener('click', () => webModal.classList.remove('show'));
document.getElementById('webUrlAddBtn').addEventListener('click', () => {
    addWebUrl(webUrlInput.value);
    webUrlInput.value = '';
});
webUrlInput.addEventListener('keydown', e => {
    if (e.key === 'Enter') {
        e.preventDefault();
        addWebUrl(webUrlInput.value);
        webUrlInput.value = '';
    }
});
document.getElementById('webBarBack').addEventListener('click', goOverview);

// ========================
// 录制面板
// ========================
const settingsModal = document.getElementById('settingsModal');
const btnRecord = document.getElementById('btnRecord');
const recIndicator = document.getElementById('recIndicator');
const recTimer = document.getElementById('recTimer');

btnRecord.addEventListener('click', () => settingsModal.classList.add('show'));
document.getElementById('closeSettings').addEventListener('click', () => settingsModal.classList.remove('show'));

document.getElementById('startRecBtn').addEventListener('click', () => {
    settingsModal.classList.remove('show');
    actualStartRecording();
});

recIndicator.addEventListener('click', stopRecording);

// ========================
// 录制功能
// ========================
let mediaRecorder = null;
let recordedChunks = [];
let recStartTime = 0;
let recTimerInterval = null;

async function actualStartRecording() {
    try {
        const displayStream = await navigator.mediaDevices.getDisplayMedia({
            video: { displaySurface: 'browser' },
            audio: false,
            preferCurrentTab: true,
            selfBrowserSurface: 'include',
            systemAudio: 'exclude'
        });
        let micStream = null;
        try {
            micStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
        } catch(e) { console.warn('麦克风不可用，仅录制画面'); }
        const tracks = [...displayStream.getVideoTracks()];
        if (micStream) tracks.push(...micStream.getAudioTracks());
        const combinedStream = new MediaStream(tracks);
        recordedChunks = [];
        const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus')
            ? 'video/webm;codecs=vp9,opus' : 'video/webm';
        mediaRecorder = new MediaRecorder(combinedStream, { mimeType });
        mediaRecorder.ondataavailable = e => { if (e.data.size > 0) recordedChunks.push(e.data); };
        mediaRecorder.onstop = saveRecording;
        displayStream.getVideoTracks()[0].onended = () => stopRecording();
        mediaRecorder.start(100);
        document.body.classList.add('recording');
        recStartTime = Date.now();
        recTimerInterval = setInterval(updateRecTimer, 200);
    } catch(e) {
        if (e.name !== 'NotAllowedError') alert('录制启动失败: ' + e.message);
    }
}

function stopRecording() {
    if (!mediaRecorder || mediaRecorder.state === 'inactive') return;
    mediaRecorder.stop();
    mediaRecorder.stream.getTracks().forEach(t => t.stop());
    document.body.classList.remove('recording');
    clearInterval(recTimerInterval);
}

function saveRecording() {
    const blob = new Blob(recordedChunks, { type: 'video/webm' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    const ts = new Date().toISOString().slice(0,19).replace(/[T:]/g, '-');
    a.href = url;
    a.download = `oddmeta-{{NUMBER}}-${ts}.webm`;
    a.click();
    URL.revokeObjectURL(url);
    recordedChunks = [];
}

function updateRecTimer() {
    const elapsed = Math.floor((Date.now() - recStartTime) / 1000);
    const m = String(Math.floor(elapsed / 60)).padStart(2, '0');
    const s = String(elapsed % 60).padStart(2, '0');
    recTimer.textContent = `${m}:${s}`;
}

// ========================
// 美颜滤镜
// ========================
const beautyPanel = document.getElementById('beautyPanel');
const sliderWhiten = document.getElementById('sliderWhiten');
const sliderSlim = document.getElementById('sliderSlim');
const sliderTeeth = document.getElementById('sliderTeeth');
const sliderSmooth = document.getElementById('sliderSmooth');

document.getElementById('btnBeauty').addEventListener('click', () => {
    beautyPanel.classList.toggle('show');
});

function applyBeauty() {
    const w = parseInt(sliderWhiten.value);
    const t = parseInt(sliderTeeth.value);
    const sm = parseInt(sliderSmooth.value);
    const sl = parseInt(sliderSlim.value);
    const brightness = 1 + w * 0.004;
    const saturate = Math.max(0.65, 1 - w * 0.0035);
    const contrast = 1 + t * 0.006;
    const blur = sm * 0.03;
    // 瘦脸：视频始终放大 1.2x 撑满圆框，靠 X/Y 比例差实现瘦脸
    const slimFactor = 1 - sl * 0.0015;
    const base = 1.2;
    const sx = -(base * slimFactor);  // 镜像 + 横向压缩
    const sy = base;                   // 纵向不变
    camVideo.style.filter = `brightness(${brightness}) saturate(${saturate}) contrast(${contrast}) blur(${blur}px)`;
    camVideo.style.transform = `scale(${sx}, ${sy})`;
}

[sliderWhiten, sliderSlim, sliderTeeth, sliderSmooth].forEach(s => {
    s.addEventListener('input', applyBeauty);
});
applyBeauty();

// ========================
// 实时字幕（Web Speech API）
// ========================
const subtitleBar = document.getElementById('subtitleBar');
const subtitleText = document.getElementById('subtitleText');
const btnSubtitle = document.getElementById('btnSubtitle');
let subtitleOn = false;
let recognition = null;
let subtitleClearTimer = null;

function startSubtitle() {
    const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
    if (!SR) { alert('当前浏览器不支持语音识别，请使用 Chrome'); return; }
    recognition = new SR();
    recognition.lang = 'zh-CN';
    recognition.continuous = true;
    recognition.interimResults = true;
    recognition.onresult = e => {
        let interim = '', final = '';
        for (let i = e.resultIndex; i < e.results.length; i++) {
            const t = e.results[i][0].transcript;
            if (e.results[i].isFinal) final += t;
            else interim += t;
        }
        subtitleText.textContent = final || interim;
        clearTimeout(subtitleClearTimer);
        if (final) {
            subtitleClearTimer = setTimeout(() => { subtitleText.textContent = ''; }, 3000);
        }
    };
    recognition.onerror = e => {
        if (e.error === 'no-speech') return;
        console.warn('语音识别错误:', e.error);
    };
    recognition.onend = () => {
        if (subtitleOn) recognition.start();
    };
    recognition.start();
    subtitleOn = true;
    subtitleBar.classList.add('show');
    btnSubtitle.classList.add('active');
}

function stopSubtitle() {
    subtitleOn = false;
    if (recognition) { recognition.abort(); recognition = null; }
    subtitleBar.classList.remove('show');
    subtitleText.textContent = '';
    btnSubtitle.classList.remove('active');
    clearTimeout(subtitleClearTimer);
}

btnSubtitle.addEventListener('click', () => subtitleOn ? stopSubtitle() : startSubtitle());

```

---

## cc 生成流程

1. **读取素材**：根据输入类型获取文章内容
2. **分析结构**：提取标题、核心数据、痛点、步骤、原理、对比、金句、亮点
3. **填充 9 张卡片**：根据文章内容选择每张卡片类型中的具体内容
4. **生成 9 段提词器脚本**：口语化，每段 80-150 字
5. **输出提词器脚本 md**：`[简短主题]-提词器脚本.md`，用户可直接编辑修改
6. **组装 HTML**：CSS 框架 + HTML 骨架 + 卡片内容 + JS 框架（SCRIPTS 数组内容与 md 一致）
7. **输出文件**：`[简短主题]-视频画布.html`
8. **生成封面图**：`[简短主题]-封面.html`，浏览器打开后可截图/下载为 PNG
9. **提示用户**：先检查提词器脚本 md，再在浏览器中打开 HTML 录制

> **用户修改提词器脚本后**：告诉 cc "更新提词器"，cc 读取修改后的 md，重新写入 HTML 的 SCRIPTS 数组。

### 输出文件命名

- 默认路径：文章同目录，或 `/tmp/`
- 视频画布：`[简短主题]-视频画布.html`
- 提词器脚本：`[简短主题]-提词器脚本.md`
- 封面图：`[简短主题]-封面.html`
- 示例：`AI印书厂-视频画布.html` + `AI印书厂-提词器脚本.md` + `AI印书厂-封面.html`

---

## 提词器脚本 md 格式

cc 输出的 md 文件格式如下，用户可直接编辑后让 cc 更新到 HTML：

```markdown
# {{主题}} — 提词器脚本

> 每张卡片对应一段口播文案。`[方括号]` 内是给自己看的提示，不念出来。
> 修改后告诉 cc "更新提词器"，会自动同步到视频画布 HTML。

---

## 第 1 页 · 标题卡

{{口播文案}}

[看镜头，停一拍]

---

## 第 2 页 · 数据卡

{{口播文案}}

[让数字沉一下]

---

## 第 3 页 · 痛点卡

{{口播文案}}

[语气坚定]

---

## 第 4 页 · 步骤卡

{{口播文案}}

[轻松语气]

---

## 第 5 页 · 架构卡

{{口播文案}}

[节奏稍慢，讲清楚]

---

## 第 6 页 · 对比卡

{{口播文案}}

[强调重点数字]

---

## 第 7 页 · 金句卡

{{口播文案}}

[让金句落地]

---

## 第 8 页 · 闭环卡

{{口播文案}}

[语气从平到燃]

---

## 第 9 页 · CTA卡

{{口播文案}}

[看镜头，干脆收]
```

### cc 解析规则

读取用户修改后的 md 时：
1. 按 `## 第 N 页` 分割为 9 段
2. 每段内容（去掉标题行和分隔线）作为 `SCRIPTS[N-1]` 的值
3. `\n` 保留原始换行
4. `[方括号内容]` 保留，HTML 中会渲染为灰色提示

### 录制文件命名

HTML 内部自动命名下载文件为 `oddmeta-{{NUMBER}}-{{时间戳}}.webm`，cc 生成时把 `{{NUMBER}}` 替换为实际期号。固定 16:9 比例，各平台都可直接上传。

---

## 封面图规范（与文章封面统一风格）

cc 同时生成一个独立的封面 HTML 文件（3:4 竖版，1080x1440），适合微贴图/视频号封面。风格与文章封面（900x383 横版）保持一致：暗底 + 渐变遮罩 + 左对齐排版 + 品牌角标 + 底部标签。

### 设计要素

| 要素 | 规范 |
|------|------|
| **尺寸** | 1080x1440 (3:4 竖版) |
| **底色** | `#111` 暗底（与文章封面一致），叠加微弱方格纸底纹 |
| **渐变遮罩** | 顶部红色光晕 `radial-gradient` + 上下暗角 `linear-gradient`（与文章封面渐变手法一致） |
| **标签 .tag** | 左上方，`rgba(196,69,54,0.3)` 底 + 粉白字，15px，全大写英文 |
| **标题 .title** | 左对齐，72px 白色 + `.mega` 110px 鱼红 `#ff5a43`，带 `text-shadow` 发光 |
| **副标题 .subtitle** | 24px，`rgba(255,255,255,0.55)`，标题下方 |
| **数据行 .stats** | `.stat-num` 48px 鱼红 + `.stat-unit` 18px 半透明白，斜杠分隔 |
| **品牌角标 .brand** | 左上角 oddmeta SVG + 文字，`rgba(255,255,255,0.4)` |
| **人像区 .portrait** | 右下角圆框（180px），`rgba(255,255,255,0.15)` 边框，暗底占位 |
| **底部标签 .bottom-tags** | 左下角 `.btag` 标签组，`rgba(255,255,255,0.06)` 底，13px |
| **下载方式** | html2canvas，scale: 2，输出 PNG |

### 封面 HTML 模板

```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>{{主题}} - 封面</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            width: 1080px; height: 1440px; overflow: hidden;
            background: #111;
            font-family: -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif;
            position: relative;
        }

        /* 方格纸底纹（轻手绘感） */
        .grid-bg {
            position: absolute; inset: 0;
            background-image:
                linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px),
                linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px);
            background-size: 28px 28px;
        }

        /* 渐变遮罩（与文章封面一致的暗角处理） */
        .overlay {
            position: absolute; inset: 0; z-index: 2;
            background:
                radial-gradient(ellipse at 50% 30%, rgba(196,69,54,0.12) 0%, transparent 60%),
                linear-gradient(to bottom, rgba(0,0,0,0.3) 0%, transparent 30%, transparent 70%, rgba(0,0,0,0.4) 100%);
        }

        /* 品牌角标（与文章封面一致） */
        .brand {
            position: absolute; top: 40px; left: 55px;
            display: flex; align-items: center; gap: 10px; z-index: 20;
        }
        .brand svg { width: 28px; height: 28px; }
        .brand-text {
            font-size: 15px; font-weight: 600;
            color: rgba(255,255,255,0.4); letter-spacing: 1.5px;
        }

        /* 内容区（左对齐，与文章封面统一） */
        .content {
            position: absolute; inset: 0; z-index: 10;
            display: flex; flex-direction: column; justify-content: center;
            padding: 0 0 0 70px;
        }

        /* 标签（与文章封面 .tag 一致） */
        .tag {
            display: inline-block; width: fit-content;
            font-size: 15px; font-weight: 700; letter-spacing: 3px;
            padding: 6px 16px; border-radius: 5px;
            background: rgba(196,69,54,0.3); color: rgba(255,200,190,0.9);
            margin-bottom: 28px;
        }

        /* 主标题（放大版文章封面 .title，爆炸感） */
        .title {
            font-size: 72px; font-weight: 900; color: #fff; line-height: 1.15;
            max-width: 780px;
        }
        .title .red { color: #ff5a43; }
        .title .mega {
            font-size: 110px; display: block; font-weight: 900;
            color: #ff5a43;
            text-shadow: 0 0 40px rgba(196,69,54,0.4);
            margin: 8px 0 4px;
        }

        /* 副标题（与文章封面 .subtitle 一致） */
        .subtitle {
            font-size: 24px; color: rgba(255,255,255,0.55);
            margin-top: 20px; font-weight: 400;
        }

        /* 数据行（与文章封面 .stats 一致） */
        .stats {
            display: flex; gap: 28px; margin-top: 28px; align-items: baseline;
        }
        .stat-num {
            font-size: 48px; font-weight: 900; color: #ff5a43;
            text-shadow: 0 0 20px rgba(196,69,54,0.4);
        }
        .stat-unit {
            font-size: 18px; color: rgba(255,255,255,0.5);
            font-weight: 500; margin-left: 4px;
        }
        .stat-sep {
            color: rgba(255,255,255,0.2); font-size: 28px;
        }

        /* 人像圆框 */
        .portrait {
            position: absolute; bottom: 100px; right: 80px; z-index: 20;
            width: 180px; height: 180px; border-radius: 50%;
            border: 3px solid rgba(255,255,255,0.15); overflow: hidden;
            box-shadow: 0 8px 30px rgba(0,0,0,0.3);
            background: #1a1a1a;
            display: flex; align-items: center; justify-content: center;
        }
        .portrait .placeholder { font-size: 64px; }
        .portrait img { width: 100%; height: 100%; object-fit: cover; }

        /* 底部标签（与文章封面 .bottom-tags 一致） */
        .bottom-tags {
            position: absolute; bottom: 40px; left: 55px;
            display: flex; gap: 10px; z-index: 20;
        }
        .btag {
            font-size: 13px; color: rgba(255,255,255,0.3);
            padding: 5px 12px;
            background: rgba(255,255,255,0.06); border-radius: 4px;
        }

        /* 下载栏 */
        .download-bar {
            position: fixed; bottom: 0; left: 0; right: 0;
            background: rgba(0,0,0,0.85); padding: 12px;
            text-align: center; z-index: 100;
        }
        .download-bar button {
            background: #C44536; color: white; border: none;
            padding: 10px 32px; border-radius: 8px;
            font-size: 14px; font-weight: 700; cursor: pointer;
        }
        .download-bar button:hover { background: #d95545; }
    </style>
</head>
<body>
    <div class="grid-bg"></div>
    <div class="overlay"></div>

    <!-- 品牌角标（与文章封面一致） -->
    <div class="brand">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 0.3 0.3">
        <defs><style>.c{fill:#000;}.c,.d{stroke-width:0px;}.d{fill:#fff;}</style></defs>
        <circle class="c" cx=".18" cy=".18" r=".11"/>
        <circle class="d" cx=".18" cy=".18" r=".09"/>
        <circle class="c" cx=".18" cy=".18" r=".06"/>
        <circle class="d" cx=".18" cy=".18" r=".04"/>
        <polygon class="d" points=".19 .23 .2 .26 .23 .27 .22 .29 .25 .31 .3 .24 .31 .15 .27 .18 .21 .18 .19 .23"/>
        <path class="c" d="M.26.24l.03-.04h.01v.08h-.02v-.05l-.02.03h0l-.02-.03v.05h-.02v-.08h.01l.03.04Z"/>
        </svg>
        <span>ODDMETA</span>
    </div>

    <!-- 主内容（左对齐层次结构） -->
    <div class="content">
        <div class="tag">{{TAG英文，如 AUTO WRITER × OPENCLAW}}</div>
        <div class="title">
            {{标题前半}}
            <span class="mega">{{爆炸核心词}}</span>
        </div>
        <div class="subtitle">{{副标题描述}}</div>
        <div class="stats">
            <span class="stat-num">{{数据1}}</span><span class="stat-unit">{{单位1}}</span>
            <span class="stat-sep">/</span>
            <span class="stat-num">{{数据2}}</span><span class="stat-unit">{{单位2}}</span>
        </div>
    </div>

    <!-- 人像出镜 -->
    <div class="portrait">
        <div class="placeholder">🐟</div>
        <!-- 有照片时: <img src="portrait.jpg" alt=""> -->
    </div>

    <!-- 底部标签 -->
    <div class="bottom-tags">
        <span class="btag">{{标签1}}</span>
        <span class="btag">{{标签2}}</span>
        <span class="btag">{{标签3}}</span>
        <span class="btag">{{标签4}}</span>
    </div>

    <div class="download-bar">
        <button onclick="downloadCover()">下载封面 PNG (1080×1440)</button>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
    <script>
        async function downloadCover() {
            const bar = document.querySelector('.download-bar');
            bar.style.display = 'none';
            const canvas = await html2canvas(document.body, { scale: 2, width: 1080, height: 1440 });
            bar.style.display = '';
            const a = document.createElement('a');
            a.download = '{{简短主题}}-封面.png';
            a.href = canvas.toDataURL('image/png');
            a.click();
        }
    </script>
</body>
</html>
```

### 人像处理

| 情况 | 处理 |
|------|------|
| 用户提供了照片路径 | `<img src="照片路径">` 填入 `.portrait`，删掉 `.placeholder` |
| 用户没提供照片 | 保留 `.placeholder`（🐟 占位），提示用户后续替换 |
| 用户说"用摄像头截图" | 提示在视频画布中开摄像头截一张 |

### 封面文字规则

- `.tag`：用英文关键词组合（如 `AUTO WRITER × OPENCLAW`），全大写，与文章封面一致
- `.title`：标题前半用白色，核心爆点用 `.mega`（110px 鱼红），与 c1（标题卡）内容一致
- `.subtitle`：一句话补充说明，24px 半透明白
- `.stats`：1-2 组核心数据（数字鱼红 + 单位半透明），可选
- `.bottom-tags`：3-4 个技术/主题标签，与文章封面底部标签一致
- 封面要在手机小图上也能看清标题，不要放太多文字

path: references/examples-chartlet/README.md
content: # 微贴图高质量范例

> 生成微贴图内容前，必须参考这里的范例，确保达到发布标准。

---

## 📱 观鸟图鉴 - 标准范例

**文件：** `example-chartlet.html`

这是一个高质量的微贴图轮播图范例，达到了可直接发布的专业水准。

### 质量标准

✅ **视觉设计专业**
- 纯信息图设计，无文章截图
- 像素风游戏界面（第6张），生动有趣
- 流程图、卡片网格、编号列表等丰富视觉元素
- 品牌色克制（墨绿85% + 鱼红5%），不喧宾夺主

✅ **文案真人感强**
- 第一人称："我书架上有本《云彩收集者手册》..."
- 个人情绪："玩疯了"、"上头了"、"停不下来"
- 口语化："然后我就..."、"感觉像..."
- 298字 + 12个标签

✅ **信息架构合理**
- 封面吸睛 → 成果展示 → 核心能力 → 痛点 → 使用步骤 → 成果展示 → 技术原理 → 理念升华 → 行动召唤
- 每张卡片重点突出，一页一个点
- 节奏流畅，引导自然

### 9张卡片拆解

| 序号 | 类型 | 内容 | 设计亮点 |
|------|------|------|---------|
| 1 | 封面 | 吸睛标题+核心价值 | 大标题清晰，传达价值 |
| 2 | 成果展示 | APP界面预览 | 像素风设计，视觉吸睛 |
| 3 | 核心能力 | 4个核心功能 | 卡片网格，信息清晰 |
| 4 | 痛点背景 | 为什么要做 | 对比式设计，引发共鸣 |
| 5 | 使用步骤 | 4步流程 | 流程图，简洁明了 |
| 6 | 成果展示 | 像素风界面 | 游戏化展示，生动有趣 |
| 7 | 技术原理 | pdf2skills工具 | 降低门槛，引导使用 |
| 8 | 核心理念 | 金句提炼 | 价值升华，引发思考 |
| 9 | 行动召唤 | 链接+微信号 | 清晰引导，转化路径 |

### 配套文案

标题：用AI手搓了一个看云APP，附详细步骤

正文：298字，真人分享风格，有真实场景（"我书架上有本..."）、个人情绪（"玩疯了"、"上头了"）、口语化表达（"然后我就..."）

标签：12个，覆盖产品类（#AI工具）、场景类（#独立开发）、生活类（#好物分享）

---

## 使用方法

每次生成微贴图内容时：

1. **打开范例文件** - 在浏览器中查看 `example-chartlet.html`
2. **参考视觉设计** - 复用CSS布局、配色方案、视觉元素
3. **参考文案风格** - 学习真人分享感、口语化表达
4. **参考信息架构** - 9张卡片的内容分配和节奏控制

生成的内容应该达到这个范例的专业水准，不低于此标准。

---

## 更多范例

后续会继续添加不同类型的高质量范例...

完整说明文档：`../../references/examples-chartlet/README.md`


path: local/brand_dark.svg
content: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 0.3 0.3">
<defs><style>.c{fill:#000;}.c,.d{stroke-width:0px;}.d{fill:#fff;}</style></defs>
<circle class="c" cx=".18" cy=".18" r=".11"/>
<circle class="d" cx=".18" cy=".18" r=".09"/>
<circle class="c" cx=".18" cy=".18" r=".06"/>
<circle class="d" cx=".18" cy=".18" r=".04"/>
<polygon class="d" points=".19 .23 .2 .26 .23 .27 .22 .29 .25 .31 .3 .24 .31 .15 .27 .18 .21 .18 .19 .23"/>
<path class="c" d="M.26.24l.03-.04h.01v.08h-.02v-.05l-.02.03h0l-.02-.03v.05h-.02v-.08h.01l.03.04Z"/>
</svg>

path: local/brand_light.svg
content: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 0.3 0.3">
<defs><style>.c{fill:#000;}.c,.d{stroke-width:0px;}.d{fill:#fff;}</style></defs>
<circle class="c" cx=".18" cy=".18" r=".11"/>
<circle class="d" cx=".18" cy=".18" r=".09"/>
<circle class="c" cx=".18" cy=".18" r=".06"/>
<circle class="d" cx=".18" cy=".18" r=".04"/>
<polygon class="d" points=".19 .23 .2 .26 .23 .27 .22 .29 .25 .31 .3 .24 .31 .15 .27 .18 .21 .18 .19 .23"/>
<path class="c" d="M.26.24l.03-.04h.01v.08h-.02v-.05l-.02.03h0l-.02-.03v.05h-.02v-.08h.01l.03.04Z"/>
</svg>


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