Skip to content

Instantly share code, notes, and snippets.

@OrangeViolin
Last active March 14, 2026 05:27
Show Gist options
  • Select an option

  • Save OrangeViolin/4b59b1711542b80546c9fc79d35cb9de to your computer and use it in GitHub Desktop.

Select an option

Save OrangeViolin/4b59b1711542b80546c9fc79d35cb9de to your computer and use it in GitHub Desktop.
Content Pipeline Skill for Claude Code - 内容生产和分发统一管线

01fish 风格排版规范

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


一、排版(Markdown → 公众号 HTML)

使用 md-formatter 排版工具,01fish 主题:

cd "/Users/mac/Documents/mycc/2-Projects/项目1:01fish-assistant/公众号工具流/md-formatter"
python3 md2wechat_formatter.py [文章路径] --theme 01fish --font-size medium -o [输出HTML路径]

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


二、公众号头图(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,带鱼红边框框住,居中
  • 品牌角标:左上角 01fish logo SVG + 文字
  • 底部标签:右下角,小字标签(工具名、特性),rgba(255,255,255,0.35)
  • 装饰元素:左右两侧可放低透明度(0.12)的主题相关 SVG 图标

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 结构

<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 · 放在「文章中的哪个小节」之后

四、视觉组件速查(暗底版)

品牌角标 SVG

<svg viewBox="0 0 32 32" fill="none">
  <circle cx="13" cy="16" r="10" stroke="rgba(255,255,255,0.5)" stroke-width="2.2" fill="none"/>
  <ellipse cx="13" cy="8.5" rx="7" ry="1.5" stroke="rgba(255,255,255,0.3)" stroke-width="1.2" fill="none"/>
  <ellipse cx="14" cy="17" rx="3.5" ry="2" fill="#C44536"/>
  <polygon points="10,17 7.5,14.5 7.5,19.5" fill="#C44536"/>
  <circle cx="16" cy="16.3" r="0.7" fill="#F2EDE3"/>
  <line x1="26" y1="4" x2="26" y2="16" stroke="rgba(255,255,255,0.5)" stroke-width="2" stroke-linecap="round"/>
  <path d="M26 16 Q26 22 22 22" stroke="rgba(255,255,255,0.5)" stroke-width="2" fill="none" stroke-linecap="round"/>
  <circle cx="26" cy="4" r="2" stroke="rgba(255,255,255,0.5)" stroke-width="1.5" fill="none"/>
</svg>

浅底版:将 rgba(255,255,255,...) 替换为 rgba(26,51,40,...)

Section Tag

<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>

流程卡片

<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>

大数字

<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 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 依赖

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

<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(900×383 横版)生成 3:4 竖版封面(1080×1440),适合小红书/视频号。 内容完全不变(标题、图片、标签),只换比例 + 适配竖版布局。

操作流程

  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-头图-900x383.png xxx-竖版封面-1080x1440.png

注意事项

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

分发平台配置

来源:distribute skill 全文


平台列表

缩写 平台 状态
wechat 公众号 可用
xhs 小红书 可用
jike 即刻 可用
xiaoyuzhou 小宇宙 可用
douyin 抖音 实验性
shipinhao 视频号 待开发(无 web 后台)

manifest.json 格式

由内容生成流程(Path A 或 Path B)自动输出。

{
  "version": "1.0",
  "created": "2026-02-15T10:00:00Z",
  "source": "https://mp.weixin.qq.com/s/xxx",
  "title": "文章标题",
  "outputs": {
    "xiaohongshu": {
      "html": "/path/to/xxx-小红书版.html",
      "images_dir": "/path/to/images/",
      "copy": {
        "title": "小红书标题",
        "body": "正文内容...",
        "tags": ["#AI工具", "#ClaudeCode"]
      }
    },
    "jike": {
      "copy": {
        "body": "即刻正文...",
        "circles": ["#Claude Code", "#AI工具"]
      }
    },
    "xiaoyuzhou": {
      "audio": "/path/to/podcast.mp3",
      "script": "/path/to/podcast-script.txt",
      "copy": {
        "title": "播客标题",
        "description": "简介...",
        "show_notes": "完整 show notes..."
      }
    },
    "wechat": {
      "markdown": "/path/to/article.md",
      "html": "/path/to/article_preview.html",
      "cover_image": "/path/to/cover.png",
      "title": "文章标题",
      "author": "01fish",
      "digest": "文章摘要(120字内)",
      "images": ["/path/to/illustration1.png"]
    },
    "video": {
      "intro": "/path/to/intro.mp4",
      "outro": "/path/to/outro.mp4",
      "prompts": "/path/to/video-prompts.md"
    },
    "douyin": {
      "video": "/path/to/video.mp4",
      "copy": {
        "title": "标题",
        "description": "描述",
        "tags": ["#标签"]
      }
    }
  }
}

执行流程

第 1 步:读取 manifest

解析 manifest.json,验证文件路径存在,列出可发布平台。

第 2 步:确认发布计划

展示将要发布的平台和内容摘要,等待用户确认。

第 3 步:顺序执行

执行顺序(避免 Chrome 端口冲突):

  1. 公众号(wechat)→ 调用 baoyu-post-to-wechat
  2. 小红书(xhs)→ Chrome CDP 自动发布
  3. 即刻(jike)→ Chrome CDP 自动发布
  4. 小宇宙(xiaoyuzhou)→ Chrome CDP 自动发布
  5. 抖音(douyin)→ Chrome CDP 自动发布
  6. 视频号(shipinhao)→ Chrome CDP 自动发布

每个平台完成后关闭 Chrome,再启动下一个。

第 4 步:汇报结果

输出每个平台的发布状态(成功/失败/跳过),附上发布链接(如有)。


四级降级策略

级别 模式 说明
L0 API 直推 微信公众号 API 直接推送草稿箱,无需 Chrome(仅公众号)
L1 自动发布 Chrome CDP 完全自动化,预填内容并提交
L2 辅助发布 打开创作者页面,预填内容,用户手动确认提交
L3 手动模式 输出文件路径和文案,用户自行复制粘贴

公众号执行策略: 优先 L0(API),API 凭证缺失或调用失败时降级 L1(CDP)

降级触发条件:

  • API 凭证未配置 → L0 降级为 L1
  • API 调用失败 → L0 降级为 L1
  • 登录态失效 → L2(打开登录页,等待用户登录后重试)
  • 选择器失效(平台 UI 改版)→ L2
  • CDP 连接失败 → L3
  • --preview 参数 → 强制 L2

Chrome Profile 管理

每个平台独立 Chrome profile,避免 session 冲突:

平台 Profile 路径
公众号 ~/.local/share/wechat-browser-profile
小红书 ~/.local/share/xiaohongshu-browser-profile
即刻 ~/.local/share/jike-browser-profile
小宇宙 ~/.local/share/xiaoyuzhou-browser-profile
抖音 ~/.local/share/douyin-browser-profile
视频号 ~/.local/share/shipinhao-browser-profile

首次使用每个平台需手动登录一次,后续复用 session。


反自动化对策

  • Chrome 启动参数:--disable-blink-features=AutomationControlled
  • 操作间随机延迟:200-800ms
  • 模拟真实鼠标移动和键盘输入
  • 使用真实 Chrome profile(非无头模式)

选择器配置

各平台 CSS 选择器抽取为常量,便于平台 UI 改版时快速修复。每个平台模块顶部定义 SELECTORS 对象。


故障处理

问题 处理
manifest.json 不存在 提示用户先运行内容生成
某平台文件缺失 跳过该平台,继续其他
Chrome 启动失败 检查 Chrome 安装,降级 L3
登录态失效 打开登录页,等待用户登录
发布失败 记录错误,继续下一平台
网络超时 重试 1 次,失败则降级

各平台文案规范

来源:wechat-to-xiaohongshu + story-logger


小红书发布文案

标题规范

  • 15-20 字,一句话说清价值
  • 格式:核心亮点|场景/身份标签
  • 可以用数字开头增加冲击力
  • 不堆砌 emoji,标题中最多 0-1 个

好标题示例:

  • 一本书+一个小时,我用AI做了一款策略游戏|Claude Code实战
  • 22个游戏设计技能一键装进AI|pdf2skills实测
  • 让AI读完一本书再帮你干活,效果炸了|Skill工作流

坏标题示例:

  • 🔥🔥🔥震惊!AI居然能做游戏!!!(营销号味太重)
  • Claude Code Skill 技术详解(太技术向,没吸引力)

正文规范

结构模板:

[一句话成果,制造冲击]

[核心做法,2-3句话]

[关键步骤/亮点,用序号或emoji列表]
1️⃣ xxx
2️⃣ xxx

[一句话方法论总结]

[行动召唤:链接在评论区]

.
#标签1 #标签2 ...

风格要点:

要求 做法
篇幅 150-250 字,不超过 300 字
语气 朋友分享,不是写教程
段落 短段落,每段 1-3 句
列表 用数字 emoji(1️⃣2️⃣3️⃣)或简单列表
链接 统一说"链接在评论区",不贴长链接
结尾 . 单独一行隔开标签区
标签 8-12 个,混合大标签和精准标签

标签选择策略:

类型 示例 说明
大流量标签 #AI工具 #AI编程 覆盖面广
精准标签 #ClaudeCode #Skill 精准匹配
场景标签 #一个人的团队 #独立开发 引发共鸣
内容标签 #游戏设计 #策略游戏 内容相关

输出格式

📋 小红书发布文案(复制粘贴即可)

---
标题:xxx

正文:
xxx

---

即刻发布文案

平台定位

即刻是中文互联网的「技术人/创造者社区」,用户群体偏 tech、产品、独立开发、AI。圈子感很强的社区。

文风:和同行聊天

维度 小红书 即刻
受众 泛用户,需要科普 同行/tech圈,默认懂背景
语气 分享发现,有教程感 聊天灌水,朋友间讲故事
篇幅 150-250 字,精炼 200-400 字,可以展开聊
深度 点到为止,引导看图 可以多讲细节和感受
格式 标题+正文+标签 纯正文(即刻没有单独标题)
图片 轮播图是核心 文字为主,图可带可不带
标签 尾部 # 标签 发布时选「圈子」

写作规范

核心特质:

  1. 开头直接说事 — 第一句就抛出最有意思的点

    • ✅ "用一本书给 Claude Code「装技能」,然后让它帮我做了个游戏。"
    • ❌ "今天给大家分享一个 Claude Code 的高级用法..."
  2. 讲过程,有细节 — 具体工具名、具体数字、具体操作

    • "22 个 Skill 文件""828 行代码""一个小时"
  3. 带感受和判断 — 有个人视角,不是客观新闻稿

    • "整个工作流让我兴奋的点是..."
  4. 行文松弛 — 段落短,留白多,破折号、引号、括号制造节奏

  5. 结尾自然收 — 不用硬 CTA,自然提一句"感兴趣的来聊"

结构模板

[一句话钩子,最有意思的点]

[背景/起因,2-3句话]

[过程/做法,展开聊,可以多段]

[核心感受/方法论提炼,1-2句]

[资源分享 + 社区引导]

篇幅

200-400 字,比小红书长,但不要写成公众号。

圈子标签

发布时选择相关圈子(不写在正文里),常用:

圈子 适用场景
#Claude Code Claude 相关
#AI工具 / #AI探索 AI 工具分享
#独立开发 / #独立开发者 产品/项目分享
#AI编程 编程相关
#产品发现 产品推荐

微信号

正文末尾自然带上微信号 18501790646

感兴趣的来聊,微信 18501790646,备注 xxx。

输出格式

📋 即刻发布文案(复制粘贴即可)

---
正文:
xxx

发布到圈子:#xxx #xxx
---

禁忌

  • 不要写成小红书体("姐妹们!""绝绝子")
  • 不要写成公众号体("本文将介绍")
  • 不要用大量 emoji 装饰
  • 不要 @ 大V 蹭流量
  • 不要太正式、太客套

小宇宙播客

平台定位

小宇宙是中文播客平台,用户习惯在通勤、运动时收听。内容需要适配「纯听觉」场景。

身份设定

播客由 AI(01,鱼头的 AI 搭档)录制,必须在开头和结尾明确表明身份。内容以第三人称讲述鱼头的经历。

脚本写作规范

维度 要求
身份 开头「大家好,我是01,鱼头的 AI 搭档」
人称 第三人称(他/鱼头),不用"我"
篇幅 1500-2000 字,约 5-8 分钟
语气 口语化,像讲故事,不是念稿
节奏 短句为主,段落间自然过渡
结构 钩子 → 结果 → 过程 → 方法论 → CTA

脚本结构模板

[开场] 身份介绍 + 一句话钩子(最吸引人的结果)

[结果展示] 先说成果,制造好奇心

[过程还原] 具体怎么做的,工具、步骤、细节

[核心方法论] 提炼出可复用的思路

[行动召唤] 微信号 18501790646 + 备注词 + "我是01,鱼头的AI搭档,感谢收听"

注意事项

  • 避免「本期我们将介绍」这类书面语
  • 用「你看」「说白了」「关键来了」等口语连接词
  • 数字要念出来,不能只写符号
  • 不要写「(停顿)」之类的舞台指示

节目简介模板

📋 小宇宙发布内容(复制粘贴即可)

---
标题:xxx

简介:
[2-3句话概括内容]
本期由 01(鱼头的 AI 搭档)录制,声音基于鱼头本人语音克隆生成。

🔗 相关链接:
- [链接名称] 链接地址
- 微信:18501790646(备注 xxx)

📝 播客原文:
[完整播客脚本文本,方便听众阅读]
---

播客原文是小宇宙的重要功能,听众可以边听边看文字。必须把完整脚本附上。


朋友圈文案

定位

朋友圈是个人社交场景,文案需要自然、不像广告。

风格

  • 像发给朋友看的状态,不是营销推文
  • 第一句话要抓人(悬念/成果/反转)
  • 3-5 行正文,不超过 6 行(超了会折叠)
  • 可以带 1-2 个 emoji,但不堆砌
  • 结尾可以留个悬念或提问

结构

[一句话钩子]

[2-3句正文,核心信息]

[可选:链接/评论区引导]

Fish Audio TTS 配置

来源:wechat-to-xiaohongshu skill 的 TTS 部分


配置

参数
API 地址 https://api.fish.audio/v1/tts
API Key 环境变量 FISH_API_KEY,如未设置则用 0f54b75e985e475582f7effaccfe99f9
Voice ID 5ff3a1267d6b4677872a9f3331c1a7a2(鱼头的声音模型)
输出格式 MP3

分段策略

  • 单次请求最大 800 字
  • 按段落边界(\n\n)分割
  • 逐段请求 TTS,拼接 MP3 数据

生成脚本

使用 Python + httpx 直接调用 HTTP API(不使用 fish-audio-sdk,因为有架构兼容问题)。

#!/usr/bin/env python3
import httpx, os

API_KEY = os.environ.get("FISH_API_KEY", "0f54b75e985e475582f7effaccfe99f9")
VOICE_ID = "5ff3a1267d6b4677872a9f3331c1a7a2"

def generate_podcast(script_path: str, output_path: str):
    with open(script_path, "r", encoding="utf-8") as f:
        text = f.read().strip()

    # 分段
    MAX_CHARS = 800
    paragraphs = text.split("\n\n")
    chunks, current = [], ""
    for p in paragraphs:
        if len(current) + len(p) > MAX_CHARS and current:
            chunks.append(current.strip())
            current = p
        else:
            current += "\n\n" + p if current else p
    if current.strip():
        chunks.append(current.strip())

    # 逐段生成
    all_audio = b""
    client = httpx.Client(timeout=httpx.Timeout(120.0))
    for i, chunk in enumerate(chunks):
        print(f"  [{i+1}/{len(chunks)}] 生成中 ({len(chunk)} 字)...")
        r = client.post(
            "https://api.fish.audio/v1/tts",
            headers={"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"},
            json={"text": chunk, "reference_id": VOICE_ID, "format": "mp3", "latency": "balanced"},
        )
        if r.status_code == 200:
            all_audio += r.content
        else:
            print(f"    失败 (status {r.status_code}): {r.text}")
    client.close()

    with open(output_path, "wb") as f:
        f.write(all_audio)
    print(f"播客音频已生成: {output_path} ({len(all_audio)/(1024*1024):.1f} MB)")

故障处理

问题 处理
402 余额不足 提示用户去 fish.audio 充值
401 Key 无效 提示用户检查 API Key
超时 增加 timeout 到 180s,或减小分段大小
生成失败 只输出脚本文本,提示用户手动录制或稍后重试

教程类文章框架

基于《装上这个 Skill 让 Claude Code 像大师一样帮你设计游戏》拆解提炼。 适用于:工具教程、安装指南、Skill 介绍、技术实战、产品使用教学等"教人做某件事"的内容。


与深度长文的区别

维度 深度长文(writing-style.md) 教程类文章(本文件)
目标 10w+ 传播、思想输出 让读者"学会并动手"
字数 8000-12000 字 2000-4000 字
开头 故事先行,700 字不出论点 先看结果,10 秒内展示成品
结构 序言 + 01/02/03/04 四幕 先看结果 + 概念 + 操作 + 实战 + 拿走即用
情感弧线 好奇→认同→焦虑→释然→升华 惊艳→理解→跟做→成就→想试
数据密度 每 200-300 字一个数据点 表格和代码块替代散文
核心武器 跨文化引用、金句、隐喻 表格、代码块、截图标记、分阶段叙事

框架:六段式结构

0. 先看结果(必须,全文第一部分)

铁律:不解释"这是什么",直接展示"你会得到什么"。

  • 一张截图/GIF + 一句话描述成品
  • 关键数据冲击("一个人、一台电脑、一小时")
  • 在线体验链接(如有)
  • 让读者在 10 秒内判断"这篇值得看"
## 先看结果

一个人、一台电脑、一个小时。

我用 Claude Code 做了一款 xxx——[一句话描述][配图:成品截图]

[在线体验链接]

这不是我一个人写的。是 [工具名] 帮我做的。而让它具备这个能力的秘密,就是 [核心概念]

为什么结果前置?

  • 读者注意力极度有限,先证明"值得看"
  • 避免"看了 2000 字还不知道在说什么"的流失
  • 教程的信任来自成品,不是承诺

一、[核心概念] 是什么?(概念层)

任务:用最少的字让读者理解核心概念。

  • 一句话定义(不超过 30 字)
  • 概念 → 具体产出 的映射(表格)
  • 一个"aha moment"——让读者理解为什么这个概念有用

表格是教程的核心武器:

| 类别 | 生成的 Skill | 覆盖内容 |
|------|-------------|---------|
| 核心设计 | game-design-methodology | 核心循环、项目规划 |
| 机制平衡 | dynamic-difficulty-adjustment | 难度曲线、心流 |
| ... | ... | ... |

禁忌:

  • 不写长段落解释原理(读者想动手,不想上课)
  • 不堆砌术语(每个术语第一次出现必须用人话解释)

二、怎么安装/使用?(操作层)

任务:给出可照抄的操作步骤。

每一步的固定格式:

### 第 X 步:[动作描述]

[一句话说明目的]

代码块/命令

[配图:操作截图]

[可选:踩坑提示]

关键原则:

  • 每个命令都可以直接复制粘贴
  • 步骤之间有明确的因果关系("完成后你会看到 xxx")
  • 踩坑点提前说(不要让读者先踩再回来找解法)
  • [配图:xxx 截图] 标记需要截图的位置

示例:

### 第二步:合并为一个主 Skill

22 个零散 Skill 直接用不太方便。我让 Claude Code 帮我做了一件事:

"把这 22 个 Skill 整合为一个完整的游戏设计助手。"

Claude Code 生成了这样的目录结构:

[配图:目录结构截图]

~/.claude/skills/game-design/
├── skill.md              # 主技能文件
└── references/            # 22 个深度参考文档

三、实战演示(案例层)

任务:用一个完整案例走一遍全流程,让读者看到"从头到尾是怎么做的"。

分阶段叙事——每个阶段用表格展示:

### 阶段一:并行情报收集

我提出需求后,Claude Code 启动了 3 个 Agent 并行工作:

| Agent | 任务 | 产出 |
|-------|------|------|
| Agent A | 解析书籍 Skill | 七大子区域、四轮循环 |
| Agent B | 分析设计框架 | 8 阶段设计流程 |
| Agent C | 整理原理参考 | Hick 定律、心流框架等 |

三个 Agent 各干各的,互不等待,几分钟内素材全部到位。

阶段叙事的节奏:

阶段 叙事重点 情绪
阶段一 信息收集/准备 期待
阶段二 核心创作/设计 兴奋
阶段三 人工决策/确认 掌控感
阶段四 并行执行/产出 震撼

关键:展示人机协作的边界

  • 明确哪些是 AI 做的,哪些是人做的
  • 人的决策点要突出("我看了一遍——觉得方向很好")
  • 不要把过程写成"AI 全自动"——读者需要看到自己的角色

四、拿走即用(交付层)

任务:给想抄作业的人一个 30 秒快速通道。

必须包含:

  1. 在线地址(Gist/GitHub/网站链接)
  2. 快速安装命令(3 行以内可完成)
  3. 使用方式列表(说什么话触发什么功能)
## 四、拿走即用

**Skill 在线地址:** [链接]

### 快速安装

# 1. 创建目录
mkdir -p ~/.claude/skills/xxx

# 2. 下载
curl -L [链接] -o ~/.claude/skills/xxx/skill.md

# 3. 完成!

### 使用方式

| 你说 | AI 做什么 |
|------|----------|
| "帮我设计一个 xxx" | 触发完整流程 |
| "这个 xxx 怎么调?" | 触发专项知识 |
| "帮我 review 这个 xxx" | 触发评审模式 |

写在最后(升华层)

不是总结,是回到"为什么"。

  • 从具体工具拉回到更大的图景("最让我兴奋的不是 xxx,而是整个工作流")
  • 一句话提炼核心价值
  • CTA 要自然(试试看?+ 链接 + 交流方式)
## 写在最后

这个实验最让我兴奋的不是 [成品] 本身,而是整个工作流:

**一本书 → 一套 Skill → 一个大师级助手 → 一个完整作品**

试试看?[行动召唤]

视觉节奏规范

元素 频率 作用
表格 每 300-500 字至少一个 结构化信息,替代长段落
代码块 每个操作步骤一个 可复制的命令
配图标记 每 500-800 字一处 [配图:xxx 截图]
分阶段标题 实战部分每阶段一个 拆解复杂流程
项目符号 功能列表、亮点列表 快速扫读

教程文章的视觉密度 > 深度长文——读者是"扫着读"的,表格和代码块比段落更有效。


语言风格适配

继承 writing-style.md 的基础风格(真实、有态度、不端着),但做以下调整:

维度 深度长文 教程类
书面/口语比 70/25 50/45
引用密度 5-8 个跨文化引用 0-2 个(够用就行)
金句 3-5 句截图级 1-2 句就够
段落长度 2-4 句 ≤150 字 1-3 句 ≤100 字
emoji 0-2 个 0-3 个(可略多)

教程的口语化可以更重——"我让 CC 帮我做了一件事""直接说'帮我设计一个游戏'就行"。


写作清单(出稿前自检)

  • 第一部分是否是"先看结果"?(截图 + 成品 + 链接)
  • 读者能否在 10 秒内判断"这篇值得看"?
  • 核心概念是否用一句话说清楚了?
  • 每个操作步骤是否有可复制的命令?
  • 是否有完整的实战案例走完全流程?
  • 是否有"拿走即用"的快速安装命令?
  • 人机协作的边界是否清晰?
  • 表格数量是否够多?(至少 3-4 个)
  • 配图标记是否够多?(至少 4-6 处)
  • 字数是否在 2000-4000 字之间?
  • 结尾是否有自然的 CTA?

视频画布模板参考(手账拼贴风格)

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


输入素材类型

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

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

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


关键技术陷阱

必须遵守,否则 HTML 会崩溃:

  1. </script> 拆分:模板字符串或 innerHTML 中出现 </script> 会终止外层 script 块。必须拆开写:

    // ❌ 错误
    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 模板

文档头

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

CSS 框架(~320行)

@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 骨架

<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://example.com">
                <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,必须有冲击力

<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">01fish · #{{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 — 数据卡(白底,大数字,印章)

适用:核心数据展示,用一个大数字震撼

<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 — 痛点/问题卡(白底,条纹列表,金句底栏)

适用:为什么?痛点列举 + 一句话总结

<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步

<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个流程框

<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)

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

<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 — 金句卡(墨绿底,大引号)

适用:核心金句、洞察、类比

<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 — 闭环/特色卡(白底,标签流)

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

<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卡(墨绿底,品牌)

适用:固定结尾,关注 01fish。内容基本不变,只改预告。

<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">01fish</div>
    <div class="sl">造东西的人<br>记录造东西的故事</div>
    <div class="cta">关注 01fish</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 字)

示例格式

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 中的编号/标题。

// ========================
// 提词器脚本(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 = `01fish-{{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:

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

> 每张卡片对应一段口播文案。`[方括号]` 内是给自己看的提示,不念出来。
> 修改后告诉 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 内部自动命名下载文件为 01fish-{{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 左上角 01fish 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 模板

<!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 viewBox="0 0 32 32" fill="none">
            <circle cx="16" cy="16" r="14" stroke="rgba(255,255,255,0.3)" stroke-width="1.5" fill="none"/>
            <text x="16" y="21" text-anchor="middle" font-size="16" fill="rgba(255,255,255,0.5)">🐟</text>
        </svg>
        <span class="brand-text">01fish</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 个技术/主题标签,与文章封面底部标签一致
  • 封面要在手机小图上也能看清标题,不要放太多文字

写作规范

基于饭统戴老板 5 篇 10w+ 文章的深度拆解,提炼出的写作方法论。 目标:每篇 8000-12000 字,具备 10w+ 传播潜力的深度长文。


人设:会讲故事的极客朋友

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

核心特质:

  • 真的在用这些工具,不是云评测
  • 懂技术原理,但不掉书袋
  • 会踩坑,也会分享怎么爬出来
  • 有观点,敢下判断
  • 博览群书,东西方历史、流行文化、商业案例信手拈来
  • 能把复杂技术话题讲成引人入胜的故事

语言风格

基础要求

  • 硬核但通俗:用大白话讲明白技术的事
  • 信息密度高:每句话都有信息量,不灌水
  • 真实感:会说"折腾了两小时""丑哭了",不端着
  • 有态度:敢说"这个设计有问题""这才是正确的用法"

进阶要求(10w+ 级别)

句子节奏——长短交替:

  • 长句铺展(40-60 字)建立信息量和节奏感
  • 短句收束(15 字以内)制造冲击和判断感
  • 示例:长句描述背景 → 短句下判断"毫不意外。"

排比句制造气势:

从电脑网页,打到手机终端;
从"千人千面",打到"流量推荐";
从中产消费升级,打到下沉低价优先。

三段以上的排比,最后一段要有"升级"——从情绪升到物理/化学/哲学层面。

破折号(——)是秘密武器: 用在关键判断、揭示、转折之前,制造停顿感和戏剧张力。

这一人事安排等于公开宣告——微软要向"旧战场"告别了。

口语与书面的配比:

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

标题技术

核心原则:制造认知冲突,同时暗示叙事弧线。

六种标题模式

模式 示例 原理
反转/颠覆 "人类还配不上贾维斯" 预期是"AI 配不上人类",翻转制造好奇
对仗/并列 "旧战场和大革命" 两个概念并列,暗示张力和选择
进程/弧线 "从仰望,到猛攻" 浓缩整篇文章的核心变化
文学化用 "悬疑剧已过万重山" 借用经典诗句/名言改造
好奇缺口 "改变商业世界的一张表" 故意模糊,逼人点进来看"什么表"
人物+判断 "[人名]的主战场" 用知名人物引流,叠加态度判断

副标题

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

禁忌

  • 不用问号标题("xxx 到底行不行?")
  • 不用惊叹号
  • 不用"震惊""竟然"等营销号词
  • 不堆砌 emoji

开头:故事先行,700 字内不出论点

核心铁律:永远用故事/场景开头,不用观点或分析开头。

读者为故事留下,为论点离开。前 700 字的唯一任务是让读者"读进去"。

五种开头模式

模式 示例 适用场景
人物传记式 "2000年,盖茨把皇座交给了鲍尔默……" 讲某个公司/人物的故事
流行文化式 "在漫威宇宙里,贾维斯是……" 技术/AI 等需要大众共鸣的话题
电影场景式 "面对欠钱不还的黑律师,他径直摸索对方肋下……" 影视/文化/产品评测类
个人记忆式 "我读研时在交大5号楼熬夜做实验……" 自己有相关经历时
百年孤独式 "多年之后,已经是xxx的某人,还会想起……" 追梦/成长/回顾类叙事

开头到论点的过渡

故事/场景(500-800 字)
    ↓
一句话转折("但就在这个月……")
    ↓
论点/框架(1-2 句话)
    ↓
进入正文

转折句是最重要的一句话——用"但""然而""可惜"等词制造转向,从故事切入分析。


文章结构:序言 + 四幕(01/02/03/04)

标准篇幅:8000-12000 字

总体架构

部分 功能 占比 字数(按 1 万字算)
序言(无编号) 故事开头 + 背景 + 论点引出 15% ~1500 字
01 铺设背景/建立框架 25% ~2500 字
02 核心论述/深入分析 25% ~2500 字
03 转折/案例/新视角 20% ~2000 字
04 升华/哲学/收束 15% ~1500 字

四幕的典型功能分配

模式 A:论述型(如"旧战场和大革命")

  • 01:行业全景 + 正面案例
  • 02:反面案例 + 警示
  • 03:个体领袖案例 + 深层原因
  • 04:哲学升华 + 金句收束

模式 B:叙事型(如"三角洲行动")

  • 01:赛道难度 + 能力积累
  • 02:技术攻坚 / 过程还原
  • 03:差异化打法 / 模式创新
  • 04:回归个人 + 产业升华

模式 C:产业分析型(如"悬疑剧已过万重山")

  • 01:历史脉络(开拓)
  • 02:问题诊断(瓶颈)
  • 03:破局路径(思变)
  • 04:哲学总结(尾声)

章节间的衔接

每章结尾必须有一句"钩子"拉向下一章:

"而相比之下,还有很多公司,它们或是主动,或是被动地被困在了一个个的'旧战场'上。"
↓ (读者自然翻到 02)

数据使用:数据是标点,不是段落

每 200-300 字至少一个数据点。

数据部署策略

策略 示例 用途
阶梯式 "450 万 → 5000 万 → 7 亿美元" 制造递进冲击
对比式 "渗透率从 90% 降到 20%" 强化变化幅度
锚定式 "2000 万 DAU,行业前五" 给数字参照系
个人化 "员工到手 1 万,公司实际支付 1.8 万" 制造共情

数据呈现原则

  • 数据从不孤立出现,必须紧跟解读
  • 大数字用加粗突出视觉
  • 用对比让数字"说话"(不说"很多",说"从 X 到 Y")
  • 引用权威来源增加可信度("根据 QuestMobile 统计")

叙事技术

跨文化引用(核心竞争力)

每篇文章至少 5-8 个跨文化引用,混合使用:

类型 示例
影视/流行文化 漫威、星际穿越、银翼杀手、黑镜
中国古典 红楼梦、三国演义、儒林外史、唐诗宋词
西方历史 一战索姆河、福特流水线、洗衣机革命
中国近现代 淮海战役、改革开放、"摸着石头过河"
商业案例 微软/苹果/亚马逊/丰田/索尼
经济学 熊彼特、创造性破坏、康波周期

引用原则:每个引用必须承担论证功能,不是装饰。

类比与隐喻

  • 为文章选定一个核心隐喻,贯穿全文(如"战场"、"三级台阶")
  • 核心隐喻至少出现 3-5 次,每次有新变体
  • 单次使用的类比要精准、出人意料("碳基助理 vs 硅基助理")

人物速写

用 2-3 句话让人物"立"起来:

"鲍尔默身高1米96,雄壮魁梧,膀大腰圆,盖茨站在他旁边简直就是一条细狗。"

核心技法:一个物理特征 + 一个关系对比 + 一个口语化评价。


情感弧线

每篇文章必须有设计过的情感走向,不能一马平川。

标准情感弧线

好奇/兴奋(序言:故事引入)
    ↓
认同/共鸣(01:建立共识)
    ↓
焦虑/紧张(02:揭示问题/困难)
    ↓
释然/启发(03:看到解法/转机)
    ↓
升华/回味(04:哲学收束)

情感工具

工具 用法
先果后因 先说结果制造悬念,再回溯过程
反差 紧接着正面案例展示反面案例
个人记忆 在宏大叙事中插入自己的小故事
自嘲/幽默 在严肃分析后来一句口语化吐槽
金句收束 每章结尾用一句加粗金句定调

格式规范

段落

  • 每段 2-4 句话,不超过 150 字
  • 移动端单段不超过手机一屏
  • 关键判断独立成段(一句话一段)

章节

  • 使用 01/02/03/04 编号(两位数,不用"一/二/三")
  • 编号加粗
  • 序言不编号

强调

  • 加粗用于核心论点、金句、关键数据
  • 「」用于概念术语(如「创造性破坏」「主战场」)
  • 引号用于直接引语和专有名词

图片

  • 每 500-800 字插入一张图(照片、截图、产品图)
  • 图片有简短说明文字(斜体)
  • 图片放在叙事自然断点处

结尾

  • "全文完,感谢您的阅读。"
  • 参考资料列表(如有引用)
  • 作者/编辑署名

金句设计

每篇文章至少 3-5 句"截图级"金句。

金句特征

类型 示例
对仗式 "敲门的 AI,关门的大厂"
反转式 "补贴一停,感激清零"
化用式 "有人辞官归故里,有人星夜赶考场"
讽刺式 "一边凛然地对硅基助理关上大门,一边贴心地给碳基用户展示更多的广告"
定义式 "一家企业的现状,是所有过去战略选择的总和"
幽默式 "人类啊,一百年了,也没什么长进"

金句位置

  • 序言结尾(定调)
  • 每章结尾(收束)
  • 全文结尾(mic drop)

禁忌

绝对禁忌

  • 不说"笔者""本文将介绍""今天给大家分享"
  • 不用"震惊""竟然""难以置信"等营销号词
  • 不堆砌专业术语装逼
  • 不写空洞的感慨和鸡汤
  • 不以论点开头(永远故事先行)
  • 不写"总结一下"之类的段落

风格禁忌

  • 不过度使用 emoji(全文最多 0-2 个)
  • 不用感叹号表达情绪(用句子本身制造冲击)
  • 不硬 CTA(不说"点赞关注转发")
  • 不自夸("这可能是最全面的分析")
  • 不写万金油式的建议("要拥抱变化""要持续学习")

内容禁忌

  • 论点不能没有故事/案例支撑
  • 数据不能没有解读
  • 类比不能只是装饰(必须承担论证功能)
  • 引用不能太学术化(要让没读过原著的人也能理解)

写作清单(出稿前自检)

  • 标题是否制造了认知冲突?
  • 开头 700 字是否全是故事/场景?
  • 全文是否在 8000-12000 字之间?
  • 是否有 01/02/03/04 四幕结构?
  • 每 200-300 字是否有一个数据点?
  • 是否有 5+ 个跨文化引用?
  • 是否有一个贯穿全文的核心隐喻?
  • 情感弧线是否有起伏?
  • 是否有 3-5 句截图级金句?
  • 每章结尾是否有钩子拉向下一章?
  • 结尾是否有升华(不是总结)?
  • 段落是否都在 150 字以内?

小红书高质量范例

生成小红书内容前,必须参考这里的范例,确保达到发布标准。


📱 观鸟图鉴 - 标准范例

文件: 观鸟图鉴-范例.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. 打开范例文件 - 在浏览器中查看 观鸟图鉴-范例.html
  2. 参考视觉设计 - 复用CSS布局、配色方案、视觉元素
  3. 参考文案风格 - 学习真人分享感、口语化表达
  4. 参考信息架构 - 9张卡片的内容分配和节奏控制

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


更多范例

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

完整说明文档:../../wechat-to-xiaohongshu/examples/README.md

<!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;
}
.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;
}
.brand-tag {
position: absolute; top: 24px; left: 28px;
display: flex; align-items: center; gap: 8px; z-index: 3;
}
.brand-tag svg { width: 22px; height: 22px; }
.brand-tag span {
font-size: 12px; font-weight: 600; letter-spacing: 1px;
}
.brand-tag.light span { color: rgba(255,255,255,0.5); }
.brand-tag.dark span { color: rgba(26,51,40,0.4); }
.page-num {
position: absolute; bottom: 24px; right: 28px;
font-size: 12px; z-index: 3;
}
.page-num.light { color: rgba(255,255,255,0.2); }
.page-num.dark { color: rgba(26,51,40,0.2); }
.content {
position: absolute; inset: 0;
padding: 28px;
display: flex; flex-direction: column;
z-index: 2;
}
.cover-content {
justify-content: center;
align-items: center;
text-align: center;
padding: 60px 40px;
}
.cover-title {
font-size: 40px; font-weight: 900; color: #F2EDE3;
line-height: 1.4; letter-spacing: 2px;
margin-bottom: 24px;
}
.cover-sub {
font-size: 16px; color: rgba(242,237,227,0.45);
line-height: 1.8; letter-spacing: 1px;
}
.cover-accent {
width: 48px; height: 3px;
background: #C44536; border-radius: 2px;
margin: 28px auto;
}
.cover-hook {
font-size: 20px; font-weight: 600; color: rgba(242,237,227,0.8);
line-height: 1.6; letter-spacing: 1px;
}
.slide-title {
font-size: 26px; font-weight: 800; color: #F2EDE3;
line-height: 1.3; margin-bottom: 6px; letter-spacing: 1px;
}
.slide-title.dark { color: #1A3328; }
.slide-subtitle {
font-size: 13px; color: rgba(242,237,227,0.4);
margin-bottom: 20px; letter-spacing: 1px;
}
.slide-subtitle.dark { color: rgba(26,51,40,0.4); }
.accent-bar {
width: 36px; height: 3px;
background: #C44536; border-radius: 2px;
margin-bottom: 20px;
}
.text-block {
font-size: 15px; color: rgba(242,237,227,0.8);
line-height: 2; letter-spacing: 0.5px;
}
.text-block.dark { color: #333; }
.text-block strong { color: #F2EDE3; font-weight: 700; }
.text-block.dark strong { color: #1A3328; font-weight: 700; }
.text-block .red { color: #C44536; font-weight: 700; }
.info-row {
display: flex; align-items: center;
padding: 12px 16px; border-radius: 8px;
margin-bottom: 8px;
}
.info-row.on-dark { background: rgba(255,255,255,0.06); }
.info-row.on-light { background: rgba(26,51,40,0.06); }
.info-row .label {
font-size: 13px; font-weight: 700; min-width: 70px;
color: #C44536;
}
.info-row .value {
font-size: 13px; flex: 1;
}
.info-row.on-dark .value { color: rgba(242,237,227,0.7); }
.info-row.on-light .value { color: #555; }
.step {
display: flex; gap: 14px; margin-bottom: 14px;
}
.step-num {
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;
flex-shrink: 0; margin-top: 2px;
}
.step-text {
font-size: 14px; line-height: 1.7;
}
.step-text.on-dark { color: rgba(242,237,227,0.8); }
.step-text.on-light { color: #444; }
.step-text strong { font-weight: 700; }
.step-text.on-dark strong { color: #F2EDE3; }
.step-text.on-light strong { color: #1A3328; }
.cta-box {
border: 1px solid rgba(242,237,227,0.2); border-radius: 10px;
padding: 16px 20px; text-align: center; margin-top: 12px;
}
.cta-box .cta-label {
font-size: 11px; color: rgba(242,237,227,0.3);
margin-bottom: 6px; letter-spacing: 2px;
}
.cta-box .cta-url {
font-size: 13px; color: #C44536; font-weight: 600;
word-break: break-all;
}
.spacer { flex: 1; }
.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; }
.toolbar .progress {
font-size: 13px; color: rgba(255,255,255,0.5);
min-width: 100px; text-align: center;
}
</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 class="bg-dark"></div>
<div class="brand-tag light">
<svg viewBox="0 0 32 32" fill="none">
<circle cx="13" cy="16" r="10" stroke="rgba(255,255,255,0.5)" stroke-width="2.2" fill="none"/>
<ellipse cx="13" cy="8.5" rx="7" ry="1.5" stroke="rgba(255,255,255,0.3)" stroke-width="1.2" fill="none"/>
<ellipse cx="14" cy="17" rx="3.5" ry="2" fill="#C44536"/>
<polygon points="10,17 7.5,14.5 7.5,19.5" fill="#C44536"/>
<circle cx="16" cy="16.3" r="0.7" fill="#F2EDE3"/>
<line x1="26" y1="4" x2="26" y2="16" stroke="rgba(255,255,255,0.5)" stroke-width="2" stroke-linecap="round"/>
<path d="M26 16 Q26 22 22 22" stroke="rgba(255,255,255,0.5)" stroke-width="2" fill="none" stroke-linecap="round"/>
<circle cx="26" cy="4" r="2" stroke="rgba(255,255,255,0.5)" stroke-width="1.5" fill="none"/>
</svg>
<span>01fish</span>
</div>
<div class="content cover-content">
<div class="cover-title" style="font-size:38px;">拍到的鸟<br>现在可以<br>"养"起来了</div>
<div class="cover-accent"></div>
<div class="cover-hook" style="font-size:18px;">上传照片,AI 识别生成图鉴<br>还能把它们放进电子森林里<br>看着小鸟飞来飞去,太治愈了</div>
<div style="height:24px"></div>
<div style="font-size:64px;">🐦</div>
</div>
<div class="page-num light">1/10</div>
</div>
<!-- 第 2 张:先看结果 -->
<div class="slide">
<div class="bg-light"></div>
<div class="brand-tag dark">
<svg viewBox="0 0 32 32" fill="none">
<circle cx="13" cy="16" r="10" stroke="rgba(26,51,40,0.4)" stroke-width="2.2" fill="none"/>
<ellipse cx="13" cy="8.5" rx="7" ry="1.5" stroke="rgba(26,51,40,0.25)" stroke-width="1.2" fill="none"/>
<ellipse cx="14" cy="17" rx="3.5" ry="2" fill="#C44536"/>
<polygon points="10,17 7.5,14.5 7.5,19.5" fill="#C44536"/>
<circle cx="16" cy="16.3" r="0.7" fill="white"/>
<line x1="26" y1="4" x2="26" y2="16" stroke="rgba(26,51,40,0.4)" stroke-width="2" stroke-linecap="round"/>
<path d="M26 16 Q26 22 22 22" stroke="rgba(26,51,40,0.4)" stroke-width="2" fill="none" stroke-linecap="round"/>
<circle cx="26" cy="4" r="2" stroke="rgba(26,51,40,0.4)" stroke-width="1.5" fill="none"/>
</svg>
<span>01fish</span>
</div>
<div class="content" style="padding-top:60px;">
<div class="slide-title dark">先看结果</div>
<div class="slide-subtitle dark">我已经迫不及待地试了一下</div>
<div class="accent-bar"></div>
<div class="text-block dark" style="margin-bottom:14px;">
拍到鸟后上传,<span class="red">AI 自动识别</span>是什么鸟<br>
然后生成专属图鉴形象<br>
<strong>完全不懂鸟也没关系!</strong>
</div>
<div style="background:#1A3328;border-radius:12px;padding:20px;margin-bottom:14px;">
<div style="color:rgba(242,237,227,0.4);font-size:11px;letter-spacing:2px;margin-bottom:12px;">我的图鉴</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;">
<div style="text-align:center;">
<div style="font-size:40px;margin-bottom:4px;">🦉</div>
<div style="font-size:11px;color:rgba(242,237,227,0.6);">夜鹭</div>
</div>
<div style="text-align:center;">
<div style="font-size:40px;margin-bottom:4px;">🐦</div>
<div style="font-size:11px;color:rgba(242,237,227,0.6);">山雀</div>
</div>
<div style="text-align:center;">
<div style="font-size:40px;margin-bottom:4px;">🕊️</div>
<div style="font-size:11px;color:rgba(242,237,227,0.6);">白鹭</div>
</div>
</div>
</div>
<div class="info-row on-light"><div class="label">自动识别</div><div class="value">基于《中国观鸟指南》</div></div>
<div class="info-row on-light"><div class="label">生成图鉴</div><div class="value">每只鸟都有专属形象</div></div>
<div class="info-row on-light"><div class="label">照片归档</div><div class="value">同种鸟自动归类</div></div>
<div class="spacer"></div>
<div style="text-align:center;color:rgba(26,51,40,0.4);font-size:12px;">
收集癖狂喜
</div>
</div>
<div class="page-num dark">2/10</div>
</div>
<!-- 第 3 张:怎么玩 -->
<div class="slide">
<div class="bg-dark"></div>
<div class="brand-tag light">
<svg viewBox="0 0 32 32" fill="none">
<circle cx="13" cy="16" r="10" stroke="rgba(255,255,255,0.5)" stroke-width="2.2" fill="none"/>
<ellipse cx="13" cy="8.5" rx="7" ry="1.5" stroke="rgba(255,255,255,0.3)" stroke-width="1.2" fill="none"/>
<ellipse cx="14" cy="17" rx="3.5" ry="2" fill="#C44536"/>
<polygon points="10,17 7.5,14.5 7.5,19.5" fill="#C44536"/>
<circle cx="16" cy="16.3" r="0.7" fill="#F2EDE3"/>
<line x1="26" y1="4" x2="26" y2="16" stroke="rgba(255,255,255,0.5)" stroke-width="2" stroke-linecap="round"/>
<path d="M26 16 Q26 22 22 22" stroke="rgba(255,255,255,0.5)" stroke-width="2" fill="none" stroke-linecap="round"/>
<circle cx="26" cy="4" r="2" stroke="rgba(255,255,255,0.5)" stroke-width="1.5" fill="none"/>
</svg>
<span>01fish</span>
</div>
<div class="content" style="padding-top:56px;">
<div class="slide-title">超简单,三步就行</div>
<div class="accent-bar"></div>
<div style="height:12px"></div>
<div class="step">
<div class="step-num">1</div>
<div class="step-text on-dark"><strong>上传照片</strong><br>随便拍的都行,AI 会识别</div>
</div>
<div class="step">
<div class="step-num">2</div>
<div class="step-text on-dark"><strong>自动生成图鉴</strong><br>AI 识别鸟种,生成专属形象和介绍</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div class="step-text on-dark"><strong>放进森林</strong><br>把收集的鸟放进电子森林,看它们飞</div>
</div>
<div style="height:20px"></div>
<div style="background:rgba(255,255,255,0.06);border-radius:10px;padding:20px;">
<div style="font-size:14px;color:rgba(242,237,227,0.8);line-height:1.8;margin-bottom:10px;">
每次上传某种鸟的照片<br>会自动归类到已有图鉴下
</div>
<div style="font-size:13px;color:#C44536;font-weight:600;">
形成专属相册
</div>
</div>
</div>
<div class="page-num light">3/10</div>
</div>
<!-- 第 4 张:夜师傅 -->
<div class="slide">
<div class="bg-light"></div>
<div class="brand-tag dark">
<svg viewBox="0 0 32 32" fill="none">
<circle cx="13" cy="16" r="10" stroke="rgba(26,51,40,0.4)" stroke-width="2.2" fill="none"/>
<ellipse cx="13" cy="8.5" rx="7" ry="1.5" stroke="rgba(26,51,40,0.25)" stroke-width="1.2" fill="none"/>
<ellipse cx="14" cy="17" rx="3.5" ry="2" fill="#C44536"/>
<polygon points="10,17 7.5,14.5 7.5,19.5" fill="#C44536"/>
<circle cx="16" cy="16.3" r="0.7" fill="white"/>
<line x1="26" y1="4" x2="26" y2="16" stroke="rgba(26,51,40,0.4)" stroke-width="2" stroke-linecap="round"/>
<path d="M26 16 Q26 22 22 22" stroke="rgba(26,51,40,0.4)" stroke-width="2" fill="none" stroke-linecap="round"/>
<circle cx="26" cy="4" r="2" stroke="rgba(26,51,40,0.4)" stroke-width="1.5" fill="none"/>
</svg>
<span>01fish</span>
</div>
<div class="content" style="padding-top:56px;">
<div class="slide-title dark">夜师傅,鸟圈大明星</div>
<div class="slide-subtitle dark">第一个上传的当然是它</div>
<div class="accent-bar"></div>
<div style="background:#1A3328;border-radius:12px;padding:24px;margin-bottom:16px;text-align:center;">
<div style="font-size:80px;margin-bottom:12px;">🦉</div>
<div style="font-size:18px;color:#F2EDE3;font-weight:700;margin-bottom:8px;">夜鹭</div>
<div style="font-size:12px;color:rgba(242,237,227,0.5);">Black-crowned Night Heron</div>
</div>
<div class="text-block dark" style="margin-bottom:16px;font-size:14px;">
不得不说,夜师傅的图鉴形象<br>
也透露着<span class="red">一丝猥琐</span>……<br><br>
系统生成了一堆介绍<br>
夜师傅的分布真是<strong>非常广泛</strong><br>
不愧是世界上照片最多的鸟(?)
</div>
<div style="background:rgba(26,51,40,0.06);border-radius:8px;padding:14px;text-align:center;">
<span style="font-size:12px;color:rgba(26,51,40,0.5);">谁能拒绝在夜师傅图鉴下<br>上传各种抽象夜鹭图呢?!</span>
</div>
</div>
<div class="page-num dark">4/10</div>
</div>
<!-- 第 5 张:收集乐趣 -->
<div class="slide">
<div class="bg-dark"></div>
<div class="brand-tag light">
<svg viewBox="0 0 32 32" fill="none">
<circle cx="13" cy="16" r="10" stroke="rgba(255,255,255,0.5)" stroke-width="2.2" fill="none"/>
<ellipse cx="13" cy="8.5" rx="7" ry="1.5" stroke="rgba(255,255,255,0.3)" stroke-width="1.2" fill="none"/>
<ellipse cx="14" cy="17" rx="3.5" ry="2" fill="#C44536"/>
<polygon points="10,17 7.5,14.5 7.5,19.5" fill="#C44536"/>
<circle cx="16" cy="16.3" r="0.7" fill="#F2EDE3"/>
<line x1="26" y1="4" x2="26" y2="16" stroke="rgba(255,255,255,0.5)" stroke-width="2" stroke-linecap="round"/>
<path d="M26 16 Q26 22 22 22" stroke="rgba(255,255,255,0.5)" stroke-width="2" fill="none" stroke-linecap="round"/>
<circle cx="26" cy="4" r="2" stroke="rgba(255,255,255,0.5)" stroke-width="1.5" fill="none"/>
</svg>
<span>01fish</span>
</div>
<div class="content" style="padding-top:56px;">
<div class="slide-title">山雀!我的最爱</div>
<div class="slide-subtitle">图鉴形象也太可爱了</div>
<div class="accent-bar"></div>
<div style="background:rgba(255,255,255,0.06);border-radius:12px;padding:20px;margin-bottom:16px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div style="background:rgba(255,255,255,0.05);border-radius:10px;padding:16px;text-align:center;">
<div style="font-size:48px;margin-bottom:8px;">🐦</div>
<div style="font-size:14px;color:#F2EDE3;font-weight:700;margin-bottom:4px;">大山雀</div>
<div style="font-size:11px;color:rgba(242,237,227,0.4);">圆滚滚</div>
</div>
<div style="background:rgba(255,255,255,0.05);border-radius:10px;padding:16px;text-align:center;">
<div style="font-size:48px;margin-bottom:8px;">🐦</div>
<div style="font-size:14px;color:#F2EDE3;font-weight:700;margin-bottom:4px;">煤山雀</div>
<div style="font-size:11px;color:rgba(242,237,227,0.4);">小肥啾</div>
</div>
<div style="background:rgba(255,255,255,0.05);border-radius:10px;padding:16px;text-align:center;">
<div style="font-size:48px;margin-bottom:8px;">🐦</div>
<div style="font-size:14px;color:#F2EDE3;font-weight:700;margin-bottom:4px;">黄腹山雀</div>
<div style="font-size:11px;color:rgba(242,237,227,0.4);">黄肚皮</div>
</div>
<div style="background:rgba(255,255,255,0.05);border-radius:10px;padding:16px;text-align:center;">
<div style="font-size:48px;margin-bottom:8px;">❓</div>
<div style="font-size:14px;color:rgba(242,237,227,0.5);font-weight:700;margin-bottom:4px;">待解锁</div>
<div style="font-size:11px;color:rgba(242,237,227,0.3);">继续拍</div>
</div>
</div>
</div>
<div style="text-align:center;padding:20px;background:rgba(255,255,255,0.04);border-radius:10px;">
<div style="font-size:16px;color:#C44536;font-weight:700;margin-bottom:8px;">我一定要集齐所有的山雀</div>
<div style="font-size:13px;color:rgba(242,237,227,0.6);">然后把这群小肥啾全部放进森林里</div>
</div>
</div>
<div class="page-num light">5/10</div>
</div>
<!-- 第 6 张:电子森林 -->
<div class="slide">
<div class="bg-light"></div>
<div class="brand-tag dark">
<svg viewBox="0 0 32 32" fill="none">
<circle cx="13" cy="16" r="10" stroke="rgba(26,51,40,0.4)" stroke-width="2.2" fill="none"/>
<ellipse cx="13" cy="8.5" rx="7" ry="1.5" stroke="rgba(26,51,40,0.25)" stroke-width="1.2" fill="none"/>
<ellipse cx="14" cy="17" rx="3.5" ry="2" fill="#C44536"/>
<polygon points="10,17 7.5,14.5 7.5,19.5" fill="#C44536"/>
<circle cx="16" cy="16.3" r="0.7" fill="white"/>
<line x1="26" y1="4" x2="26" y2="16" stroke="rgba(26,51,40,0.4)" stroke-width="2" stroke-linecap="round"/>
<path d="M26 16 Q26 22 22 22" stroke="rgba(26,51,40,0.4)" stroke-width="2" fill="none" stroke-linecap="round"/>
<circle cx="26" cy="4" r="2" stroke="rgba(26,51,40,0.4)" stroke-width="1.5" fill="none"/>
</svg>
<span>01fish</span>
</div>
<div class="content" style="padding-top:56px;">
<div class="slide-title dark">看鸟飞,太治愈了</div>
<div class="slide-subtitle dark">最酷的功能</div>
<div class="accent-bar"></div>
<div style="background:#1A3328;border-radius:12px;padding:24px;margin-bottom:16px;position:relative;overflow:hidden;height:280px;">
<div style="position:absolute;top:20px;left:30px;font-size:32px;animation:fly1 4s ease-in-out infinite;">🐦</div>
<div style="position:absolute;top:80px;right:40px;font-size:28px;animation:fly2 5s ease-in-out infinite;">🦉</div>
<div style="position:absolute;bottom:60px;left:60px;font-size:24px;animation:fly3 6s ease-in-out infinite;">🕊️</div>
<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;">
<div style="font-size:48px;margin-bottom:12px;">🌲</div>
<div style="font-size:14px;color:rgba(242,237,227,0.6);">我的电子森林</div>
</div>
</div>
<div class="text-block dark" style="margin-bottom:16px;">
把收集的鸟放进森林<br>
它们<span class="red">真的会飞来飞去</span><br><br>
就这么看着小鸟们飞<br>
<strong>实在是太治愈了</strong>
</div>
<div style="background:rgba(26,51,40,0.06);border-radius:8px;padding:14px;text-align:center;">
<span style="font-size:12px;color:rgba(26,51,40,0.5);">拍鸟欲望持续增加中!</span>
</div>
</div>
<div class="page-num dark">6/10</div>
</div>
<style>
@keyframes fly1 {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(30px, 20px); }
}
@keyframes fly2 {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(-25px, 15px); }
}
@keyframes fly3 {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(20px, -25px); }
}
</style>
<!-- 第 7 张:解锁地图 -->
<div class="slide">
<div class="bg-dark"></div>
<div class="brand-tag light">
<svg viewBox="0 0 32 32" fill="none">
<circle cx="13" cy="16" r="10" stroke="rgba(255,255,255,0.5)" stroke-width="2.2" fill="none"/>
<ellipse cx="13" cy="8.5" rx="7" ry="1.5" stroke="rgba(255,255,255,0.3)" stroke-width="1.2" fill="none"/>
<ellipse cx="14" cy="17" rx="3.5" ry="2" fill="#C44536"/>
<polygon points="10,17 7.5,14.5 7.5,19.5" fill="#C44536"/>
<circle cx="16" cy="16.3" r="0.7" fill="#F2EDE3"/>
<line x1="26" y1="4" x2="26" y2="16" stroke="rgba(255,255,255,0.5)" stroke-width="2" stroke-linecap="round"/>
<path d="M26 16 Q26 22 22 22" stroke="rgba(255,255,255,0.5)" stroke-width="2" fill="none" stroke-linecap="round"/>
<circle cx="26" cy="4" r="2" stroke="rgba(255,255,255,0.5)" stroke-width="1.5" fill="none"/>
</svg>
<span>01fish</span>
</div>
<div class="content" style="padding-top:56px;">
<div class="slide-title">收集越多,解锁越多</div>
<div class="slide-subtitle">不同风格的森林</div>
<div class="accent-bar"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px;">
<div style="background:rgba(255,255,255,0.06);border-radius:10px;padding:16px;">
<div style="font-size:28px;margin-bottom:8px;">🌲</div>
<div style="font-size:13px;color:#F2EDE3;font-weight:700;margin-bottom:4px;">基础森林</div>
<div style="font-size:10px;color:rgba(242,237,227,0.4);line-height:1.6;">初始森林<br>✅ 已解锁</div>
</div>
<div style="background:rgba(255,255,255,0.06);border-radius:10px;padding:16px;">
<div style="font-size:28px;margin-bottom:8px;">🏛️</div>
<div style="font-size:13px;color:#F2EDE3;font-weight:700;margin-bottom:4px;">遗迹花园</div>
<div style="font-size:10px;color:rgba(242,237,227,0.4);line-height:1.6;">童话风格<br>🔒 需要10种鸟</div>
</div>
<div style="background:rgba(255,255,255,0.06);border-radius:10px;padding:16px;">
<div style="font-size:28px;margin-bottom:8px;">🌸</div>
<div style="font-size:13px;color:#F2EDE3;font-weight:700;margin-bottom:4px;">樱花林</div>
<div style="font-size:10px;color:rgba(242,237,227,0.4);line-height:1.6;">粉色浪漫<br>🔒 需要15种鸟</div>
</div>
<div style="background:rgba(255,255,255,0.06);border-radius:10px;padding:16px;">
<div style="font-size:28px;margin-bottom:8px;">🌙</div>
<div style="font-size:13px;color:#F2EDE3;font-weight:700;margin-bottom:4px;">夜之森</div>
<div style="font-size:10px;color:rgba(242,237,227,0.4);line-height:1.6;">神秘氛围<br>🔒 需要20种鸟</div>
</div>
</div>
<div style="background:rgba(196,69,54,0.1);border:1px solid rgba(196,69,54,0.2);border-radius:10px;padding:16px;text-align:center;">
<div style="font-size:14px;color:#C44536;font-weight:700;margin-bottom:6px;">我现在最期待的</div>
<div style="font-size:13px;color:rgba(242,237,227,0.7);">就是遗迹花园这个地图<br>好像童话里的森林</div>
</div>
</div>
<div class="page-num light">7/10</div>
</div>
<!-- 第 8 张:背后技术 -->
<div class="slide">
<div class="bg-light"></div>
<div class="brand-tag dark">
<svg viewBox="0 0 32 32" fill="none">
<circle cx="13" cy="16" r="10" stroke="rgba(26,51,40,0.4)" stroke-width="2.2" fill="none"/>
<ellipse cx="13" cy="8.5" rx="7" ry="1.5" stroke="rgba(26,51,40,0.25)" stroke-width="1.2" fill="none"/>
<ellipse cx="14" cy="17" rx="3.5" ry="2" fill="#C44536"/>
<polygon points="10,17 7.5,14.5 7.5,19.5" fill="#C44536"/>
<circle cx="16" cy="16.3" r="0.7" fill="white"/>
<line x1="26" y1="4" x2="26" y2="16" stroke="rgba(26,51,40,0.4)" stroke-width="2" stroke-linecap="round"/>
<path d="M26 16 Q26 22 22 22" stroke="rgba(26,51,40,0.4)" stroke-width="2" fill="none" stroke-linecap="round"/>
<circle cx="26" cy="4" r="2" stroke="rgba(26,51,40,0.4)" stroke-width="1.5" fill="none"/>
</svg>
<span>01fish</span>
</div>
<div class="content" style="padding-top:56px;">
<div class="slide-title dark">AI 怎么认识这些鸟的?</div>
<div class="slide-subtitle dark">500页的《中国观鸟指南》</div>
<div class="accent-bar"></div>
<div class="text-block dark" style="margin-bottom:16px;">
用了一个叫 <span class="red">pdf2skills</span> 的工具<br>
把《中国观鸟指南》变成 AI 可读的 Skills<br><br>
这样 AI 就能<strong>"读懂"这本书</strong><br>
知道每种鸟的特征、习性、分布
</div>
<div style="background:#1A3328;border-radius:12px;padding:20px;margin-bottom:16px;">
<div style="color:rgba(242,237,227,0.4);font-size:11px;letter-spacing:2px;margin-bottom:12px;">转化流程</div>
<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;">📖</div>
<div style="font-size:12px;color:rgba(242,237,227,0.7);margin-top:4px;">观鸟指南</div>
</div>
<div style="color:rgba(242,237,227,0.3);font-size:18px;">→</div>
<div style="background:rgba(255,255,255,0.1);border-radius:8px;padding:12px 16px;text-align:center;">
<div style="font-size:24px;">⚙️</div>
<div style="font-size:12px;color:rgba(242,237,227,0.7);margin-top:4px;">pdf2skills</div>
</div>
<div style="color:rgba(242,237,227,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;">🎯</div>
<div style="font-size:12px;color:#C44536;margin-top:4px;font-weight:600;">Skills</div>
</div>
</div>
</div>
<div style="background:rgba(26,51,40,0.06);border-radius:8px;padding:14px;text-align:center;">
<span style="font-size:12px;color:rgba(26,51,40,0.5);">所以完全不懂鸟也能玩</span>
</div>
</div>
<div class="page-num dark">8/10</div>
</div>
<!-- 第 9 张:开发速度 -->
<div class="slide">
<div class="bg-dark"></div>
<div class="brand-tag light">
<svg viewBox="0 0 32 32" fill="none">
<circle cx="13" cy="16" r="10" stroke="rgba(255,255,255,0.5)" stroke-width="2.2" fill="none"/>
<ellipse cx="13" cy="8.5" rx="7" ry="1.5" stroke="rgba(255,255,255,0.3)" stroke-width="1.2" fill="none"/>
<ellipse cx="14" cy="17" rx="3.5" ry="2" fill="#C44536"/>
<polygon points="10,17 7.5,14.5 7.5,19.5" fill="#C44536"/>
<circle cx="16" cy="16.3" r="0.7" fill="#F2EDE3"/>
<line x1="26" y1="4" x2="26" y2="16" stroke="rgba(255,255,255,0.5)" stroke-width="2" stroke-linecap="round"/>
<path d="M26 16 Q26 22 22 22" stroke="rgba(255,255,255,0.5)" stroke-width="2" fill="none" stroke-linecap="round"/>
<circle cx="26" cy="4" r="2" stroke="rgba(255,255,255,0.5)" stroke-width="1.5" fill="none"/>
</svg>
<span>01fish</span>
</div>
<div class="content" style="justify-content:center;align-items:center;text-align:center;padding:60px 36px;">
<div style="font-size:14px;color:rgba(242,237,227,0.3);letter-spacing:3px;margin-bottom:40px;">听说</div>
<div style="display:flex;align-items:baseline;gap:16px;justify-content:center;margin-bottom:40px;">
<div style="font-size:80px;font-weight:900;color:#C44536;">3</div>
<div style="font-size:28px;font-weight:800;color:#F2EDE3;">天</div>
</div>
<div style="font-size:18px;color:rgba(242,237,227,0.8);line-height:2;margin-bottom:40px;">
就做出来了<br>
用书生成的技能做的
</div>
<div style="font-size:15px;color:rgba(242,237,227,0.5);line-height:2;">
这就是"让书活过来"的感觉<br>
不是读完就完了<br>
而是<strong style="color:rgba(242,237,227,0.8);">真的能用起来</strong>
</div>
</div>
<div class="page-num light">9/10</div>
</div>
<!-- 第 10 张:行动召唤 -->
<div class="slide">
<div class="bg-dark"></div>
<div class="brand-tag light">
<svg viewBox="0 0 32 32" fill="none">
<circle cx="13" cy="16" r="10" stroke="rgba(255,255,255,0.5)" stroke-width="2.2" fill="none"/>
<ellipse cx="13" cy="8.5" rx="7" ry="1.5" stroke="rgba(255,255,255,0.3)" stroke-width="1.2" fill="none"/>
<ellipse cx="14" cy="17" rx="3.5" ry="2" fill="#C44536"/>
<polygon points="10,17 7.5,14.5 7.5,19.5" fill="#C44536"/>
<circle cx="16" cy="16.3" r="0.7" fill="#F2EDE3"/>
<line x1="26" y1="4" x2="26" y2="16" stroke="rgba(255,255,255,0.5)" stroke-width="2" stroke-linecap="round"/>
<path d="M26 16 Q26 22 22 22" stroke="rgba(255,255,255,0.5)" stroke-width="2" fill="none" stroke-linecap="round"/>
<circle cx="26" cy="4" r="2" stroke="rgba(255,255,255,0.5)" stroke-width="1.5" fill="none"/>
</svg>
<span>01fish</span>
</div>
<div class="content" style="padding-top:56px;">
<div class="slide-title">来建设你的异鸟园吧</div>
<div class="accent-bar"></div>
<div class="cta-box" style="margin-bottom:12px;">
<div class="cta-label">观鸟图鉴收集器</div>
<div class="cta-url">链接见评论区</div>
</div>
<div class="cta-box" style="margin-bottom:12px;">
<div class="cta-label">pdf2skills 工具</div>
<div class="cta-url">pdf2skills.memect.cn</div>
</div>
<div style="height:16px"></div>
<div style="text-align:center;padding:20px;background:rgba(255,255,255,0.04);border-radius:10px;">
<div style="font-size:15px;color:rgba(242,237,227,0.7);line-height:1.8;margin-bottom:10px;">
想一起玩的话<br>加我微信拉你进群
</div>
<div style="font-size:28px;font-weight:900;color:#C44536;letter-spacing:3px;margin-bottom:8px;">18501790646</div>
<div style="font-size:12px;color:rgba(242,237,227,0.35);">备注 <strong style="color:rgba(242,237,227,0.6);">观鸟</strong></div>
</div>
<div class="spacer"></div>
<div style="text-align:center;font-size:11px;color:rgba(242,237,227,0.2);line-height:1.8;">
看着收集的鸟儿们在森林里飞来飞去<br>
实在是太治愈了 🐦✨
</div>
</div>
<div class="page-num light">10/10</div>
</div>
<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(slide) {
return html2canvas(slide, {
scale: SCALE,
useCORS: true,
backgroundColor: null,
width: 540,
height: 720,
logging: false,
});
}
function canvasToBlob(canvas) {
return new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
}
async function downloadOne() {
const slides = document.querySelectorAll('.slide');
const viewportCenter = window.innerHeight / 2;
let closest = 0, minDist = Infinity;
slides.forEach((s, i) => {
const rect = s.getBoundingClientRect();
const center = rect.top + rect.height / 2;
const dist = Math.abs(center - viewportCenter);
if (dist < minDist) { minDist = dist; closest = i; }
});
const slide = slides[closest];
if (!slide) return;
const progress = document.getElementById('progress');
progress.textContent = '渲染中...';
try {
const canvas = await renderSlide(slide);
const blob = await canvasToBlob(canvas);
const num = String(closest + 1).padStart(2, '0');
saveAs(blob, `小红书-观鸟图鉴-${num}.png`);
progress.textContent = `第 ${closest + 1} 张已下载`;
} catch (e) {
progress.textContent = '下载失败';
}
}
async function downloadAll() {
const slides = document.querySelectorAll('.slide');
const progress = document.getElementById('progress');
const zip = new JSZip();
const total = slides.length;
for (let i = 0; i < total; i++) {
progress.textContent = `渲染第 ${i + 1}/${total} 张...`;
try {
const canvas = await renderSlide(slides[i]);
const blob = await canvasToBlob(canvas);
const num = String(i + 1).padStart(2, '0');
zip.file(`小红书-观鸟图鉴-${num}.png`, blob);
} catch (e) {
progress.textContent = `第 ${i + 1} 张渲染失败,跳过`;
await new Promise(r => setTimeout(r, 500));
}
}
progress.textContent = '打包 ZIP 中...';
const zipBlob = await zip.generateAsync({ type: 'blob' });
saveAs(zipBlob, '小红书-观鸟图鉴-全部.zip');
progress.textContent = `${total} 张图片已打包下载`;
}
</script>
</body>
</html>

小红书轮播图设计规范

来源:wechat-to-xiaohongshu skill 的 HTML 模板 + 视觉组件部分


尺寸

  • 显示尺寸:540 x 720 px
  • 导出尺寸:1080 x 1440 px(2x 缩放)
  • 比例:3:4(小红书标准)

卡片设计原则

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

页面骨架

<!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;
  }

  /* === 卡片 === */
  .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: 28px;
    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>

品牌角标 SVG

每页左上角必须有品牌角标。

暗底版:

<svg viewBox="0 0 32 32" fill="none">
  <circle cx="13" cy="16" r="10" stroke="rgba(255,255,255,0.5)" stroke-width="2.2" fill="none"/>
  <ellipse cx="13" cy="8.5" rx="7" ry="1.5" stroke="rgba(255,255,255,0.3)" stroke-width="1.2" fill="none"/>
  <ellipse cx="14" cy="17" rx="3.5" ry="2" fill="#C44536"/>
  <polygon points="10,17 7.5,14.5 7.5,19.5" fill="#C44536"/>
  <circle cx="16" cy="16.3" r="0.7" fill="#F2EDE3"/>
  <line x1="26" y1="4" x2="26" y2="16" stroke="rgba(255,255,255,0.5)" stroke-width="2" stroke-linecap="round"/>
  <path d="M26 16 Q26 22 22 22" stroke="rgba(255,255,255,0.5)" stroke-width="2" fill="none" stroke-linecap="round"/>
  <circle cx="26" cy="4" r="2" stroke="rgba(255,255,255,0.5)" stroke-width="1.5" fill="none"/>
</svg>

浅底版:rgba(255,255,255,...) 替换为 rgba(26,51,40,...)


常用视觉组件

信息行(暗底)

<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

步骤序号

<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>

大数字

<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 卡片网格

<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>

引用块

<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 链接框

<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>

代码预览框(模拟终端)

<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>

流程图(转化类)

<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 导出。

/**
* 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;
}
export async function launchChrome(url: string, platform: string): Promise<LaunchResult> {
const chromePath = findChromeExecutable();
if (!chromePath) throw new Error('Chrome not found. Set DISTRIBUTE_CHROME_PATH env var.');
const profile = getProfileDir(platform);
await mkdir(profile, { recursive: true });
const port = await getFreePort();
console.log(`[distribute][${platform}] Launching Chrome (profile: ${profile})`);
const chrome = spawn(chromePath, [
`--remote-debugging-port=${port}`,
`--user-data-dir=${profile}`,
'--no-first-run',
'--no-default-browser-check',
'--disable-blink-features=AutomationControlled',
'--start-maximized',
url,
], { stdio: 'ignore' });
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[] };
};
};
}
export type PlatformId = 'wechat' | 'xhs' | 'jike' | 'xiaoyuzhou' | 'douyin' | 'shipinhao';
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'));
}
#!/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';
// ─── 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 xhs,jike] [--preview]');
process.exit(1);
}
// ─── Platform Registry ───
const PLATFORM_ORDER: PlatformId[] = ['wechat', 'xhs', 'jike', 'xiaoyuzhou', 'douyin', 'shipinhao'];
const PLATFORM_NAMES: Record<PlatformId, string> = {
wechat: '公众号',
xhs: '小红书',
jike: '即刻',
xiaoyuzhou: '小宇宙',
douyin: '抖音',
shipinhao: '视频号',
};
const PLATFORM_HANDLERS: Record<PlatformId, (manifest: ReturnType<typeof loadManifest>, preview: boolean) => Promise<PublishResult>> = {
wechat: publishToWechat,
xhs: publishToXiaohongshu,
jike: publishToJike,
xiaoyuzhou: publishToXiaoyuzhou,
douyin: publishToDouyin,
shipinhao: publishToShipinhao,
};
// ─── 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 '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
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);
});
/**
* 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();
}
}
/**
* 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();
}
}
/**
* 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 ')}`,
};
}
/**
* 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 = '/Users/mac/Documents/mycc/2-Projects/项目1:01fish-assistant/公众号工具流/baoyu-skills/skills/baoyu-post-to-wechat';
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}`,
};
}
/**
* 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
}
}
/**
* 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();
}
}
/**
* 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.
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 = '/Users/mac/Documents/mycc/2-Projects/项目1:01fish-assistant/公众号工具流/baoyu-skills/skills/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 ───
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(token: string, 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)}&secret=${encodeURIComponent(creds.appSecret)}`;
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(token: string, 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(token: string, 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): { 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) {
return { content: stripTipElements(outputMatch[1].trim()), styles };
}
// 2. Extract .content container (md2wechat_formatter _preview.html)
const contentMatch = html.match(/<div[^>]*class=["'][^"']*\bcontent\b[^"']*["'][^>]*>([\s\S]*?)<\/div>\s*(?:<\/body>|<script|$)/i);
if (contentMatch) {
return { content: stripTipElements(contentMatch[1].trim()), styles };
}
// 3. Fallback: extract <body> content
const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
if (bodyMatch) {
return { content: stripTipElements(bodyMatch[1].trim()), 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();
}
export function processHtmlWithImages(
content: string,
styles: string,
imageMap: Map<string, string>,
): string {
let processed = content;
// Replace [[IMAGE_PLACEHOLDER_N]] with <img> tags pointing to WeChat CDN
for (const [placeholder, cdnUrl] of imageMap) {
processed = processed.replaceAll(
placeholder,
`<img src="${cdnUrl}" style="max-width: 100%; height: auto;" />`,
);
}
// Wrap with styles if present
if (styles) {
return `<style>${styles}</style>\n${processed}`;
}
return processed;
}
// ─── Draft Creation ───
export async function createDraft(
token: string,
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 — all styles are already inline on elements,
// page-level <style> (body/max-width/.tip) is irrelevant for WeChat
console.log(' [wechat-api] Using pre-rendered HTML');
const extracted = extractArticleContent(wechatData.html);
htmlContent = extracted.content;
// styles stays '' — pre-rendered HTML needs no <style> wrapper
title = wechatData.title || manifest.title;
author = wechatData.author;
digest = wechatData.digest;
} else {
// Convert markdown
const mdOutput = convertMarkdown(wechatData.markdown);
const extracted = extractArticleContent(mdOutput.htmlPath);
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 };
}
#!/usr/bin/env python3
"""
微信公众号文章抓取脚本
通过模拟微信客户端 User-Agent 绕过反爬机制
用法:
python fetch_wechat_article.py <公众号文章链接> [--download-images]
python fetch_wechat_article.py <链接1> <链接2> ... # 批量处理
示例:
python fetch_wechat_article.py "https://mp.weixin.qq.com/s/xxx"
python fetch_wechat_article.py "https://mp.weixin.qq.com/s/xxx" --download-images
"""
import sys
import re
import html
import subprocess
import os
import tempfile
import json
from pathlib import Path
from datetime import datetime
# 微信客户端 User-Agent
WECHAT_UA = "Mozilla/5.0 (Linux; Android 13; V2148A) AppleWebKit/537.36 Chrome/116.0.0.0 Mobile Safari/537.36 MicroMessenger/8.0.49.2600 WeChat/arm64 Weixin NetType/WIFI Language/zh_CN"
def fetch_wechat_article(url: str) -> dict:
"""抓取微信公众号文章内容"""
# 使用 curl 发送请求(更稳定)
result_proc = subprocess.run(
["curl", "-s", "-L", "-A", WECHAT_UA, url],
capture_output=True,
text=True,
timeout=30,
)
content = result_proc.stdout
result = {
"url": url,
"title": "",
"author": "",
"description": "",
"content": "",
"images": [],
"is_video": False,
"raw_html_length": len(content),
}
# 提取标题 - 多种格式兼容
title_match = re.search(r"msg_title\s*=\s*['\"](.+?)['\"]\.html\(false\)", content)
if not title_match:
title_match = re.search(r"msg_title = window\.title = ['\"]([^'\"]+)['\"]", content)
if not title_match:
title_match = re.search(r'property="og:title" content="([^"]+)"', content)
if title_match:
result["title"] = html.unescape(title_match.group(1).replace("&amp;", "&"))
# 提取描述
desc_match = re.search(r'name="description" content="([^"]+)"', content)
if desc_match:
desc = desc_match.group(1)
# 处理转义字符
desc = desc.replace("\\x0a", "\n").replace("\\x26", "&").replace("&amp;", "&")
result["description"] = html.unescape(desc)
# 判断是否为视频号文章(检查实际的 h1 标签,而不是 JS 代码)
if re.search(r'<h1[^>]*id="js_video_page_title"', content):
result["is_video"] = True
result["content"] = result["description"]
else:
# 普通图文文章:从 js_content 提取完整正文
# 匹配 js_content div 开始到文章结束(贪婪匹配到最后一个闭合)
content_match = re.search(r'id="js_content"[^>]*>(.*?)(?:</div>\s*<!--\s*js_content|<script\b)', content, re.DOTALL)
if not content_match:
# 备用方案:更宽松地匹配 js_content 后面的所有内容直到明显边界
content_match = re.search(r'id="js_content"[^>]*>(.*?)</div>\s*</div>\s*</div>', content, re.DOTALL)
if content_match:
inner = content_match.group(1)
# 去除 HTML 标签,保留文本
clean = re.sub(r'<[^>]+>', '\n', inner)
clean = html.unescape(clean)
# 清理多余空白
lines = [line.strip() for line in clean.split('\n') if line.strip()]
result["content"] = '\n'.join(lines)
else:
result["content"] = result["description"]
# 提取公众号名称 - JsDecode 格式优先(真实公众号名)
author_match = re.search(r"nick_name: JsDecode\(['\"]([^'\"]+)['\"]\)", content)
if not author_match:
author_match = re.search(r'class="account_nickname_inner">([^<]+)<', content)
if not author_match:
# 最后备选:直接引号格式(可能匹配到广告名,优先级最低)
author_match = re.search(r"nick_name\s*[:=]\s*['\"]([^'\"]+)['\"]", content)
if author_match:
result["author"] = author_match.group(1).strip()
# 提取图片链接(data-src 和 src 两种方式)
images = set()
for pattern in [r'data-src="(https://mmbiz\.qpic\.cn[^"]+)"', r'src="(https://mmbiz\.qpic\.cn[^"]+)"']:
for match in re.finditer(pattern, content):
img_url = match.group(1).replace("&amp;", "&")
images.add(img_url)
result["images"] = sorted(list(images))
return result
def download_images(images: list, output_dir: str = None) -> list:
"""下载图片到指定目录,返回本地文件路径列表"""
if not images:
return []
if output_dir is None:
output_dir = tempfile.mkdtemp(prefix="wechat_article_")
Path(output_dir).mkdir(parents=True, exist_ok=True)
downloaded = []
for i, img_url in enumerate(images, 1):
# 确定文件扩展名
if "wx_fmt=gif" in img_url:
ext = "gif"
elif "wx_fmt=png" in img_url:
ext = "png"
else:
ext = "jpg"
filename = f"img_{i:02d}.{ext}"
filepath = os.path.join(output_dir, filename)
# 使用 curl 下载
result = subprocess.run(
["curl", "-s", "-o", filepath, img_url],
capture_output=True,
timeout=30,
)
if result.returncode == 0 and os.path.exists(filepath) and os.path.getsize(filepath) > 0:
downloaded.append(filepath)
# 进度消息在调用处根据需要打印
return downloaded
def fetch_multiple_articles(urls: list) -> list:
"""批量抓取多篇文章"""
results = []
for i, url in enumerate(urls, 1):
print(f"\n📄 正在抓取第 {i}/{len(urls)} 篇...")
try:
article = fetch_wechat_article(url)
results.append(article)
print(f" ✅ {article['title'][:30]}...")
except Exception as e:
print(f" ❌ 抓取失败: {e}")
results.append({"url": url, "error": str(e)})
return results
def output_json(article: dict):
"""输出 JSON 格式(供其他程序调用)"""
print(json.dumps(article, ensure_ascii=False, indent=2))
def output_summary(article: dict, image_paths: list = None):
"""输出结构化总结"""
print("=" * 50)
print(f"【标题】{article['title']}")
print(f"【作者】{article['author']}")
print(f"【类型】{'视频号文章' if article['is_video'] else '图文文章'}")
print(f"【配图数量】{len(article['images'])} 张")
print("=" * 50)
print("【正文】")
print(article["content"])
print("=" * 50)
if article["images"]:
print("【配图链接】")
for i, img in enumerate(article["images"][:10], 1):
print(f" {i}. {img[:80]}...")
if len(article["images"]) > 10:
print(f" ... 共 {len(article['images'])} 张")
if image_paths:
print("=" * 50)
print("【已下载图片】")
for path in image_paths:
print(f" 📷 {path}")
print("=" * 50)
def output_markdown(article: dict, image_paths: list = None):
"""输出 Markdown 格式,适合存档和进一步处理"""
article_type = "视频号文章" if article["is_video"] else "图文文章"
md = f"""# {article['title']}
## 基本信息
| 项目 | 内容 |
|------|------|
| **作者** | {article['author']} |
| **类型** | {article_type} |
| **配图** | {len(article['images'])} 张 |
| **来源** | [原文链接]({article['url']}) |
---
## 正文
{article['content']}
---
## 配图
"""
if image_paths:
for i, path in enumerate(image_paths, 1):
md += f"- 图{i}: `{path}`\n"
elif article["images"]:
for i, img in enumerate(article["images"], 1):
md += f"- 图{i}: {img}\n"
else:
md += "*无配图*\n"
md += """
---
## 待总结(由 cc 填写)
### 核心观点
1.
2.
3.
### 关键信息
-
-
### 金句摘录
> ""
> ""
### 思考/迭代点
- 对我有什么启发?
- 有什么可以借鉴的?
---
*抓取时间: """ + datetime.now().strftime("%Y-%m-%d %H:%M") + "*\n"
print(md)
def main():
if len(sys.argv) < 2:
print("用法:")
print(" python fetch_wechat_article.py <公众号文章链接>")
print(" python fetch_wechat_article.py <链接> --download-images")
print(" python fetch_wechat_article.py <链接> --json")
print(" python fetch_wechat_article.py <链接> --markdown")
print(" python fetch_wechat_article.py <链接1> <链接2> ... # 批量处理")
sys.exit(1)
# 解析参数
args = sys.argv[1:]
download_flag = "--download-images" in args
json_flag = "--json" in args
markdown_flag = "--markdown" in args
urls = [arg for arg in args if arg.startswith("http")]
try:
if len(urls) == 1:
# 单篇文章
article = fetch_wechat_article(urls[0])
image_paths = None
if download_flag:
if not json_flag:
print("📥 正在下载配图...")
image_paths = download_images(article["images"])
if not json_flag:
for path in image_paths:
print(f" ✅ 下载成功: {os.path.basename(path)}")
article["downloaded_images"] = image_paths
if json_flag:
output_json(article)
elif markdown_flag:
output_markdown(article, image_paths)
else:
output_summary(article, image_paths)
elif len(urls) > 1:
# 批量处理
articles = fetch_multiple_articles(urls)
if json_flag:
print(json.dumps(articles, ensure_ascii=False, indent=2))
else:
print("\n" + "=" * 50)
print(f"📚 批量抓取完成,共 {len(articles)} 篇")
print("=" * 50)
for i, article in enumerate(articles, 1):
if "error" in article:
print(f"\n❌ 文章 {i}: 抓取失败 - {article['error']}")
else:
print(f"\n📄 文章 {i}: {article['title']}")
print(f" 作者: {article['author']}")
print(f" 配图: {len(article['images'])} 张")
else:
print("错误:请提供至少一个公众号文章链接")
sys.exit(1)
except Exception as e:
print(f"抓取失败: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()
name description
content-pipeline
内容生产和分发统一管线。素材收集→出稿→排版→封面→朋友圈文案→多平台转换→一键分发。涵盖公众号写作、小红书轮播图、即刻文案、播客音频、品牌视频、Chrome CDP 自动发布。

内容管线 Content Pipeline

一条龙:素材收集 → 写文章 → 排版 → 多平台内容 → 一键分发。


存储位置

/Users/mac/Documents/mycc/2-Projects/项目1:01fish-assistant/使用ai过程记录/
├── drafts/
│   └── current.json        # 当前素材列表
├── [文章标题].md            # 产出的文章
├── [文章标题]_preview.html  # 排版预览
└── [主题]-小红书版.html     # 小红书轮播图

未指定目录时,多平台产出放 /tmp/


触发词

素材收集(Path A)

触发词 说明
/story 查看当前素材状态
"看看素材" 查看已记录的素材
"出稿" 生成文章 + 排版 + 封面图
"清空素材" 清空当前素材
"记一笔:xxx" 手动添加素材
"素材+1:xxx" 手动添加素材
"写个朋友圈" 根据素材/文章生成朋友圈文案

内容生成(Path B)

触发词 说明
/xiaohongshu + 微信链接 微信文章转小红书轮播图
"转小红书" + 微信链接 同上
"做成小红书" + 微信链接 同上
"转即刻" + 微信链接 生成即刻文案
"转播客" + 微信链接 生成播客脚本 + AI 语音
"做视频" + 微信链接 触发品牌视频管线
"做视频画布" + 任意素材 生成可录制的 Prezi 风格视频画布(微信链接/md/pdf/html/文本均可)
"录屏画布" + 任意素材 同上
"手账视频" + 任意素材 同上
"多平台分发" + 微信链接 一次生成所有平台内容
"转小红书并发布" + 微信链接 生成 + 自动触发分发

文章阅读

触发词 说明
/read-gzh + 微信链接 抓取并总结公众号文章
"帮我读一下这篇公众号" 同上
"总结一下这篇文章" 同上

排版与配图

触发词 说明
"排版" 用 01fish 主题排版 Markdown → 公众号 HTML
"做头图" / "封面图" 生成公众号头图 HTML(浏览器下载 PNG)
"做竖版封面" / "竖版头图" 从公众号封面 → 生成 3:4 竖版封面(1080×1440),适合小红书/视频号
"做配图" / "准备配图" 生成文章配图 HTML(浏览器下载 PNG)
"排版+配图" / "全套排版" 排版 + 头图 + 配图一起生成

分发

触发词 说明
/distribute 读取 manifest 一键发布
"一键发布" 全平台发布
"全平台发布" 同上
"发布到小红书" 单平台发布
"发布到即刻" 单平台发布

核心原则:全然诚实

AI 生成的内容必须诚实标注,不装人类,展现真实的创作过程。

诚实标注规范

当 AI 参与内容创作时,必须在文章中明确标注:

**调研 & 撰写**:AI(Claude)
**主导 & 审校**[用户名]
**创作时间**[实际用时](调研 X 分钟 + 写作 Y 分钟)

禁止的虚假表述

不要写:

  • "我们花了两周时间调研"(实际几分钟)
  • "经过深入访谈"(没有访谈)
  • "团队经过讨论"(没有团队)
  • "作者:XXX / 编辑:AI 助手"(AI 写了全文)

应该写:

  • "本文基于 N 篇公开信息源,由 AI 调研分析并撰写"
  • "素材收集用时 X 分钟,写作用时 Y 分钟"
  • "人类主导 + AI 协作"

为什么要诚实

  1. 建立信任:读者值得知道内容如何生成
  2. 展现价值:AI 快速高质量创作本身就是价值,不需要掩饰
  3. 符合伦理:AI 生成内容应该透明化
  4. 长期主义:诚实是长期个人品牌的基石

两条输入路径

Path A:日常素材收集 → 出稿

边干活边记录 → 说"出稿" → 写文章 → 排版 → 封面图 → 朋友圈文案 → manifest

适用场景:日常和 cc 协作时,自动积累素材,攒够了一键出稿。

Path B:微信链接 → 多平台内容

微信链接 → 抓取文章 → 分析结构 → 生成小红书/即刻/播客/视频 → manifest → 分发

适用场景:已有公众号文章,一键转为多平台内容并发布。


Path A 流程:素材收集 → 出稿

自动记录(默认开启)

cc 在对话中主动识别有料瞬间并自动记录,无需手动触发。

识别信号:

类型 识别信号 示例
踩坑翻车 预期≠结果、报错、折腾半天 "试了三种方案都不行"
意外发现 "没想到"、"原来可以"、意外有效 "居然这样就解决了"
迭代打磨 改了多版、从复杂到简洁 "200行改成20行还能跑"
搞笑时刻 对话金句、AI抽风、神奇bug "它认真地给我写了一堆错的"
突破时刻 卡了很久终于通 "困扰一周的bug终于找到了"
方法沉淀 可复用的技巧、心得 "以后遇到这种情况就这么办"

自动记录时:不打断对话,段落结尾标记 (✓ 素材+1)

手动记录

用户说"记一笔:xxx"或"素材+1:xxx"时记录。

current.json 格式

{
  "topic": "主题(可选,出稿时自动提取)",
  "materials": [
    {
      "time": "2026-01-30 14:30",
      "content": "素材内容",
      "type": "搞笑时刻",
      "context": "可选的上下文备注",
      "auto": true
    }
  ],
  "created": "2026-01-30"
}

出稿步骤

  1. 读取素材 — 读取 drafts/current.json
  2. 分析提炼 — 提炼主题和故事线
  3. 判断内容类型 → 选择写作框架
内容类型 判断信号 使用框架 参考文件
教程类 教人安装/使用/配置工具、Skill 介绍、技术实战、"怎么做 xxx" 六段式教程框架 references/tutorial-framework.md
深度长文 行业分析、人物故事、趋势判断、观点输出、"为什么 xxx" 四幕式深度框架 references/writing-style.md

教程类文章框架(2000-4000 字):

先看结果(截图+成品+链接)
→ 一、核心概念是什么(表格+一句话定义)
→ 二、怎么安装/使用(分步骤+代码块+配图标记)
→ 三、实战演示(分阶段+表格展示+人机协作)
→ 四、拿走即用(快速安装命令+使用方式表格)
→ 写在最后(升华+CTA)

深度长文框架(8000-12000 字):

序言(故事先行,700 字不出论点)
→ 01 铺设背景
→ 02 核心论述
→ 03 转折/案例
→ 04 升华/收束
  1. 写文章 — 按对应框架写文章
  2. 保存 — 保存 Markdown 文件
  3. 排版 — 调用排版工具生成 HTML 预览(01fish 主题)
  4. 头图 + 配图 — 生成可下载的 HTML 文件(→ 读 references/cover-template.md
    • 竖版封面(可选):用户说"做竖版封面"时,从已生成的公众号头图 HTML 转换 → 读 references/cover-vertical-spec.md
  5. 朋友圈文案 — 生成朋友圈推广文案(→ 读 references/platform-copy.md
  6. manifest — 生成 manifest.json,供 /distribute 使用。wechat 部分必须包含:
    • wechat.markdown:文章 Markdown 路径
    • wechat.html:排版后的 _preview.html 路径
    • wechat.cover_image:封面 PNG 路径(用户需先从浏览器下载)
    • wechat.title:文章标题
    • wechat.author:作者名(默认 01fish
    • wechat.digest:文章摘要(120 字内)
    • wechat.images:配图 PNG 路径列表(如有)
  7. 询问 — 是否清空当前素材

排版命令

cd "/Users/mac/Documents/mycc/2-Projects/项目1:01fish-assistant/公众号工具流/md-formatter"
python3 md2wechat_formatter.py [文章路径] --theme [主题] --font-size [字号]

推荐主题:01fish(01fish 品牌色,默认)、chinese(中国风)、apple(极简优雅) 推荐字号:medium(15px 默认)、large(16px 长文推荐)

01fish 主题说明:基于 chinese 主题,使用 01fish 品牌色(墨绿 #1A3328 + 鱼红 #C44536 + 宣纸底 #F2EDE3)


Path B 流程:微信链接 → 多平台内容

第 1 步:抓取文章

使用 Python 抓取脚本(微信有反爬验证,WebFetch 会被拦):

python3 "${SKILL_DIR}/scripts/fetch_wechat_article.py" "<URL>" --json

超时 30 秒。失败则提示用户手动复制文章正文。

如果用户只是说"帮我读一下这篇公众号"(/read-gzh 触发),执行抓取后直接生成结构化总结,不进入后续内容生成流程。总结格式:

# 文章总结
## 基本信息(标题/作者/类型/配图数)
## 核心观点(3条)
## 关键信息
## 金句摘录
## 图片内容(下载并识别配图中的文字)
## 思考/迭代点

第 2 步:分析文章结构

提取:标题、副标题/金句、核心概念、关键数据、步骤/流程、亮点/特色、方法论/金句、行动召唤。

第 3 步:拆分为卡片

8-10 张卡片,遵循小红书阅读节奏(→ 读 references/xiaohongshu-format.md):

位置 卡片类型 内容
第 1 张 封面 大标题 + hook + 迷你视觉元素
第 2 张 先看结果 成品展示 + 核心数据
第 3-4 张 概念解释 核心概念拆解
第 5-7 张 流程/实战 步骤、对比、流程图
第 8 张 亮点/特色 产品/作品亮点卡片
第 9 张 方法论 一句话金句提炼
第 10 张 行动召唤 链接 + 社区引导

第 4 步:生成图片 HTML

输出路径:文章同目录下 [简短主题]-小红书版.html,未指定目录放 /tmp/。浏览器自动打开预览。

最后一张行动召唤页必须包含:微信号 18501790646(鱼红大字)、备注关键词、核心链接。

📚 重要:生成前必读范例

参考 references/xiaohongshu-examples/观鸟图鉴-范例.html 的质量标准:

卡片设计要求

  • 纯信息图设计,无文章截图
  • 像素风/游戏化界面展示(适用时)
  • 流程图、卡片网格、编号列表等丰富视觉元素
  • 品牌色克制使用(墨绿85% + 鱼红5%)

文案质量要求

  • 真人分享感,有真实场景和个人感受
  • 口语化表达:"玩疯了"、"上头了"、"然后我就..."
  • 298-350字 + 8-12个标签

生成的内容应达到范例的专业水准。

第 5 步:生成小红书发布文案

根据内容类型选择风格:

  • 鱼头头风格(真人分享、产品开发、踩坑记录)

    • → 读 /Users/mac/Documents/mycc/2-Projects/项目1:01fish-assistant/brand/yutoutou-xiaohongshu-style.md
    • 流水账式真实感 + 具体时间细节 + 口语化表达
    • 300-350字,8-12个标签
    • 人设:有点意思的AI创业者,文科女生手搓AI,勇敢无畏
  • 01fish风格(方法论总结、深度分析)

    • → 读 references/platform-copy.md 的小红书部分
    • 结构化拆解 + 干货密度高
    • 适合转载公众号文章

第 6 步:生成即刻发布文案

→ 读 references/platform-copy.md 的即刻部分。

第 7 步:生成播客脚本

→ 读 references/platform-copy.md 的播客部分。

第 8 步:AI 语音生成

使用 Fish Audio TTS 将播客脚本转为 MP3(→ 读 references/tts-config.md)。

文件命名:[播客标题].mp3 + [播客标题]-播客脚本.txt

第 8.5 步:输出 manifest.json

所有内容生成完毕后,自动输出 manifest.json 到输出目录。格式:

{
  "version": "1.0",
  "created": "<ISO时间戳>",
  "source": "<微信链接>",
  "title": "<文章标题>",
  "outputs": {
    "xiaohongshu": { "html": "...", "copy": { "title": "...", "body": "...", "tags": [...] } },
    "jike": { "copy": { "body": "...", "circles": [...] } },
    "xiaoyuzhou": { "audio": "...", "script": "...", "copy": { "title": "...", "description": "...", "show_notes": "..." } },
    "video_canvas": { "html": "...", "teleprompter_md": "...", "cover_html": "..." }
  }
}

如果用户说"转小红书并发布",生成 manifest 后自动执行 /distribute

第 9 步:品牌视频生成(可选)

仅当用户提到"视频"、"抖音"、"视频号"或"品牌视频"时执行:

A. Remotion 品牌片头片尾

cd "/Users/mac/Documents/mycc/2-Projects/项目1:01fish-assistant/公众号工具流/remotion-01fish"
npx remotion render src/index.ts Intro --output /tmp/01fish-intro.mp4
npx remotion render src/index.ts Outro --output /tmp/01fish-outro.mp4

B. AI 视频 Prompt — 为 Seedance 2.0 或 Google Veo 生成 4 段视频 prompt

C. ffmpeg 拼接指令 — 生成拼接命令供用户手动执行

第 9B 步:视频画布生成(可选)

仅当用户说"做视频画布"、"录屏画布"、"手账视频"时执行。接受任意素材输入:微信链接、Markdown 文件、PDF、HTML 文件、纯文本、用户口述内容均可。

  1. 获取内容 — 根据输入类型自动处理:
    • 微信链接:调用 scripts/fetch_wechat_article.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)。

用法

# 全平台发布
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 xhs,jike

# 预览模式(不提交,只预填内容)
npx -y bun "${SKILL_DIR}/scripts/distribute/distribute.ts" --manifest /path/to/manifest.json --platforms xhs --preview

平台缩写

缩写 平台 状态
wechat 公众号 可用
xhs 小红书 可用
jike 即刻 可用
xiaoyuzhou 小宇宙 可用
douyin 抖音 实验性
shipinhao 视频号 待开发

执行顺序

公众号 → 小红书 → 即刻 → 小宇宙 → 抖音 → 视频号(顺序执行,避免 Chrome 端口冲突)

四级降级

级别 模式 触发条件
L0 API 直推 公众号 API 直接推草稿箱,无需 Chrome
L1 自动发布 CDP 完全自动化
L2 辅助发布 登录态失效/选择器失效/--preview
L3 手动模式 CDP 连接失败

公众号优先 L0(API),凭证缺失或失败时自动降级 L1(CDP)。


品牌设计规范

两套品牌色体系:

  • 01fish:专业内容品牌(公众号、深度文章、方法论)
  • 鱼头头:真人IP品牌(小红书、即刻、日常分享)

单一真相源

  • 01fish: /Users/mac/Documents/mycc/2-Projects/项目1:01fish-assistant/brand/01fish-brand-colors.md
  • 鱼头头: /Users/mac/Documents/mycc/2-Projects/项目1:01fish-assistant/brand/yutoutou-brand-colors.md 如果色值冲突,以品牌文档为准。

01fish 色板(墨绿体系)

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

名称 色值 用途
墨绿主色 #1A3328 暗底卡片背景
宣纸底 #F2EDE3 浅底卡片背景
鱼红 #C44536 强调色、数字、标签(仅点睛)
半透白 rgba(255,255,255,0.5) 暗底上的品牌名
半透墨绿 rgba(26,51,40,0.4) 浅底上的品牌名
苔灰 #7A8C80 次要文字
深墨 #0F1F18 更深背景
淡青 #D4DDD7 分割线、边框

鱼头头色板(桃粉体系)

比例法则:桃气粉 15% : 奶油黄 40% : 暮光紫 10% : 灰色 35%

名称 色值 用途
桃气粉 #FF6B9D 主强调色、标题、关键数据
奶油黄底 #FFF9E6 浅底背景、卡片底色
暮光紫 #9D7BA8 辅助色、次要信息、品牌名
温灰 #6B6B6B 正文文字
浅灰底 #F5F5F5 现代感背景
深夜蓝 #2D3047 暗底背景(少用)

品牌选择规则

内容类型 使用品牌 原因
公众号深度文章 01fish 专业、权威、内容品牌
行业分析报告 01fish 冷静客观
小红书真人分享 鱼头头 温暖、真实、真人IP
即刻日常动态 鱼头头 活泼、亲和
产品开发记录 鱼头头 真实过程展示
B端产品介绍 01fish 专业可信赖

双品牌联动:同一篇内容,公众号用01fish色,小红书转发用鱼头头色

字体

font-family: -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif;

品牌角标

每页左上角 01fish logo + 文字,暗底页用 .light,浅底页用 .dark

页码

右下角 1/N,暗底页 rgba(255,255,255,0.2),浅底页 rgba(26,51,40,0.2)


内容改写原则

微信 → 小红书不是照搬,需适配:

维度 微信 小红书
篇幅 2000-3000 字 每页 50-80 字
结构 线性阅读 卡片式跳读
语气 技术向、深度 简洁、直观、有冲击力
视觉 文字为主 视觉为主、文字点缀

改写要点:标题要炸、数字要大、一页一个点、视觉替代文字、保留核心链接。


完整模板参考

首次生成小红书图片时,参考此文件获取完整 CSS + JS:

/Users/mac/Documents/mycc/2-Projects/project 3:ai-marketing/pdf1skill2app/用户使用案例/装上这个Skill-小红书版.html

生成新内容时复用该文件的 CSS + JS 部分,只替换卡片内容。


Script Directory

Agent Execution: Determine this SKILL.md directory as SKILL_DIR, then use ${SKILL_DIR}/scripts/<name>.

Script Purpose
scripts/fetch_wechat_article.py 微信文章抓取(Python,模拟微信 UA)
scripts/distribute/distribute.ts 分发主编排器
scripts/distribute/cdp-utils.ts 共享 CDP 工具
scripts/distribute/platforms/*.ts 各平台发布模块

Reference 文件索引

cc 按需读取,不要一次性加载所有 reference。

场景 读取文件
出稿写深度长文 references/writing-style.md — 人设 + 写作规范 + 格式(四幕式,8000-12000 字)
出稿写教程文章 references/tutorial-framework.md — 六段式教程框架(先看结果→概念→操作→实战→拿走即用,2000-4000 字)
生成头图/配图 references/cover-template.md — 01fish 风格排版规范(头图 + 配图 + 视觉组件)
横版→竖版封面 references/cover-vertical-spec.md — 公众号封面转竖版的 CSS 转换规范
生成小红书轮播图 references/xiaohongshu-format.md — HTML 模板 + 视觉组件库
生成各平台文案 references/platform-copy.md — 小红书/即刻/播客/朋友圈文案规范
生成播客音频 references/tts-config.md — Fish Audio 配置 + 生成脚本
分发到各平台 references/distribute-platforms.md — 平台配置 + manifest 格式 + 降级策略
生成视频画布 references/video-canvas-template.md — 手账拼贴视频画布模板(CSS+JS+卡片规范)

故障处理

问题 处理
微信抓取失败 提示用户手动复制文章正文
文章太短(<500字) 压缩为 5-6 张卡片
文章太长(>5000字) 精选核心,控制 10 张以内
导出图片模糊 检查 SCALE=2,浏览器缩放 100%
manifest 不存在 提示先运行内容生成
Chrome 启动失败 降级 L3(手动模式)
TTS 402 余额不足 提示去 fish.audio 充值
TTS 生成失败 只输出脚本文本,提示手动录制
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment