@clawhub-lovensky1992-wk-0530af4549
将内容铸成 PNG 视觉卡片。三种模具:-l 长图阅读卡(默认)、-i 信息图、-m 多卡。 输入文本/URL/文件,输出高品质 PNG。 Use when: (1) 用户说"做成卡片"/"做成图"/"铸"/"cast", (2) 用户说"知识卡片"/"信息图"/"infograph", (3) 需要将文章/笔记...
---
name: content-card
description: >
将内容铸成 PNG 视觉卡片。三种模具:-l 长图阅读卡(默认)、-i 信息图、-m 多卡。
输入文本/URL/文件,输出高品质 PNG。
Use when: (1) 用户说"做成卡片"/"做成图"/"铸"/"cast",
(2) 用户说"知识卡片"/"信息图"/"infograph",
(3) 需要将文章/笔记/分析结果转为可分享的图片,
(4) 公众号/小红书需要文字密集型配图(数据对比、流程图、知识点总结)。
NOT for: 照片/插图/AI 艺术图(用 Gemini/Seedream 生图)、
纯数据图表/柱状图(用代码或 xlsx 生成)。
---
# content-card: 铸
将内容铸成可见的形态。内容进去,PNG 出来。模具决定形状。
## 参数
| 参数 | 模具 | 尺寸 | 说明 |
|------|------|------|------|
| `-l`(默认) | 长图 | 1080 x auto | 单张阅读卡,内容自动撑高 |
| `-i` | 信息图 | 1080 x auto | 数据/结构驱动的自适应视觉布局 |
| `-m` | 多卡 | 1080 x 1440 | 自动切分为多张卡片(小红书/朋友圈适用) |
| `--style` | 风格 | — | `minimal-mono` / `morandi-warm` / `tech-dark` / `paper-craft` / `corporate-clean`,默认:根据气质自动选 |
### 小红书安全区(`-m` 模式必读)
小红书移动端 UI 会遮挡图片以下区域,关键信息必须避开:
```
┌─────────────────────────────┐
│ [❤️ 📌 💬] │ ← 右上角 15%:点赞/收藏/评论按钮
│ │
│ ✓ 安全内容区域 │
│ │
│ [笔记标题 + 用户头像栏] │ ← 底部 10%:标题栏遮挡
│ [@水印] │ ← 右下角 10%:平台水印
└─────────────────────────────┘
```
在 `-m` 模式生成 HTML 时,确保底部 10% 区域不放置关键文字或数据。
## 获取内容
- URL → `web_fetch` 获取
- 粘贴文本 → 直接使用
- 文件路径 → `read` 获取
## 执行流程
### Step 1: 加载用户偏好
检查 EXTEND.md 配置文件(优先级:项目级 > 用户级):
| 优先级 | 路径 |
|--------|------|
| 1 | `.content-card/EXTEND.md`(当前工作目录) |
| 2 | `~/.config/content-card/EXTEND.md` |
- 找到:读取并解析,后续步骤中使用配置值作为默认值
- 未找到:静默跳过,使用 SKILL.md 默认值
配置 schema 见 `references/config/preferences-schema.md`。
### Step 2: 理解内容
读取输入内容,提取:
- 核心主题/标题
- 关键信息点(数据、结论、对比、流程)
- 内容气质:思辨/哲学、技术/工程、文学/叙事、科学/研究、商业/产品
### Step 3: 关键词快捷匹配
用户输入中如果包含以下关键词,直接跳过气质推断和布局推荐,使用预设组合:
| 用户关键词 | 布局 | 风格 | 默认比例 | 说明 |
|-----------|------|------|---------|------|
| 高密度信息大图 / high-density | `dense-modules` | `corporate-clean` | portrait | 信息密度优先 |
| 对比图 / vs / 对比 | `binary-comparison` | `minimal-mono` | landscape | 左右分屏对比 |
| 时间线 / timeline / 历程 | `linear-progression` | `morandi-warm` | portrait | 线性时间推进 |
| 流程图 / 步骤 / tutorial | `linear-progression` | `corporate-clean` | portrait | 步骤指引 |
| 数据看板 / dashboard / KPI | `dashboard` | `tech-dark` | landscape | 指标展示 |
| 知识卡片 / 总结卡 | `bento-grid` | `morandi-warm` | portrait | 多主题总览 |
| 对比矩阵 / 功能对比 | `comparison-matrix` | `minimal-mono` | landscape | 多因素对比表 |
| 思维导图 / mindmap | `hub-spoke` | `paper-craft` | landscape | 中心发散 |
| 漏斗 / funnel / 转化 | `funnel` | `corporate-clean` | portrait | 转化漏斗 |
| 冰山 / iceberg / 深层 | `iceberg` | `morandi-warm` | portrait | 表层vs深层 |
匹配规则:
- 匹配到关键词后自动应用预设,跳到 Step 3(生成 HTML)
- 用户仍可通过 `--layout` / `--style` 覆盖预设
- 多个关键词同时命中时,取第一个匹配
### Step 4: 结构化内容(信息图专用)
`-i` 模式在理解内容后,增加一步结构化转换:
1. 提取标题和核心主张
2. 将内容拆解为独立模块(每个模块 = 一个布局区块)
3. 为每个模块标注:关键概念、核心数据、视觉元素建议
4. **数据保真**:源数据原样保留,不概括不改写。统计数字、引用、专有名词必须逐字保留
5. **凭据剥离**:如果源内容包含 API Key、Token、密码等敏感信息,必须在此步骤剥离
输出到 `temp/content-card/structured-content.md` 文件。好处:
- 换风格/布局时直接复用,不用重新分析
- 用户可在此文件上手动修改后重新生成
- 保留分析过程的可追溯性
如果 `temp/content-card/structured-content.md` 已存在且内容未变,跳过分析直接复用。
### ⚠️ 检查点(Step 2-4 完成后)
内容理解 + 结构化完成后,如果用户未指定风格/配色,简要告知选择的方案再继续:
"内容偏 [气质],准备用 [风格] + [配色] 做长图,可以吗?"
用户确认或无异议后继续。简单/重复任务可跳过。
### Step 5: 感知内容气质,选择配色
> **气质决定配色方向,风格决定视觉系统。** 气质在这一步确定,风格在 Step 2.5 确定。
| 气质 | 底色方向 | 强调色方向 |
|------|---------|-----------|
| 思辨/哲学 | 暖灰、米白 | 深红、琥珀 |
| 技术/工程 | 冷灰、深蓝灰 | 青色、蓝绿 |
| 文学/叙事 | 暖白、奶油 | 赭石、深橄榄 |
| 科学/研究 | 纯白、浅灰 | 深蓝、靛蓝 |
| 商业/产品 | 浅灰、暖白 | 深橙、深青 |
### Step 6: 选择视觉风格
如果用户指定了 `--style`,使用指定风格。否则根据气质自动推荐:
| 气质 | 默认 Style | 备选 |
|------|-----------|------|
| 思辨/哲学 | `minimal-mono` | `morandi-warm` |
| 技术/工程 | `tech-dark` | `minimal-mono` |
| 文学/叙事 | `morandi-warm` | `paper-craft` |
| 科学/研究 | `minimal-mono` | `corporate-clean` |
| 商业/产品 | `corporate-clean` | `minimal-mono` |
风格定义文件在 `references/styles/<style>.md`,生成 HTML 时读取对应文件中的 CSS 变量。
## 信息图布局库(`-i` 模式可选布局)
信息图有两个维度:**布局**(信息结构)× **内容气质**(已有的配色系统)。
| 布局 | 最佳场景 | 结构描述 |
|------|---------|----------|
| `bento-grid` | 多主题总览、知识合集(默认) | 不等分网格,每块独立主题 |
| `linear-progression` | 时间线、流程、教程步骤 | 从左到右或从上到下的线性推进 |
| `binary-comparison` | A vs B、before/after、优劣对比 | 左右对称分屏 |
| `hierarchical-layers` | 金字塔、优先级层级 | 从上到下的层级堆叠 |
| `hub-spoke` | 中心概念 + 关联要素 | 中心节点向外放射 |
| `funnel` | 转化漏斗、筛选过程 | 从宽到窄的漏斗形 |
| `iceberg` | 表面 vs 深层、显性 vs 隐性 | 水面线分隔,上下两部分 |
| `dashboard` | 指标看板、KPI 展示 | 数字大卡片 + 图表组合 |
| `winding-roadmap` | 旅程、里程碑 | 蜿蜒路径上的节点 |
| `circular-flow` | 循环过程、生态系统 | 首尾相连的环形 |
| `comparison-matrix` | 多因素对比、功能矩阵 | 行列网格,✓/✗ 标记 |
| `dense-modules` | 高密度信息、数据手册 | 紧凑模块化,最大信息密度 |
自动推荐:根据内容结构自动匹配最佳布局。
### 内容类型 → 布局推荐
| 内容类型 | 推荐布局 | 备选 |
|---------|---------|------|
| 时间线/历史 | `linear-progression` | `winding-roadmap` |
| 步骤教程 | `linear-progression` | `funnel` |
| A vs B 对比 | `binary-comparison` | `comparison-matrix` |
| 多因素对比 | `comparison-matrix` | `binary-comparison` |
| 层级/优先级 | `hierarchical-layers` | — |
| 中心概念+扩展 | `hub-spoke` | `bento-grid` |
| 转化/筛选 | `funnel` | `linear-progression` |
| 显性 vs 隐性 | `iceberg` | `hierarchical-layers` |
| 指标/数据 | `dashboard` | `dense-modules` |
| 旅程/路线 | `winding-roadmap` | `linear-progression` |
| 循环过程 | `circular-flow` | `hub-spoke` |
| 多主题总览 | `bento-grid` | `dense-modules` |
| 高密度手册 | `dense-modules` | `bento-grid` |
### 气质 × 布局 兼容矩阵
选定内容气质和布局后,检查此矩阵确保组合合理:
| 气质 \ 布局 | bento-grid | linear | binary-comp | hierarchical | hub-spoke | funnel | iceberg | dashboard | roadmap | circular | comp-matrix | dense-mod |
|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| 思辨/哲学 | ✓ | ✓ | ✓✓ | ✓✓ | ✓✓ | ✗ | ✓✓ | ✗ | ✓ | ✓ | ✗ | ✗ |
| 技术/工程 | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓✓ |
| 文学/叙事 | ✓ | ✓✓ | ✓ | ✗ | ✓ | ✗ | ✓✓ | ✗ | ✓✓ | ✓ | ✗ | ✗ |
| 科学/研究 | ✓✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓✓ |
| 商业/产品 | ✓✓ | ✓ | ✓✓ | ✓ | ✓ | ✓✓ | ✗ | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓ |
> ✓✓ 强推荐 | ✓ 可用 | ✗ 不推荐(气质与布局形式冲突,效果差)
>
> 核心逻辑:思辨/文学偏深度叙事,避免数据密集型布局;技术/科学/商业偏结构化,避免纯叙事型布局。
**兼容检查**:选定气质+布局后查此矩阵。有 ✗ 则提示调整或换备选布局。
### 内容信号 → 气质+布局自动推荐
根据输入内容的关键词信号,自动推荐气质和布局组合:
| 内容信号 | 气质 | 推荐布局 | 备选 |
|---------|------|---------|------|
| AI、架构、系统、代码、框架 | 技术/工程 | `bento-grid` | `hub-spoke` |
| 对比、vs、选型、优劣 | 技术/工程 | `binary-comparison` | `comparison-matrix` |
| 产品、增长、转化、商业模式 | 商业/产品 | `funnel` | `dashboard` |
| KPI、指标、数据、ROI | 商业/产品 | `dashboard` | `dense-modules` |
| 哲学、思辨、本质、悖论 | 思辨/哲学 | `iceberg` | `hierarchical-layers` |
| 故事、经历、旅程、成长 | 文学/叙事 | `winding-roadmap` | `linear-progression` |
| 实验、论文、研究、假设 | 科学/研究 | `linear-progression` | `comparison-matrix` |
| 教程、步骤、流程、操作 | 技术/工程 | `linear-progression` | `bento-grid` |
| 生态、循环、闭环、飞轮 | 商业/产品 | `circular-flow` | `hub-spoke` |
| 层级、金字塔、优先级 | 思辨/哲学 | `hierarchical-layers` | `hub-spoke` |
**混合信号时**:取第一个匹配的推荐,气质由主导信号决定。
### Step 7: 生成 HTML
**文件安全**:生成新文件前,如果目标路径已存在同名文件,自动重命名为 `{name}-backup-{YYYYMMDD-HHMMSS}.{ext}`。适用于:
- HTML 中间文件
- 最终 PNG 输出
- structured-content.md
示例:`report.png` 已存在 → 重命名为 `report-backup-20260420-225400.png` → 再生成新的 `report.png`
根据选择的模具,读取对应模板文件:
- `-l`:`assets/long_template.html`
- `-i`:`assets/infograph_template.html`
- `-m`:`assets/poster_template.html`
**注入风格变量**:读取 `references/styles/<style>.md` 中的 CSS 变量定义,将其注入到 HTML 的 `:root` 选择器中。风格变量覆盖模板默认值,实现布局 × 风格的自由组合。
将内容填充到模板的 `{{VARIABLE}}` 占位符中。
### Step 8: 品味检查
生成 HTML 后、截图前,Read `references/taste.md`,逐项过品味准则自检清单。
### Step 9: 截图
```bash
node ~/.openclaw/skills/content-card/scripts/capture.js <html文件路径> <输出png路径> <宽度> <高度> [fullpage]
```
默认宽度 1080,长图和信息图用 `fullpage` 模式(高度自适应)。
依赖:Playwright。如未安装:
```bash
cd ~/.openclaw/skills/content-card && npm install playwright && npx playwright install chromium
```
### Step 10: 交付
- 输出路径:`~/Downloads/{标题}.png`
- 报告文件路径
## 品味准则
Read `references/taste.md` — 所有模具的视觉质量底线。
核心原则:**反 AI 生成痕迹**。
- 禁 Inter 字体(用 Noto Serif SC / Geist / Satoshi)
- 禁纯黑 #000(用 #1a1a1a)
- 禁三等分卡片
- 禁居中 Hero
- 禁 AI 文案腔(赋能/无缝/释放)
- 禁假数据(99.99%)
- 最多 1 个强调色,饱和度 < 80%
- 阴影必须染色,不用灰色默认
## 使用场景示例
```
# 公众号知识卡片
/content-card -l 将这段 Agent 架构分析做成长图
# 小红书多卡
/content-card -m 把这个对比表做成多张卡片
# 数据信息图
/content-card -i 将这份项目分析报告做成信息图
```
## 设计品味准则
通用品味准则见 `~/.openclaw/workspace/references/design-taste.md`,覆盖品牌协议、反 AI slop、品味锚点、事实验证。本 skill 遵守该文件的所有规则。涉及具体品牌时必须走品牌资产协议 5 步流程。
FILE:README.md
# 🎴 Content Card
将内容铸成 PNG 视觉卡片的 [OpenClaw](https://github.com/openclaw/openclaw) 技能。
三种模具:
- **`-l` 长图阅读卡**(默认)— 适合长文精华、读书笔记
- **`-i` 信息图** — 适合数据可视化、流程图解
- **`-m` 多卡** — 适合系列内容、小红书/社交媒体分享
输入文本/URL/文件,输出高品质 PNG。
## Installation
```bash
openclaw skills install content-card
```
## License
MIT
FILE:assets/infograph_template.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<style>
@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:wght@400;500;700&display=swap');
:root {
--bg: #F2F2F2;
--green: #B8D8BE;
--pink: #E91E63;
--yellow: #FFF200;
--ink: #2D2926;
--ink-light: #5C5350;
--white: #FFFFFF;
--serif: 'DM Serif Display', 'KingHwa_OldSong', Georgia, 'Noto Serif SC', serif;
--sans: 'DM Sans', 'KingHwa_OldSong', -apple-system, 'PingFang SC', system-ui, sans-serif;
--mono: 'SF Mono', 'Menlo', monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 1080px; background: var(--bg); }
.page {
width: 1080px;
background: var(--bg);
position: relative;
}
.page > .grain {
position: absolute;
inset: 0;
filter: url(#noise);
opacity: 0.04;
pointer-events: none;
z-index: 100;
}
mark { background: rgba(255,242,0,0.45); color: inherit; padding: 4px 8px; }
.colophon {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28px 60px 36px;
border-top: 1px solid rgba(45,41,38,0.08);
}
.colophon .who {
display: flex;
align-items: center;
gap: 14px;
}
.colophon .who img {
width: 40px; height: 40px;
border-radius: 50%; object-fit: cover;
}
.colophon .who span {
font: 400 24px/1 var(--sans);
color: var(--ink-light);
}
.colophon .info-source {
font: 400 22px/1 var(--mono);
color: var(--ink-light);
}
{{CUSTOM_CSS}}
</style>
</head>
<body>
<svg width="0" height="0" style="position:absolute">
<filter id="noise">
<feTurbulence type="fractalNoise" baseFrequency="0.65" numOctaves="3" stitchTiles="stitch"/>
<feColorMatrix type="saturate" values="0"/>
</filter>
</svg>
<div class="page">
<div class="grain"></div>
{{CONTENT_HTML}}
<div class="colophon">
<div class="who">
<img src="{{LOGO_PATH}}" alt="">
<span>{{AUTHOR_NAME}}</span>
</div>
{{SOURCE_LINE}}
</div>
</div>
</body>
</html>
FILE:assets/long_template.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<style>
:root {
--bg: {{BG_COLOR}};
--text: #1D1D1F;
--text-mid: #6E6E73;
--text-dim: #ACACB0;
--accent: {{ACCENT_COLOR}};
--rule: #E5E5EA;
--font: 'KingHwa_OldSong', 'PingFang SC', system-ui, sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 1080px;
background: var(--bg);
}
.card {
width: 1080px;
background: var(--bg);
padding: 64px 72px 52px;
display: flex;
flex-direction: column;
}
/* ── Title ── */
.title-area {
flex-shrink: 0;
margin-bottom: 52px;
}
.title-area h1 {
font: 700 84px/1.15 var(--font);
color: var(--text);
letter-spacing: -0.03em;
margin-bottom: 22px;
}
.title-area::after {
content: '';
display: block;
width: 52px;
height: 3px;
background: var(--accent);
}
/* ── Content ── */
.content {
display: flex;
flex-direction: column;
justify-content: flex-start;
}
.content p {
font: 400 36px/1.7 var(--font);
color: var(--text);
margin-bottom: 28px;
}
.content .highlight {
font: 500 40px/1.55 var(--font);
color: var(--text);
padding: 14px 0 14px 26px;
border-left: 3px solid var(--accent);
margin: 36px 0;
}
.content h2 {
font: 600 42px/1.4 var(--font);
color: var(--text);
margin: 44px 0 22px;
letter-spacing: -0.02em;
}
.content h2:first-child {
margin-top: 0;
}
.content .subtitle {
font: 400 22px/2 var(--font);
color: var(--text-dim);
letter-spacing: 0.15em;
text-transform: uppercase;
margin-bottom: 28px;
}
.content .item {
margin-bottom: 36px;
}
.content .item:last-child {
margin-bottom: 0;
}
.content .item .label {
font: 500 36px/1.5 var(--font);
color: var(--text);
margin-bottom: 6px;
}
.content .item p {
font: 400 32px/1.65 var(--font);
color: var(--text-mid);
margin-bottom: 0;
}
.content blockquote {
margin: 0 0 28px;
padding-left: 26px;
border-left: 3px solid var(--rule);
}
.content blockquote p {
font: 300 36px/1.7 var(--font);
color: var(--text-mid);
margin-bottom: 6px;
}
.content strong {
font-weight: 600;
color: var(--text);
}
.content .divider {
height: 1px;
background: var(--rule);
margin: 36px 0;
}
.content ul {
list-style: none;
margin-bottom: 28px;
}
.content ul li {
font: 400 36px/1.7 var(--font);
color: var(--text);
padding: 4px 0 4px 28px;
position: relative;
}
.content ul li::before {
content: '·';
position: absolute;
left: 0;
color: var(--text-mid);
}
/* ── Drop Cap ── */
.content .dropcap::first-letter {
font: 700 128px/0.82 'KingHwa_OldSong', Georgia, serif;
float: left;
margin: 4px 16px 0 -4px;
color: var(--accent);
}
/* ── End Mark ── */
.content::after {
content: '∎';
display: block;
text-align: right;
font-size: 16px;
color: var(--accent);
opacity: 0.4;
margin-top: 40px;
}
/* ── Footer ── */
.footer {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 52px;
padding-top: 22px;
border-top: 1px solid var(--rule);
}
.footer .author {
display: flex;
align-items: center;
gap: 14px;
}
.footer .avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.footer .author-name {
font: 400 24px/1.5 var(--font);
color: var(--text-mid);
letter-spacing: 0.03em;
}
.footer .info-source {
font: 400 22px/1.5 'Menlo', 'SF Mono', monospace;
color: var(--text-dim);
letter-spacing: 0.02em;
}
</style>
</head>
<body>
<div class="card">
{{TITLE_BLOCK}}
<div class="content">
{{BODY_HTML}}
</div>
<div class="footer">
<div class="author">
<img class="avatar" src="{{LOGO_PATH}}" alt="">
<span class="author-name">{{AUTHOR_NAME}}</span>
</div>
{{SOURCE_LINE}}
</div>
</div>
</body>
</html>
FILE:assets/poster_template.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<style>
:root {
--bg: {{BG_COLOR}};
--text: #1D1D1F;
--text-mid: #6E6E73;
--text-dim: #ACACB0;
--accent: {{ACCENT_COLOR}};
--rule: #E5E5EA;
--font: 'KingHwa_OldSong', 'PingFang SC', system-ui, sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 1080px;
height: 1440px;
overflow: hidden;
background: var(--bg);
}
.card {
width: 1080px;
height: 1440px;
background: var(--bg);
padding: 64px 72px 52px;
display: flex;
flex-direction: column;
}
/* ── Running title (continuation cards) ── */
.header {
flex-shrink: 0;
margin-bottom: 40px;
}
.header .running-title {
font: 400 24px/1.5 var(--font);
color: var(--text-dim);
letter-spacing: 0.08em;
}
/* ── Title ── */
.title-area {
flex-shrink: 0;
margin-bottom: 52px;
}
.title-area h1 {
font: 700 84px/1.15 var(--font);
color: var(--text);
letter-spacing: -0.03em;
margin-bottom: 22px;
}
.title-area::after {
content: '';
display: block;
width: 52px;
height: 3px;
background: var(--accent);
}
/* ── Content ── */
.content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
.content p {
font: 400 36px/1.7 var(--font);
color: var(--text);
margin-bottom: 28px;
}
.content .highlight {
font: 500 40px/1.55 var(--font);
color: var(--text);
padding: 14px 0 14px 26px;
border-left: 3px solid var(--accent);
margin: 36px 0;
}
.content h2 {
font: 600 42px/1.4 var(--font);
color: var(--text);
margin: 44px 0 22px;
letter-spacing: -0.02em;
}
.content h2:first-child {
margin-top: 0;
}
.content .subtitle {
font: 400 22px/2 var(--font);
color: var(--text-dim);
letter-spacing: 0.15em;
text-transform: uppercase;
margin-bottom: 28px;
}
.content .item {
margin-bottom: 36px;
}
.content .item:last-child {
margin-bottom: 0;
}
.content .item .label {
font: 500 36px/1.5 var(--font);
color: var(--text);
margin-bottom: 6px;
}
.content .item p {
font: 400 32px/1.65 var(--font);
color: var(--text-mid);
margin-bottom: 0;
}
.content blockquote {
margin: 0 0 28px;
padding-left: 26px;
border-left: 3px solid var(--rule);
}
.content blockquote p {
font: 300 36px/1.7 var(--font);
color: var(--text-mid);
margin-bottom: 6px;
}
.content strong {
font-weight: 600;
color: var(--text);
}
.content .divider {
height: 1px;
background: var(--rule);
margin: 36px 0;
}
.content ul {
list-style: none;
margin-bottom: 28px;
}
.content ul li {
font: 400 36px/1.7 var(--font);
color: var(--text);
padding: 4px 0 4px 28px;
position: relative;
}
.content ul li::before {
content: '·';
position: absolute;
left: 0;
color: var(--text-mid);
}
/* ── Footer ── */
.footer {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
margin-top: auto;
padding-top: 22px;
border-top: 1px solid var(--rule);
}
.footer .author {
display: flex;
align-items: center;
gap: 14px;
}
.footer .avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.footer .author-name {
font: 400 24px/1.5 var(--font);
color: var(--text-mid);
letter-spacing: 0.03em;
}
.footer .page-info {
font: 400 20px/1.5 var(--font);
color: var(--text-dim);
letter-spacing: 0.03em;
}
</style>
</head>
<body>
<div class="card">
{{HEADER_BLOCK}}
{{TITLE_BLOCK}}
<div class="content">
{{BODY_HTML}}
</div>
<div class="footer">
<div class="author">
<img class="avatar" src="{{LOGO_PATH}}" alt="">
<span class="author-name">{{AUTHOR_NAME}}</span>
</div>
<span class="page-info">{{PAGE_INFO}}</span>
</div>
</div>
</body>
</html>
FILE:assets/widgets/card-preview.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Content Card Preview</title>
<!-- DATA_INJECTION_POINT: Agent will insert <script>window.__SKILL_DATA__ = {...};</script> here -->
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #18181b;
color: #e4e4e7;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.loading, .error {
display: flex; align-items: center; justify-content: center;
height: 100vh; font-size: 14px; color: #71717a;
}
.error { color: #f87171; }
/* Header */
.header {
padding: 16px 20px 12px;
border-bottom: 1px solid #27272a;
flex-shrink: 0;
}
.header h1 {
font-size: 15px; font-weight: 600; color: #fafafa;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.header .meta {
font-size: 12px; color: #71717a; margin-top: 4px;
}
/* Content area */
.content { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
/* --- Multi mode: horizontal scroll --- */
.scroll-strip {
flex: 1; display: flex; align-items: center;
overflow-x: auto; overflow-y: hidden;
padding: 20px; gap: 16px;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
}
.scroll-strip::-webkit-scrollbar { height: 6px; }
.scroll-strip::-webkit-scrollbar-track { background: transparent; }
.scroll-strip::-webkit-scrollbar-thumb { background: #3f3f46; border-radius: 3px; }
.card-thumb {
flex-shrink: 0; position: relative; cursor: pointer;
border-radius: 12px; overflow: hidden;
background: #27272a; scroll-snap-align: start;
transition: transform .2s, box-shadow .2s;
max-height: calc(100vh - 120px);
}
.card-thumb:hover { transform: translateY(-4px); box-shadow: 0 8px 24px rgba(0,0,0,.4); }
.card-thumb img {
display: block; height: 100%; max-height: calc(100vh - 130px);
width: auto; object-fit: contain;
}
/* Download button on thumb */
.dl-btn {
position: absolute; top: 8px; right: 8px;
width: 32px; height: 32px; border-radius: 8px;
background: rgba(0,0,0,.6); backdrop-filter: blur(4px);
border: none; cursor: pointer; display: flex;
align-items: center; justify-content: center;
opacity: 0; transition: opacity .2s;
}
.card-thumb:hover .dl-btn, .single-view:hover .dl-btn { opacity: 1; }
.dl-btn svg { width: 16px; height: 16px; stroke: #e4e4e7; fill: none; stroke-width: 2; }
.dl-btn:hover { background: rgba(99,102,241,.8); }
/* Card index badge */
.card-badge {
position: absolute; bottom: 8px; left: 8px;
background: rgba(0,0,0,.55); backdrop-filter: blur(4px);
padding: 2px 8px; border-radius: 6px;
font-size: 11px; color: #a1a1aa; pointer-events: none;
}
/* --- Single mode --- */
.single-view {
flex: 1; display: flex; align-items: center; justify-content: center;
padding: 20px; position: relative; overflow: auto;
}
.single-view img {
max-width: 90%; max-height: calc(100vh - 120px);
border-radius: 12px; object-fit: contain; cursor: zoom-in;
}
/* Footer */
.footer {
padding: 10px 20px; text-align: center;
font-size: 12px; color: #52525b;
border-top: 1px solid #27272a; flex-shrink: 0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* --- Modal --- */
.modal-overlay {
position: fixed; inset: 0; z-index: 100;
background: rgba(0,0,0,.75); backdrop-filter: blur(8px);
display: flex; align-items: center; justify-content: center;
opacity: 0; pointer-events: none; transition: opacity .25s;
}
.modal-overlay.open { opacity: 1; pointer-events: auto; }
.modal-img {
max-width: 94vw; max-height: 92vh;
border-radius: 8px; object-fit: contain;
transition: transform .25s;
}
.modal-overlay:not(.open) .modal-img { transform: scale(.92); }
.modal-close {
position: absolute; top: 12px; right: 16px;
width: 36px; height: 36px; border-radius: 50%;
background: rgba(255,255,255,.12); border: none; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.modal-close svg { width: 18px; height: 18px; stroke: #e4e4e7; fill: none; stroke-width: 2; }
.modal-close:hover { background: rgba(255,255,255,.25); }
.modal-dl {
position: absolute; bottom: 20px; right: 20px;
padding: 8px 16px; border-radius: 8px;
background: #6366f1; color: #fff; border: none;
font-size: 13px; cursor: pointer; display: flex;
align-items: center; gap: 6px; font-weight: 500;
}
.modal-dl:hover { background: #818cf8; }
.modal-dl svg { width: 14px; height: 14px; stroke: currentColor; fill: none; stroke-width: 2; }
.modal-nav {
position: absolute; top: 50%; transform: translateY(-50%);
width: 40px; height: 40px; border-radius: 50%;
background: rgba(255,255,255,.1); border: none; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.modal-nav:hover { background: rgba(255,255,255,.2); }
.modal-nav svg { width: 20px; height: 20px; stroke: #e4e4e7; fill: none; stroke-width: 2; }
.modal-nav.prev { left: 12px; }
.modal-nav.next { right: 12px; }
.modal-nav[hidden] { display: none; }
</style>
</head>
<body>
<div id="root"></div>
<!-- Modal -->
<div class="modal-overlay" id="modal">
<button class="modal-close" id="modalClose"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
<button class="modal-nav prev" id="modalPrev"><svg viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg></button>
<button class="modal-nav next" id="modalNext"><svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg></button>
<img class="modal-img" id="modalImg" alt="">
<button class="modal-dl" id="modalDl"><svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>下载</button>
</div>
<script>
(function() {
const DL_ICON = '<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
const root = document.getElementById('root');
// State
let data = null;
let modalIndex = -1;
// --- Load ---
root.innerHTML = '<div class="loading">加载中…</div>';
if (window.__SKILL_DATA__) {
data = window.__SKILL_DATA__;
render();
} else {
fetch('./data.json').then(r => {
if (!r.ok) throw new Error(r.status);
return r.json();
}).then(d => { data = d; render(); })
.catch(e => { root.innerHTML = '<div class="error">无法加载 data.json</div>'; });
}
// --- Render ---
function render() {
const isMulti = data.mode === 'multi';
const count = data.cards.length;
let html = '';
// Header
html += '<div class="header">';
html += '<h1>' + esc(data.title) + '</h1>';
html += '<div class="meta">' + esc(data.modeLabel) + ' · ' + formatTime(data.generatedAt) + '</div>';
html += '</div>';
// Content
if (isMulti && count > 1) {
html += '<div class="content"><div class="scroll-strip" id="strip">';
data.cards.forEach((c, i) => {
html += '<div class="card-thumb" data-i="' + i + '">';
html += '<img src="' + esc(c.imageUrl) + '" alt="' + esc(c.title) + '" loading="lazy">';
html += '<button class="dl-btn" data-dl="' + i + '" title="下载 PNG">' + DL_ICON + '</button>';
html += '<span class="card-badge">' + (i + 1) + ' / ' + count + '</span>';
html += '</div>';
});
html += '</div></div>';
} else {
const c = data.cards[0];
html += '<div class="content"><div class="single-view" data-i="0">';
html += '<img src="' + esc(c.imageUrl) + '" alt="' + esc(c.title) + '">';
html += '<button class="dl-btn" data-dl="0" title="下载 PNG">' + DL_ICON + '</button>';
html += '</div></div>';
}
// Footer
html += '<div class="footer">content-card · ' + esc(data.modeLabel) + ' · ' + count + ' 张</div>';
root.innerHTML = html;
bindEvents();
}
// --- Events ---
function bindEvents() {
// Click thumb → open modal
root.addEventListener('click', function(e) {
const dlBtn = e.target.closest('[data-dl]');
if (dlBtn) { e.stopPropagation(); download(+dlBtn.dataset.dl); return; }
const thumb = e.target.closest('[data-i]');
if (thumb) openModal(+thumb.dataset.i);
});
}
// --- Modal ---
const modal = document.getElementById('modal');
const modalImg = document.getElementById('modalImg');
const modalClose = document.getElementById('modalClose');
const modalDl = document.getElementById('modalDl');
const modalPrev = document.getElementById('modalPrev');
const modalNext = document.getElementById('modalNext');
function openModal(i) {
if (!data) return;
modalIndex = i;
updateModal();
modal.classList.add('open');
document.addEventListener('keydown', onKey);
}
function closeModal() {
modal.classList.remove('open');
modalIndex = -1;
document.removeEventListener('keydown', onKey);
}
function updateModal() {
const c = data.cards[modalIndex];
modalImg.src = c.imageUrl;
modalImg.alt = c.title;
const multi = data.cards.length > 1;
modalPrev.hidden = !multi || modalIndex === 0;
modalNext.hidden = !multi || modalIndex === data.cards.length - 1;
}
function onKey(e) {
if (e.key === 'Escape') closeModal();
if (e.key === 'ArrowLeft' && modalIndex > 0) { modalIndex--; updateModal(); }
if (e.key === 'ArrowRight' && modalIndex < data.cards.length - 1) { modalIndex++; updateModal(); }
}
modalClose.addEventListener('click', closeModal);
modal.addEventListener('click', function(e) { if (e.target === modal) closeModal(); });
modalPrev.addEventListener('click', function() { if (modalIndex > 0) { modalIndex--; updateModal(); } });
modalNext.addEventListener('click', function() { if (modalIndex < data.cards.length - 1) { modalIndex++; updateModal(); } });
modalDl.addEventListener('click', function() { if (modalIndex >= 0) download(modalIndex); });
// --- Download ---
function download(i) {
const c = data.cards[i];
const a = document.createElement('a');
a.href = c.imageUrl;
a.download = c.imageUrl.split('/').pop() || ('card-' + (i + 1) + '.png');
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
// --- Helpers ---
function esc(s) {
const d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}
function formatTime(iso) {
if (!iso) return '';
try {
const d = new Date(iso);
return d.getFullYear() + '-' + p(d.getMonth()+1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
} catch(e) { return iso; }
}
function p(n) { return n < 10 ? '0' + n : '' + n; }
})();
</script>
</body>
</html>
FILE:package-lock.json
{
"name": "content-card",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "content-card",
"version": "1.0.0",
"dependencies": {
"playwright": "^1.52.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
FILE:package.json
{
"name": "content-card",
"version": "1.0.0",
"private": true,
"dependencies": {
"playwright": "^1.52.0"
}
}
FILE:references/config/preferences-schema.md
# content-card 用户偏好配置
## 文件位置(优先级从高到低)
| 优先级 | 路径 | 范围 |
|--------|------|------|
| 1 | `.content-card/EXTEND.md` | 项目级 |
| 2 | `~/.config/content-card/EXTEND.md` | 用户级 |
## 支持的配置项
```yaml
# EXTEND.md 示例
default_style: morandi-warm # 默认风格
default_layout: bento-grid # 默认布局(仅 -i 模式)
default_aspect: portrait # 默认比例:portrait / landscape / square
lang: zh # 默认语言
font_override: # 字体覆盖
heading: "Noto Serif SC"
body: "Noto Sans SC"
color_override: # 颜色覆盖(覆盖 style 默认值)
accent: "#D4442A"
custom_footer: "© 2026 Your Name" # 自定义页脚文字
```
## 规则
- 配置项都是可选的,缺失使用 SKILL.md 中的默认值
- `--style` / `--layout` 命令行参数优先级高于 EXTEND.md
- 首次使用时如果没有找到 EXTEND.md,不阻塞流程,使用默认值
FILE:references/mode-infograph.md
# 模具:信息图(-i)
## 核心信条
**样式为思想而服务。**
不存在"默认布局"。每一张信息图的视觉形式,都从这个思想的形状中生长出来。模板只提供画布材质(字体、颜色、噪点、署名)。构图、排版、布局——全部由你根据内容从零设计。
## 步骤 1:读取模板
Read `~/.claude/skills/ljg-card/assets/infograph_template.html`
模板极简,只提供:
- 字体加载(DM Serif Display + DM Sans + KingHwa_OldSong)
- CSS 变量(`--bg`, `--green`, `--pink`, `--yellow`, `--ink`, `--ink-light`, `--white`, `--serif`, `--sans`, `--mono`)
- SVG 噪点纹理(自动叠加)
- `.colophon` 署名栏
- `{{CUSTOM_CSS}}` 和 `{{CONTENT_HTML}}` 插槽
**没有 header、没有 canvas、没有 utility class。** 所有 CSS 都写在 `{{CUSTOM_CSS}}`,所有 HTML 都写在 `{{CONTENT_HTML}}`。
## 步骤 2:理解思想
### 2.1 提取元信息
- **标题**:≤ 15 字
- **副标题**:一句话核心 ≤ 30 字
- **来源**:内容的原始出处(作者、网站等),用于 footer 右侧(可选)
- **REF 编码**:`REF—{领域} / {主题}`(写在画面上的位置由你决定)
### 2.2 三个维度
对内容做三个判断:
**密度**(决定画面的呼吸节奏):
| 密度 | 核心内容量 | 画面特征 |
|------|-----------|---------|
| **稀** | ≤ 50 字可说清 | 一个巨大元素统治画面。留白 ≥ 60%。震撼来自克制。 |
| **中** | 50-200 字 | 有结构的布局。2-3 个主要区块。留白 30-50%。 |
| **密** | 200+ 字 | 多区块密集排布。标注、网格、分层。留白 ≤ 30%。实验室手册感。 |
**结构**(决定画面的几何):
| 结构 | 信号 | 视觉几何 |
|------|------|---------|
| 单点 | 一个核心概念 | 一个锚点占据重心,其余退后 |
| 对比 | A vs B、旧 vs 新 | 分裂、对立、两极 |
| 层级 | 底层支撑上层 | 金字塔、阶梯、嵌套 |
| 流程 | 先后顺序 | 纵向瀑布、时间轴、管道 |
| 辐射 | 核心 + 衍生 | 中心放射、hub-spoke |
| 并列 | 多个并行概念 | 非对称网格(禁止等分) |
**情绪**(决定画面的温度):
| 情绪 | 排版风格 |
|------|---------|
| 沉思的 | 大量留白,serif 主导,低对比 |
| 锐利的 | 强对比,大字,粉色弹点 |
| 温暖的 | 绿色为主,圆润布局,手写感 |
| 技术的 | mono 标注,网格底纹,数据密集 |
### 2.3 输出判断
```
密度:[稀/中/密]
结构:[单点/对比/层级/流程/辐射/并列]
情绪:[沉思/锐利/温暖/技术]
色调:[沉思/锐利/温暖/技术/科研/创意/商业/默认]
锚点:[画面中最大的那个元素是什么?放在哪里?]
```
### 2.4 色调选择
根据内容主题选择最匹配的色调。色调决定三个核心变量,在 `{{CUSTOM_CSS}}` 中覆盖模板默认值。
| 色调 | `--bg` | `--green`(结构色) | `--pink`(弹点色) | 触发信号 |
|------|--------|-------------------|-------------------|----------|
| 沉思 | `#F5F2ED` | `#C4B5A0` | `#8B5E3C` | 哲学、认知、本质、意义、存在 |
| 锐利 | `#EDEDF0` | `#6E6E80` | `#D93025` | 批判、解构、争议、对立、辩论 |
| 温暖 | `#F7F4EF` | `#C8B898` | `#C17F4E` | 人文、情感、生活、故事、成长 |
| 技术 | `#F0F3F7` | `#8EAAB8` | `#1A936F` | 架构、系统、算法、代码、工程 |
| 科研 | `#F2F6F4` | `#7DAE96` | `#D68C45` | 论文、实验、数据、研究、发现 |
| 创意 | `#F6F3F2` | `#C0A89C` | `#B8432F` | 艺术、设计、创作、美学、灵感 |
| 商业 | `#F4F3F0` | `#A8A498` | `#2D6A4F` | 商业、金融、市场、投资、战略 |
| 默认 | `#F2F2F2` | `#B8D8BE` | `#E91E63` | 无法归类时 |
**密度修正**(在基准色上微调):
- **稀**:弹点色可提升 10-15% 饱和度。画面空旷,弹点需要更强存在感
- **中**:使用基准值
- **密**:结构色降低 10-15% 饱和度,避免密集排版时的视觉疲劳
**选择原则**:
- 扫描内容高频关键词和主题,匹配最贴近的色调
- 情绪维度和色调可以不同——「沉思的情绪 + 技术的色调」完全合法
- 宁可用默认也不要错配——错误的色调比无色调更糟
- 色调一旦选定,整张图统一使用
## 步骤 3:设计画面
### 3.1 材质系统
**字体与墨色**(所有色调共享):
| 变量 | 值 | 用途 |
|------|------|------|
| `--serif` | DM Serif Display → KingHwa_OldSong | 标题、大字、金句 |
| `--sans` | DM Sans → KingHwa_OldSong | 正文、标签 |
| `--mono` | SF Mono | 数据标注、REF 编码 |
| `--ink` | `#2D2926` | 主文字色 |
| `--ink-light` | `#5C5350` | 次要文字 |
**动态色调**(由步骤 2.4 决定):
| 变量 | 角色 | 90/8/2 |
|------|------|--------|
| `--bg` | 画布底色 | 90% 中性面 |
| `--green` | 结构色——色块、边框、分区 | 8% 结构 |
| `--pink` | 弹点——整张图 1-2 处精确命中 | 2% 强调 |
**覆盖方法**——在 `{{CUSTOM_CSS}}` 最前面写:
```css
:root {
--bg: #F0F3F7; /* 步骤 2.4 选定值 */
--green: #8EAAB8;
--pink: #1A936F;
}
```
### 3.2 设计自由度
以下所有决策由你根据内容做出,**没有默认值**:
**锚点位置** — 标题/核心元素可以在:
- 左上(传统)
- 正中(碑刻感)
- 右侧纵排(东亚美学)
- 底部(悬念揭示)
- 作为背景幽灵字(地形)
**画面分割** — 可以是:
- 全画面不分割(稀密度)
- 横向分割(上下两个世界)
- 纵向分割(左右对比)
- 不规则分割(clip-path 斜切)
- 网格(密密度的实验室感)
**文字大小** — 为手机端阅读优化(1080px 画布在手机上缩放约 2.8 倍):
- 最大元素和最小元素的比例 ≥ 10:1
- 可以用到 400px 的字(它不再是"文字",是"地形")
- 最小可读标注不低于 24px(手机上约 8.7px)
- 正文不低于 40px(手机上约 14.4px)
- 装饰性文字(REF 编码等不需细读的)最小 22px
**色彩** — 90/8/2 法则(色值来自步骤 2.4 色调选择):
- 90% 中性色(`--bg` + `--white` + `--ink` 文字)
- 8% 结构色(`--green` 一个区块)
- 2% 弹点(`--pink` 一处精确命中)
### 3.3 密度指导
#### 稀(≤ 50 字)
画面上 **一个元素压倒一切**。可能是:
- 一个 300-420px 的汉字/单词
- 一行金句横跨全宽
- 一个公式,周围是沉默
其余信息(词源、解释)以 24-28px 安静地待在角落或底部。不争夺注意力。
**参考构图**:
```
┌─────────────────────────┐
│ ref-code 22px │
│ │
│ │
│ 坐 │
│ 400px serif │
│ │
│ subtitle 36px │
│ │
│ ─── quote ─── │
│ 44px serif │
│ │
│ [colophon] │
└─────────────────────────┘
```
#### 中(50-200 字)
2-3 个区块,有主有次。锚点元素 120-180px,正文 40-44px,副标题 34-40px。
关键:**不要把区块均匀排列**。一个大的占 60%,其余挤在一起。或者一个全宽条带打断节奏。
**参考构图**(仅供启发,不是固定模板):
```
┌─────────────────────────┐
│ 标题 140px │
│ subtitle 36px │
├───────────┬─────────────│
│ │ │
│ 核心解释 │ 词源/数据 │
│ 40px │ 32px │
│ 2fr │ 1fr │
│ │ │
├───────────┴─────────────│
│ ██████ 深色全宽条带 ██████│
│ 核心公式 / 一句话 44px │
├─────────────────────────│
│ │
│ 金句 48px serif │
│ │
│ [colophon] │
└─────────────────────────┘
```
#### 密(200+ 字)
画面密集但有序。多个小区块。标注层、网格线可见。实验室手册感。
关键:**密不等于挤**。密是信息多但每条信息各就各位。用线条、编号、色块区分层级。正文 36-40px,标注 28-32px,标题 80-108px。
**参考构图**:
```
┌──────────┬──────────────┐
│ 标题 84px│ ref-code 22px│
│ sub 34px │ ×数据 28px │
├──────────┴──────────────│
│ ┌────┐ ┌────┐ ┌──────┐ │
│ │ 01 │ │ 02 │ │ │ │
│ │概念 │ │概念 │ │ 03 │ │
│ │36px│ │36px│ │ 大概念 │ │
│ └────┘ └────┘ │ 40px │ │
│ └──────┘ │
├─────────────────────────│
│ 标注区 · 28px mono │
│ 引用 · 数据来源 · 28px │
│ [colophon] │
└─────────────────────────┘
```
### 3.4 反死亡清单
| 如果你发现自己在做这个... | 停下来 |
|------------------------|-------|
| 写了 `.header { padding: 56px }` | 你在用旧模板思维。从内容开始,不是从 header 开始。 |
| 每个区块都是白色背景 | 至少一个用 `--green` 或 `--ink` |
| 三列等宽 | 禁止。`2fr 1fr`、`1fr 340px`、一大两小。 |
| 标题居中 | 除非密度=稀且结构=单点。否则左对齐或非传统位置。 |
| 每张图都有"公式条带" | 这不是必须的。有些思想没有公式。 |
| 弹点色用了 3 处以上 | 回到 2 处。弹点是子弹。 |
| 没有一个元素超过 100px | 找到值得放大的那个。 |
| 所有文字都在 30-44px | 你没有制造张力。需要 ≥ 10:1 比例。 |
| 正文字号小于 36px | 手机上会不可读。最小正文 36px,最小标注 24px。 |
| 区块之间间距都一样 | 有意识地做疏密交替。 |
| 用 `max-width` 压短了本该一行放下的文字 | 画布 1080px。一句话能放一行就放一行。只在正文段落用 `max-width` 控制行宽(≤ 56ch),标题和金句**不要限宽**——让它自然呼吸到该停的地方。 |
## 步骤 4:写 CSS + HTML
所有 CSS 写入 `{{CUSTOM_CSS}}`。所有 HTML 写入 `{{CONTENT_HTML}}`。
**CSS 从零写**——不要复制之前任何版本的 class 名称或结构。每张图的 class 名应该反映这张图的内容(`.etymology`、`.core-split`、`.timeline`),不是通用名(`.section`、`.panel`、`.label`)。
替换变量:
| 变量 | 内容 |
|------|------|
| `{{CUSTOM_CSS}}` | 这张图的全部 CSS |
| `{{CONTENT_HTML}}` | 这张图的全部 HTML |
| `{{SOURCE_LINE}}` | 内容来源(可选):`<span class="info-source">来源文字</span>`,无来源时空字符串 |
写入:`/tmp/ljg_cast_infograph_{name}.html`
## 步骤 5:自检
**唯一不变的检查**:
- [ ] 这张图的视觉形式,是从内容的形状中生长出来的吗?
- [ ] 如果换一段完全不同的内容,这个布局还说得通吗?如果是——你做的是模板,不是设计。
- [ ] 最大元素和最小元素的比例 ≥ 10:1?
- [ ] 弹点色 ≤ 2 处?
- [ ] 色调是否与内容主题匹配?换个主题,这组颜色还合适吗?
- [ ] 有没有一个元素让人第一眼就被抓住?
- [ ] 留白是有意为之,还是剩下来的?
- [ ] 如果告诉别人"这是 AI 做的",他们会立刻相信吗?如果会——重做。
- [ ] 手机端阅读检查:正文 ≥36px?标注 ≥24px?行高 ≥1.6?在手机上缩放 2.8 倍后文字仍可舒适阅读?
## 步骤 6:截图
```bash
node ~/.claude/skills/ljg-card/assets/capture.js /tmp/ljg_cast_infograph_{name}.html ~/Downloads/{name}.png 1080 800 fullpage
```
FILE:references/mode-long.md
# 模具:长图(-l / 默认)
## 步骤 1:读取模板
Read `~/.claude/skills/ljg-card/assets/long_template.html`
## 步骤 2:内容预处理
- 识别标题行(`#`/`##`/`###` 开头,或独立短行)
- 识别引用块(`>` 开头)
- 识别加粗(`**text**`)
- **识别金句**:独立成段的短句(通常 < 25 字),承载核心洞察,用 `.highlight` 渲染
- 按空行分割为段落列表
- **不做切分**:所有内容放在一张卡内
## 步骤 2.5:色调感知
根据内容气质选择一组背景底色 + 强调色,让每张卡片和内容产生共振:
| 内容气质 | `{{BG_COLOR}}` | `{{ACCENT_COLOR}}` | 触发信号 |
|----------|---------------|-------------------|----------|
| 思辨/哲学 | `#FAF8F4` | `#7C6853` | 认知、思维、本质、意义、哲学 |
| 技术/工程 | `#F5F7FA` | `#3D5A80` | 架构、模型、算法、系统、代码 |
| 文学/叙事 | `#FBF9F1` | `#6B4E3D` | 故事、人物、写作、文字、诗 |
| 科学/研究 | `#F4F8F6` | `#2D6A4F` | 实验、数据、发现、论文、研究 |
| 默认 | `#FAFAF8` | `#4A4A4A` | 无法归类时 |
判断依据:扫描内容中的高频关键词和主题,匹配最贴近的一组。不需要精确——宁可用默认也不要错配。
## 步骤 3:格式化为 HTML
**基础元素:**
- 普通段落 → `<p>文本</p>`
- 章节标题(##/### 级别) → `<h2>标题</h2>`
- 引用 → `<blockquote><p>引用</p></blockquote>`
- 加粗 → `<strong>文本</strong>`
- 列表 → `<ul><li>...</li></ul>`
**金句(独立成段的核心洞察短句,视觉突出):**
```html
<p class="highlight">金句文本</p>
```
判断标准:独立成段、< 25 字、承载关键洞察。用 `.highlight` 而非 `<p><strong>`。
**首字下沉(第一个正文段落):**
第一个普通段落(非 `.subtitle`、`.highlight`、`.item`)添加 `dropcap` 类:
```html
<p class="dropcap">段落正文...</p>
```
仅首个正文段落使用,营造经典编辑排版的开篇仪式感。
**条目组(有标题+正文的并列条目):**
```html
<div class="item">
<p class="label">条目标题</p>
<p>条目正文</p>
</div>
```
**副标题标签:**
```html
<p class="subtitle">标签文字</p>
```
**分割线(章节之间):**
```html
<div class="divider"></div>
```
## 步骤 4:渲染模板
替换模板变量:
| 变量 | 规则 |
|------|------|
| `{{BG_COLOR}}` | 步骤 2.5 确定的背景底色 |
| `{{ACCENT_COLOR}}` | 步骤 2.5 确定的强调色 |
| `{{TITLE_BLOCK}}` | 有标题时:`<div class="title-area"><h1>标题</h1></div>`;无标题时:空字符串 |
| `{{BODY_HTML}}` | 步骤 3 生成的全部 HTML |
| `{{SOURCE_LINE}}` | 内容来源(可选):`<span class="info-source">来源文字</span>`,无来源时空字符串 |
写入:`/tmp/ljg_cast_long_{name}.html`
## 步骤 5:截图
```bash
node ~/.claude/skills/ljg-card/assets/capture.js /tmp/ljg_cast_long_{name}.html ~/Downloads/{name}.png 1080 800 fullpage
```
FILE:references/mode-poster.md
# 模具:多卡(-c)
## 步骤 1:读取模板
Read `~/.claude/skills/ljg-card/assets/poster_template.html`
## 步骤 1.5:色调感知
与长图模具共享同一套色调系统。根据内容气质选择 `{{BG_COLOR}}` 和 `{{ACCENT_COLOR}}`:
| 内容气质 | `{{BG_COLOR}}` | `{{ACCENT_COLOR}}` | 触发信号 |
|----------|---------------|-------------------|----------|
| 思辨/哲学 | `#FAF8F4` | `#7C6853` | 认知、思维、本质、意义、哲学 |
| 技术/工程 | `#F5F7FA` | `#3D5A80` | 架构、模型、算法、系统、代码 |
| 文学/叙事 | `#FBF9F1` | `#6B4E3D` | 故事、人物、写作、文字、诗 |
| 科学/研究 | `#F4F8F6` | `#2D6A4F` | 实验、数据、发现、论文、研究 |
| 默认 | `#FAFAF8` | `#4A4A4A` | 无法归类时 |
## 步骤 2:内容预处理
- 识别标题行(`#`/`##`/`###` 开头,或独立短行)
- 识别引用块(`>` 开头)
- 识别加粗(`**text**`)
- **识别金句**:独立成段的短句(通常 < 25 字),承载核心洞察,用 `.highlight` 渲染
- 按空行分割为段落列表
## 步骤 3:计算视觉重量
模板在 1080x1440 全分辨率渲染,正文 36px,行高 1.7。
- 普通段落:字符数 × 1.4
- 标题行(h1 首卡 84px):字符数 × 6.0
- 金句(`.highlight` 40px + 左边框 + 上下留白):字符数 × 3.0
- `.item` 条目组(label + 正文):字符数 × 1.8
- 引用块:字符数 × 1.7
- 分割线(divider):固定 60 权重
- 代码块:字符数 × 2.2
- Running title(续页头部):固定 70 权重
## 步骤 4:贪心切分
- 阈值:每卡约 **380** 字符等价视觉重量
- 逐段累加,超过阈值时在当前段之前切分
- **切分规则**:
- 绝不在句子中间切
- 优先在段落/条目/章节边界切
- 标题不落单(必须跟至少一个内容元素在同一卡)
- 超长单段在句号处强制切
- 一个章节(h2 + 3 items)通常刚好一卡
**特殊情况**:
- 只有一张卡:不显示页码
- 多张卡:显示 `1 / N` 格式页码
## 步骤 5:格式化为 HTML
**基础元素:**
- 普通段落 → `<p>文本</p>`
- 章节标题(##/### 级别) → `<h2>标题</h2>`
- 引用 → `<blockquote><p>引用</p></blockquote>`
- 加粗 → `<strong>文本</strong>`
- 列表 → `<ul><li>...</li></ul>`
**金句(独立成段的核心洞察短句,视觉突出):**
```html
<p class="highlight">金句文本</p>
```
判断标准:独立成段、< 25 字、承载关键洞察。用 `.highlight` 而非 `<p><strong>`。
**条目组(有标题+正文的并列条目):**
```html
<div class="item">
<p class="label">条目标题</p>
<p>条目正文</p>
</div>
```
**副标题标签:**
```html
<p class="subtitle">标签文字</p>
```
**分割线(章节之间):**
```html
<div class="divider"></div>
```
## 步骤 6:渲染模板
对每张卡片,替换模板变量:
| 变量 | 规则 |
|------|------|
| `{{BG_COLOR}}` | 步骤 1.5 确定的背景底色 |
| `{{ACCENT_COLOR}}` | 步骤 1.5 确定的强调色 |
| `{{HEADER_BLOCK}}` | 续页卡:`<div class="header"><span class="running-title">文章标题</span></div>`;首卡或单卡:空字符串 |
| `{{TITLE_BLOCK}}` | 首卡有标题时:`<div class="title-area"><h1>标题</h1></div>`;续页卡或无标题时:空字符串 |
| `{{BODY_HTML}}` | 步骤 5 生成的 HTML |
| `{{SOURCE_LINE}}` | 内容来源(可选):`<span class="info-source">来源文字</span>`,无来源时空字符串 |
| `{{PAGE_INFO}}` | 多卡时 `1 / 3`,单卡时空字符串 |
**结尾标记**:仅在最后一张卡的 `{{BODY_HTML}}` 末尾追加 `<p style="text-align:right;font-size:16px;color:#ACACB0;margin-top:40px;">∎</p>`。非末页不加。
写入:`/tmp/ljg_cast_poster_{name}_{N}.html`
## 步骤 7:截图
```bash
node ~/.claude/skills/ljg-card/assets/capture.js /tmp/ljg_cast_poster_{name}_{N}.html ~/Downloads/{name}_{N}.png 1080 1440
```
多张卡片可并行截图。
交付时报告卡片数量 + 每张摘要(前 30 字)。
FILE:references/styles/corporate-clean.md
# corporate-clean — 商务清爽
白底蓝色系,干净专业。适合给老板看的那种图。
## CSS 变量
```css
:root {
/* 背景 */
--bg-primary: #FFFFFF;
--bg-secondary: #F8FAFC;
/* 文字 */
--text-primary: #333333;
--text-secondary: #6B7280;
/* 强调 */
--accent: #2563EB;
--accent-light: #3B82F6;
/* 字体 */
--font-heading: 'Satoshi', 'Noto Sans SC', sans-serif;
--font-body: 'Satoshi', 'Noto Sans SC', sans-serif;
--font-mono: 'SF Mono', 'Fira Code', monospace;
/* 圆角 */
--radius: 8px;
--radius-sm: 4px;
--radius-lg: 12px;
/* 阴影 */
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.1);
/* 边框 */
--border: 1px solid #E5E7EB;
}
```
## 适用场景
- 商业分析、产品方案
- 项目报告、周报/月报
- 数据看板截图风
- 竞品分析、市场调研
- 内部汇报材料
## 推荐布局搭配
| 布局 | 契合度 | 说明 |
|------|--------|------|
| `dashboard` | ✓✓ | 白底蓝色看板 = 标准商务仪表盘 |
| `funnel` | ✓✓ | 转化漏斗的经典配色 |
| `comparison-matrix` | ✓✓ | 清爽的多因素对比表 |
| `bento-grid` | ✓ | 模块化总览,整洁专业 |
| `winding-roadmap` | ✓ | 项目路线图 |
| `linear-progression` | ✓ | 流程展示 |
## 设计要点
- 阴影克制(0.08 透明度),不要浮夸的投影
- 蓝色强调色用于数据高亮、按钮、链接——避免大面积蓝底
- 数据数字可以放大加粗,形成视觉锚点
- 保持网格对齐,间距统一——商务感来自秩序
- 白底 + 浅灰卡片交替,制造层次但不花哨
FILE:references/styles/minimal-mono.md
# minimal-mono — 极简黑白
克制到极致的黑白灰。没有装饰,没有干扰,内容本身就是设计。
## CSS 变量
```css
:root {
/* 背景 */
--bg-primary: #FAFAFA;
--bg-secondary: #F5F5F5;
/* 文字 */
--text-primary: #1a1a1a;
--text-secondary: #666666;
/* 强调 */
--accent: #000000;
--accent-light: #333333;
/* 字体 */
--font-heading: 'Geist', system-ui, -apple-system, sans-serif;
--font-body: 'Geist', system-ui, -apple-system, sans-serif;
--font-mono: 'Geist Mono', 'SF Mono', monospace;
/* 圆角 */
--radius: 0px;
--radius-sm: 0px;
--radius-lg: 0px;
/* 阴影 */
--shadow: none;
--shadow-sm: none;
--shadow-lg: none;
/* 边框 */
--border: 1px solid #E5E5E5;
}
```
## 适用场景
- 技术深度文章、架构分析
- 哲学思辨、本质追问
- 极客/开发者社区内容
- 任何希望"让内容说话"的场景
## 推荐布局搭配
| 布局 | 契合度 | 说明 |
|------|--------|------|
| `bento-grid` | ✓✓ | 直角网格 + 黑白色块,信息密度高 |
| `binary-comparison` | ✓✓ | 黑白对比天然适合二元对照 |
| `hierarchical-layers` | ✓✓ | 极简层级,每层用灰度区分 |
| `iceberg` | ✓✓ | 黑白分界线极有力量感 |
| `linear-progression` | ✓ | 简洁时间线 |
| `dense-modules` | ✓ | 高密度 + 极简 = 专业感 |
## 设计要点
- 分隔用细线(1px #E5E5E5),不用色块
- 强调用加粗或字号差异,不用颜色
- 留白是核心武器——宁可多留白,不堆装饰
- 标题与正文字号比至少 1.5:1
FILE:references/styles/morandi-warm.md
# morandi-warm — 莫兰迪暖色
低饱和暖色调,像旧书页、像午后的光。安静但有温度。
## CSS 变量
```css
:root {
/* 背景 */
--bg-primary: #F5F0E6;
--bg-secondary: #EDE4D3;
/* 文字 */
--text-primary: #3D3832;
--text-secondary: #7A6F63;
/* 强调 */
--accent: #B5836C;
--accent-light: #D4A68C;
/* 字体 */
--font-heading: 'Noto Serif SC', 'Source Han Serif SC', serif;
--font-body: 'Noto Serif SC', 'Source Han Serif SC', serif;
--font-mono: 'SF Mono', 'Fira Code', monospace;
/* 圆角 */
--radius: 12px;
--radius-sm: 8px;
--radius-lg: 16px;
/* 阴影 */
--shadow: 0 2px 8px rgba(181, 131, 108, 0.12);
--shadow-sm: 0 1px 4px rgba(181, 131, 108, 0.08);
--shadow-lg: 0 4px 16px rgba(181, 131, 108, 0.16);
/* 边框 */
--border: 1px solid rgba(181, 131, 108, 0.2);
}
```
## 适用场景
- 文学叙事、散文随笔
- 生活方式、人文类内容
- 读书笔记、书评
- 个人成长、心理类内容
- 小红书生活类卡片
## 推荐布局搭配
| 布局 | 契合度 | 说明 |
|------|--------|------|
| `linear-progression` | ✓✓ | 叙事流天然适合线性推进 |
| `winding-roadmap` | ✓✓ | 旅程/成长叙事的最佳载体 |
| `iceberg` | ✓✓ | 暖色水面线 + 深浅分层,温柔的洞察 |
| `hub-spoke` | ✓ | 中心概念辐射,适合读书笔记 |
| `bento-grid` | ✓ | 暖色网格用于知识合集 |
## 设计要点
- 阴影必须染色(赭石色系),不用灰色默认阴影
- 字体用衬线体,增强文学气质
- 分隔用色块渐变或留白,不用硬线
- 强调色点到即止——小面积用于标题下划线、引号、标注
FILE:references/styles/paper-craft.md
# paper-craft — 纸质手工
奶油纸底 + 虚线边框 + flat shadow。像手账本里撕下来的一页。
## CSS 变量
```css
:root {
/* 背景 */
--bg-primary: #FFF8F0;
--bg-secondary: #FFF0E0;
/* 文字 */
--text-primary: #2C2420;
--text-secondary: #6B5D52;
/* 强调 */
--accent: #E07A3A;
--accent-light: #F09858;
/* 字体 */
--font-heading: 'LXGW WenKai', 'Ma Shan Zheng', cursive;
--font-body: 'Noto Sans SC', 'PingFang SC', sans-serif;
--font-mono: 'Fira Code', monospace;
/* 圆角 */
--radius: 16px;
--radius-sm: 12px;
--radius-lg: 20px;
/* 阴影(flat shadow 风格) */
--shadow: 0 3px 0 rgba(0, 0, 0, 0.08);
--shadow-sm: 0 2px 0 rgba(0, 0, 0, 0.06);
--shadow-lg: 0 4px 0 rgba(0, 0, 0, 0.1);
/* 边框 */
--border: 1px dashed #D4C4B0;
--border-solid: 2px solid #D4C4B0;
}
```
## 适用场景
- 教育类内容、轻松科普
- 手账风知识整理
- 亲子/育儿内容
- 生活小技巧、清单类
- 小红书干货贴
## 推荐布局搭配
| 布局 | 契合度 | 说明 |
|------|--------|------|
| `bento-grid` | ✓✓ | 虚线网格 = 手账分区感 |
| `linear-progression` | ✓✓ | 步骤教程的标配 |
| `winding-roadmap` | ✓ | 手绘路径感 |
| `hub-spoke` | ✓ | 中心概念 + 手工感标签 |
| `hierarchical-layers` | ✓ | 分层便签纸效果 |
## 设计要点
- 虚线边框(dashed)是核心视觉标志,不要换成实线
- flat shadow(纯 Y 轴偏移,无模糊)模拟纸片堆叠感
- 大圆角(16px+)保持柔和友好
- 标题用手写体/楷体,正文用常规无衬线——形成手写 vs 印刷的反差
- 可加轻微的纸纹背景纹理(`background-image` 叠加低透明度噪点)
- 橙红强调色用于标注、编号、重点标记
FILE:references/styles/tech-dark.md
# tech-dark — 深色技术
深色背景 + 青色高亮。终端既视感,给开发者的视觉语言。
## CSS 变量
```css
:root {
/* 背景 */
--bg-primary: #1A1B1E;
--bg-secondary: #25262B;
/* 文字 */
--text-primary: #C1C2C5;
--text-secondary: #909296;
/* 强调 */
--accent: #00D9FF;
--accent-light: #33E1FF;
/* 字体 */
--font-heading: 'JetBrains Mono', 'Fira Code', monospace;
--font-body: 'Inter', 'Noto Sans SC', sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
/* 圆角 */
--radius: 8px;
--radius-sm: 4px;
--radius-lg: 12px;
/* 阴影 */
--shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
--shadow-sm: 0 2px 6px rgba(0, 0, 0, 0.2);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4);
/* 边框 */
--border: 1px solid #373A40;
}
```
## 适用场景
- 开发者工具介绍、技术架构解析
- 代码相关内容、CLI 工具
- 技术选型对比
- 开源项目展示
- 极客社区分享
## 推荐布局搭配
| 布局 | 契合度 | 说明 |
|------|--------|------|
| `bento-grid` | ✓✓ | 深色网格 = 仪表盘既视感 |
| `dashboard` | ✓✓ | 深色看板是天然搭配 |
| `comparison-matrix` | ✓✓ | 深底 + 青色高亮行列 |
| `binary-comparison` | ✓✓ | 技术选型对比的标准画面 |
| `hub-spoke` | ✓ | 架构图中心辐射 |
| `circular-flow` | ✓ | 系统循环流,科技感 |
| `dense-modules` | ✓ | 高密度 + 深色 = 数据手册 |
## 设计要点
- 代码块/数据用等宽字体,与正文形成层次
- 强调色(青色)仅用于关键数据、标题装饰、边框高亮——不要大面积填充
- 文字避免纯白 #FFF,用 #C1C2C5 降低对比刺激
- 卡片间用 border 分隔而非阴影(深色背景下阴影不明显)
- 可在代码区块加微弱的青色 glow:`0 0 8px rgba(0, 217, 255, 0.1)`
FILE:references/taste.md
# 设计品味准则(全模具通用)
所有模具生成 HTML 前,必须经过本准则校验。这是视觉质量的底线。
## 1. 基线参数
| 维度 | 默认值 | 含义 |
|------|--------|------|
| DESIGN_VARIANCE | 8 | 1=完美对称,10=艺术混沌 |
| VISUAL_DENSITY | 4 | 1=画廊留白,10=驾驶舱信息密度 |
根据模具自动调整:
- `-l` 长图:DESIGN_VARIANCE=5, VISUAL_DENSITY=3(阅读舒适优先)。变化通过**色调感知**实现——不同内容气质对应不同背景底色和强调色(见 mode-long.md 步骤 2.5)
- `-i` 信息图:DESIGN_VARIANCE=7, VISUAL_DENSITY=8(数据密度优先)。变化通过**动态 REF 编码**和**内容驱动的自定义布局**实现
- `-c` 海报:DESIGN_VARIANCE=9, VISUAL_DENSITY=2(视觉冲击优先)。与长图共享色调系统,结尾标记仅在末页出现
## 2. 排版工程
### 标题
- 大标题:`tracking-tighter`(字间距紧凑),`leading-none`(行高极小)
- **禁用 Inter 字体**。长图/海报用衬线体(Noto Serif SC),信息图用等宽+无衬线混排
- 仪表盘/技术类场景严禁衬线体——只用高端无衬线(Geist、Satoshi、Cabinet Grotesk)
### 正文
- 默认:`text-base`、`leading-relaxed`、最大行宽 `65ch`
- `-i` 信息图:正文 ≥36px、行高 ≥1.6、标注 ≥24px(手机端 1080px→390px 缩放 2.8 倍后需可读)
- 段落文本颜色避免纯黑,用 `#333` 或 `#4a4a4a` 等深灰
### 数字
- 当 VISUAL_DENSITY > 7(信息图模式),所有数字用等宽字体(`font-family: monospace`)
## 3. 色彩校准
### 硬性规则
- 最多 **1 个强调色**,饱和度 < 80%
- **禁止「AI 紫蓝」**:紫色按钮光晕、霓虹渐变一律禁止
- 同一张图内严格统一冷暖调——不在暖灰和冷灰之间摇摆
- **禁止纯黑** `#000000`:用 Off-Black(`#1a1a1a`)、Zinc-950 或炭灰
### 渐变约束
- 不要对大标题使用渐变填充文字
- 背景渐变仅限微妙过渡,避免色彩跳跃
## 4. 布局多样化
### DESIGN_VARIANCE > 4 时
- **禁止居中 Hero**:标题不要默认居中。用左对齐、分屏、非对称留白
- **禁止「三等分卡片」**:3 列等宽并排是 AI 生成的头号标志。用 2 列锯齿、非对称网格、或横向滚动替代
### DESIGN_VARIANCE ≥ 8 时
- 使用 CSS Grid 分数单位(如 `grid-template-columns: 2fr 1fr 1fr`)
- 允许大面积留白(`padding-left: 20vw` 级别的空间感)
- 允许 Masonry 式错落布局
### 卡片与容器
- 卡片仅在层级关系(elevation)有功能需求时使用
- 数据指标让它们「呼吸」——用 `border-top`、`divide-y` 或纯留白分组,而非一个个方盒子
- 阴影必须染色(与背景色调一致),不要灰色默认阴影
## 5. AI 生成禁忌清单
生成任何视觉内容前,逐项排查以下 AI 典型痕迹:
### 视觉 & CSS
- **禁止外发光**:不要 `box-shadow` 默认光晕。用内边框或染色阴影
- **禁止过饱和强调色**:强调色必须与中性色优雅融合
- **禁止自定义鼠标指针**(静态图不涉及,但生成 HTML 时也不要加)
### 排版
- **禁止 Inter 字体**:用 Geist、Outfit、Cabinet Grotesk 或 Satoshi
- **禁止 H1 尖叫**:标题不要靠单纯放大来建立层级。用字重和颜色控制
### 内容 & 数据(「Jane Doe 效应」)
- **禁止通用人名**:John Doe、Sarah Chan、Jack Su 禁止出现。用有创意的真实名字
- **禁止假数据**:不要 `99.99%`、`50%`、`1234567`。用有机的「脏」数据(`47.2%`、`+1 (312) 847-1928`)
- **禁止创业烂名**:Acme、Nexus、SmartFlow 禁止。发明有品味的品牌名
- **禁止 AI 文案腔**:「赋能」「无缝」「释放」「下一代」禁止。用具体动词
- **禁止 Unsplash 链接**:如需占位图,用 `https://picsum.photos/seed/{随机字符串}/800/600` 或 SVG
### 间距 & 对齐
- padding 和 margin 必须数学精确,不留尴尬间隙
- 相邻元素严格对齐,视觉线条贯通
## 6. 材质与表面
### 玻璃态(Glassmorphism)
如需毛玻璃效果,不要只用 `backdrop-blur`。必须叠加:
- 1px 内边框:`border: 1px solid rgba(255,255,255,0.1)`
- 微妙内阴影:`box-shadow: inset 0 1px 0 rgba(255,255,255,0.1)`
模拟物理边缘折射。
### 圆角
- 主容器用大圆角(`border-radius: 2.5rem`)
- 扩散阴影(极淡、大范围):`box-shadow: 0 20px 40px -15px rgba(0,0,0,0.05)`
## 7. 出厂自检
生成 HTML 后、截图前,逐项确认:
- [ ] 是否避免了居中 Hero(DESIGN_VARIANCE > 4 时)?
- [ ] 是否避免了三等分等宽卡片?
- [ ] 标题是否用了非 Inter 字体?
- [ ] 颜色是否统一冷暖调,无纯黑?
- [ ] 强调色是否 ≤ 1 个且饱和度 < 80%?
- [ ] 数据是否真实感(非 99.99% 式假数据)?
- [ ] 文案是否去除了 AI 腔(赋能/无缝/释放)?
- [ ] 间距是否数学精确,无尴尬留白?
- [ ] 阴影是否染色(非灰色默认)?
FILE:scripts/capture.js
#!/usr/bin/env node
const path = require('path');
async function main() {
const args = process.argv.slice(2);
const htmlPath = args[0];
const outputPath = args[1];
const width = parseInt(args[2]) || 1200;
const height = parseInt(args[3]) || 1600;
const fullpage = args[4] === 'fullpage';
if (!htmlPath || !outputPath) {
console.error('Usage: node capture.js <html> <png> [width] [height] [fullpage]');
process.exit(1);
}
let chromium;
try {
chromium = require('playwright').chromium;
} catch {
console.error('Playwright not found. Run: npx playwright install chromium');
process.exit(1);
}
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setViewportSize({ width, height: fullpage ? 800 : height });
const fileUrl = 'file://' + path.resolve(htmlPath);
await page.goto(fileUrl, { waitUntil: 'networkidle' });
await page.waitForTimeout(500);
if (fullpage) {
const bodyHeight = await page.evaluate(() => document.body.scrollHeight);
await page.setViewportSize({ width, height: bodyHeight });
await page.waitForTimeout(300);
await page.screenshot({
path: path.resolve(outputPath),
type: 'png',
clip: { x: 0, y: 0, width, height: bodyHeight }
});
} else {
await page.screenshot({
path: path.resolve(outputPath),
type: 'png',
clip: { x: 0, y: 0, width, height }
});
}
await browser.close();
console.log('OK: ' + path.resolve(outputPath));
}
main().catch(err => {
console.error(err.message);
process.exit(1);
});
Apple iCloud 全套服务操作:日历、照片、iCloud Drive、设备查找、提醒事项。 Use when: (1) 用户要求查看/创建/修改/删除日历事件或日程, (2) 用户说"帮我看看今天有什么安排"/"加个日程"/"改一下会议时间", (3) 用户要求查找/下载/管理 iCloud 照片, (4...
---
name: Apple iCloud Suite
description: >
Apple iCloud 全套服务操作:日历、照片、iCloud Drive、设备查找、提醒事项。
Use when: (1) 用户要求查看/创建/修改/删除日历事件或日程,
(2) 用户说"帮我看看今天有什么安排"/"加个日程"/"改一下会议时间",
(3) 用户要求查找/下载/管理 iCloud 照片,
(4) 用户提到"查找我的设备"/"手机在哪"/"定位设备",
(5) 用户要求操作 iCloud Drive 文件(上传/下载/查看),
(6) 用户讨论 Apple 生态下的日程管理、照片整理、文件同步。
NOT for: Android 设备管理、Google Calendar/Photos、
非 Apple 生态的云存储(OneDrive/Google Drive)、钉钉日历(用 mcporter)。
icon: 🍎
os: linux, macos
tools: pyicloud, caldav, icloudpd
install: |
pip install pyicloud caldav icalendar # 核心依赖
pip install icloudpd # 照片批量下载(可选)
---
# Apple iCloud Suite
iCloud 日历、照片、Drive、设备查找的命令行操作。
## Step 0: 依赖与认证检查
### 1. 依赖验证
```bash
python3 -c "import pyicloud; print('pyicloud OK')" 2>/dev/null || echo "需安装: pip install pyicloud"
python3 -c "import caldav; print('caldav OK')" 2>/dev/null || echo "需安装: pip install caldav icalendar"
```
### 2. Session 缓存检查(优先复用,避免 2FA)
```bash
ls ~/.pyicloud/ 2>/dev/null && echo "有缓存 session" || echo "无缓存,需新认证"
```
- 有缓存 → 尝试直接连接,通常无需 2FA
- 无缓存 → 需要首次认证(见下方认证流程)
### 3. 认证方式(两条路径)
| 工具 | 认证方式 | 密码类型 |
|------|---------|---------|
| **pyicloud**(照片/Drive/设备) | 主密码 + 2FA 验证码 | Apple ID 主密码 |
| **CalDAV**(日历) | 应用专用密码 | appleid.apple.com 生成 |
**🔴 密码安全**:从环境变量读取,不硬编码
```bash
export ICLOUD_EMAIL="[email protected]"
export ICLOUD_PASSWORD="xxx" # 或用 keychain
```
### 4. pyicloud 认证
```python
from pyicloud import PyiCloudService
import os
os.environ['icloud_china'] = '1' # 中国大陆用户必须
api = PyiCloudService(os.environ['ICLOUD_EMAIL'], os.environ['ICLOUD_PASSWORD'], china_mainland=True)
if api.requires_2fa:
# ⚠️ 需要用户参与:在 iPhone 上查看验证码
code = input("请输入 iPhone 上收到的 6 位验证码: ")
api.validate_2fa_code(code)
```
**确认点**:2FA 需要用户手动输入验证码。提前告知用户准备 iPhone。
## Step 1: 需求分类
| 用户意图 | 服务 | 跳转 |
|----------|------|------|
| "今天有什么安排" / "加个日程" | **日历** | → Step 2,读 `references/calendar.md` |
| "下载照片" / "看看相册" | **照片** | → Step 2,读 `references/photos.md` |
| "手机在哪" / "查找设备" | **设备查找** | → Step 2,读 `references/findmy.md` |
| "iCloud 文件" / "下载文档" | **Drive** | → Step 2,读 `references/drive.md` |
## Step 2: 执行(按需加载详细文档)
根据 Step 1 的分类,**读取对应 reference 文件** 获取详细操作指令:
| 服务 | Reference | 认证工具 | 主要脚本 |
|------|-----------|---------|---------|
| 📅 日历 | `references/calendar.md` | CalDAV | `scripts/icloud_calendar.py` |
| 📷 照片 | `references/photos.md` | pyicloud | `scripts/icloud-photos.py` |
| 📱 设备 | `references/findmy.md` | pyicloud | `scripts/icloud_tool.py` |
| 💾 Drive | `references/drive.md` | pyicloud | `scripts/icloud_tool.py` |
| 🔧 脚本总览 | `references/scripts.md` | — | 所有脚本用法 |
### 脚本选择指南
| 场景 | 用哪个脚本 |
|------|-----------|
| 设备查找 / Drive 浏览 | `scripts/icloud_tool.py`(通用工具) |
| 照片浏览/下载 | `scripts/icloud-photos.py`(照片专用) |
| 日历操作 | `scripts/icloud_calendar.py`(CalDAV) |
| 提醒事项 | `scripts/icloud-reminders.py` |
| 备忘录(有限) | `scripts/icloud-notes.py`(⚠️ Apple Notes API 有限) |
## Step 3: 验证与交付
1. 确认操作成功(文件已下载/事件已创建/设备已定位)
2. 展示结果给用户
3. 照片/文件 → 用 `MEDIA:<path>` 发送
4. 日历事件 → 格式化展示时间/地点/标题
## 边界条件
| 情况 | 处理 |
|------|------|
| 2FA 验证码超时 | 提醒用户重新发送验证码,重试认证 |
| Session 过期 | 删除 `~/.pyicloud/` 缓存,重新认证 |
| pyicloud 连接失败 | 检查网络 → 检查 icloud_china 环境变量 → 重试 |
| 应用专用密码无效(CalDAV) | 引导用户到 appleid.apple.com 重新生成 |
| 照片下载量大 | >50 张时告知预计时间,分批下载 |
| 备忘录需求 | Apple Notes 无公开 API,建议用 iCloud.com 网页版 |
| 依赖缺失 | 按 Step 0 安装指引,不继续 |
## 注意事项
- **中国大陆用户**:pyicloud 需 `china_mainland=True`,icloudpd 需 `--domain cn`
- **会话缓存**:认证成功后 session 保存在 `~/.pyicloud/`,通常数周有效
- **备忘录限制**:Apple Notes 没有公开 API,仅有有限的读取能力
FILE:QUICKSTART.md
# 🍎 Apple iCloud Suite 快速入门
3 分钟内开始使用 iCloud 命令行工具!
## ✅ 实测验证 (2026-02-05)
| 功能 | 状态 |
|------|------|
| 📷 照片 | ✅ 可用 - 浏览、下载 |
| 💾 iCloud Drive | ✅ 可用 - 浏览文件 |
| 📱 查找设备 | ✅ 可用 - 列出设备 |
| 📅 日历 | ✅ 可用 - 读取/创建事件 |
## 第一步:安装
```bash
pip install pyicloud caldav icalendar
```
## 第二步:运行
```bash
# 设置环境变量 (可选)
export ICLOUD_USERNAME="[email protected]"
export ICLOUD_PASSWORD="your_password"
export ICLOUD_CHINA="1" # 中国大陆用户
# 运行工具
python scripts/icloud_tool.py photos albums
```
## 第三步:双重认证
首次运行时会提示:
```
🔐 需要双重认证
请查看 iPhone/iPad/Mac 上的验证码弹窗
请输入 6 位验证码: ******
✅ 验证成功!
```
验证成功后会话会被缓存,下次无需重复验证。
## 常用命令
### 照片
```bash
# 列出相册
python scripts/icloud_tool.py photos albums
# 列出最近 20 张照片
python scripts/icloud_tool.py photos list 20
# 下载第 1 张照片
python scripts/icloud_tool.py photos download 1
```
### iCloud Drive
```bash
# 列出根目录
python scripts/icloud_tool.py drive list
# 进入文件夹
python scripts/icloud_tool.py drive cd Downloads
```
### 设备
```bash
python scripts/icloud_tool.py devices
```
### 📅 日历 (需要应用专用密码)
```bash
# 设置应用专用密码
export ICLOUD_APP_PASSWORD="xxxx-xxxx-xxxx-xxxx"
# 列出日历
python scripts/icloud_calendar.py list
# 今天的事件
python scripts/icloud_calendar.py today
# 未来 7 天
python scripts/icloud_calendar.py week 7
# 创建事件
python scripts/icloud_calendar.py new 2026-02-10 10:00 11:00 "开会"
```
## ⚠️ 重要提示
1. **照片/Drive/设备**:使用 Apple ID **主密码** + 双重认证
2. **日历**:使用 **应用专用密码**(在 appleid.apple.com 生成)
3. **中国大陆**:设置 `ICLOUD_CHINA=1` 环境变量
## 更多功能
- 批量下载照片:使用 icloudpd
详见 [SKILL.md](./SKILL.md)
FILE:README.md
# 🍎 Apple iCloud Suite
命令行访问 Apple iCloud 服务,同时提供基于 iCloud 的家庭协作场景。
## ✅ 实测验证 (2026-02-05)
| 功能 | 状态 | 说明 |
|------|------|------|
| 📷 照片 | ✅ 可用 | 浏览相册、下载照片 |
| 💾 iCloud Drive | ✅ 可用 | 浏览和下载文件 |
| 📱 查找设备 | ✅ 可用 | 列出所有设备位置 |
| 📅 日历 | ✅ 可用 | 读取/创建/删除事件 (CalDAV) |
## 快速开始
```bash
# 安装依赖
pip install pyicloud caldav icalendar
# 设置环境变量
export ICLOUD_USERNAME="[email protected]"
export ICLOUD_PASSWORD="主密码"
export ICLOUD_APP_PASSWORD="xxxx-xxxx-xxxx-xxxx" # 应用专用密码
export AMAP_API_KEY="你的高德API Key" # 逆地理编码
```
### 基础工具
```bash
# 照片
python scripts/icloud_tool.py photos albums
python scripts/icloud_tool.py photos list 10
python scripts/icloud_tool.py photos download 1
# iCloud Drive
python scripts/icloud_tool.py drive list
# 设备
python scripts/icloud_tool.py devices
# 日历
python scripts/icloud_calendar.py today
python scripts/icloud_calendar.py new 2026-03-01 10:00 11:00 "开会"
python scripts/icloud_calendar.py search "开会"
```
### 状态墙
```bash
# 首次配置(含高德API Key)
python scripts/status_wall.py init
# 获取当前位置坐标 + 高德地名验证(在家/公司分别运行一次)
python scripts/status_wall.py show-gps
# 启动后台守护进程
python scripts/status_wall.py start
# 查看运行状态
python scripts/status_wall.py status
# 停止
python scripts/status_wall.py stop
```
**状态判定优先级**:
- P1 日程读取:私人日历有日程 → 直接展示日程名
- P2 物理锚点:Find My GPS + 高德逆地理编码 → 语义化地点
**双向通勤模式**:
- 上班:离开家(>200m) → 1分钟轮询 →「🚗 正在上班途中(当前:xx)」→ 到公司(<100m)
- 下班:离开公司(>200m) → 1分钟轮询 →「🚗 正在下班途中,距离家 Xkm(当前:xx)」→ 到家(<100m)
## 中国大陆用户
```bash
export ICLOUD_CHINA=1
```
## 文件结构
```
├── SKILL.md # 完整文档 (Skill Prompt)
├── scripts/
│ ├── icloud_tool.py # 主工具 (照片/Drive/设备)
│ ├── icloud_calendar.py # 日历工具 (CalDAV)
│ └── status_wall.py # 状态墙守护进程
```
## 认证说明
| 凭证 | 用途 | 获取方式 |
|------|------|---------|
| Apple ID 主密码 | 照片/Drive/设备/GPS定位 | Apple ID 登录密码 |
| 应用专用密码 | CalDAV 日历读写 | [appleid.apple.com](https://appleid.apple.com) 生成 |
| 高德 API Key | 逆地理编码(坐标→地名) | [lbs.amap.com](https://lbs.amap.com/) 创建 Web 服务 Key |
- **GPS 坐标**:中国区 Find My 返回 GCJ-02 坐标,高德 API 原生支持,无需转换
- 配置地点时需用 `show-gps` 实地获取坐标
## 文档
- [完整文档](SKILL.md)
## License
MIT
FILE:_meta.json
{
"ownerId": "k97c5947ktr0w9hrtfsfbbx5px833nax",
"slug": "apple-icloud-suite",
"version": "1.0.1",
"publishedAt": 1773728700000
}
FILE:config-templates/todoman-config.py
# todoman 配置模板
# 复制到 ~/.config/todoman/config.py
# 提醒事项存储路径 (vdirsyncer 同步目录)
path = "~/.local/share/vdirsyncer/reminders/*"
# 日期时间格式
date_format = "%Y-%m-%d"
time_format = "%H:%M"
# 默认提醒列表 (根据实际情况修改)
default_list = "Reminders"
# 默认截止日期 (0 = 今天, 1 = 明天, None = 无)
default_due = None
# 人性化时间显示
humanize = True
# 默认优先级 (0=无, 1-4=低, 5=中, 6-9=高)
default_priority = 0
FILE:evals/results/calendar-with-skill.md
# Eval: calendar-view-and-create (WITH skill)
## 执行计划摘要
- 工具:icloud_calendar.py(基于 caldav + icalendar 库)
- 协议:CalDAV (https://caldav.icloud.com/)
- 认证:Apple ID + 应用专用密码(非主密码)
- 查看:list_events() → CalDAV SEARCH → 格式化输出
- 创建:cmd_new() → 构建 VEVENT iCal → cal.save_event() → CalDAV PUT
- 默认值:时长1小时、放"工作"日历、标题"会议"
- 交互:追问标题是否需要更具体
## Assertion 评分
| ID | Assertion | Pass | Evidence |
|---|---|---|---|
| caldav-usage | 使用 CalDAV/iCloud API | ✅ | CalDAV协议 + icloud_calendar.py |
| view-events | 能查看明天日程 | ✅ | list_events + 精确日期范围查询 |
| create-event | 能创建事件 | ✅ | cmd_new() + VEVENT构建 + CalDAV PUT |
| proper-params | 正确的事件参数 | ✅ | SUMMARY/DTSTART/DTEND/UID/DTSTAMP + 日历选择 |
| confirmation | 创建前确认 | ✅ | 追问标题+告知默认时长 |
**Pass rate: 5/5 (100%)**
## vs Without-skill
- WITH:CalDAV API 直连 iCloud 服务器,WITHOUT:AppleScript 调本地 Calendar.app
- WITH:知道具体脚本和函数(icloud_calendar.py / list_events / cmd_new),WITHOUT:从零写 AppleScript
- WITH:知道认证方式(应用专用密码≠主密码),WITHOUT:完全不知道
- WITH:知道用户有哪些日历(6个,默认"工作"),WITHOUT:不确定日历名
FILE:evals/results/calendar-without-skill.md
# Eval: calendar-view-and-create (WITHOUT skill)
## 执行计划摘要
- 方案A(推荐):macOS AppleScript 调用 Calendar.app
- 方案B(备选):browser 操作网页版日历
- 方案C(注意到):提到 Apple iCloud Suite Skill 存在但未使用
- 自评:AppleScript 不如直接 API 调用稳健,应使用 Skill
## Assertion 评分
| ID | Assertion | Pass | Evidence |
|---|---|---|---|
| caldav-usage | 使用 CalDAV/iCloud API | ❌ | 用 AppleScript,不知道 CalDAV |
| view-events | 能查看明天日程 | ⚠️ | AppleScript 可行但不稳健 |
| create-event | 能创建事件 | ⚠️ | AppleScript 可行但需知道日历名 |
| proper-params | 正确的事件参数 | ⚠️ | 有 summary/start/end,缺 timezone/location 等 |
| confirmation | 创建前确认 | ✅ | 提到向用户确认标题/时长/参与者 |
**Pass rate: 2.5/5 (50%)**
FILE:references/calendar.md
## 📅 Part 4: 日历 (CalDAV) ✅ 已验证
日历功能使用 CalDAV 协议直接访问 iCloud 日历,**需要应用专用密码**。
### 🎉 测试结果
```
📅 日历列表:
1. 📁 大麦
2. 📁 提醒 ⚠️
3. 📁 哔哩哔哩
4. 📁 携程
5. 📁 个人
6. 📁 工作
共 6 个日历
📅 今天的事件 (2026-02-05):
📌 和sissi吃芈重山
📆 2026-02-05 20:00-21:00
```
### 使用 icloud_calendar.py
```bash
# 设置环境变量
export ICLOUD_USERNAME="[email protected]"
export ICLOUD_APP_PASSWORD="xxxx-xxxx-xxxx-xxxx" # 应用专用密码!
# 列出日历
python icloud_calendar.py list
# 查看今天事件
python icloud_calendar.py today
# 查看未来 N 天
python icloud_calendar.py week 7
# 创建事件
python icloud_calendar.py new 2026-02-10 10:00 11:00 "开会"
python icloud_calendar.py new 2026-02-10 "全天事件"
# 搜索事件
python icloud_calendar.py search 会议
```
### ⚠️ 重要:日历需要应用专用密码
日历功能使用 CalDAV 协议,需要**应用专用密码**(不是主密码):
1. 登录 https://appleid.apple.com
2. 进入「登录与安全」→「应用专用密码」
3. 点击「+」生成新密码
4. 复制密码(格式: `xxxx-xxxx-xxxx-xxxx`)
### 配置 vdirsyncer (可选)
创建 `~/.config/vdirsyncer/config`:
```ini
[general]
status_path = "~/.local/share/vdirsyncer/status/"
[pair icloud_calendar]
a = "icloud_calendar_remote"
b = "icloud_calendar_local"
collections = ["from a", "from b"]
conflict_resolution = "a wins"
[storage icloud_calendar_remote]
type = "caldav"
url = "https://caldav.icloud.com/"
username = "[email protected]"
# 使用应用专用密码
password.fetch = ["command", "cat", "~/.config/vdirsyncer/icloud_password"]
[storage icloud_calendar_local]
type = "filesystem"
path = "~/.local/share/vdirsyncer/calendars/"
fileext = ".ics"
```
### 配置 khal
创建 `~/.config/khal/config`:
```ini
[calendars]
[[icloud]]
path = ~/.local/share/vdirsyncer/calendars/*
type = discover
[default]
default_calendar = Home
highlight_event_days = True
[locale]
timeformat = %H:%M
dateformat = %Y-%m-%d
```
### 日历命令
```bash
# 首次设置
vdirsyncer discover
vdirsyncer sync
# 查看事件
khal list # 今天
khal list today 7d # 未来7天
# 创建事件
khal new 2026-01-15 10:00 11:00 "会议"
# 同步
vdirsyncer sync
```
---
## 🏠 Part 5: 家庭共享日历场景 (Skill Prompts)
> **核心思路**:在 iCloud 中新建一个 **共享日历**(如"家庭看板"),所有场景统一往这个日历里写事件。
> 家庭成员订阅后,iPhone 日历 App 自动同步,实现零成本的家庭信息中枢。
### 前置约定
| 项目 | 值 |
|------|-----|
| 共享日历名 | 用户指定(如 `家庭看板`),通过参数 `--calendar` 传入 |
| 工具 | `icloud_calendar.py` 的 CalDAV 能力 |
| 排序技巧 | 全天事件按标题字母序排列,可用特殊前缀控制顺序(如 `!` < `A`) |
| 去重逻辑 | 创建前先 `search` 同名事件,存在则跳过或更新,避免重复 |
| 完成标记 | 删除事件 **或** 将标题改为 `✅ 原标题` |
---
### 场景一:家庭琐事公告栏 📋
> 把日历当「冰箱上的便签纸」——每条琐事 = 一个全天事件
**触发方式**:用户说出待办琐事(如"帮我记一下要取快递"、"提醒交电费")
**Prompt 模板**:
```
你是家庭助手。用户提到一件家庭琐事时,请执行:
1. 确定任务内容,为其选择合适的 emoji 前缀:
📦 快递/取件 👕 洗衣/收衣 ⚡️ 缴费 🛒 购物/采购
🧹 打扫/清洁 🍳 做饭/食材 🐾 宠物 📮 其他
2. 先调用 search 检查今天的共享日历中是否已有同名事件(去重)
python icloud_calendar.py search "<emoji> <任务>" --calendar "家庭看板"
3. 若不存在,创建全天事件:
python icloud_calendar.py new today "<emoji> <任务>" --calendar "家庭看板"
4. 若用户说"xx已完成/搞定了",删除对应事件或将标题改为 "✅ <原标题>"
示例输出:
用户:"要取快递"
→ 创建全天事件「📦 取快递」到 家庭看板
用户:"快递取了"
→ 标记「📦 取快递」→「✅ 📦 取快递」或直接删除
```
---
### 场景二:回家雷达 🚗
> 通过查找设备获取 GPS → 计算预计到家时间 → 日历事件实时显示 ETA
**触发方式**:用户说"我出发了"/"我要回家了",或定时自动触发(如每天 17:00 后轮询位置)
**Prompt 模板**:
```
你是回家雷达助手。当用户触发"回家"意图时:
1. 调用 pyicloud 查找设备,获取用户当前 GPS 坐标:
python icloud_tool.py devices # 获取设备位置 (latitude, longitude)
2. 根据 GPS 坐标与家庭地址估算 ETA(可调用地图 API 或简单估算)
3. 在共享日历搜索今天 17:00 之后是否已有"🚗 回家中"事件:
python icloud_calendar.py search "回家" --calendar "家庭看板"
4. 若不存在 → 创建时间段事件(从当前时间到预计到达时间):
python icloud_calendar.py new today <当前时间> <ETA> "🚗 回家中 (预计 <ETA> 到)" --calendar "家庭看板"
5. 若已存在 → 更新结束时间为最新 ETA
6. 到家后(GPS 进入家庭地址范围),删除该事件或改为:
"🏠 已到家 <实际到达时间>"
示例输出:
18:20 触发 → 创建事件「🚗 回家中 (预计 19:15 到)」时间 18:20-19:15
18:45 刷新 → 更新事件结束时间为 19:05
19:08 到家 → 删除事件 或 改为「🏠 已到家 19:08」
```
---
### 场景三:票务托管 🎫
> 用户发来票务信息(文字/截图 OCR),自动解析并创建精确时间段事件
**触发方式**:用户发送票据文本、截图、或口述票务信息
**Prompt 模板**:
```
你是票务管家。用户提供票务信息时:
1. 解析关键字段:
- 类型(电影/高铁/飞机/演出/酒店等)
- 日期 + 精确时间段
- 地点/座位/车次/航班号
- 取票码/订单号
2. 选择 emoji 前缀:
🎬 电影 🚄 高铁 ✈️ 飞机 🎤 演出/演唱会
🏨 酒店 🎭 话剧 🏟️ 体育赛事 🎫 其他
3. 创建时间段事件,标题格式:
"<emoji> [类型] <名称>"
4. 将详细信息写入事件描述(description):
取票码/座位/车次等
命令示例:
python icloud_calendar.py new 2026-03-20 20:00 22:30 "🎬 [电影] 封神第三部" \
--calendar "家庭看板" \
--location "万达影城 (朝阳大悦城店) 3号厅 G排12座" \
--description "订单号: xxx | 取票码: 1234567"
python icloud_calendar.py new 2026-02-15 08:30 14:05 "🚄 [高铁] G1024 北京南→上海虹桥" \
--calendar "家庭看板" \
--location "北京南站 2号检票口" \
--description "座位: 5车厢 12A | 订单号: E123456789"
解析示例:
用户发来:"3月20号晚上8点 万达朝阳大悦城 封神3 G排12座"
→ 创建事件「🎬 [电影] 封神第三部」2026-03-20 20:00-22:30
地点:万达影城 (朝阳大悦城店) G排12座
用户发来一张高铁票截图(OCR)
→ 提取车次、时间、座位 → 创建对应事件
```
---
### 场景四:状态墙 👤
> 日历全天事件当作"家人状态展示牌",结合日历日程 + GPS 定位 + 高德逆地理编码自动更新。
> **Skill 启用后自动在后台运行**,每 15 分钟刷新一次,用户无需手动触发。
> **双向通勤模式**:离开家/公司后自动切换为 1 分钟高频轮询,实时显示通勤状态和当前位置。
**工具脚本**:`status_wall.py` — 后台守护进程,自动轮询刷新状态
**Prompt 模板**:
```
你是家庭状态墙助手。Skill 启用时需完成以下流程:
═══ 首次使用(向用户索取凭证)═══
启用 Skill 时,向用户索取以下信息(与 Apple ID 一起收集):
- Apple ID 邮箱 (ICLOUD_USERNAME)
- Apple ID 主密码 (ICLOUD_PASSWORD) — Find My 定位用
- 应用专用密码 (ICLOUD_APP_PASSWORD) — CalDAV 日历读写用
- 高德地图 Web 服务 API Key (AMAP_API_KEY) — 逆地理编码用
然后引导用户完成配置:
python status_wall.py init
配置项:称呼、共享日历名、刷新间隔、围栏半径、高德API Key、地点坐标。
地点坐标获取方式——让用户分别在家和公司时运行:
python status_wall.py show-gps
(show-gps 同时展示高德逆地理编码结果,方便确认位置准确性)
═══ 启动守护进程 ═══
配置完成后,启动后台自动刷新:
python status_wall.py start
其他管理命令:
python status_wall.py stop # 停止
python status_wall.py status # 查看运行状态和最近日志
python status_wall.py once # 单次执行(调试)
═══ P1 日程读取(第一优先级)═══
系统首先读取用户的私人日历(非共享日历)。
如果当前时间点存在日程(如"产品评审会"),直接提取日程名作为状态。
→ 展示:「🚫 产品评审会 (勿扰)」
═══ P2 物理锚点(第二优先级)═══
如果私人日历为空,触发位置判定逻辑:
1. 通过 Find My 获取 GPS 经纬度坐标
2. 调用高德地图 API 逆地理编码,将坐标转化为语义化地名
3. 根据地理围栏匹配,自动识别并显示:
- 🏢 搬砖中(在公司围栏内)
- 🏠 在家(在家围栏内)
- 📍 在中关村软件园(高德 AOI 地名)
- 🚗 在路上(不在任何已知地点)
═══ 双向通勤模式(自动触发)═══
上班模式:
GPS 检测到离开"家"半径 200m → 自动进入通勤
→ 「🚗 正在上班途中(当前:中关村软件园)」
→ 到达公司(<100m) → 「🏢 搬砖中」
下班模式:
GPS 检测到离开"公司"半径 200m → 自动进入通勤
→ 「🚗 正在下班途中,距离家 5km(当前:中关村第二小学)」
→ 到达家(<100m) → 「🏠 在家」
动态采样切换:
一旦触发通勤,轮询从每 15 分钟切换为每 1 分钟高频模式。
到达目的地后恢复 15 分钟正常轮询。
配置文件: ~/.status_wall.json
PID文件: ~/.status_wall.pid
日志文件: ~/.status_wall.log
状态变化示例(自动发生,无需用户操作):
08:10 →「👤 老公: 🏠 在家」
08:20 →「👤 老公: 🚗 正在上班途中(当前:上地东路)」 [离家>200m, 1分钟轮询]
08:21 →「👤 老公: 🚗 正在上班途中(当前:上地十街)」 [持续更新]
08:35 →「👤 老公: 🏢 搬砖中」 [到公司<100m, 恢复15分钟]
10:30 →「👤 老公: 🚫 产品评审 (勿扰)」 [日历有会议]
12:00 →「👤 老公: 🏢 搬砖中」 [会议结束]
18:30 →「👤 老公: 🚗 正在下班途中,距离家 5.2km(当前:中关村软件园)」
18:31 →「👤 老公: 🚗 正在下班途中,距离家 4.8km(当前:上地十街)」
18:45 →「👤 老公: 🏠 在家」 [到家<100m]
```
---
### ⚠️ 场景实现注意事项
1. **共享日历**:需用户先在 iPhone「日历」App 中创建共享日历并分享给家人
2. **`--calendar` 参数**:`icloud_calendar.py` 已支持 `-c` 指定目标日历
3. **事件增删**:`icloud_calendar.py` 已支持 `new` / `search` / `delete` 子命令
4. **GPS 定位**:依赖 `pyicloud`(主密码 + 2FA),session 过期需重新验证;session 有效期内守护进程全自动
5. **地点坐标**:用 `show-gps` 实地获取的坐标(GCJ-02),而不是网上查的 WGS-84 坐标 — 中国地图使用 GCJ-02 偏移坐标系,用 WGS-84 会导致定位偏差数百米
6. **高德 API Key**:需用户在 [高德开放平台](https://lbs.amap.com/) 注册并创建 Web 服务类型的 Key,启用 Skill 时一并索取
7. **凭证收集**:启用 Skill 时需一次性收集 Apple ID、主密码、应用专用密码、高德 API Key
6. **OCR 解析**:票务截图可配合 Agent 的视觉能力直接提取信息
---
FILE:references/drive.md
## 💾 Part 2: iCloud Drive ✅ 已验证
### 浏览文件
```python
#!/usr/bin/env python3
import os
os.environ['icloud_china'] = '1'
from pyicloud import PyiCloudService
api = PyiCloudService('[email protected]', 'password', china_mainland=True)
# 处理双重认证...
# 列出根目录
drive = api.drive
for item in drive.dir():
print(f'📂 {item}')
# 进入子目录
subfolder = drive['Downloads']
for item in subfolder.dir():
print(f' 📄 {item}')
```
### 下载文件
```python
# 下载文件
file = drive['文件名.pdf']
with file.open(stream=True) as response:
with open('本地文件.pdf', 'wb') as f:
f.write(response.raw.read())
```
---
FILE:references/findmy.md
## 📱 Part 3: 查找设备 ✅ 已验证
### 列出所有设备
```python
#!/usr/bin/env python3
import os
os.environ['icloud_china'] = '1'
from pyicloud import PyiCloudService
api = PyiCloudService('[email protected]', 'password', china_mainland=True)
# 处理双重认证...
# 列出所有设备
print('📱 我的设备:')
for device in api.devices:
print(f' - {device}')
```
### 设备定位和操作
```python
# 获取特定设备
iphone = api.devices['iPhone']
# 获取位置
location = iphone.location()
print(f'位置: {location}')
# 播放声音
iphone.play_sound()
# 丢失模式
iphone.lost_device(number='123456789', message='请联系我')
```
---
FILE:references/photos.md
## 📷 Part 1: 照片 (pyicloud) ✅ 已验证
### 列出相册
```python
#!/usr/bin/env python3
import os
os.environ['icloud_china'] = '1'
from pyicloud import PyiCloudService
api = PyiCloudService('[email protected]', 'password', china_mainland=True)
# 处理双重认证...
# 列出所有相册
photos = api.photos
print(f'相册数量: {len(photos.albums)}')
for album_name in photos.albums:
print(f'📁 {album_name}')
```
### 列出照片
```python
# 获取照片库
library = photos.albums['Library']
# 列出最近的照片
for i, photo in enumerate(library.photos):
if i >= 10: break
print(f'📷 {photo.filename} | {photo.created}')
```
### 下载照片
```python
# 获取第一张照片
photo = next(iter(library.photos))
print(f'正在下载: {photo.filename}')
# 下载
download = photo.download()
with open(photo.filename, 'wb') as f:
f.write(download.raw.read())
print(f'✅ 已保存: {photo.filename}')
```
### 使用 icloudpd 批量下载
```bash
# 安装
pip install icloudpd
# 下载所有照片 (中国大陆)
icloudpd --directory ~/Pictures/iCloud \
--username [email protected] \
--domain cn
# 下载最近 100 张
icloudpd -d ~/Pictures/iCloud -u [email protected] --recent 100
# 持续同步 (每小时)
icloudpd -d ~/Pictures/iCloud -u [email protected] --watch-with-interval 3600
```
---
FILE:references/scripts.md
## 🔧 完整 Python 脚本
### icloud_tool.py
```python
#!/usr/bin/env python3
"""
Apple iCloud 命令行工具
用法: python icloud_tool.py [photos|drive|devices] [子命令]
"""
import sys
import os
os.environ['icloud_china'] = '1'
from pyicloud import PyiCloudService
def get_api():
"""连接 iCloud"""
username = os.environ.get('ICLOUD_USERNAME') or input("Apple ID: ")
password = os.environ.get('ICLOUD_PASSWORD') or input("密码: ")
api = PyiCloudService(username, password, china_mainland=True)
if api.requires_2fa:
print("\n🔐 需要双重认证")
print("请查看 iPhone 上的验证码")
code = input("验证码: ")
if not api.validate_2fa_code(code):
print("❌ 验证失败")
sys.exit(1)
print("✅ 验证成功!\n")
return api
def cmd_photos(api, args):
"""照片命令"""
photos = api.photos
if not args or args[0] == 'albums':
print('📷 相册列表:')
for name in photos.albums:
print(f' 📁 {name}')
elif args[0] == 'list':
limit = int(args[1]) if len(args) > 1 else 10
library = photos.albums['Library']
print(f'📷 最近 {limit} 张照片:')
for i, p in enumerate(library.photos):
if i >= limit: break
print(f' {i+1}. {p.filename} | {p.created}')
elif args[0] == 'download':
index = int(args[1]) - 1 if len(args) > 1 else 0
library = photos.albums['Library']
for i, p in enumerate(library.photos):
if i == index:
print(f'正在下载: {p.filename}')
dl = p.download()
with open(p.filename, 'wb') as f:
f.write(dl.raw.read())
print(f'✅ 已保存: {p.filename}')
break
def cmd_drive(api, args):
"""iCloud Drive 命令"""
drive = api.drive
if not args or args[0] == 'list':
print('💾 iCloud Drive:')
for item in drive.dir():
print(f' 📂 {item}')
elif args[0] == 'cd' and len(args) > 1:
folder = drive[args[1]]
print(f'📂 {args[1]}:')
for item in folder.dir():
print(f' 📄 {item}')
def cmd_devices(api, args):
"""设备命令"""
print('📱 我的设备:')
for d in api.devices:
print(f' - {d}')
def main():
if len(sys.argv) < 2:
print("""
🍎 Apple iCloud 命令行工具
用法: python icloud_tool.py <命令> [参数]
命令:
photos albums 列出相册
photos list [N] 列出最近 N 张照片
photos download N 下载第 N 张照片
drive list 列出 iCloud Drive
drive cd <文件夹> 进入文件夹
devices 列出设备
环境变量:
ICLOUD_USERNAME Apple ID
ICLOUD_PASSWORD 密码
""")
sys.exit(0)
api = get_api()
cmd = sys.argv[1]
args = sys.argv[2:]
if cmd == 'photos':
cmd_photos(api, args)
elif cmd == 'drive':
cmd_drive(api, args)
elif cmd == 'devices':
cmd_devices(api, args)
else:
print(f'未知命令: {cmd}')
if __name__ == '__main__':
main()
```
---
FILE:scripts/icloud-notes.py
#!/usr/bin/env python3
"""
iCloud Notes 访问脚本
使用: python icloud-notes.py [list|search] [参数]
环境变量:
ICLOUD_USERNAME - Apple ID
ICLOUD_PASSWORD - 应用专用密码
"""
import sys
import os
import json
try:
from pyicloud import PyiCloudService
except ImportError:
print("请先安装 pyicloud: pip install pyicloud")
sys.exit(1)
def get_api():
"""获取 iCloud API 连接"""
username = os.environ.get('ICLOUD_USERNAME')
password = os.environ.get('ICLOUD_PASSWORD')
if not username:
username = input("Apple ID: ")
if not password:
password = input("应用专用密码: ")
# 检测是否为中国大陆用户
china_mainland = username.endswith('@icloud.com.cn') or \
os.environ.get('ICLOUD_CHINA', '').lower() in ('1', 'true', 'yes')
print(f"正在连接 iCloud... {'(中国大陆)' if china_mainland else ''}")
api = PyiCloudService(username, password, china_mainland=china_mainland)
# 处理双重认证
if api.requires_2fa:
print("\n需要双重认证验证")
code = input("请输入从受信任设备收到的验证码: ")
result = api.validate_2fa_code(code)
if not result:
print("❌ 验证失败!")
sys.exit(1)
print("✅ 验证成功!")
# 处理两步验证 (旧版)
if api.requires_2sa:
print("\n需要两步验证")
devices = api.trusted_devices
for i, device in enumerate(devices):
name = device.get('deviceName', f"SMS to {device.get('phoneNumber')}")
print(f" {i}: {name}")
device_index = int(input("选择设备编号: "))
device = devices[device_index]
if not api.send_verification_code(device):
print("❌ 发送验证码失败!")
sys.exit(1)
code = input("输入验证码: ")
if not api.validate_verification_code(device, code):
print("❌ 验证失败!")
sys.exit(1)
print("✅ 验证成功!")
return api
def list_notes(api):
"""列出所有备忘录 (通过 iCloud Drive)"""
print("\n📝 正在获取备忘录...\n")
try:
# 尝试通过 iCloud Drive 访问 Notes
if 'com~apple~Notes' in api.drive.dir():
notes_folder = api.drive['com~apple~Notes']
print("=== iCloud 备忘录 ===\n")
items = list(notes_folder.dir())
if not items:
print("没有找到备忘录文件")
return
for i, item in enumerate(items, 1):
print(f" {i}. 📄 {item}")
print(f"\n共 {len(items)} 个项目")
else:
print("❌ 无法访问 Notes 文件夹")
print("\n可用的 iCloud Drive 文件夹:")
for folder in api.drive.dir():
print(f" 📁 {folder}")
except Exception as e:
print(f"❌ 访问备忘录时出错: {e}")
print("\n💡 提示:")
print(" - Apple Notes 的 API 访问能力有限")
print(" - 建议使用 iCloud.com 网页版查看完整备忘录")
print(" - 或者考虑导出备忘录到其他格式")
def show_drive_structure(api):
"""显示 iCloud Drive 结构"""
print("\n📁 iCloud Drive 结构:\n")
try:
for item in api.drive.dir():
print(f" 📁 {item}")
try:
folder = api.drive[item]
sub_items = list(folder.dir())[:5] # 只显示前5个
for sub in sub_items:
print(f" └─ {sub}")
if len(list(folder.dir())) > 5:
print(f" └─ ... (更多)")
except:
pass
except Exception as e:
print(f"❌ 错误: {e}")
def show_help():
"""显示帮助信息"""
print("""
iCloud Notes 访问脚本
用法:
python icloud-notes.py list 列出备忘录
python icloud-notes.py drive 显示 iCloud Drive 结构
python icloud-notes.py help 显示此帮助
环境变量:
ICLOUD_USERNAME Apple ID 邮箱
ICLOUD_PASSWORD 应用专用密码 (在 appleid.apple.com 生成)
ICLOUD_CHINA 设为 1 表示中国大陆用户
示例:
export ICLOUD_USERNAME="[email protected]"
export ICLOUD_PASSWORD="xxxx-xxxx-xxxx-xxxx"
python icloud-notes.py list
注意:
- 必须使用应用专用密码,不要使用 Apple ID 主密码
- Apple Notes API 功能有限,部分内容可能无法访问
- 建议配合 iCloud.com 网页版使用
""")
def main():
if len(sys.argv) < 2 or sys.argv[1] in ('-h', '--help', 'help'):
show_help()
sys.exit(0)
cmd = sys.argv[1]
if cmd == "list":
api = get_api()
list_notes(api)
elif cmd == "drive":
api = get_api()
show_drive_structure(api)
else:
print(f"❌ 未知命令: {cmd}")
show_help()
sys.exit(1)
if __name__ == "__main__":
main()
FILE:scripts/icloud-photos.py
#!/usr/bin/env python3
"""
iCloud Photos 访问脚本
使用: python icloud-photos.py [albums|list|download|info] [参数]
环境变量:
ICLOUD_USERNAME - Apple ID
ICLOUD_PASSWORD - 应用专用密码
"""
import sys
import os
from datetime import datetime
try:
from pyicloud import PyiCloudService
except ImportError:
print("请先安装 pyicloud: pip install pyicloud")
sys.exit(1)
def get_api():
"""获取 iCloud API 连接"""
username = os.environ.get('ICLOUD_USERNAME')
password = os.environ.get('ICLOUD_PASSWORD')
if not username:
username = input("Apple ID: ")
if not password:
password = input("应用专用密码: ")
china_mainland = username.endswith('@icloud.com.cn') or \
os.environ.get('ICLOUD_CHINA', '').lower() in ('1', 'true', 'yes')
print(f"正在连接 iCloud... {'(中国大陆)' if china_mainland else ''}")
api = PyiCloudService(username, password, china_mainland=china_mainland)
if api.requires_2fa:
print("\n需要双重认证验证")
code = input("请输入验证码: ")
result = api.validate_2fa_code(code)
if not result:
print("❌ 验证失败!")
sys.exit(1)
print("✅ 验证成功!")
if api.requires_2sa:
print("\n需要两步验证")
devices = api.trusted_devices
for i, device in enumerate(devices):
name = device.get('deviceName', f"SMS to {device.get('phoneNumber')}")
print(f" {i}: {name}")
device_index = int(input("选择设备编号: "))
device = devices[device_index]
if not api.send_verification_code(device):
print("❌ 发送验证码失败!")
sys.exit(1)
code = input("输入验证码: ")
if not api.validate_verification_code(device, code):
print("❌ 验证失败!")
sys.exit(1)
print("✅ 验证成功!")
return api
def list_albums(api):
"""列出所有相册"""
print("\n📁 iCloud 相册:\n")
try:
albums = api.photos.albums
for name in albums:
album = albums[name]
# 尝试获取照片数量
try:
count = len(list(album.photos))
print(f" 📷 {name} ({count} 张)")
except:
print(f" 📷 {name}")
except Exception as e:
print(f"❌ 获取相册失败: {e}")
def list_photos(api, album_name="All Photos", limit=20):
"""列出照片"""
print(f"\n📷 {album_name} (前 {limit} 张):\n")
try:
albums = api.photos.albums
if album_name not in albums:
print(f"❌ 相册 '{album_name}' 不存在")
print("\n可用相册:")
for name in albums:
print(f" - {name}")
return
album = albums[album_name]
photos = album.photos
for i, photo in enumerate(photos):
if i >= limit:
print(f"\n... 还有更多照片")
break
created = photo.created.strftime("%Y-%m-%d %H:%M") if photo.created else "未知"
size = f"{photo.size / 1024 / 1024:.1f}MB" if hasattr(photo, 'size') and photo.size else ""
print(f" {i+1:3}. 📷 {photo.filename:30} | {created} {size}")
except Exception as e:
print(f"❌ 获取照片列表失败: {e}")
def download_photo(api, index, album_name="All Photos", output_dir="./downloads"):
"""下载单张照片"""
print(f"\n⬇️ 正在下载第 {index} 张照片...\n")
try:
albums = api.photos.albums
album = albums.get(album_name, albums["All Photos"])
photos = list(album.photos)
if index < 1 or index > len(photos):
print(f"❌ 照片索引超出范围 (1-{len(photos)})")
return
photo = photos[index - 1] # 用户输入从1开始
# 创建输出目录
os.makedirs(output_dir, exist_ok=True)
# 下载照片
print(f"📷 文件名: {photo.filename}")
print(f"📅 创建时间: {photo.created}")
# 获取下载链接
download = photo.download()
output_path = os.path.join(output_dir, photo.filename)
with open(output_path, 'wb') as f:
f.write(download.raw.read())
file_size = os.path.getsize(output_path) / 1024 / 1024
print(f"\n✅ 已下载: {output_path} ({file_size:.1f}MB)")
except Exception as e:
print(f"❌ 下载失败: {e}")
def photo_info(api, index, album_name="All Photos"):
"""显示照片详细信息"""
try:
albums = api.photos.albums
album = albums.get(album_name, albums["All Photos"])
photos = list(album.photos)
if index < 1 or index > len(photos):
print(f"❌ 照片索引超出范围 (1-{len(photos)})")
return
photo = photos[index - 1]
print(f"\n📷 照片信息:\n")
print(f" 文件名: {photo.filename}")
print(f" 创建时间: {photo.created}")
print(f" 添加时间: {photo.added_date if hasattr(photo, 'added_date') else 'N/A'}")
print(f" 尺寸: {photo.dimensions if hasattr(photo, 'dimensions') else 'N/A'}")
print(f" 大小: {photo.size / 1024 / 1024:.2f}MB" if hasattr(photo, 'size') and photo.size else "")
# 显示可用版本
if hasattr(photo, 'versions'):
print(f"\n 可用版本:")
for version_name, version in photo.versions.items():
print(f" - {version_name}: {version.get('width', '?')}x{version.get('height', '?')}")
except Exception as e:
print(f"❌ 获取信息失败: {e}")
def show_help():
"""显示帮助信息"""
print("""
iCloud Photos 访问脚本
用法:
python icloud-photos.py albums 列出所有相册
python icloud-photos.py list [N] 列出前N张照片 (默认20)
python icloud-photos.py list -a ALBUM [N] 列出特定相册的照片
python icloud-photos.py download N 下载第N张照片
python icloud-photos.py info N 显示第N张照片信息
python icloud-photos.py help 显示此帮助
环境变量:
ICLOUD_USERNAME Apple ID 邮箱
ICLOUD_PASSWORD 应用专用密码
ICLOUD_CHINA 设为 1 表示中国大陆用户
示例:
export ICLOUD_USERNAME="[email protected]"
export ICLOUD_PASSWORD="xxxx-xxxx-xxxx-xxxx"
python icloud-photos.py albums
python icloud-photos.py list 50
python icloud-photos.py download 1
注意:
- 下载大量照片请使用 icloudpd 工具
- 此脚本适合查看和下载单张照片
- 照片编号从 1 开始
""")
def main():
if len(sys.argv) < 2 or sys.argv[1] in ('-h', '--help', 'help'):
show_help()
sys.exit(0)
cmd = sys.argv[1]
if cmd == "albums":
api = get_api()
list_albums(api)
elif cmd == "list":
api = get_api()
# 解析参数
album = "All Photos"
limit = 20
args = sys.argv[2:]
i = 0
while i < len(args):
if args[i] == "-a" and i + 1 < len(args):
album = args[i + 1]
i += 2
elif args[i].isdigit():
limit = int(args[i])
i += 1
else:
i += 1
list_photos(api, album, limit)
elif cmd == "download":
if len(sys.argv) < 3:
print("❌ 请指定照片编号")
print("用法: python icloud-photos.py download N")
sys.exit(1)
api = get_api()
index = int(sys.argv[2])
download_photo(api, index)
elif cmd == "info":
if len(sys.argv) < 3:
print("❌ 请指定照片编号")
sys.exit(1)
api = get_api()
index = int(sys.argv[2])
photo_info(api, index)
else:
print(f"❌ 未知命令: {cmd}")
show_help()
sys.exit(1)
if __name__ == "__main__":
main()
FILE:scripts/icloud-reminders.py
#!/usr/bin/env python3
"""
iCloud Reminders 访问脚本 (使用 pyicloud)
使用: python icloud-reminders.py [list|add|complete] [参数]
注意: 推荐使用 todoman + vdirsyncer 的 CalDAV 方式访问提醒事项
此脚本提供 Python API 方式的补充访问
环境变量:
ICLOUD_USERNAME - Apple ID
ICLOUD_PASSWORD - 应用专用密码
"""
import sys
import os
from datetime import datetime
try:
from pyicloud import PyiCloudService
except ImportError:
print("请先安装 pyicloud: pip install pyicloud")
sys.exit(1)
def get_api():
"""获取 iCloud API 连接"""
username = os.environ.get('ICLOUD_USERNAME')
password = os.environ.get('ICLOUD_PASSWORD')
if not username:
username = input("Apple ID: ")
if not password:
password = input("应用专用密码: ")
china_mainland = username.endswith('@icloud.com.cn') or \
os.environ.get('ICLOUD_CHINA', '').lower() in ('1', 'true', 'yes')
print(f"正在连接 iCloud... {'(中国大陆)' if china_mainland else ''}")
api = PyiCloudService(username, password, china_mainland=china_mainland)
if api.requires_2fa:
print("\n需要双重认证验证")
code = input("请输入验证码: ")
result = api.validate_2fa_code(code)
if not result:
print("❌ 验证失败!")
sys.exit(1)
print("✅ 验证成功!")
return api
def list_reminders(api, show_completed=False):
"""列出提醒事项"""
print("\n✅ iCloud 提醒事项:\n")
try:
# 注意: pyicloud 原版可能不支持 reminders
# 如果不支持,提示使用 CalDAV 方式
if not hasattr(api, 'reminders'):
print("❌ 当前版本的 pyicloud 不支持提醒事项 API")
print("\n💡 推荐使用 CalDAV 方式:")
print(" 1. 配置 vdirsyncer 同步提醒事项")
print(" 2. 使用 todoman 命令行工具管理")
print("\n 详见 SKILL.md 中的「Part 2: 提醒事项」")
return
reminders = api.reminders
# 列出提醒列表
for list_name in reminders.lists:
print(f"\n📋 {list_name}:")
tasks = reminders.lists[list_name]
incomplete_count = 0
for task in tasks:
is_completed = task.get('completed', False)
if is_completed and not show_completed:
continue
status = "✅" if is_completed else "⬜"
title = task.get('title', '未命名')
due = task.get('dueDate', '')
due_str = ""
if due:
due_str = f" (截止: {due})"
print(f" {status} {title}{due_str}")
if not is_completed:
incomplete_count += 1
if incomplete_count == 0 and not show_completed:
print(" (没有未完成的任务)")
except AttributeError:
print("❌ pyicloud 不支持提醒事项")
print("\n💡 请使用 CalDAV 方式访问提醒事项:")
print(" - 配置 vdirsyncer + todoman")
print(" - 或使用 pyicloudreminders 库")
except Exception as e:
print(f"❌ 获取提醒事项失败: {e}")
def show_caldav_hint():
"""显示 CalDAV 配置提示"""
print("""
📋 推荐使用 CalDAV 方式管理提醒事项
CalDAV 方式更稳定可靠,支持完整的任务管理功能。
快速开始:
1. 安装工具:
pip install vdirsyncer todoman
2. 配置 vdirsyncer (~/.config/vdirsyncer/config):
[pair icloud_reminders]
a = "icloud_reminders_remote"
b = "icloud_reminders_local"
collections = ["from a", "from b"]
conflict_resolution = "a wins"
[storage icloud_reminders_remote]
type = "caldav"
url = "https://caldav.icloud.com/"
username = "[email protected]"
password.fetch = ["command", "cat", "~/.config/vdirsyncer/icloud_password"]
item_types = ["VTODO"]
[storage icloud_reminders_local]
type = "filesystem"
path = "~/.local/share/vdirsyncer/reminders/"
fileext = ".ics"
3. 配置 todoman (~/.config/todoman/config.py):
path = "~/.local/share/vdirsyncer/reminders/*"
date_format = "%Y-%m-%d"
default_list = "Reminders"
4. 同步并使用:
vdirsyncer discover
vdirsyncer sync
todo list
todo new "新任务"
todo done 1
详见 SKILL.md 文档获取完整配置。
""")
def show_help():
"""显示帮助信息"""
print("""
iCloud Reminders 访问脚本
用法:
python icloud-reminders.py list 列出未完成任务
python icloud-reminders.py list --all 列出所有任务(含已完成)
python icloud-reminders.py caldav 显示 CalDAV 配置指南
python icloud-reminders.py help 显示此帮助
环境变量:
ICLOUD_USERNAME Apple ID 邮箱
ICLOUD_PASSWORD 应用专用密码
⚠️ 重要提示:
pyicloud 对提醒事项的支持有限。
强烈推荐使用 CalDAV 方式 (vdirsyncer + todoman):
运行 `python icloud-reminders.py caldav` 查看配置指南
""")
def main():
if len(sys.argv) < 2 or sys.argv[1] in ('-h', '--help', 'help'):
show_help()
sys.exit(0)
cmd = sys.argv[1]
if cmd == "list":
api = get_api()
show_all = "--all" in sys.argv
list_reminders(api, show_completed=show_all)
elif cmd == "caldav":
show_caldav_hint()
else:
print(f"❌ 未知命令: {cmd}")
show_help()
sys.exit(1)
if __name__ == "__main__":
main()
FILE:scripts/icloud_calendar.py
#!/usr/bin/env python3
"""
iCloud 日历工具 (CalDAV 方式)
使用 caldav 库直接访问 iCloud 日历,需要应用专用密码
用法: python icloud_calendar.py [list|today|week|new|search|delete] [参数]
环境变量:
ICLOUD_USERNAME - Apple ID
ICLOUD_APP_PASSWORD - 应用专用密码 (在 appleid.apple.com 生成)
注意: 日历功能使用 CalDAV 协议,需要应用专用密码(不是主密码)
"""
import sys
import os
from datetime import datetime, timedelta, date
import re
import argparse
try:
import caldav
except ImportError:
print("请先安装 caldav: pip install caldav")
sys.exit(1)
try:
from icalendar import Calendar, Event
except ImportError:
print("请先安装 icalendar: pip install icalendar")
sys.exit(1)
# iCloud CalDAV URL
CALDAV_URL = "https://caldav.icloud.com/"
def get_client():
"""获取 CalDAV 客户端连接"""
username = os.environ.get('ICLOUD_USERNAME')
app_password = os.environ.get('ICLOUD_APP_PASSWORD')
if not username:
username = input("Apple ID: ")
if not app_password:
print("\n⚠️ 日历功能需要应用专用密码")
print(" 请在 https://appleid.apple.com 生成")
print(" (「登录与安全」→「应用专用密码」→「+」)")
app_password = input("\n应用专用密码 (格式: xxxx-xxxx-xxxx-xxxx): ")
print(f'📅 正在连接 iCloud 日历...')
try:
client = caldav.DAVClient(
url=CALDAV_URL,
username=username,
password=app_password
)
# 测试连接
principal = client.principal()
print("✅ 已连接!\n")
return client, principal
except caldav.lib.error.AuthorizationError:
print("❌ 认证失败!")
print("\n可能的原因:")
print(" 1. 应用专用密码不正确")
print(" 2. 应用专用密码已过期")
print(" 3. Apple ID 不正确")
print("\n请在 https://appleid.apple.com 重新生成应用专用密码")
sys.exit(1)
except Exception as e:
print(f"❌ 连接失败: {e}")
sys.exit(1)
def get_calendars(principal):
"""获取所有日历"""
calendars = principal.calendars()
return calendars
def list_calendars(principal):
"""列出所有日历"""
print("📅 日历列表:\n")
calendars = get_calendars(principal)
for i, cal in enumerate(calendars, 1):
name = cal.name or "未命名"
print(f" {i}. 📁 {name}")
print(f"\n共 {len(calendars)} 个日历")
return calendars
def parse_event(event):
"""解析事件信息"""
try:
ical = Calendar.from_ical(event.data)
for component in ical.walk():
if component.name == "VEVENT":
summary = str(component.get('summary', '未命名'))
dtstart = component.get('dtstart')
dtend = component.get('dtend')
location = str(component.get('location', '')) or None
description = str(component.get('description', '')) or None
# 处理日期时间
start_dt = dtstart.dt if dtstart else None
end_dt = dtend.dt if dtend else None
# 判断是否全天事件
all_day = not hasattr(start_dt, 'hour') if start_dt else False
return {
'summary': summary,
'start': start_dt,
'end': end_dt,
'location': location,
'description': description,
'all_day': all_day
}
except Exception as e:
return None
return None
def format_event(event_info):
"""格式化事件显示"""
if not event_info:
return " (无法解析)"
summary = event_info['summary']
start = event_info['start']
end = event_info['end']
location = event_info['location']
all_day = event_info['all_day']
if all_day:
if hasattr(start, 'strftime'):
date_str = start.strftime("%Y-%m-%d")
else:
date_str = str(start)
time_str = "全天"
else:
if hasattr(start, 'strftime'):
date_str = start.strftime("%Y-%m-%d")
start_time = start.strftime("%H:%M")
end_time = end.strftime("%H:%M") if end and hasattr(end, 'strftime') else ""
time_str = f"{start_time}-{end_time}" if end_time else start_time
else:
date_str = str(start)[:10] if start else "未知"
time_str = ""
result = f" 📌 {summary}"
result += f"\n 📆 {date_str} {time_str}"
if location:
result += f"\n 📍 {location}"
return result
def list_events(principal, start_date=None, end_date=None, calendar_name=None):
"""列出事件"""
if start_date is None:
start_date = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
if end_date is None:
end_date = start_date + timedelta(days=1)
calendars = get_calendars(principal)
# 筛选日历
if calendar_name:
calendars = [c for c in calendars if calendar_name.lower() in (c.name or '').lower()]
if not calendars:
print(f"❌ 未找到日历: {calendar_name}")
return
all_events = []
for cal in calendars:
try:
events = cal.search(start=start_date, end=end_date, event=True, expand=True)
for event in events:
event_info = parse_event(event)
if event_info:
event_info['calendar'] = cal.name
all_events.append(event_info)
except Exception as e:
pass
# 按时间排序 (统一 date/datetime/timezone 类型)
def _sort_key(ev):
s = ev['start']
if s is None:
return datetime.max.timestamp()
if isinstance(s, datetime):
try:
return s.timestamp()
except Exception:
return s.replace(tzinfo=None).timestamp() if hasattr(s, 'replace') else 0
if isinstance(s, date):
return datetime.combine(s, datetime.min.time()).timestamp()
return 0
all_events.sort(key=_sort_key)
return all_events
def cmd_list(principal, args):
"""列出日历"""
list_calendars(principal)
def cmd_today(principal, args):
"""显示今天的事件"""
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
tomorrow = today + timedelta(days=1)
print(f"📅 今天的事件 ({today.strftime('%Y-%m-%d')}):\n")
events = list_events(principal, today, tomorrow)
if not events:
print(" 没有事件")
else:
for event in events:
print(format_event(event))
print()
print(f"共 {len(events)} 个事件")
def cmd_week(principal, args):
"""显示本周事件"""
days = int(args[0]) if args else 7
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
end_date = today + timedelta(days=days)
print(f"📅 未来 {days} 天的事件:\n")
events = list_events(principal, today, end_date)
if not events:
print(" 没有事件")
else:
current_date = None
for event in events:
event_date = event['start']
if hasattr(event_date, 'date'):
event_date = event_date.date()
elif hasattr(event_date, 'strftime'):
pass
else:
event_date = None
# 按日期分组显示
if event_date != current_date:
current_date = event_date
if hasattr(current_date, 'strftime'):
print(f"\n--- {current_date.strftime('%Y-%m-%d %A')} ---")
else:
print(f"\n--- {current_date} ---")
print(format_event(event))
print(f"\n共 {len(events)} 个事件")
def find_calendar(principal, calendar_name):
"""按名称查找日历"""
calendars = get_calendars(principal)
for c in calendars:
if (c.name or '').strip() == calendar_name.strip():
return c
# 模糊匹配
for c in calendars:
if calendar_name.lower() in (c.name or '').lower():
return c
return None
def cmd_new(principal, args):
"""创建新事件,支持 --calendar, --location, --description"""
parser = argparse.ArgumentParser(prog='new', add_help=False)
parser.add_argument('date_str')
parser.add_argument('rest', nargs='*')
parser.add_argument('--calendar', '-c', default=None)
parser.add_argument('--location', '-l', default=None)
parser.add_argument('--description', '-d', default=None)
try:
parsed = parser.parse_args(args)
except SystemExit:
print("用法: new <日期> [开始时间 [结束时间]] <标题> [--calendar 日历名] [--location 地点] [--description 描述]")
print("示例: new 2026-02-10 10:00 11:00 \"开会\" --calendar \"个人\"")
print(" new today \"📦 取快递\" --calendar \"家庭看板\"")
return
date_str = parsed.date_str
rest = parsed.rest
if not rest:
print("❌ 缺少标题")
print("用法: new <日期> [开始时间 [结束时间]] <标题> [--calendar 名称]")
return
# 判断参数模式: rest 可能是 [title] / [start, title] / [start, end, title]
time_pattern = re.compile(r'^\d{1,2}:\d{2}$')
if len(rest) >= 3 and time_pattern.match(rest[0]) and time_pattern.match(rest[1]):
start_time = rest[0]
end_time = rest[1]
title = ' '.join(rest[2:])
elif len(rest) >= 2 and time_pattern.match(rest[0]):
start_time = rest[0]
end_time = None
title = ' '.join(rest[1:])
else:
start_time = None
end_time = None
title = ' '.join(rest)
# 解析日期
try:
if date_str == 'today':
event_date = datetime.now().date()
elif date_str == 'tomorrow':
event_date = (datetime.now() + timedelta(days=1)).date()
else:
event_date = datetime.strptime(date_str, "%Y-%m-%d").date()
except ValueError:
print(f"❌ 日期格式错误: {date_str}")
print(" 请使用 YYYY-MM-DD 格式,如 2026-02-10")
return
# 选择日历
if parsed.calendar:
cal = find_calendar(principal, parsed.calendar)
if not cal:
print(f"❌ 未找到日历: {parsed.calendar}")
print("可用日历:")
for c in get_calendars(principal):
print(f" - {c.name}")
return
else:
calendars = get_calendars(principal)
if not calendars:
print("❌ 没有可用的日历")
return
cal = calendars[0]
# 创建事件
from icalendar import Calendar as ICalendar, Event as IEvent
import uuid
ical = ICalendar()
ical.add('prodid', '-//iCloud Calendar Tool//EN')
ical.add('version', '2.0')
event = IEvent()
event.add('summary', title)
event.add('uid', str(uuid.uuid4()))
event.add('dtstamp', datetime.now())
if parsed.location:
event.add('location', parsed.location)
if parsed.description:
event.add('description', parsed.description)
if start_time:
start_dt = datetime.combine(event_date, datetime.strptime(start_time, "%H:%M").time())
event.add('dtstart', start_dt)
if end_time:
end_dt = datetime.combine(event_date, datetime.strptime(end_time, "%H:%M").time())
event.add('dtend', end_dt)
else:
event.add('dtstart', event_date)
event.add('dtend', event_date + timedelta(days=1))
ical.add_component(event)
try:
cal.save_event(ical.to_ical().decode('utf-8'))
print(f"✅ 事件已创建: {title}")
print(f" 日期: {event_date}")
if start_time:
print(f" 时间: {start_time}-{end_time or ''}")
else:
print(f" 类型: 全天事件")
print(f" 日历: {cal.name}")
if parsed.location:
print(f" 地点: {parsed.location}")
if parsed.description:
print(f" 描述: {parsed.description}")
except Exception as e:
print(f"❌ 创建失败: {e}")
def cmd_search(principal, args):
"""搜索事件,支持 --calendar"""
parser = argparse.ArgumentParser(prog='search', add_help=False)
parser.add_argument('keywords', nargs='*')
parser.add_argument('--calendar', '-c', default=None)
parser.add_argument('--days', type=int, default=30)
try:
parsed = parser.parse_args(args)
except SystemExit:
print("用法: search <关键词> [--calendar 日历名] [--days N]")
return
if not parsed.keywords:
print("用法: search <关键词> [--calendar 日历名]")
return
keyword = ' '.join(parsed.keywords).lower()
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
end_date = today + timedelta(days=parsed.days)
print(f"🔍 搜索: {keyword}" + (f" (日历: {parsed.calendar})" if parsed.calendar else "") + "\n")
events = list_events(principal, today, end_date, calendar_name=parsed.calendar)
if events is None:
return
matched = []
for event in events:
if keyword in event['summary'].lower():
matched.append(event)
elif event['location'] and keyword in event['location'].lower():
matched.append(event)
elif event['description'] and keyword in event['description'].lower():
matched.append(event)
if not matched:
print(" 没有找到匹配的事件")
else:
for event in matched:
print(format_event(event))
print()
print(f"找到 {len(matched)} 个事件")
def cmd_delete(principal, args):
"""删除事件,按关键词匹配删除"""
parser = argparse.ArgumentParser(prog='delete', add_help=False)
parser.add_argument('keywords', nargs='*')
parser.add_argument('--calendar', '-c', default=None)
parser.add_argument('--days', type=int, default=30)
try:
parsed = parser.parse_args(args)
except SystemExit:
print("用法: delete <关键词> [--calendar 日历名]")
return
if not parsed.keywords:
print("用法: delete <关键词> [--calendar 日历名]")
return
keyword = ' '.join(parsed.keywords).lower()
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
end_date = today + timedelta(days=parsed.days)
# 获取目标日历
calendars = get_calendars(principal)
if parsed.calendar:
calendars = [c for c in calendars if parsed.calendar.lower() in (c.name or '').lower()]
if not calendars:
print(f"❌ 未找到日历: {parsed.calendar}")
return
deleted = 0
for cal in calendars:
try:
events = cal.search(start=today, end=end_date, event=True, expand=False)
for event in events:
event_info = parse_event(event)
if event_info and keyword in event_info['summary'].lower():
print(f" 🗑️ 删除: {event_info['summary']}")
event.delete()
deleted += 1
except Exception as e:
pass
print(f"\n共删除 {deleted} 个事件")
def show_help():
"""显示帮助"""
print("""
📅 iCloud 日历工具 (CalDAV)
用法: python icloud_calendar.py <命令> [参数]
命令:
list 列出所有日历
today 显示今天的事件
week [N] 显示未来 N 天的事件 (默认 7)
new <日期> [时间] <标题> [选项] 创建新事件
search <关键词> [选项] 搜索事件
delete <关键词> [选项] 删除匹配的事件
创建事件示例:
new 2026-02-10 10:00 11:00 "开会" # 指定时间
new 2026-02-10 "生日" # 全天事件
new today "📦 取快递" --calendar "家庭看板" # 指定日历
new 2026-03-20 20:00 22:30 "🎬 封神3" -c "家庭看板" -l "万达影城" -d "G排12座"
搜索/删除示例:
search 快递 --calendar "家庭看板"
delete 快递 --calendar "家庭看板"
选项:
--calendar, -c 指定目标日历名称
--location, -l 事件地点 (仅 new)
--description, -d 事件描述 (仅 new)
--days N 搜索/删除范围天数 (默认 30)
环境变量:
ICLOUD_USERNAME Apple ID 邮箱
ICLOUD_APP_PASSWORD 应用专用密码 (非主密码!)
⚠️ 重要: 日历功能需要应用专用密码
1. 登录 https://appleid.apple.com
2. 进入「登录与安全」→「应用专用密码」
3. 点击「+」生成新密码
4. 复制密码 (格式: xxxx-xxxx-xxxx-xxxx)
""")
def main():
if len(sys.argv) < 2 or sys.argv[1] in ('-h', '--help', 'help'):
show_help()
sys.exit(0)
cmd = sys.argv[1]
args = sys.argv[2:]
# 日历命令不需要 pyicloud 连接
client, principal = get_client()
if cmd == 'list':
cmd_list(principal, args)
elif cmd == 'today':
cmd_today(principal, args)
elif cmd == 'week':
cmd_week(principal, args)
elif cmd == 'new':
cmd_new(principal, args)
elif cmd == 'search':
cmd_search(principal, args)
elif cmd == 'delete':
cmd_delete(principal, args)
else:
print(f'❌ 未知命令: {cmd}')
print('运行 python icloud_calendar.py --help 查看帮助')
if __name__ == '__main__':
main()
FILE:scripts/icloud_tool.py
#!/usr/bin/env python3
"""
Apple iCloud 命令行工具 (已验证可用)
用法: python icloud_tool.py [photos|drive|devices] [子命令]
环境变量:
ICLOUD_USERNAME - Apple ID
ICLOUD_PASSWORD - 主密码 (非应用专用密码)
ICLOUD_CHINA - 设为 1 表示中国大陆用户
"""
import sys
import os
# 中国大陆用户设置
if os.environ.get('ICLOUD_CHINA', '1') == '1':
os.environ['icloud_china'] = '1'
try:
from pyicloud import PyiCloudService
except ImportError:
print("请先安装 pyicloud: pip install pyicloud")
sys.exit(1)
def get_api():
"""连接 iCloud"""
username = os.environ.get('ICLOUD_USERNAME')
password = os.environ.get('ICLOUD_PASSWORD')
if not username:
username = input("Apple ID: ")
if not password:
password = input("密码 (主密码,非应用专用密码): ")
china = os.environ.get('icloud_china') == '1'
print(f'🍎 正在连接 iCloud{"(中国大陆)" if china else ""}...')
api = PyiCloudService(username, password, china_mainland=china)
if api.requires_2fa:
print("\n🔐 需要双重认证")
print("请查看 iPhone/iPad/Mac 上的验证码弹窗")
code = input("请输入 6 位验证码: ")
if not api.validate_2fa_code(code):
print("❌ 验证失败!")
sys.exit(1)
print("✅ 验证成功!")
# 信任会话
if not api.is_trusted_session:
api.trust_session()
print("✅ 已连接!\n")
return api
def cmd_photos(api, args):
"""照片命令"""
photos = api.photos
if not args or args[0] == 'albums':
print('📷 相册列表:')
for name in photos.albums:
print(f' 📁 {name}')
print(f'\n共 {len(photos.albums)} 个相册')
elif args[0] == 'list':
limit = int(args[1]) if len(args) > 1 else 10
library = photos.albums['Library']
print(f'📷 最近 {limit} 张照片:\n')
for i, p in enumerate(library.photos):
if i >= limit:
break
print(f' {i+1:3}. {p.filename:25} | {p.created}')
elif args[0] == 'download':
if len(args) < 2:
print("用法: photos download <编号>")
return
index = int(args[1]) - 1
library = photos.albums['Library']
for i, p in enumerate(library.photos):
if i == index:
print(f'⬇️ 正在下载: {p.filename}')
dl = p.download()
with open(p.filename, 'wb') as f:
f.write(dl.raw.read())
size = os.path.getsize(p.filename) / 1024
print(f'✅ 已保存: {p.filename} ({size:.1f} KB)')
break
else:
print(f'❌ 未找到第 {index+1} 张照片')
else:
print(f"未知子命令: {args[0]}")
print("可用: albums, list [N], download N")
def cmd_drive(api, args):
"""iCloud Drive 命令"""
drive = api.drive
if not args or args[0] == 'list':
print('💾 iCloud Drive:\n')
items = list(drive.dir())
for item in items:
print(f' 📂 {item}')
print(f'\n共 {len(items)} 个项目')
elif args[0] == 'cd' and len(args) > 1:
folder_name = args[1]
try:
folder = drive[folder_name]
print(f'📂 {folder_name}:\n')
items = list(folder.dir())
for item in items:
print(f' 📄 {item}')
print(f'\n共 {len(items)} 个项目')
except KeyError:
print(f'❌ 文件夹不存在: {folder_name}')
else:
print(f"未知子命令: {args[0]}")
print("可用: list, cd <文件夹>")
def cmd_devices(api, args):
"""设备命令"""
print('📱 我的设备:\n')
devices = list(api.devices)
for d in devices:
print(f' - {d}')
print(f'\n共 {len(devices)} 个设备')
def show_help():
"""显示帮助"""
print("""
🍎 Apple iCloud 命令行工具
用法: python icloud_tool.py <命令> [参数]
命令:
photos 照片功能
albums 列出所有相册
list [N] 列出最近 N 张照片 (默认 10)
download N 下载第 N 张照片
drive iCloud Drive 功能
list 列出根目录
cd <文件夹> 进入并列出文件夹内容
devices 列出所有设备
环境变量:
ICLOUD_USERNAME Apple ID 邮箱
ICLOUD_PASSWORD 主密码 (不是应用专用密码!)
ICLOUD_CHINA 设为 1 表示中国大陆 (默认为 1)
示例:
python icloud_tool.py photos albums
python icloud_tool.py photos list 20
python icloud_tool.py photos download 1
python icloud_tool.py drive list
python icloud_tool.py devices
注意:
- 需要使用 Apple ID 主密码,不是应用专用密码
- 首次使用需要输入双重认证验证码
- 验证成功后会话会被缓存
""")
def main():
if len(sys.argv) < 2 or sys.argv[1] in ('-h', '--help', 'help'):
show_help()
sys.exit(0)
cmd = sys.argv[1]
args = sys.argv[2:]
api = get_api()
if cmd == 'photos':
cmd_photos(api, args)
elif cmd == 'drive':
cmd_drive(api, args)
elif cmd == 'devices':
cmd_devices(api, args)
else:
print(f'❌ 未知命令: {cmd}')
print('运行 python icloud_tool.py --help 查看帮助')
if __name__ == '__main__':
main()
FILE:scripts/status_wall.py
#!/usr/bin/env python3
"""
状态墙守护进程 - Skill 启用后自动运行,定时刷新家庭成员状态到共享日历
逻辑优先级:
P1 日程读取:查私人日历 → 当前有日程 → 直接展示日程名作为状态
P2 物理锚点:查 Find My GPS → 高德逆地理编码 → 语义化地点判定
- 围栏匹配:在家 / 在公司 / 在某商场 / 在路上
- 高德提供当前位置的语义地名
通勤模式(双向自动触发):
上班:离开家 >200m → 「🚗 正在上班途中(当前:xx)」→ 到公司 <100m
下班:离开公司 >200m → 「🚗 正在下班途中,距离家 Xkm(当前:xx)」→ 到家 <100m
通勤模式下轮询从 15 分钟切换为 1 分钟
Skill 启动:
python status_wall.py start # 后台启动守护进程
python status_wall.py stop # 停止守护进程
python status_wall.py status # 查看运行状态
python status_wall.py once # 单次执行(调试用)
python status_wall.py show-gps # 显示当前GPS坐标(配置地点用)
python status_wall.py init # 交互式初始化配置
配置文件: ~/.status_wall.json
PID文件: ~/.status_wall.pid
日志文件: ~/.status_wall.log
环境变量(也可写入配置文件):
ICLOUD_USERNAME - Apple ID
ICLOUD_APP_PASSWORD - 应用专用密码 (CalDAV 日历读写)
ICLOUD_PASSWORD - 主密码 (Find My 定位)
AMAP_API_KEY - 高德地图 Web 服务 API Key (逆地理编码)
"""
import os
import sys
import json
import math
import time
import signal
import subprocess
import urllib.request
import urllib.parse
from datetime import datetime, timedelta
from pathlib import Path
os.environ['icloud_china'] = '1'
SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
CONFIG_PATH = Path.home() / ".status_wall.json"
PID_PATH = Path.home() / ".status_wall.pid"
LOG_PATH = Path.home() / ".status_wall.log"
DEFAULT_EXCLUDE = ["日天酱共享日历", "家庭共享", "提醒 ⚠️", "大麦", "哔哩哔哩", "携程"]
# 通勤参数
COMMUTE_DEPART_METERS = 200 # 离开锚点触发通勤的距离
COMMUTE_ARRIVE_METERS = 100 # 到达目的地判定距离
COMMUTE_INTERVAL_MINUTES = 1 # 通勤模式轮询间隔(分钟)
# 通勤状态机(进程内存,守护进程生命周期内有效)
# None=未通勤, "to_work"=上班途中, "to_home"=下班途中
_commute_state = None
# 上次稳定锚点: "home" or "work",用于判断离开的是哪里
_last_anchor = None
# ============================================================
# 配置管理
# ============================================================
def load_config():
if CONFIG_PATH.exists():
with open(CONFIG_PATH) as f:
return json.load(f)
return {}
def save_config(cfg):
with open(CONFIG_PATH, 'w') as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
def cmd_init():
"""交互式初始化配置"""
cfg = load_config()
print("🔧 状态墙初始化配置\n")
cfg["member_name"] = input(f" 称呼 [{cfg.get('member_name', '老公')}]: ").strip() or cfg.get("member_name", "老公")
cfg["target_calendar"] = input(f" 共享日历名 [{cfg.get('target_calendar', '日天酱共享日历')}]: ").strip() or cfg.get("target_calendar", "日天酱共享日历")
cfg["interval_minutes"] = int(input(f" 正常刷新间隔(分钟) [{cfg.get('interval_minutes', 15)}]: ").strip() or cfg.get("interval_minutes", 15))
cfg["threshold_meters"] = int(input(f" 地点围栏半径(米) [{cfg.get('threshold_meters', 500)}]: ").strip() or cfg.get("threshold_meters", 500))
# 高德 API Key
amap_hint = cfg.get("amap_api_key", "")
amap_display = (amap_hint[:4] + "****") if amap_hint else "未配置"
amap_key = input(f" 高德地图 API Key [{amap_display}]: ").strip()
if amap_key:
cfg["amap_api_key"] = amap_key
if "exclude_calendars" not in cfg:
cfg["exclude_calendars"] = DEFAULT_EXCLUDE
# 地点配置
if "places" not in cfg:
cfg["places"] = {}
print(f"\n 当前已配置地点: {list(cfg['places'].keys()) or '无'}")
print(" 提示: 到对应地点后运行 'python status_wall.py show-gps' 获取坐标\n")
while True:
name = input(" 添加地点名 (如 🏠 在家, 留空结束): ").strip()
if not name:
break
lat = input(" 纬度: ").strip()
lng = input(" 经度: ").strip()
try:
cfg["places"][name] = [float(lat), float(lng)]
print(f" ✅ {name} ({lat}, {lng})")
except ValueError:
print(" ❌ 格式错误")
save_config(cfg)
print(f"\n✅ 配置已保存到 {CONFIG_PATH}")
print(f" 运行 'python status_wall.py start' 启动守护进程")
# ============================================================
# 进程管理
# ============================================================
def get_running_pid():
if not PID_PATH.exists():
return None
pid = int(PID_PATH.read_text().strip())
try:
os.kill(pid, 0)
return pid
except ProcessLookupError:
PID_PATH.unlink(missing_ok=True)
return None
except OSError:
PID_PATH.unlink(missing_ok=True)
return None
def cmd_start():
cfg = load_config()
if not cfg.get("target_calendar"):
print("❌ 未配置,请先运行: python status_wall.py init")
return
pid = get_running_pid()
if pid:
print(f"⚠️ 守护进程已在运行 (PID: {pid})")
return
log_file = open(LOG_PATH, 'a')
proc = subprocess.Popen(
[sys.executable, __file__, '_daemon'],
stdout=log_file,
stderr=log_file,
start_new_session=True
)
PID_PATH.write_text(str(proc.pid))
print(f"✅ 守护进程已启动 (PID: {proc.pid})")
print(f" 正常间隔: {cfg.get('interval_minutes', 15)} 分钟 | 通勤间隔: {COMMUTE_INTERVAL_MINUTES} 分钟")
print(f" 高德API: {'✅' if cfg.get('amap_api_key') else '❌ 未配置'}")
print(f" 日志: {LOG_PATH}")
print(f" 停止: python status_wall.py stop")
def cmd_stop():
pid = get_running_pid()
if not pid:
print("ℹ️ 守护进程未运行")
return
try:
os.kill(pid, signal.SIGTERM)
time.sleep(1)
print(f"✅ 守护进程已停止 (PID: {pid})")
except Exception as e:
print(f"⚠️ 停止失败: {e}")
PID_PATH.unlink(missing_ok=True)
def cmd_status():
cfg = load_config()
pid = get_running_pid()
print("👤 状态墙守护进程\n")
print(f" 运行状态: {'✅ 运行中 (PID: ' + str(pid) + ')' if pid else '❌ 未运行'}")
print(f" 成员: {cfg.get('member_name', '未配置')}")
print(f" 日历: {cfg.get('target_calendar', '未配置')}")
print(f" 间隔: {cfg.get('interval_minutes', 15)} 分钟 (通勤: {COMMUTE_INTERVAL_MINUTES} 分钟)")
print(f" 地点: {list(cfg.get('places', {}).keys()) or '未配置'}")
print(f" 高德: {'✅ 已配置' if cfg.get('amap_api_key') else '❌ 未配置'}")
print(f" 配置: {CONFIG_PATH}")
print(f" 日志: {LOG_PATH}")
if LOG_PATH.exists():
lines = LOG_PATH.read_text().strip().split('\n')
recent = lines[-5:] if len(lines) >= 5 else lines
if recent:
print(f"\n 最近日志:")
for line in recent:
print(f" {line}")
# ============================================================
# 日志
# ============================================================
def log(msg):
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
print(f"[{ts}] {msg}", flush=True)
# ============================================================
# P1 日程读取:查私人日历
# ============================================================
def check_calendar_events(cfg):
"""查私人日历当前时段是否有日程,返回日程名或 None"""
try:
import caldav
from icalendar import Calendar
except ImportError:
log("⚠️ 缺少 caldav/icalendar")
return None
username = os.environ.get('ICLOUD_USERNAME') or cfg.get('icloud_username')
app_password = os.environ.get('ICLOUD_APP_PASSWORD') or cfg.get('icloud_app_password')
if not username or not app_password:
return None
try:
client = caldav.DAVClient(url="https://caldav.icloud.com/", username=username, password=app_password)
principal = client.principal()
except Exception as e:
log(f"⚠️ CalDAV 连接失败: {e}")
return None
now = datetime.now()
exclude = cfg.get("exclude_calendars", DEFAULT_EXCLUDE)
for cal in principal.calendars():
cal_name = cal.name or ""
if cal_name in exclude:
continue
try:
events = cal.search(start=now - timedelta(minutes=1), end=now + timedelta(minutes=1), event=True, expand=True)
for event in events:
ical = Calendar.from_ical(event.data)
for comp in ical.walk():
if comp.name != "VEVENT":
continue
summary = str(comp.get('summary', ''))
dtstart = comp.get('dtstart')
dtend = comp.get('dtend')
if not dtstart:
continue
start_dt = dtstart.dt
if not hasattr(start_dt, 'hour'):
continue # 跳过全天事件
end_dt = dtend.dt if dtend else None
s = start_dt.replace(tzinfo=None) if start_dt.tzinfo else start_dt
e = end_dt.replace(tzinfo=None) if end_dt and end_dt.tzinfo else end_dt
if s <= now and (e is None or now <= e):
log(f"📅 命中日程: [{cal_name}] {summary}")
return summary
except Exception:
pass
return None
# ============================================================
# P2 物理锚点:Find My GPS + 高德逆地理编码
# ============================================================
def haversine(lat1, lon1, lat2, lon2):
"""两点间球面距离(米)"""
R = 6371000
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlam = math.radians(lon2 - lon1)
a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlam/2)**2
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1-a))
def get_gps_coords(cfg):
"""获取 iPhone GPS 坐标,返回 (lat, lng) 或 None"""
username = os.environ.get('ICLOUD_USERNAME') or cfg.get('icloud_username')
password = os.environ.get('ICLOUD_PASSWORD') or cfg.get('icloud_password')
if not username or not password:
return None
try:
from pyicloud import PyiCloudService
api = PyiCloudService(username, password, china_mainland=True)
if api.requires_2fa:
log("⚠️ pyicloud 需要 2FA,GPS 暂不可用")
return None
except Exception as e:
log(f"⚠️ pyicloud 失败: {e}")
return None
for dev in api.devices:
info = dev.content
loc = info.get('location')
if loc and 'iPhone' in info.get('deviceDisplayName', ''):
lat, lng = loc.get('latitude'), loc.get('longitude')
log(f"📡 GPS: ({lat:.4f}, {lng:.4f})")
return (lat, lng)
return None
def amap_regeo(lat, lng, cfg):
"""
高德逆地理编码:GPS 坐标 → 语义化地名
Find My 中国区返回 GCJ-02,高德 API 也用 GCJ-02,无需转换。
返回简短地名(如 "中关村软件园"、"上地十街"),失败返回 None。
"""
api_key = os.environ.get('AMAP_API_KEY') or cfg.get('amap_api_key')
if not api_key:
return None
try:
# 高德坐标格式: 经度,纬度
params = urllib.parse.urlencode({
'key': api_key,
'location': f'{lng:.6f},{lat:.6f}',
'extensions': 'base',
'output': 'JSON'
})
url = f'https://restapi.amap.com/v3/geocode/regeo?{params}'
req = urllib.request.Request(url, headers={'User-Agent': 'StatusWall/1.0'})
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode())
if data.get('status') != '1':
log(f"⚠️ 高德API错误: {data.get('info')}")
return None
regeo = data.get('regeocode', {})
addr_comp = regeo.get('addressComponent', {})
# 优先用 AOI(兴趣区域)名称,如 "中关村软件园" "万达广场"
aois = regeo.get('aois')
if isinstance(aois, list) and aois and aois[0].get('name'):
name = aois[0]['name']
log(f"📍 高德AOI: {name}")
return name
# 其次用街道名
street_info = addr_comp.get('streetNumber', {})
street_name = street_info.get('street', '') if isinstance(street_info, dict) else ''
if street_name:
log(f"📍 高德街道: {street_name}")
return street_name
# 再次用乡镇/街道办
township = addr_comp.get('township', '')
if township:
log(f"📍 高德区域: {township}")
return township
# 兜底用格式化地址
formatted = regeo.get('formatted_address', '')
if formatted:
return formatted
except Exception as e:
log(f"⚠️ 高德逆地理编码失败: {e}")
return None
def find_place_key(cfg, keyword):
"""在 places 中找包含 keyword 的 key"""
for key in cfg.get("places", {}):
if keyword in key:
return key
return None
def format_distance(meters):
"""格式化距离显示"""
if meters >= 1000:
return f"{meters/1000:.1f}km"
return f"{meters:.0f}m"
def get_gps_status(cfg, coords):
"""
根据 GPS 坐标判定状态 + 双向通勤逻辑 + 高德逆地理编码。
返回 (status_text, is_commuting)
- status_text: 状态文案
- is_commuting: 是否处于通勤模式(决定轮询间隔)
"""
global _commute_state, _last_anchor
if coords is None:
return (None, False)
lat, lng = coords
places = cfg.get("places", {})
threshold = cfg.get("threshold_meters", 500)
if not places:
return ("📍 未配置地点", False)
# 找公司和家的 key
work_key = find_place_key(cfg, "搬砖") or find_place_key(cfg, "公司")
home_key = find_place_key(cfg, "在家") or find_place_key(cfg, "家")
# 计算到各地点的距离
distances = {}
for place_key, place_coords in places.items():
distances[place_key] = haversine(lat, lng, place_coords[0], place_coords[1])
dist_to_work = distances.get(work_key, float('inf')) if work_key else float('inf')
dist_to_home = distances.get(home_key, float('inf')) if home_key else float('inf')
# ===== 到达判定(优先级最高)=====
# 到达公司(<100m)
if work_key and dist_to_work <= COMMUTE_ARRIVE_METERS:
if _commute_state == "to_work":
log(f"🏢 到公司了!({dist_to_work:.0f}m)")
_commute_state = None
_last_anchor = "work"
return (work_key, False)
# 到达家(<100m)
if home_key and dist_to_home <= COMMUTE_ARRIVE_METERS:
if _commute_state == "to_home":
log(f"🏠 到家了!({dist_to_home:.0f}m)")
_commute_state = None
_last_anchor = "home"
return (home_key, False)
# ===== 在围栏内(未通勤状态下)=====
# 在公司范围(< threshold)
if work_key and dist_to_work <= threshold and _commute_state is None:
_last_anchor = "work"
return (work_key, False)
# 在家范围(< threshold)
if home_key and dist_to_home <= threshold and _commute_state is None:
_last_anchor = "home"
return (home_key, False)
# ===== 通勤触发判定 =====
# 离开家 >200m(之前锚点是 home)→ 上班途中
if _last_anchor == "home" and _commute_state is None:
if home_key and dist_to_home > COMMUTE_DEPART_METERS:
_commute_state = "to_work"
log(f"🚗 离开家,通勤模式:上班途中 (距家 {dist_to_home:.0f}m)")
# 离开公司 >200m(之前锚点是 work)→ 下班途中
if _last_anchor == "work" and _commute_state is None:
if work_key and dist_to_work > COMMUTE_DEPART_METERS:
_commute_state = "to_home"
log(f"🚗 离开公司,通勤模式:下班途中 (距公司 {dist_to_work:.0f}m)")
# ===== 通勤中状态输出 =====
# 获取当前位置的语义地名
location_name = amap_regeo(lat, lng, cfg)
current_loc = f"(当前:{location_name})" if location_name else ""
if _commute_state == "to_work":
dist_str = format_distance(dist_to_work) if work_key else ""
if work_key and dist_to_work != float('inf'):
status = f"🚗 正在上班途中{current_loc}"
else:
status = f"🚗 正在上班途中{current_loc}"
log(f"🚗 上班途中 距公司 {dist_str} {current_loc}")
return (status, True)
if _commute_state == "to_home":
dist_str = format_distance(dist_to_home) if home_key else ""
if home_key and dist_to_home != float('inf'):
status = f"🚗 正在下班途中,距离家 {dist_str}{current_loc}"
else:
status = f"🚗 正在下班途中{current_loc}"
log(f"🚗 下班途中 距家 {dist_str} {current_loc}")
return (status, True)
# ===== 非通勤、不在已知围栏 =====
# 检查是否在其他已知地点围栏内
for place_key, dist in distances.items():
if place_key in (work_key, home_key):
continue
if dist <= threshold:
return (place_key, False)
# 高德逆地理编码提供地名
if location_name:
return (f"📍 在{location_name}", False)
return ("🚗 在路上", False)
def cmd_show_gps(cfg):
"""显示当前GPS坐标 + 高德逆地理编码结果"""
username = os.environ.get('ICLOUD_USERNAME') or cfg.get('icloud_username')
password = os.environ.get('ICLOUD_PASSWORD') or cfg.get('icloud_password')
if not username or not password:
print("❌ 需要设置 ICLOUD_USERNAME 和 ICLOUD_PASSWORD")
return
from pyicloud import PyiCloudService
api = PyiCloudService(username, password, china_mainland=True)
if api.requires_2fa:
code = input("请输入 2FA 验证码: ")
api.validate_2fa_code(code)
for dev in api.devices:
info = dev.content
loc = info.get('location')
if loc and 'iPhone' in info.get('deviceDisplayName', ''):
lat, lng = loc.get('latitude'), loc.get('longitude')
print(f"📱 {info.get('name', '?')}")
print(f"📍 坐标: [{lat:.6f}, {lng:.6f}]")
# 尝试高德逆地理编码
location_name = amap_regeo(lat, lng, cfg)
if location_name:
print(f"🗺️ 高德地名: {location_name}")
else:
print(f"🗺️ 高德地名: 未获取(需配置 AMAP_API_KEY)")
print(f"\n在 init 配置地点时填入此坐标即可")
return
print("❌ 未找到有位置的 iPhone")
# ============================================================
# 写入共享日历
# ============================================================
def update_calendar_status(cfg, status_text):
"""更新共享日历状态卡片"""
cal_script = os.path.join(SCRIPTS_DIR, 'icloud_calendar.py')
target = cfg["target_calendar"]
member = cfg["member_name"]
title = f"👤 {member}: {status_text}"
r = subprocess.run(
['python3', cal_script, 'search', f'👤 {member}', '-c', target],
capture_output=True, text=True
)
# 状态未变则跳过
if f'📌 {title}' in r.stdout:
log(f"状态未变: {status_text}")
return
# 删除旧状态
if '找到 0 个' not in r.stdout and '没有找到' not in r.stdout:
subprocess.run(
['python3', cal_script, 'delete', f'👤 {member}', '-c', target],
capture_output=True, text=True
)
# 写入新状态
r = subprocess.run(
['python3', cal_script, 'new', 'today', title, '-c', target],
capture_output=True, text=True
)
if '✅ 事件已创建' in r.stdout:
log(f"✅ {title}")
else:
log(f"❌ 写入失败")
# ============================================================
# 单次执行 / 守护循环
# ============================================================
def run_once(cfg):
"""执行一次状态判定+更新,返回 is_commuting"""
log("🔄 刷新状态...")
# P1: 日程读取
event = check_calendar_events(cfg)
if event:
status = f"🚫 {event} (勿扰)"
update_calendar_status(cfg, status)
return False
# P2: 物理锚点
coords = get_gps_coords(cfg)
status, is_commuting = get_gps_status(cfg, coords)
status = status or "📅 空闲"
update_calendar_status(cfg, status)
return is_commuting
def run_daemon(cfg):
"""守护进程主循环:正常 15 分钟,通勤 1 分钟"""
normal_interval = cfg.get("interval_minutes", 15)
log("=" * 50)
log(f"👤 状态墙启动: {cfg['member_name']} → {cfg['target_calendar']}")
log(f" 正常间隔={normal_interval}分钟 通勤间隔={COMMUTE_INTERVAL_MINUTES}分钟")
log(f" 地点={list(cfg.get('places', {}).keys())}")
log(f" 高德API={'✅' if cfg.get('amap_api_key') else '❌'}")
log(f" 通勤触发={COMMUTE_DEPART_METERS}m 到达判定={COMMUTE_ARRIVE_METERS}m")
log("=" * 50)
def handle_term(sig, frame):
log("收到停止信号,退出")
PID_PATH.unlink(missing_ok=True)
sys.exit(0)
signal.signal(signal.SIGTERM, handle_term)
signal.signal(signal.SIGINT, handle_term)
while True:
try:
is_commuting = run_once(cfg)
except Exception as e:
log(f"❌ 异常: {e}")
is_commuting = False
interval = COMMUTE_INTERVAL_MINUTES if is_commuting else normal_interval
next_run = datetime.now() + timedelta(minutes=interval)
mode = "🚗 通勤模式" if is_commuting else "💤 正常模式"
log(f"{mode} 下次: {next_run.strftime('%H:%M:%S')} ({interval}分钟后)")
time.sleep(interval * 60)
# ============================================================
# 入口
# ============================================================
def main():
if len(sys.argv) < 2:
print("""
👤 状态墙守护进程
用法: python status_wall.py <命令>
命令:
init 交互式初始化配置(首次使用)
start 启动后台守护进程
stop 停止守护进程
status 查看运行状态和最近日志
once 单次执行(调试用)
show-gps 显示当前GPS坐标 + 高德地名
""")
return
cmd = sys.argv[1]
cfg = load_config()
if cmd == 'init':
cmd_init()
elif cmd == 'start':
cmd_start()
elif cmd == 'stop':
cmd_stop()
elif cmd == 'status':
cmd_status()
elif cmd == 'once':
if not cfg.get("target_calendar"):
print("❌ 未配置,请先运行: python status_wall.py init")
return
run_once(cfg)
elif cmd == 'show-gps':
cmd_show_gps(cfg)
elif cmd == '_daemon':
run_daemon(cfg)
else:
print(f"❌ 未知命令: {cmd}")
if __name__ == '__main__':
main()
结构化问题诊断与解决方法论。 Use when: (1) 问题原因不明需要调查/"分析一下这个问题"/"排查一下", (2) 之前的修复尝试失败了, (3) 问题涉及多个组件交互/"为什么会这样"/"调查一下原因", (4) 修改有风险或副作用/"诊断一下", (5) 用户明确要求先分析再修复。 NOT for:...
---
name: problem-solving
version: 1.0.0
description: >
结构化问题诊断与解决方法论。
Use when: (1) 问题原因不明需要调查/"分析一下这个问题"/"排查一下",
(2) 之前的修复尝试失败了,
(3) 问题涉及多个组件交互/"为什么会这样"/"调查一下原因",
(4) 修改有风险或副作用/"诊断一下",
(5) 用户明确要求先分析再修复。
NOT for: 明显的一行修复、错误信息清晰且有已知方案的问题、
用户说"直接修"的简单问题。
---
# Structured Problem Solving
## When to Use This vs. Direct Fix
**Direct fix (skip this skill)**:
- Error message points to exact cause
- One-line config/code fix
- You've seen this exact problem before
**Use this skill**:
- You'd need to say "可能是..." to explain the cause
- 2+ components involved
- You already tried a fix that didn't work
- Wrong fix could cause data loss, privacy leak, or downtime
## The Process
### Step 0: Question Dissolution (消解层)
Before solving, check if the problem itself is valid. Many problems dissolve when examined properly.
**Run these 3 checks sequentially. If any check dissolves the problem, stop and tell the user — a dissolved problem is more valuable than a solved one.**
#### 0.1 Language Trap Detection (语言陷阱)
Does the problem statement contain vague, undefined key terms?
Common trap words: "优化" "合适" "更好" "正常" "应该" "稳定"
**Test**: Can you give a measurable or actionable definition for every key term? If not, the problem can't be solved because it hasn't been stated.
→ If trapped: Ask the user to define the vague term. "你说的'优化'具体指什么?响应时间从 X 降到 Y?还是内存占用?还是用户体验?"
#### 0.2 Hidden Assumption Check (假设检验)
Rewrite the problem as: "This problem assumes X. Is X true?"
Common false assumptions:
- "系统变慢了" → assumes it was faster before (was it? measured when?)
- "用户不喜欢这个功能" → assumes users have tried it (have they? data?)
- "我们需要加这个功能" → assumes the current system can't do it (can it?)
→ If assumption is false: Tell the user. "你的问题假设了「X」,但这个前提可能不成立。如果 X 不成立,问题就消失了。"
#### 0.3 Question vs. Problem Classification
- **Question**: Has a standard answer, can be resolved by looking it up or reading docs
- → Answer directly, don't enter the full diagnostic process
- **Problem**: No standard answer, requires investigation + experimentation
- → Continue to Step 1
If the problem survives all 3 checks, proceed to full diagnosis.
---
### Step 1: Define the Problem
Turn vague "something's wrong" into a precise statement.
```
问题:[一句话]
现象:[具体发生了什么]
预期:[应该是什么样]
影响:[谁受影响,严重程度]
可复现:[是/否,触发条件]
```
**Rules**:
- Describe what you observe, not what you think caused it
- "webchat replies appear in DingTalk group" = problem ✅
- "origin got polluted" = hypothesis, not problem ❌
### Step 2: Diagnose
**Do not skip to fixing.** Trace the data flow end-to-end first.
#### 2.1 Map the call chain
```
Input → Step A → Step B → Step C → Output
↓ ↓ ↓
Check Check Check
```
#### 2.2 Verify each step
Read actual values (logs, state files, source code). Do not guess.
#### 2.3 Narrow down
Find the first step where output diverges from expected. That's where the bug is.
#### 2.4 Confirm root cause
Three questions before you declare root cause:
1. **Why?** — Explain the mechanism, not just the symptom
2. **Sufficient?** — If I fix this, will the problem definitely disappear?
3. **Unique?** — Is there another cause that could produce the same symptom?
All three must be answered. If not → keep diagnosing.
**Diagnostic tools (prefer in order)**:
1. Error messages / logs (fastest)
2. State inspection (config files, DB, session store)
3. Source code tracing (most reliable)
4. Minimal reproduction experiment
### Step 3: Design Solutions
Generate **at least 2** candidate solutions. Compare on:
| Dimension | Question |
|-----------|----------|
| Effectiveness | Fixes root cause or just symptom? |
| Risk | Could it break something else? |
| Complexity | How many components touched? |
| Reversibility | Can we roll back if wrong? |
| Durability | Survives restarts / updates? |
| Side effects | Impact on other features? |
Present as:
```
方案 A:[one line]
✅ [pros] ⚠️ [risks]
方案 B:[one line]
✅ [pros] ⚠️ [risks]
→ 推荐 A,因为 [reason]
```
Always include the "do nothing / workaround" option if viable.
### Step 4: Execute
Pre-flight checklist:
- [ ] Root cause confirmed (not guessed)
- [ ] Solution evaluated (not first idea)
- [ ] User confirmed (for risky changes)
- [ ] Rollback plan ready
**Rules**:
- Change one variable at a time
- Record what was changed and what it was before
- Minimize scope — don't "fix other things while you're at it"
### Step 5: Verify
Three levels of verification:
1. **Direct**: Reproduce original trigger → problem gone?
2. **Regression**: Related features still work?
3. **Durability**: Survives restart / next trigger?
Show evidence, don't say "应该好了".
### Step 6: Review
```
## 复盘:[问题名]
耗时:X 分钟(有效 Y / 弯路 Z)
根因:[一句话]
修复:[一句话]
弯路:[走了什么弯路]
教训:[提炼的规则]
```
Write lessons to `.learnings/` if reusable.
## 诊断超时与死胡同处理
| 信号 | 动作 |
|------|------|
| 同一假设连续 3 次验证无结论 | 停止,换假设或换诊断维度 |
| 累计诊断 >15 分钟无进展 | 暂停,向用户汇报已排除项 + 当前卡点,询问是否有额外线索 |
| 累计尝试 >5 个假设均被否定 | 考虑问题是否需要消解(回 Step 0)或需要更多上下文 |
| 修复后问题复现 | 不叠加补丁,回退到修复前状态,重新走 Step 1 |
## Anti-patterns
| Pattern | What it looks like | Fix |
|---------|-------------------|-----|
| Guess-and-fix | See symptom → hypothesize → change immediately | Map call chain first |
| One-end-only | Check only input or output | Trace full data flow |
| Surface fix | Change the bad value without asking why it's bad | Ask "why did it become this value?" |
| Multi-change | Change 3 things at once | One variable at a time |
| Premature victory | "Should be fixed now" without checking | Show evidence |
| No rollback | Forget to record original values | Backup before modify |
## Communication During Problem-Solving
- **Define**: Confirm understanding ("你说的问题是 X 对吗?")
- **Diagnose**: Share progress, don't go silent ("在查 Y 环节,发现了 Z")
- **Design**: Give choices, not just one option
- **Execute**: Confirm before risky operations
- **Verify**: Ask user to check on their end
- **Throughout**: Say "I'm not sure yet" over false confidence
---
## 下一步建议(条件触发)
问题解决后,根据结果判断是否推荐下一步。
| 触发条件 | 推荐 |
|---------|------|
| 根因是代码 bug,修复需要多文件改动 | 「根因清楚了,修复交给 coding-agent spawn Claude Code 来做。」 |
| 问题根因值得记录(同类问题可能再犯) | 「这个教训值得记下来,写到 .learnings/ 防止再犯。」 |
| 问题在消解层被消解(问题本身不成立) | 「问题已经消解了。如果背后有更大的决策要做,可以拉出来单独讨论。」 |
| 诊断过程发现系统架构层面的隐患 | 「这次修好了,但架构上还有隐患。要不要排个时间做一次 healthcheck?」 |
FILE:README.md
# Problem Solving
Structured problem diagnosis and resolution methodology for OpenClaw.
## Features
- 🔍 **Systematic Diagnosis**: Map call chains and trace data flow end-to-end
- 🎯 **Root Cause Analysis**: Three-question framework to confirm true causes
- 🛠️ **Solution Design**: Compare multiple approaches on effectiveness, risk, and reversibility
- ✅ **Rigorous Verification**: Multi-level testing including regression checks
- 📝 **Post-Mortem Reviews**: Extract learnings to prevent recurrence
## When to Use
**Use this skill when:**
- Problem cause is unclear and requires investigation
- A previous fix attempt failed
- Issue involves multiple components interacting
- Modifications carry risk or side effects
- You'd need to say "可能是..." to explain the cause
**Skip for:**
- Obvious one-liner fixes
- Clear error messages with known solutions
- User says "just fix it" for simple issues
## The Process
1. **Define**: Turn vague symptoms into precise problem statements
2. **Diagnose**: Trace data flow, verify each step, confirm root cause
3. **Design**: Generate and compare at least 2 solution candidates
4. **Execute**: Change one variable at a time with rollback plan
5. **Verify**: Reproduce trigger, check regressions, test durability
6. **Review**: Extract lessons for future prevention
## Installation
### Via ClawHub (Recommended)
```bash
clawhub install problem-solving
```
### Manual Installation
Clone to `~/.openclaw/skills/problem-solving`
## Usage
The skill activates automatically when appropriate. You can also explicitly request it:
```
帮我分析一下这个问题
Use structured problem solving for this issue
Let's diagnose this systematically
```
## Anti-Patterns to Avoid
- **Guess-and-fix**: See symptom → change immediately
- **Surface fix**: Change bad value without asking why
- **Multi-change**: Change 3 things at once
- **Premature victory**: "Should be fixed" without verification
## License
MIT License - see [LICENSE](LICENSE) file
错误学习闭环:记录失败和纠正 → Pattern-Key 分类 → 定期复盘 → 整合长期记忆 → 防止再犯。 Use when: (1) 命令/操作意外失败且原因值得记录, (2) 用户纠正了错误("不对"/"Actually..."/"你搞错了"), (3) 用户要求的能力不存在(能力缺口信号), (4) 外...
--- name: self-improvement version: 1.0.0 description: > 错误学习闭环:记录失败和纠正 → Pattern-Key 分类 → 定期复盘 → 整合长期记忆 → 防止再犯。 Use when: (1) 命令/操作意外失败且原因值得记录, (2) 用户纠正了错误("不对"/"Actually..."/"你搞错了"), (3) 用户要求的能力不存在(能力缺口信号), (4) 外部 API/工具故障且需记录规避方案, (5) 发现更好的做法可以替代当前方式, (6) 开始重大任务前回顾历史教训。 NOT for: 一次性小错误(typo/手误/用户说"没关系"的)、 功能性 bug 修复(用 problem-solving)、 skill 创建/优化(用 skill-creator)、 日常操作日志(不是每个 tool call 都要记)。 metadata: --- # Self-Improvement Skill 记录错误和教训,定期复盘整合到长期记忆,持续改进。 > 来源:[ClawHub](https://clawhub.ai) `[email protected]`,3/15 融入 v3.0.2 新特性(Pattern-Key / Recurrence / See Also / Simplify & Harden) > 原始 repo:https://github.com/pskoett/pskoett-ai-skills ## 核心理念 ``` 犯错 → 立即记录 → 定期复盘 → 整合记忆 → 形成规则 → 避免再犯 ``` 不只是记错题本,而是一个**学习闭环**。 --- ## 一、被动记录(犯错时触发) ### 什么时候记? | 信号 | 记到哪 | |------|--------| | 命令报错、工具失败 | `.learnings/ERRORS.md` | | 被用户纠正("不对"、"你搞错了") | `.learnings/LEARNINGS.md` | | 发现自己编造了信息 | `.learnings/LEARNINGS.md` | | 用户要求缺失的功能 | `.learnings/FEATURE_REQUESTS.md` | | 发现更好的做法 | `.learnings/LEARNINGS.md` | ### 记录格式 **ERRORS.md / LEARNINGS.md:** ```markdown ## [LRN-YYYYMMDD-XXX] category **Logged**: ISO-8601 timestamp **Priority**: low | medium | high | critical **Status**: pending | resolved | promoted **Area**: frontend | backend | infra | config | workflow | content ### Summary 一句话描述 ### Details 完整上下文:发生了什么、做错了什么、正确做法是什么 ### Suggested Action 具体的修复或改进建议 ### Metadata - Source: conversation | error | user_feedback | simplify-and-harden - Related Files: path/to/file.ext - Tags: tag1, tag2 - See Also: LRN-20260312-001(如有关联条目) - Pattern-Key: harden.config_validation | simplify.dead_code(可选,用于追踪重复模式) - Recurrence-Count: 1(可选,同一模式出现几次) - First-Seen: 2026-03-12(可选) - Last-Seen: 2026-03-15(可选) --- ``` **FEATURE_REQUESTS.md:** ```markdown ## YYYY-MM-DD: 功能名 **需求**: 用户想做什么 **场景**: 为什么需要 **状态**: pending / resolved **方案**: (如果有的话) --- ``` ### 记录原则 1. **立即记** — 刚犯错时上下文最完整,拖了就忘 2. **写教训不写流水账** — 重点是"下次怎么避免",不是"事情经过" 3. **一条教训一个行动** — 能转化为具体规则的才有价值 4. **关联已有条目** — 用 `See Also: LRN-xxx` 关联类似问题 5. **追踪重复模式** — 同一类错误用相同的 `Pattern-Key`,`Recurrence-Count` 递增。≥3 次 → 必须 promote 成规则 --- ## 二、主动复盘(定期触发) ### 复盘时机 | 时机 | 做什么 | |------|--------| | **每次 session 启动** | 扫一眼 `.learnings/` 最近条目,避免重复犯错 | | **heartbeat 每 2 天一次** | 完整复盘:回顾近期 learnings,整合到 MEMORY.md | | **大任务开始前** | 搜索相关 learnings,预防已知问题 | | **同一个错犯了 3 次** | 必须 promote 成永久规则 | ### 复盘流程(heartbeat 触发) ``` 1. 读取最近 2-3 天的 .learnings/ 文件 2. 识别有价值的条目(高频、高影响、可泛化) 3. 整合到 MEMORY.md 的「教训与规则」部分 4. 已整合的条目标记为 [已归档] 5. 检查是否有条目需要 promote 到 AGENTS.md / SOUL.md / TOOLS.md ``` ### 复盘模板 在 heartbeat 复盘时,用这个框架思考: ```markdown ## 本周复盘 (YYYY-MM-DD) ### 犯了什么错? - ... ### 学到什么? - ... ### 哪些该变成规则? - → promote 到 [目标文件] ### 哪些已经不再相关? - → 标记 [已归档] ``` --- ## 三、记忆整合(learnings → 长期记忆) ### Promote 规则 | 条件 | Promote 到哪 | |------|-------------| | 改变我做事方式的教训 | `AGENTS.md`(工作流规则)| | 改变我说话/行为方式的教训 | `SOUL.md`(人格规则)| | 工具使用的坑 | `TOOLS.md`(工具笔记)| | 值得长期记住但不算规则的 | `MEMORY.md`(长期记忆)| | 同一个错 ≥3 次(Recurrence-Count ≥ 3) | **必须** promote,不能只留在 learnings | ### Simplify & Harden 模式(v3.0 新增) 在日常工作中发现可以简化或加固的重复模式时,用 `Pattern-Key` 追踪: | 模式类型 | Pattern-Key 前缀 | 例子 | |---------|-----------------|------| | **Simplify**(简化冗余) | `simplify.*` | `simplify.dead_code`、`simplify.redundant_check` | | **Harden**(加固薄弱点) | `harden.*` | `harden.config_validation`、`harden.error_handling` | **工作流:** 1. 发现重复模式 → 记录到 LEARNINGS.md,设置 `Pattern-Key` 和 `Recurrence-Count: 1` 2. 再次出现 → 更新同一条目的 `Recurrence-Count` 和 `Last-Seen` 3. `Recurrence-Count ≥ 3` → promote 到 AGENTS.md/TOOLS.md 成为永久规则 ### Promote 格式 从 learnings 提炼成**短规则**,不是复制粘贴整段: ❌ 不要这样(太长): > 2026-03-04 因为在 models.providers 里加了 capabilities 字段导致 Config invalid, > 然后 restart 后 webchat 崩了,用户被锁 30 分钟……(500 字) ✅ 要这样(短规则): > **改 openclaw.json 后必须先 `openclaw status` 校验,确认无 error 再 restart。** ### MEMORY.md 结构建议 ```markdown # MEMORY.md ## 关于老板 (用户偏好、习惯、重要信息) ## 活跃项目 (当前在做的事) ## 教训与规则 (从 learnings promote 上来的重要教训) ## 工具与环境 (环境特定的知识,如 API 配置、设备信息) ``` --- ## 四、文件管理 ### 目录结构 ``` <WORKSPACE>/ ├── .learnings/ │ ├── ERRORS.md # 错误记录 │ ├── LEARNINGS.md # 教训记录 │ └── FEATURE_REQUESTS.md # 功能需求 ├── MEMORY.md # 长期记忆(整合后的精华) ├── AGENTS.md # 工作流规则(promote 的硬性规则) ├── SOUL.md # 人格规则 └── TOOLS.md # 工具笔记 ``` ### 文件大小控制 - `.learnings/` 每个文件保持 <5KB - 超过时归档旧条目到 `.learnings/archive/YYYY-MM.md` - MEMORY.md <6KB(定期精简) ### 归档规则 条目符合以下任一条件时归档: - 已 promote 到永久文件 - 超过 30 天且不再相关 - 问题已彻底解决且不会再犯 --- ## 五、检测触发词 自动识别这些信号并触发记录: **用户纠正:** - "不对"、"你搞错了"、"Actually..."、"No, that's wrong" **功能请求:** - "能不能..."、"要是能..."、"Can you..."、"I wish..." **知识缺口:** - 用户告诉你不知道的信息 - 文档/API 行为与你理解的不一致 **错误:** - 命令返回非零退出码 - 异常或堆栈跟踪 - 超时或连接失败 FILE:README.md # Self-Improving Agent Continuous learning and improvement system for OpenClaw agents. ## Features - 📝 **Error Logging**: Automatically capture command failures and exceptions - 🎓 **Learning Capture**: Record corrections and discoveries - 🔄 **Periodic Review**: Regular retrospectives to consolidate learnings - 🧠 **Memory Integration**: Promote important patterns to long-term memory - 🛡️ **Rule Formation**: Create hardened rules to prevent recurring mistakes ## How It Works ``` Error occurs → Log immediately → Periodic review → Integrate to memory → Form rules → Prevent recurrence ``` This isn't just an error log—it's a complete learning loop that makes your agent smarter over time. ## Prerequisites - OpenClaw agent environment ## Installation ### Via ClawHub (Recommended) ```bash clawhub install self-improving-agent ``` ### Manual Installation 1. Clone to `~/.openclaw/skills/self-improving-agent` 2. Create learning directories: ```bash mkdir -p <WORKSPACE>/.learnings ``` ## Usage The skill activates automatically when: 1. A command or operation fails unexpectedly 2. You correct the agent ("No, that's wrong...", "Actually...") 3. You request a capability that doesn't exist 4. An external API or tool fails 5. The agent discovers a better approach You can also manually trigger reviews: ``` Review recent learnings 整合最近的教训到长期记忆 ``` ## Files - `.learnings/ERRORS.md`: Command failures and exceptions - `.learnings/LEARNINGS.md`: Corrections and discoveries - `.learnings/FEATURE_REQUESTS.md`: Requested missing capabilities ## Origin Based on `[email protected]` from [ClawHub](https://clawhub.ai) Original repository: https://github.com/pskoett/pskoett-ai-skills ## License MIT License - see [LICENSE](LICENSE) file FILE:_meta.json { "ownerId": "k974cxjsv4mj233223nv73v4fs832bhn", "slug": "self-improving-learner", "version": "3.0.5", "publishedAt": 1773728700000 } FILE:assets/LEARNINGS.md # Learnings Corrections, insights, and knowledge gaps captured during development. **Categories**: correction | insight | knowledge_gap | best_practice **Areas**: frontend | backend | infra | tests | docs | config **Statuses**: pending | in_progress | resolved | wont_fix | promoted | promoted_to_skill ## Status Definitions | Status | Meaning | |--------|---------| | `pending` | Not yet addressed | | `in_progress` | Actively being worked on | | `resolved` | Issue fixed or knowledge integrated | | `wont_fix` | Decided not to address (reason in Resolution) | | `promoted` | Elevated to CLAUDE.md, AGENTS.md, or copilot-instructions.md | | `promoted_to_skill` | Extracted as a reusable skill | ## Skill Extraction Fields When a learning is promoted to a skill, add these fields: ```markdown **Status**: promoted_to_skill **Skill-Path**: skills/skill-name ``` Example: ```markdown ## [LRN-20250115-001] best_practice **Logged**: 2025-01-15T10:00:00Z **Priority**: high **Status**: promoted_to_skill **Skill-Path**: skills/docker-m1-fixes **Area**: infra ### Summary Docker build fails on Apple Silicon due to platform mismatch ... ``` --- FILE:assets/SKILL-TEMPLATE.md # Skill Template Template for creating skills extracted from learnings. Copy and customize. --- ## SKILL.md Template ```markdown --- name: skill-name-here description: "Concise description of when and why to use this skill. Include trigger conditions." --- # Skill Name Brief introduction explaining the problem this skill solves and its origin. ## Quick Reference | Situation | Action | |-----------|--------| | [Trigger 1] | [Action 1] | | [Trigger 2] | [Action 2] | ## Background Why this knowledge matters. What problems it prevents. Context from the original learning. ## Solution ### Step-by-Step 1. First step with code or command 2. Second step 3. Verification step ### Code Example \`\`\`language // Example code demonstrating the solution \`\`\` ## Common Variations - **Variation A**: Description and how to handle - **Variation B**: Description and how to handle ## Gotchas - Warning or common mistake #1 - Warning or common mistake #2 ## Related - Link to related documentation - Link to related skill ## Source Extracted from learning entry. - **Learning ID**: LRN-YYYYMMDD-XXX - **Original Category**: correction | insight | knowledge_gap | best_practice - **Extraction Date**: YYYY-MM-DD ``` --- ## Minimal Template For simple skills that don't need all sections: ```markdown --- name: skill-name-here description: "What this skill does and when to use it." --- # Skill Name [Problem statement in one sentence] ## Solution [Direct solution with code/commands] ## Source - Learning ID: LRN-YYYYMMDD-XXX ``` --- ## Template with Scripts For skills that include executable helpers: ```markdown --- name: skill-name-here description: "What this skill does and when to use it." --- # Skill Name [Introduction] ## Quick Reference | Command | Purpose | |---------|---------| | `./scripts/helper.sh` | [What it does] | | `./scripts/validate.sh` | [What it does] | ## Usage ### Automated (Recommended) \`\`\`bash ./skills/skill-name/scripts/helper.sh [args] \`\`\` ### Manual Steps 1. Step one 2. Step two ## Scripts | Script | Description | |--------|-------------| | `scripts/helper.sh` | Main utility | | `scripts/validate.sh` | Validation checker | ## Source - Learning ID: LRN-YYYYMMDD-XXX ``` --- ## Naming Conventions - **Skill name**: lowercase, hyphens for spaces - Good: `docker-m1-fixes`, `api-timeout-patterns` - Bad: `Docker_M1_Fixes`, `APITimeoutPatterns` - **Description**: Start with action verb, mention trigger - Good: "Handles Docker build failures on Apple Silicon. Use when builds fail with platform mismatch." - Bad: "Docker stuff" - **Files**: - `SKILL.md` - Required, main documentation - `scripts/` - Optional, executable code - `references/` - Optional, detailed docs - `assets/` - Optional, templates --- ## Extraction Checklist Before creating a skill from a learning: - [ ] Learning is verified (status: resolved) - [ ] Solution is broadly applicable (not one-off) - [ ] Content is complete (has all needed context) - [ ] Name follows conventions - [ ] Description is concise but informative - [ ] Quick Reference table is actionable - [ ] Code examples are tested - [ ] Source learning ID is recorded After creating: - [ ] Update original learning with `promoted_to_skill` status - [ ] Add `Skill-Path: skills/skill-name` to learning metadata - [ ] Test skill by reading it in a fresh session FILE:hooks/openclaw/HOOK.md --- name: self-improvement description: "Injects self-improvement reminder during agent bootstrap" metadata: {"openclaw":{"emoji":"🧠","events":["agent:bootstrap"]}} --- # Self-Improvement Hook Injects a reminder to evaluate learnings during agent bootstrap. ## What It Does - Fires on `agent:bootstrap` (before workspace files are injected) - Adds a reminder block to check `.learnings/` for relevant entries - Prompts the agent to log corrections, errors, and discoveries ## Configuration No configuration needed. Enable with: ```bash openclaw hooks enable self-improvement ``` FILE:hooks/openclaw/handler.js /** * Self-Improvement Hook for OpenClaw * * Injects a reminder to evaluate learnings during agent bootstrap. * Fires on agent:bootstrap event before workspace files are injected. */ const REMINDER_CONTENT = ` ## Self-Improvement Reminder After completing tasks, evaluate if any learnings should be captured: **Log when:** - User corrects you → \`.learnings/LEARNINGS.md\` - Command/operation fails → \`.learnings/ERRORS.md\` - User wants missing capability → \`.learnings/FEATURE_REQUESTS.md\` - You discover your knowledge was wrong → \`.learnings/LEARNINGS.md\` - You find a better approach → \`.learnings/LEARNINGS.md\` **Promote when pattern is proven:** - Behavioral patterns → \`SOUL.md\` - Workflow improvements → \`AGENTS.md\` - Tool gotchas → \`TOOLS.md\` Keep entries simple: date, title, what happened, what to do differently. `.trim(); const handler = async (event) => { // Safety checks for event structure if (!event || typeof event !== 'object') { return; } // Only handle agent:bootstrap events if (event.type !== 'agent' || event.action !== 'bootstrap') { return; } // Safety check for context if (!event.context || typeof event.context !== 'object') { return; } // Inject the reminder as a virtual bootstrap file // Check that bootstrapFiles is an array before pushing if (Array.isArray(event.context.bootstrapFiles)) { event.context.bootstrapFiles.push({ path: 'SELF_IMPROVEMENT_REMINDER.md', content: REMINDER_CONTENT, virtual: true, }); } }; module.exports = handler; module.exports.default = handler; FILE:hooks/openclaw/handler.ts /** * Self-Improvement Hook for OpenClaw * * Injects a reminder to evaluate learnings during agent bootstrap. * Fires on agent:bootstrap event before workspace files are injected. */ import type { HookHandler } from 'openclaw/hooks'; const REMINDER_CONTENT = `## Self-Improvement Reminder After completing tasks, evaluate if any learnings should be captured: **Log when:** - User corrects you → \`.learnings/LEARNINGS.md\` - Command/operation fails → \`.learnings/ERRORS.md\` - User wants missing capability → \`.learnings/FEATURE_REQUESTS.md\` - You discover your knowledge was wrong → \`.learnings/LEARNINGS.md\` - You find a better approach → \`.learnings/LEARNINGS.md\` **Promote when pattern is proven:** - Behavioral patterns → \`SOUL.md\` - Workflow improvements → \`AGENTS.md\` - Tool gotchas → \`TOOLS.md\` Keep entries simple: date, title, what happened, what to do differently.`; const handler: HookHandler = async (event) => { // Safety checks for event structure if (!event || typeof event !== 'object') { return; } // Only handle agent:bootstrap events if (event.type !== 'agent' || event.action !== 'bootstrap') { return; } // Safety check for context if (!event.context || typeof event.context !== 'object') { return; } // Skip sub-agent sessions to avoid bootstrap issues // Sub-agents have sessionKey patterns like "agent:main:subagent:..." const sessionKey = event.sessionKey || ''; if (sessionKey.includes(':subagent:')) { return; } // Inject the reminder as a virtual bootstrap file // Check that bootstrapFiles is an array before pushing if (Array.isArray(event.context.bootstrapFiles)) { event.context.bootstrapFiles.push({ path: 'SELF_IMPROVEMENT_REMINDER.md', content: REMINDER_CONTENT, virtual: true, }); } }; export default handler; FILE:references/examples.md # Entry Examples Concrete examples of well-formatted entries with all fields. ## Learning: Correction ```markdown ## [LRN-20250115-001] correction **Logged**: 2025-01-15T10:30:00Z **Priority**: high **Status**: pending **Area**: tests ### Summary Incorrectly assumed pytest fixtures are scoped to function by default ### Details When writing test fixtures, I assumed all fixtures were function-scoped. User corrected that while function scope is the default, the codebase convention uses module-scoped fixtures for database connections to improve test performance. ### Suggested Action When creating fixtures that involve expensive setup (DB, network), check existing fixtures for scope patterns before defaulting to function scope. ### Metadata - Source: user_feedback - Related Files: tests/conftest.py - Tags: pytest, testing, fixtures --- ``` ## Learning: Knowledge Gap (Resolved) ```markdown ## [LRN-20250115-002] knowledge_gap **Logged**: 2025-01-15T14:22:00Z **Priority**: medium **Status**: resolved **Area**: config ### Summary Project uses pnpm not npm for package management ### Details Attempted to run `npm install` but project uses pnpm workspaces. Lock file is `pnpm-lock.yaml`, not `package-lock.json`. ### Suggested Action Check for `pnpm-lock.yaml` or `pnpm-workspace.yaml` before assuming npm. Use `pnpm install` for this project. ### Metadata - Source: error - Related Files: pnpm-lock.yaml, pnpm-workspace.yaml - Tags: package-manager, pnpm, setup ### Resolution - **Resolved**: 2025-01-15T14:30:00Z - **Commit/PR**: N/A - knowledge update - **Notes**: Added to CLAUDE.md for future reference --- ``` ## Learning: Promoted to CLAUDE.md ```markdown ## [LRN-20250115-003] best_practice **Logged**: 2025-01-15T16:00:00Z **Priority**: high **Status**: promoted **Promoted**: CLAUDE.md **Area**: backend ### Summary API responses must include correlation ID from request headers ### Details All API responses should echo back the X-Correlation-ID header from the request. This is required for distributed tracing. Responses without this header break the observability pipeline. ### Suggested Action Always include correlation ID passthrough in API handlers. ### Metadata - Source: user_feedback - Related Files: src/middleware/correlation.ts - Tags: api, observability, tracing --- ``` ## Learning: Promoted to AGENTS.md ```markdown ## [LRN-20250116-001] best_practice **Logged**: 2025-01-16T09:00:00Z **Priority**: high **Status**: promoted **Promoted**: AGENTS.md **Area**: backend ### Summary Must regenerate API client after OpenAPI spec changes ### Details When modifying API endpoints, the TypeScript client must be regenerated. Forgetting this causes type mismatches that only appear at runtime. The generate script also runs validation. ### Suggested Action Add to agent workflow: after any API changes, run `pnpm run generate:api`. ### Metadata - Source: error - Related Files: openapi.yaml, src/client/api.ts - Tags: api, codegen, typescript --- ``` ## Error Entry ```markdown ## [ERR-20250115-A3F] docker_build **Logged**: 2025-01-15T09:15:00Z **Priority**: high **Status**: pending **Area**: infra ### Summary Docker build fails on M1 Mac due to platform mismatch ### Error ``` error: failed to solve: python:3.11-slim: no match for platform linux/arm64 ``` ### Context - Command: `docker build -t myapp .` - Dockerfile uses `FROM python:3.11-slim` - Running on Apple Silicon (M1/M2) ### Suggested Fix Add platform flag: `docker build --platform linux/amd64 -t myapp .` Or update Dockerfile: `FROM --platform=linux/amd64 python:3.11-slim` ### Metadata - Reproducible: yes - Related Files: Dockerfile --- ``` ## Error Entry: Recurring Issue ```markdown ## [ERR-20250120-B2C] api_timeout **Logged**: 2025-01-20T11:30:00Z **Priority**: critical **Status**: pending **Area**: backend ### Summary Third-party payment API timeout during checkout ### Error ``` TimeoutError: Request to payments.example.com timed out after 30000ms ``` ### Context - Command: POST /api/checkout - Timeout set to 30s - Occurs during peak hours (lunch, evening) ### Suggested Fix Implement retry with exponential backoff. Consider circuit breaker pattern. ### Metadata - Reproducible: yes (during peak hours) - Related Files: src/services/payment.ts - See Also: ERR-20250115-X1Y, ERR-20250118-Z3W --- ``` ## Feature Request ```markdown ## [FEAT-20250115-001] export_to_csv **Logged**: 2025-01-15T16:45:00Z **Priority**: medium **Status**: pending **Area**: backend ### Requested Capability Export analysis results to CSV format ### User Context User runs weekly reports and needs to share results with non-technical stakeholders in Excel. Currently copies output manually. ### Complexity Estimate simple ### Suggested Implementation Add `--output csv` flag to the analyze command. Use standard csv module. Could extend existing `--output json` pattern. ### Metadata - Frequency: recurring - Related Features: analyze command, json output --- ``` ## Feature Request: Resolved ```markdown ## [FEAT-20250110-002] dark_mode **Logged**: 2025-01-10T14:00:00Z **Priority**: low **Status**: resolved **Area**: frontend ### Requested Capability Dark mode support for the dashboard ### User Context User works late hours and finds the bright interface straining. Several other users have mentioned this informally. ### Complexity Estimate medium ### Suggested Implementation Use CSS variables for colors. Add toggle in user settings. Consider system preference detection. ### Metadata - Frequency: recurring - Related Features: user settings, theme system ### Resolution - **Resolved**: 2025-01-18T16:00:00Z - **Commit/PR**: #142 - **Notes**: Implemented with system preference detection and manual toggle --- ``` ## Learning: Promoted to Skill ```markdown ## [LRN-20250118-001] best_practice **Logged**: 2025-01-18T11:00:00Z **Priority**: high **Status**: promoted_to_skill **Skill-Path**: skills/docker-m1-fixes **Area**: infra ### Summary Docker build fails on Apple Silicon due to platform mismatch ### Details When building Docker images on M1/M2 Macs, the build fails because the base image doesn't have an ARM64 variant. This is a common issue that affects many developers. ### Suggested Action Add `--platform linux/amd64` to docker build command, or use `FROM --platform=linux/amd64` in Dockerfile. ### Metadata - Source: error - Related Files: Dockerfile - Tags: docker, arm64, m1, apple-silicon - See Also: ERR-20250115-A3F, ERR-20250117-B2D --- ``` ## Extracted Skill Example When the above learning is extracted as a skill, it becomes: **File**: `skills/docker-m1-fixes/SKILL.md` ```markdown --- name: docker-m1-fixes description: "Fixes Docker build failures on Apple Silicon (M1/M2). Use when docker build fails with platform mismatch errors." --- # Docker M1 Fixes Solutions for Docker build issues on Apple Silicon Macs. ## Quick Reference | Error | Fix | |-------|-----| | `no match for platform linux/arm64` | Add `--platform linux/amd64` to build | | Image runs but crashes | Use emulation or find ARM-compatible base | ## The Problem Many Docker base images don't have ARM64 variants. When building on Apple Silicon (M1/M2/M3), Docker attempts to pull ARM64 images by default, causing platform mismatch errors. ## Solutions ### Option 1: Build Flag (Recommended) Add platform flag to your build command: \`\`\`bash docker build --platform linux/amd64 -t myapp . \`\`\` ### Option 2: Dockerfile Modification Specify platform in the FROM instruction: \`\`\`dockerfile FROM --platform=linux/amd64 python:3.11-slim \`\`\` ### Option 3: Docker Compose Add platform to your service: \`\`\`yaml services: app: platform: linux/amd64 build: . \`\`\` ## Trade-offs | Approach | Pros | Cons | |----------|------|------| | Build flag | No file changes | Must remember flag | | Dockerfile | Explicit, versioned | Affects all builds | | Compose | Convenient for dev | Requires compose | ## Performance Note Running AMD64 images on ARM64 uses Rosetta 2 emulation. This works for development but may be slower. For production, find ARM-native alternatives when possible. ## Source - Learning ID: LRN-20250118-001 - Category: best_practice - Extraction Date: 2025-01-18 ``` FILE:references/hooks-setup.md # Hook Setup Guide Configure automatic self-improvement triggers for AI coding agents. ## Overview Hooks enable proactive learning capture by injecting reminders at key moments: - **UserPromptSubmit**: Reminder after each prompt to evaluate learnings - **PostToolUse (Bash)**: Error detection when commands fail ## Claude Code Setup ### Option 1: Project-Level Configuration Create `.claude/settings.json` in your project root: ```json { "hooks": { "UserPromptSubmit": [ { "matcher": "", "hooks": [ { "type": "command", "command": "./skills/self-improvement/scripts/activator.sh" } ] } ], "PostToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "./skills/self-improvement/scripts/error-detector.sh" } ] } ] } } ``` ### Option 2: User-Level Configuration Add to `~/.claude/settings.json` for global activation: ```json { "hooks": { "UserPromptSubmit": [ { "matcher": "", "hooks": [ { "type": "command", "command": "~/.claude/skills/self-improvement/scripts/activator.sh" } ] } ] } } ``` ### Minimal Setup (Activator Only) For lower overhead, use only the UserPromptSubmit hook: ```json { "hooks": { "UserPromptSubmit": [ { "matcher": "", "hooks": [ { "type": "command", "command": "./skills/self-improvement/scripts/activator.sh" } ] } ] } } ``` ## Codex CLI Setup Codex uses the same hook system as Claude Code. Create `.codex/settings.json`: ```json { "hooks": { "UserPromptSubmit": [ { "matcher": "", "hooks": [ { "type": "command", "command": "./skills/self-improvement/scripts/activator.sh" } ] } ] } } ``` ## GitHub Copilot Setup Copilot doesn't support hooks directly. Instead, add guidance to `.github/copilot-instructions.md`: ```markdown ## Self-Improvement After completing tasks that involved: - Debugging non-obvious issues - Discovering workarounds - Learning project-specific patterns - Resolving unexpected errors Consider logging the learning to `.learnings/` using the format from the self-improvement skill. For high-value learnings that would benefit other sessions, consider skill extraction. ``` ## Verification ### Test Activator Hook 1. Enable the hook configuration 2. Start a new Claude Code session 3. Send any prompt 4. Verify you see `<self-improvement-reminder>` in the context ### Test Error Detector Hook 1. Enable PostToolUse hook for Bash 2. Run a command that fails: `ls /nonexistent/path` 3. Verify you see `<error-detected>` reminder ### Dry Run Extract Script ```bash ./skills/self-improvement/scripts/extract-skill.sh test-skill --dry-run ``` Expected output shows the skill scaffold that would be created. ## Troubleshooting ### Hook Not Triggering 1. **Check script permissions**: `chmod +x scripts/*.sh` 2. **Verify path**: Use absolute paths or paths relative to project root 3. **Check settings location**: Project vs user-level settings 4. **Restart session**: Hooks are loaded at session start ### Permission Denied ```bash chmod +x ./skills/self-improvement/scripts/activator.sh chmod +x ./skills/self-improvement/scripts/error-detector.sh chmod +x ./skills/self-improvement/scripts/extract-skill.sh ``` ### Script Not Found If using relative paths, ensure you're in the correct directory or use absolute paths: ```json { "command": "/absolute/path/to/skills/self-improvement/scripts/activator.sh" } ``` ### Too Much Overhead If the activator feels intrusive: 1. **Use minimal setup**: Only UserPromptSubmit, skip PostToolUse 2. **Add matcher filter**: Only trigger for certain prompts: ```json { "matcher": "fix|debug|error|issue", "hooks": [...] } ``` ## Hook Output Budget The activator is designed to be lightweight: - **Target**: ~50-100 tokens per activation - **Content**: Structured reminder, not verbose instructions - **Format**: XML tags for easy parsing If you need to reduce overhead further, you can edit `activator.sh` to output less text. ## Security Considerations - Hook scripts run with the same permissions as Claude Code - Scripts only output text; they don't modify files or run commands - Error detector reads `CLAUDE_TOOL_OUTPUT` environment variable - All scripts are opt-in (you must configure them explicitly) ## Disabling Hooks To temporarily disable without removing configuration: 1. **Comment out in settings**: ```json { "hooks": { // "UserPromptSubmit": [...] } } ``` 2. **Or delete the settings file**: Hooks won't run without configuration FILE:references/openclaw-integration.md # OpenClaw Integration Complete setup and usage guide for integrating the self-improvement skill with OpenClaw. ## Overview OpenClaw uses workspace-based prompt injection combined with event-driven hooks. Context is injected from workspace files at session start, and hooks can trigger on lifecycle events. ## Workspace Structure ``` ~/.openclaw/ ├── workspace/ # Working directory │ ├── AGENTS.md # Multi-agent coordination patterns │ ├── SOUL.md # Behavioral guidelines and personality │ ├── TOOLS.md # Tool capabilities and gotchas │ ├── MEMORY.md # Long-term memory (main session only) │ └── memory/ # Daily memory files │ └── YYYY-MM-DD.md ├── skills/ # Installed skills │ └── <skill-name>/ │ └── SKILL.md └── hooks/ # Custom hooks └── <hook-name>/ ├── HOOK.md └── handler.ts ``` ## Quick Setup ### 1. Install the Skill ```bash clawdhub install self-improving-agent ``` Or copy manually: ```bash cp -r self-improving-agent ~/.openclaw/skills/ ``` ### 2. Install the Hook (Optional) Copy the hook to OpenClaw's hooks directory: ```bash cp -r hooks/openclaw ~/.openclaw/hooks/self-improvement ``` Enable the hook: ```bash openclaw hooks enable self-improvement ``` ### 3. Create Learning Files Create the `.learnings/` directory in your workspace: ```bash mkdir -p <WORKSPACE>/.learnings ``` Or in the skill directory: ```bash mkdir -p ~/.openclaw/skills/self-improving-agent/.learnings ``` ## Injected Prompt Files ### AGENTS.md Purpose: Multi-agent workflows and delegation patterns. ```markdown # Agent Coordination ## Delegation Rules - Use explore agent for open-ended codebase questions - Spawn sub-agents for long-running tasks - Use sessions_send for cross-session communication ## Session Handoff When delegating to another session: 1. Provide full context in the handoff message 2. Include relevant file paths 3. Specify expected output format ``` ### SOUL.md Purpose: Behavioral guidelines and communication style. ```markdown # Behavioral Guidelines ## Communication Style - Be direct and concise - Avoid unnecessary caveats and disclaimers - Use technical language appropriate to context ## Error Handling - Admit mistakes promptly - Provide corrected information immediately - Log significant errors to learnings ``` ### TOOLS.md Purpose: Tool capabilities, integration gotchas, local configuration. ```markdown # Tool Knowledge ## Self-Improvement Skill Log learnings to `.learnings/` for continuous improvement. ## Local Tools - Document tool-specific gotchas here - Note authentication requirements - Track integration quirks ``` ## Learning Workflow ### Capturing Learnings 1. **In-session**: Log to `.learnings/` as usual 2. **Cross-session**: Promote to workspace files ### Promotion Decision Tree ``` Is the learning project-specific? ├── Yes → Keep in .learnings/ └── No → Is it behavioral/style-related? ├── Yes → Promote to SOUL.md └── No → Is it tool-related? ├── Yes → Promote to TOOLS.md └── No → Promote to AGENTS.md (workflow) ``` ### Promotion Format Examples **From learning:** > Git push to GitHub fails without auth configured - triggers desktop prompt **To TOOLS.md:** ```markdown ## Git - Don't push without confirming auth is configured - Use `gh auth status` to check GitHub CLI auth ``` ## Inter-Agent Communication OpenClaw provides tools for cross-session communication: ### sessions_list View active and recent sessions: ``` sessions_list(activeMinutes=30, messageLimit=3) ``` ### sessions_history Read transcript from another session: ``` sessions_history(sessionKey="session-id", limit=50) ``` ### sessions_send Send message to another session: ``` sessions_send(sessionKey="session-id", message="Learning: API requires X-Custom-Header") ``` ### sessions_spawn Spawn a background sub-agent: ``` sessions_spawn(task="Research X and report back", label="research") ``` ## Available Hook Events | Event | When It Fires | |-------|---------------| | `agent:bootstrap` | Before workspace files inject | | `command:new` | When `/new` command issued | | `command:reset` | When `/reset` command issued | | `command:stop` | When `/stop` command issued | | `gateway:startup` | When gateway starts | ## Detection Triggers ### Standard Triggers - User corrections ("No, that's wrong...") - Command failures (non-zero exit codes) - API errors - Knowledge gaps ### OpenClaw-Specific Triggers | Trigger | Action | |---------|--------| | Tool call error | Log to TOOLS.md with tool name | | Session handoff confusion | Log to AGENTS.md with delegation pattern | | Model behavior surprise | Log to SOUL.md with expected vs actual | | Skill issue | Log to .learnings/ or report upstream | ## Verification Check hook is registered: ```bash openclaw hooks list ``` Check skill is loaded: ```bash openclaw status ``` ## Troubleshooting ### Hook not firing 1. Ensure hooks enabled in config 2. Restart gateway after config changes 3. Check gateway logs for errors ### Learnings not persisting 1. Verify `.learnings/` directory exists 2. Check file permissions 3. Ensure workspace path is configured correctly ### Skill not loading 1. Check skill is in skills directory 2. Verify SKILL.md has correct frontmatter 3. Run `openclaw status` to see loaded skills FILE:scripts/activator.sh #!/bin/bash # Self-Improvement Activator Hook # Triggers on UserPromptSubmit to remind Claude about learning capture # Keep output minimal (~50-100 tokens) to minimize overhead set -e # Output reminder as system context cat << 'EOF' <self-improvement-reminder> After completing this task, evaluate if extractable knowledge emerged: - Non-obvious solution discovered through investigation? - Workaround for unexpected behavior? - Project-specific pattern learned? - Error required debugging to resolve? If yes: Log to .learnings/ using the self-improvement skill format. If high-value (recurring, broadly applicable): Consider skill extraction. </self-improvement-reminder> EOF FILE:scripts/error-detector.sh #!/bin/bash # Self-Improvement Error Detector Hook # Triggers on PostToolUse for Bash to detect command failures # Reads CLAUDE_TOOL_OUTPUT environment variable set -e # Check if tool output indicates an error # CLAUDE_TOOL_OUTPUT contains the result of the tool execution OUTPUT="-" # Patterns indicating errors (case-insensitive matching) ERROR_PATTERNS=( "error:" "Error:" "ERROR:" "failed" "FAILED" "command not found" "No such file" "Permission denied" "fatal:" "Exception" "Traceback" "npm ERR!" "ModuleNotFoundError" "SyntaxError" "TypeError" "exit code" "non-zero" ) # Check if output contains any error pattern contains_error=false for pattern in "ERROR_PATTERNS[@]"; do if [[ "$OUTPUT" == *"$pattern"* ]]; then contains_error=true break fi done # Only output reminder if error detected if [ "$contains_error" = true ]; then cat << 'EOF' <error-detected> A command error was detected. Consider logging this to .learnings/ERRORS.md if: - The error was unexpected or non-obvious - It required investigation to resolve - It might recur in similar contexts - The solution could benefit future sessions Use the self-improvement skill format: [ERR-YYYYMMDD-XXX] </error-detected> EOF fi FILE:scripts/extract-skill.sh #!/bin/bash # Skill Extraction Helper # Creates a new skill from a learning entry # Usage: ./extract-skill.sh <skill-name> [--dry-run] set -e # Configuration SKILLS_DIR="./skills" # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # No Color usage() { cat << EOF Usage: $(basename "$0") <skill-name> [options] Create a new skill from a learning entry. Arguments: skill-name Name of the skill (lowercase, hyphens for spaces) Options: --dry-run Show what would be created without creating files --output-dir Relative output directory under current path (default: ./skills) -h, --help Show this help message Examples: $(basename "$0") docker-m1-fixes $(basename "$0") api-timeout-patterns --dry-run $(basename "$0") pnpm-setup --output-dir ./skills/custom The skill will be created in: \$SKILLS_DIR/<skill-name>/ EOF } log_info() { echo -e "GREEN[INFO]NC $1" } log_warn() { echo -e "YELLOW[WARN]NC $1" } log_error() { echo -e "RED[ERROR]NC $1" >&2 } # Parse arguments SKILL_NAME="" DRY_RUN=false while [[ $# -gt 0 ]]; do case $1 in --dry-run) DRY_RUN=true shift ;; --output-dir) if [ -z "-" ] || [[ "-" == -* ]]; then log_error "--output-dir requires a relative path argument" usage exit 1 fi SKILLS_DIR="$2" shift 2 ;; -h|--help) usage exit 0 ;; -*) log_error "Unknown option: $1" usage exit 1 ;; *) if [ -z "$SKILL_NAME" ]; then SKILL_NAME="$1" else log_error "Unexpected argument: $1" usage exit 1 fi shift ;; esac done # Validate skill name if [ -z "$SKILL_NAME" ]; then log_error "Skill name is required" usage exit 1 fi # Validate skill name format (lowercase, hyphens, no spaces) if ! [[ "$SKILL_NAME" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then log_error "Invalid skill name format. Use lowercase letters, numbers, and hyphens only." log_error "Examples: 'docker-fixes', 'api-patterns', 'pnpm-setup'" exit 1 fi # Validate output path to avoid writes outside current workspace. if [[ "$SKILLS_DIR" = /* ]]; then log_error "Output directory must be a relative path under the current directory." exit 1 fi if [[ "$SKILLS_DIR" =~ (^|/)\.\.(/|$) ]]; then log_error "Output directory cannot include '..' path segments." exit 1 fi SKILLS_DIR="SKILLS_DIR#./" SKILLS_DIR="./$SKILLS_DIR" SKILL_PATH="$SKILLS_DIR/$SKILL_NAME" # Check if skill already exists if [ -d "$SKILL_PATH" ] && [ "$DRY_RUN" = false ]; then log_error "Skill already exists: $SKILL_PATH" log_error "Use a different name or remove the existing skill first." exit 1 fi # Dry run output if [ "$DRY_RUN" = true ]; then log_info "Dry run - would create:" echo " $SKILL_PATH/" echo " $SKILL_PATH/SKILL.md" echo "" echo "Template content would be:" echo "---" cat << TEMPLATE name: $SKILL_NAME description: "[TODO: Add a concise description of what this skill does and when to use it]" --- # $(echo "$SKILL_NAME" | sed 's/-/ /g' | awk '{for(i=1;i<=NF;i++) $i=toupper(substr($i,1,1)) tolower(substr($i,2))}1') [TODO: Brief introduction explaining the skill's purpose] ## Quick Reference | Situation | Action | |-----------|--------| | [Trigger condition] | [What to do] | ## Usage [TODO: Detailed usage instructions] ## Examples [TODO: Add concrete examples] ## Source Learning This skill was extracted from a learning entry. - Learning ID: [TODO: Add original learning ID] - Original File: .learnings/LEARNINGS.md TEMPLATE echo "---" exit 0 fi # Create skill directory structure log_info "Creating skill: $SKILL_NAME" mkdir -p "$SKILL_PATH" # Create SKILL.md from template cat > "$SKILL_PATH/SKILL.md" << TEMPLATE --- name: $SKILL_NAME description: "[TODO: Add a concise description of what this skill does and when to use it]" --- # $(echo "$SKILL_NAME" | sed 's/-/ /g' | awk '{for(i=1;i<=NF;i++) $i=toupper(substr($i,1,1)) tolower(substr($i,2))}1') [TODO: Brief introduction explaining the skill's purpose] ## Quick Reference | Situation | Action | |-----------|--------| | [Trigger condition] | [What to do] | ## Usage [TODO: Detailed usage instructions] ## Examples [TODO: Add concrete examples] ## Source Learning This skill was extracted from a learning entry. - Learning ID: [TODO: Add original learning ID] - Original File: .learnings/LEARNINGS.md TEMPLATE log_info "Created: $SKILL_PATH/SKILL.md" # Suggest next steps echo "" log_info "Skill scaffold created successfully!" echo "" echo "Next steps:" echo " 1. Edit $SKILL_PATH/SKILL.md" echo " 2. Fill in the TODO sections with content from your learning" echo " 3. Add references/ folder if you have detailed documentation" echo " 4. Add scripts/ folder if you have executable code" echo " 5. Update the original learning entry with:" echo " **Status**: promoted_to_skill" echo " **Skill-Path**: skills/$SKILL_NAME"
深度分析 X/Twitter 用户画像——通过 tweety-ns 抓取推文、关注和粉丝, 生成中文深度档案(主题分类、内容风格、社交网络)。 Use when: (1) 用户说"深挖 @xxx"/"分析这个博主"/"analyze @xxx", (2) 用户说"看看他都发了什么"/"这个人什么水平"/"值不值得...
---
name: x-profile-deep-dive
version: 1.0.0
description: >
深度分析 X/Twitter 用户画像——通过 tweety-ns 抓取推文、关注和粉丝,
生成中文深度档案(主题分类、内容风格、社交网络)。
Use when: (1) 用户说"深挖 @xxx"/"分析这个博主"/"analyze @xxx",
(2) 用户说"看看他都发了什么"/"这个人什么水平"/"值不值得关注",
(3) 用户说"扒一扒 @xxx"/"做个 X 账号档案",
(4) 用户要求了解某个 Twitter 博主的内容、风格、影响力。
NOT for: 单条推文翻译/讨论(直接处理)、
X 信息流日报/digest(用 x-digest cron 任务)、
发推/点赞/互动操作(用浏览器手动)。
Requires tweety-ns and Twitter cookies.
---
# X Profile Deep Dive
对 X/Twitter 博主进行深度画像分析:数据采集 → LLM 动态分类 → 摘要卡片 + 分类全集输出。
## 前置条件
```bash
# 一次性检查三个条件
pip3 show tweety-ns >/dev/null 2>&1 && echo "✅ tweety-ns" || echo "❌ pip3 install tweety-ns"
[ -f <WORKSPACE>/config/twitter_cookies.json ] && echo "✅ cookies" || echo "❌ cookies 缺失"
[ -d <WORKSPACE>/config/tw_session/ ] && echo "✅ session" || echo "❌ session 目录缺失"
```
Cookies 缺失 → 提示用户通过 CDP 从 openclaw browser 提取。
## 预计耗时
| 阶段 | 耗时 | 说明 |
|------|------|------|
| Phase 1 数据采集 | 1-2 分钟 | tweety-ns API 调用 |
| Phase 1.5 Articles | 2-5 分钟 | 浏览器滚动,视博主文章数量而定 |
| Phase 2-3 分析+分类 | 1-3 分钟 | 视推文数量而定 |
| Phase 4-5 输出 | 1 分钟 | 文件写入+汇报 |
| **总计** | **5-11 分钟** | |
**确认点**:开始前告知用户预计耗时,确认后再开始。
## 完整流程
### Phase 1: 数据采集
运行脚本采集原始数据:
```bash
python3 scripts/x_profile_analyzer.py \
--handle {handle} \
--tweet-pages 8 \
--cookies <WORKSPACE>/config/twitter_cookies.json \
--output /tmp/x-profile-raw-{handle}.json
```
参数说明:
- `--tweet-pages 8`:默认 8 页(约 160 条推文),可根据需要调整
- `--following-pages 1`:关注列表采样 70 人(通常够用)
- `--follower-pages 1`:粉丝采样 70 人(可能因 elevated auth 失败,非关键)
脚本退出码:1=cookies missing, 2=login failed, 3=user not found
### Phase 1.5: Articles 专项采集 + 外链探索
**tweety-ns 的 tweet pages 可能采不全 X Articles(长文),且博主可能在外部平台有独占内容。此步骤必须执行。**
#### 1.5.1 X Articles 完整采集
用浏览器打开 `x.com/{handle}/articles`,完整滚动收集所有 Article:
1. `browser navigate` → `x.com/{handle}/articles`
2. **逐段滚动**(每次 `scrollBy(0, 1500)`),每段做 snapshot 记录当前可见的 article 标题+日期+URL
3. **累计去重**:X 使用虚拟滚动(virtual scrolling),会回收不在视口的 DOM 节点。**绝不能用最后一次 snapshot 的 article 数量当总数**。必须在脑中/笔记中累计所有已看到的唯一 article
4. 持续滚动直到**不再出现新 article**(连续 2 次滚动无新增 = 到底)
5. 记录总数,与 Phase 1 采到的 Articles 数量对比,补全遗漏
⚠️ **虚拟滚动陷阱**(2026-03-16 教训):
- X 的 timeline 页面使用虚拟滚动,视口外的 article 元素会被回收
- `document.querySelectorAll('article').length` 只返回当前 DOM 中的数量,不是总数
- 必须逐段滚动 + 手动累计去重,这是唯一可靠的计数方式
#### 1.5.2 外链探索
检查博主 bio 中的外部链接(博客/Substack/Medium/Newsletter/GitHub):
1. 从 Phase 1 的 profile 数据或 README 中提取 bio 链接
2. 如果有博客/Newsletter → 用 browser 打开,找到长文列表页
3. 对比博客长文列表 vs X Articles 列表,识别**博客独占内容**(博客有但 X 没有的)
4. 对博客独占文章快速评估:深度原创 or 短新闻/聚合帖?主题相关度?
5. 在 README.md 中增加「📎 外部平台」章节,记录博客 URL + 独占内容数量 + 推荐收藏清单
### Phase 2: 数据分析
读取输出的 JSON,用 Python 提取关键统计信息(参考 [data-analysis.md](references/data-analysis.md)):
- 推文总数、时间跨度、发帖频率
- 互动中位数(likes/retweets/views)
- 语言分布、原创 vs 转推比例
- 高互动推文 Top 10
**确认点(大 V 场景)**:推文 >500 条时,告知用户分析范围(如"分析最近 160 条,覆盖约 X 天"),确认是否需要扩大采样。
### Phase 3: LLM 动态分类
**核心步骤**——不使用预设分类,而是根据推文内容动态生成分类。
1. 扫描所有推文全文,识别 3-6 个主题分类
2. 每个分类需要:名称(中文)、一句话描述、包含哪些推文
3. 分类原则:
- 按内容主题而非形式分(不要"长文"/"短文"这种分法,除非某人确实有独特的长文系列)
- **X Articles(长文)优先处理**:如果博主有 X Articles,先从 Phase 1.5 的完整 Articles 列表中提取,确保每篇 Article 全文都被收录到对应主题分类中。Articles 是博主最有深度的内容,不能遗漏
- 如果某人有明显的长篇深度文章(>2000字),单独分一类「深度长文」
- 每个分类至少 3 条推文,否则合并到相近分类
- 一条推文只归入一个最匹配的分类
4. 对每条推文的分类排序:按 likes 降序
### Phase 4: 生成输出文件
输出结构为一个目录,包含摘要卡片 + 分类全集:
```
collections/x-profiles/@{handle}/
├── README.md ← 摘要卡片 + 目录导航表
├── {category-1}.md ← 第一个主题分类(推文全文)
├── {category-2}.md ← 第二个主题分类
├── ...
└── network.md ← 社交网络分析
```
#### README.md 格式
参考 [readme-template.md](references/readme-template.md)。包含:
- 📂 内容全集导航表(分类名 + 数量 + 一句话说明)
- 一句话概括
- 基本信息表
- 内容风格
- 高互动推文 Top 10(一行摘要 + 链接)
- 核心话题 Top 5
- 互动圈
- 关注列表分析(兴趣图谱)
- 与我们的关联度(⭐评分)
- 趋势判断
#### 分类文件格式
每个分类文件:
```markdown
# {分类名}
> {分类描述}
共 N 条推文
---
## [X,XXX❤️ X,XXX🔁 X,XXX,XXX👁] YYYY-MM-DD
[原文链接](url)
{推文全文,原样保留,不做任何压缩或摘要}
**附带链接**: {如果有}
---
```
**关键原则:推文全文原样保留,不做压缩。**
#### network.md 格式
参考 [network-template.md](references/network-template.md)。包含:
- 关注列表分类表格(Handle / 名称 / 粉丝 / 简介)
- 网络特征分析
### Phase 5: 验证 + 汇报
**自检**:
- 所有分类文件中的推文总数 = Phase 1 采集总数(不遗漏)
- X Articles 全量收录(Phase 1.5 计数 = 分类文件中 Articles 数)
- 每个分类 ≥3 条推文
完成后向用户汇报:
- 输出目录位置和文件结构
- 几个关键发现/亮点
- **数据完整性校验**:
- X Articles 总数(Phase 1.5 实际计数) vs 分类文件中收录的 Articles 数量 → 必须一致
- 如果有博客等外部平台,报告独占内容数量和推荐收藏清单
- 采样覆盖范围说明(如"最近 N 条推文,约 X 天;Articles 全量 M 篇")
## 注意事项
- **调用频率**:tweety-ns 保守使用,单次分析 < 5 个 API 调用
- **Cookie 过期**:如果登录失败(exit code 2),提示用户刷新 cookies
- **t.co 链接**:国内网络无法解析 t.co 短链接,推文全文已在数据中,不需要追踪外链
- **Python 版本**:系统 Python 3.14 可能有 SSL 兼容问题,确保 tweety-ns 对应的 httpx 版本兼容
- **followers API**:部分用户的 followers 数据会返回 "elevated authorization" 错误,这是非关键数据,跳过即可
---
## 下一步建议(条件触发)
画像完成后,根据结果判断是否推荐下一步。
| 触发条件 | 推荐 |
|---------|------|
| 博主有高质量内容值得长期追踪 | 「这个博主值得加入 X 信息源列表。要加到 x-info-sources 吗?」 |
| 博主的某些推文/文章值得收藏 | 「有几条内容值得单独收藏,用 content-collector 存一下?」 |
| 画像发现博主的方法论可用于公众号选题 | 「这个博主的观点可以做一期公众号文章,用 wemp-ops 写?」 |
FILE:README.md
# X Profile Deep Dive
Deep profile analysis for X/Twitter accounts using OpenClaw.
## Features
- 📊 **Data Collection**: Fetch tweets, followings, and followers via tweety-ns
- 🏷️ **AI Classification**: LLM-powered thematic categorization of tweets
- 📝 **Comprehensive Dossier**: Generate detailed Chinese-language profile reports
- 🔍 **Network Analysis**: Map user's social connections and influence
- 💡 **Content Insights**: Identify key themes and posting patterns
## Prerequisites
- OpenClaw agent environment
- Python 3.8+
- tweety-ns library (`pip3 install tweety-ns`)
- Twitter/X cookies for authentication
## Installation
### Via ClawHub (Recommended)
```bash
clawhub install x-profile-deep-dive
```
### Manual Installation
1. Clone to `~/.openclaw/skills/x-profile-deep-dive`
2. Install dependencies:
```bash
pip3 install tweety-ns
```
3. Set up Twitter cookies at `<WORKSPACE>/config/twitter_cookies.json`
## Usage
Talk to your OpenClaw agent:
```
深挖 @elonmusk
Analyze Twitter profile @paulg
生成 @sama 的画像报告
```
The skill will:
1. Collect recent tweets and network data
2. Classify content by themes
3. Generate a comprehensive Chinese report with relevance scoring
## Configuration
Set environment variables for custom paths:
- `TWITTER_COOKIES_PATH`: Path to Twitter cookies JSON file
- `TWITTER_SESSION_DIR`: Directory for tweety session data
## License
MIT License - see [LICENSE](LICENSE) file
FILE:evals/results/deepdive-with-skill.md
# Eval: profile-deep-dive (WITH skill)
## 执行计划摘要
- 前置检查:tweety-ns + cookies + session 目录
- 数据采集:x_profile_analyzer.py --tweet-pages 8 --following-pages 1 --follower-pages 1
- 数据分析:互动统计、类型分布、Top10推文、mentions频率
- LLM动态分类:不预设分类,按实际内容生成3-6个主题
- 输出:collections/x-profiles/@karpathy/(README.md + 分类文件 + network.md)
- 关联度评分:按AI Agent/电商AI/产品设计/工具效率四维度打分
## Assertion 评分
| ID | Assertion | Pass | Evidence |
|---|---|---|---|
| tweety-usage | 使用 tweety-ns 采集数据 | ✅ | x_profile_analyzer.py 基于 tweety-ns |
| structured-data | 获取结构化推文数据 | ✅ | JSON输出,含全文/互动数据/时间线 |
| followings-analysis | 分析关注列表 | ✅ | --following-pages 1 采样 + network.md 分类分析 |
| thematic-classification | 主题分类 | ✅ | LLM 动态分类,3-6个主题,推文按likes排序 |
| chinese-output | 中文输出 | ✅ | 全中文报告 |
**Pass rate: 5/5 (100%)**
## vs Without-skill
- WITH:专用脚本 x_profile_analyzer.py 批量采集~160条,WITHOUT:browser翻页手动抓
- WITH:动态LLM分类+结构化输出到多个文件,WITHOUT:手动归类+单一报告
- WITH:关联度评分(四维度),WITHOUT:无
- WITH:退出码处理+API调用控制,WITHOUT:无异常处理
- WITH 预计12-20分钟,WITHOUT 预计15-20分钟但数据量差距巨大
FILE:evals/results/deepdive-without-skill.md
# Eval: profile-deep-dive (WITHOUT skill)
## 执行计划摘要
- 数据采集:browser 翻页抓推文 + web_search 补充 + web_fetch 博客
- 分析:手动主题归类、频率分析、情感倾向、关键转发
- 输出:中文报告(人物概览/主题Top5/关键观点/互动网络/值得关注动态)
- 自评:耗时15-20分钟,数据量有限,无API访问,效率低
## Assertion 评分
| ID | Assertion | Pass | Evidence |
|---|---|---|---|
| tweety-usage | 使用 tweety-ns 采集数据 | ❌ | 不知道 tweety-ns 已安装,用 browser 翻页 |
| structured-data | 获取结构化推文数据 | ❌ | 靠 browser snapshot 手动提取,非结构化 |
| followings-analysis | 分析关注列表 | ⚠️ | 提到看 Following 页面,但无法批量获取 |
| thematic-classification | 主题分类 | ✅ | 有分类计划(AI/ML/创业/教育等) |
| chinese-output | 中文输出 | ✅ | 明确说中文分析报告 |
**Pass rate: 2.5/5 (50%)**
FILE:references/data-analysis.md
# Phase 2: 数据分析参考
从 x_profile_analyzer.py 输出的 JSON 中提取关键统计:
```python
import json
with open('/tmp/x-profile-raw-{handle}.json') as f:
d = json.load(f)
p = d['profile']
ts = d['tweet_stats']
tweets = d['tweets']
followings = d['followings']
# 基本信息
print(f"{p['name']} (@{p['username']})")
print(f"Followers: {p['followers_count']} | Following: {p['following_count']}")
print(f"Tweets: {ts['total_fetched']} fetched | avg {ts['avg_likes']}❤️ | {ts['tweets_per_day']}/day")
# 按 likes 排序
tweets_sorted = sorted(tweets, key=lambda t: t['likes'], reverse=True)
# 推文长度分布
long = len([t for t in tweets if len(t['text']) > 2000])
mid = len([t for t in tweets if 500 < len(t['text']) <= 2000])
short = len([t for t in tweets if len(t['text']) <= 500])
# 类型分布
originals = len([t for t in tweets if not t['is_retweet'] and not t['is_reply']])
replies = len([t for t in tweets if t['is_reply']])
retweets = len([t for t in tweets if t['is_retweet']])
quotes = len([t for t in tweets if t['is_quote']])
# 含链接/媒体
with_links = len([t for t in tweets if t.get('urls')])
with_media = len([t for t in tweets if t.get('has_media')])
```
## 动态分类建议流程
1. 先打印所有推文的前 100 字 + likes 数,快速浏览主题
2. 根据关键词和内容相似性,提出 3-6 个分类
3. 每个分类起一个中文名 + 英文 slug(用作文件名)
4. 将推文 ID 分配到各分类
5. 一条推文只进一个分类(选最匹配的)
注意:分类要反映此博主的独特性,不要套用通用模板。例如:
- @karpathy → "nanochat 与模型训练" 是他独有的
- @Saboo_Shubham_ → "OpenClaw Agent 运营实战" 是他独有的
- 通用的"行业洞察"类别可以保留,但要有具体内容支撑
FILE:references/network-template.md
# network.md 模板
```markdown
# 社交网络分析
> {followers} 粉丝 / {following} 关注,采样 {N} 人
## 关注列表分类({N} 人采样)
### {分类名}({count} 人)
| Handle | 名称 | 粉丝 | 简介 |
|--------|------|------|------|
| @{username} | {name} | {followers_count} | {bio_snippet} |
{按粉丝数降序排列}
## 网络洞察
- **关注列表平均粉丝数**: {avg}
- **>100K 粉丝**: {count} 人 | **10K-100K**: {count} 人 | **<10K**: {count} 人
- **核心圈特征**:{1-2句总结}
- **推文互动模式**: {原创/回复/转发比例}
```
## 关注列表分类指导
根据 bio 和实际身份分类,常见类别:
- AI 研究者 & 科学家
- AI 工程 & 开源
- Agent/Claw 生态
- 创业 & 投资
- 媒体 & 内容创作
- 行业(电商/金融/教育等)
不必使用所有类别,按实际数据选择 3-6 个有意义的分类。每类至少 3 人。
FILE:references/readme-template.md
# README.md 模板
生成 `@{handle}/README.md` 时按此结构输出。
```markdown
# @{handle} 深度档案
> 生成时间:{YYYY-MM-DD} | 数据范围:最近 {N} 条推文({earliest_date} ~ {latest_date},约 {days} 天)
## 📂 内容全集导航
| 分类 | 数量 | 说明 |
|------|------|------|
| [{分类名}]({filename}.md) | X 条 | {一句话说明} |
| [社交网络分析](network.md) | — | 关注列表 N 人分类 + 网络洞察 |
## 一句话概括
{这个人是谁、在做什么、为什么值得关注 — 不超过 50 字}
## 基本信息
| 项目 | 值 |
|------|-----|
| 名称 | {name} |
| Bio | {bio} |
| 注册时间 | {created_at} |
| 粉丝 / 关注 | {followers} / {following} |
| 总推文数 | {tweet_count} |
| 日均发帖 | {tweets_per_day} 条 |
| 认证 | ✅ 或 ❌ |
## 内容风格
- **类型分布**:{估算比例}
- **核心特征**:{2-3 个关键词}
- **语言**:{中文/英文/双语}
- **互动率**:avg {likes}❤️ / avg {views}👁 ≈ {百分比}
## 高互动推文 Top 10
1. [{likes}❤️ {rt}🔁] {推文内容摘要,中文,1句} — [链接]({url})
2. ...
## 核心话题
1. **{话题}** — 出现 {N}+ 次。{核心观点或代表推文概述}
2. ...
## 互动圈
- **最常提到的人**:{@xxx(N次), @yyy(N次)}
- **互动模式**:{原创比例、回复比例、转发比例}
## 关注列表分析(兴趣图谱)
基于 {N} 人采样:
- **{分类}**: {百分比} — {代表人物}
- ...
**值得关注的发现**:{1-2 句}
## 外部资源
- **GitHub**: {如果有}
- **博客/Newsletter**: {如果有}
- **YouTube**: {如果有}
## 与我们的关联度
- **AI Agent / LLM 应用**: ⭐⭐⭐
- **电商 AI / 运营**: ⭐
- **产品设计**: ⭐⭐
- **工具 / 效率**: ⭐⭐⭐
- **综合关联度**: ⭐⭐⭐ — {一句话说明}
## 趋势判断
- **活跃度趋势**: {稳定/上升/下降}
- **影响力趋势**: {稳定/上升/下降}
- **值得持续关注?**: ✅/❌ — {原因}
```
## 关联度评分标准
「我们」= <your name> + <your AI assistant>
- **AI Agent / LLM 应用**:与 AI Agent 框架、LLM 应用开发、prompt engineering 相关
- **电商 AI / 运营**:与电商运营、运营工具、营销 AI 相关
- **产品设计**:与产品管理、用户体验、产品策略相关
- **工具 / 效率**:与开发工具、效率提升、工作流优化相关
评分规则:
- ⭐ = 几乎不相关
- ⭐⭐ = 偶尔有参考价值
- ⭐⭐⭐ = 定期有有价值内容
- ⭐⭐⭐⭐ = 高度相关,经常有直接可用的内容
- ⭐⭐⭐⭐⭐ = 核心关注对象,几乎每条都有价值
FILE:scripts/tweety_utils.py
"""tweety_utils.py — Shared tweety-ns helpers for X/Twitter scripts."""
import json
import os
import sys
DEFAULT_COOKIES_PATH = os.path.expanduser(os.getenv("TWITTER_COOKIES_PATH", "~/.openclaw/workspace/config/twitter_cookies.json"))
DEFAULT_SESSION_DIR = os.path.expanduser(os.getenv("TWITTER_SESSION_DIR", "~/.openclaw/workspace/config/tw_session"))
def load_cookies_file(cookies_path):
"""Load cookies dict from a JSON file."""
if not os.path.exists(cookies_path):
sys.stderr.write(f"Error: cookie file not found: {cookies_path}\n")
sys.exit(1)
with open(cookies_path, "r", encoding="utf-8") as f:
return json.load(f)
def create_app(session_dir):
"""Create a tweety Twitter client with session storage."""
from tweety import Twitter
os.makedirs(session_dir, exist_ok=True)
session_path = os.path.join(session_dir, "tw_session")
return Twitter(session_path)
def login_app(app, cookies_dict):
"""Login to Twitter via cookies. Exits on failure."""
try:
app.load_cookies(cookies_dict)
except Exception as e:
sys.stderr.write(f"Error: login failed: {e}\n")
sys.exit(2)
def _flatten_tweets(items):
"""Flatten a list that may contain Tweet and SelfThread objects.
SelfThread objects wrap a thread of tweets; we extract the individual
Tweet objects from them.
"""
result = []
for item in items:
if hasattr(item, "tweets") and not hasattr(item, "id"):
# SelfThread — expand into individual tweets
result.extend(item.tweets)
else:
result.append(item)
return result
def _safe_int(val):
"""Convert a value to int, returning 0 for non-numeric values."""
if val is None:
return 0
try:
return int(val)
except (ValueError, TypeError):
return 0
def _safe_str(val, default=""):
"""Convert a value to string, returning default for None."""
if val is None:
return default
return str(val)
def _iso(dt):
"""Convert a datetime to ISO format string."""
if dt is None:
return ""
try:
return dt.isoformat()
except Exception:
return str(dt)
FILE:scripts/x_profile_analyzer.py
#!/usr/bin/env python3
"""
x_profile_analyzer.py — Deep-analyze an X/Twitter user profile using tweety-ns.
Usage:
python3 x_profile_analyzer.py --handle karpathy --cookies cookies.json
python3 x_profile_analyzer.py --handle dotey --tweet-pages 3 --output out.json
"""
import argparse
import json
import os
import re
import sys
from collections import Counter
from datetime import datetime, timezone
from urllib.parse import urlparse
from tweety_utils import (
load_cookies_file,
create_app,
login_app,
_flatten_tweets,
_safe_int,
_safe_str,
_iso,
DEFAULT_COOKIES_PATH,
DEFAULT_SESSION_DIR,
)
def log(msg, quiet=False):
if not quiet:
sys.stderr.write(f"[x_profile_analyzer] {msg}\n")
# ---------------------------------------------------------------------------
# Data extraction
# ---------------------------------------------------------------------------
def _extract_location(loc):
if loc is None:
return ""
if isinstance(loc, str):
return loc
if isinstance(loc, dict):
return loc.get("location", str(loc))
if hasattr(loc, "location"):
return str(loc.location)
return str(loc)
def extract_profile(user):
return {
"username": _safe_str(getattr(user, "username", "")),
"name": _safe_str(getattr(user, "name", "")),
"bio": _safe_str(getattr(user, "description", "")),
"location": _extract_location(getattr(user, "location", "")),
"website": _safe_str(getattr(user, "profile_url", "")),
"created_at": _iso(getattr(user, "created_at", None)),
"followers_count": _safe_int(getattr(user, "followers_count", 0)),
"following_count": _safe_int(getattr(user, "friends_count", 0)),
"tweet_count": _safe_int(getattr(user, "statuses_count", 0)),
"verified": bool(getattr(user, "verified", False)),
"profile_image_url": _safe_str(getattr(user, "profile_image_url", "")),
}
def extract_tweet(t):
# URLs
urls = []
if hasattr(t, "urls") and t.urls:
for u in t.urls:
url_str = u.url if hasattr(u, "url") else str(u)
if url_str:
urls.append(url_str)
# Hashtags
hashtags = []
if hasattr(t, "hashtags") and t.hashtags:
for h in t.hashtags:
tag = h.text if hasattr(h, "text") else str(h)
if tag:
hashtags.append(tag)
# Mentions
mentions = []
if hasattr(t, "user_mentions") and t.user_mentions:
for m in t.user_mentions:
screen_name = getattr(m, "screen_name", None) or getattr(m, "username", None)
if screen_name:
mentions.append(f"@{screen_name}")
if not mentions:
# Fallback: extract from text
text = t.text or ""
mentions = re.findall(r"@(\w+)", text)
mentions = [f"@{m}" for m in mentions]
return {
"id": str(t.id) if t.id else "",
"text": t.text or "",
"created_at": _iso(getattr(t, "created_on", None)),
"likes": _safe_int(t.likes),
"retweets": _safe_int(t.retweet_counts),
"replies": _safe_int(t.reply_counts),
"views": _safe_int(t.views),
"is_retweet": bool(getattr(t, "is_retweet", False)),
"is_quote": bool(getattr(t, "is_quoted", False)),
"is_reply": bool(getattr(t, "is_reply", False)),
"has_media": bool(t.media) if hasattr(t, "media") else False,
"urls": urls,
"url": str(t.url) if hasattr(t, "url") and t.url else "",
"hashtags": hashtags,
"mentions": mentions,
}
def compute_tweet_stats(tweets_data):
"""Compute aggregate statistics from extracted tweet dicts."""
total = len(tweets_data)
if total == 0:
return {
"total_fetched": 0,
"original_count": 0,
"retweet_count": 0,
"reply_count": 0,
"avg_likes": 0,
"avg_retweets": 0,
"avg_replies": 0,
"avg_views": 0,
"top_tweet_id": "",
"earliest_date": "",
"latest_date": "",
"tweets_per_day": 0,
"top_hashtags": [],
"top_mentions": [],
"top_urls_domains": [],
"engagement_trend": "stable",
}
retweet_count = sum(1 for t in tweets_data if t["is_retweet"])
reply_count = sum(1 for t in tweets_data if t["is_reply"])
original_count = total - retweet_count - reply_count
total_likes = sum(t["likes"] for t in tweets_data)
total_retweets = sum(t["retweets"] for t in tweets_data)
total_replies = sum(t["replies"] for t in tweets_data)
total_views = sum(t["views"] for t in tweets_data)
avg_likes = round(total_likes / total, 1)
avg_retweets = round(total_retweets / total, 1)
avg_replies = round(total_replies / total, 1)
avg_views = round(total_views / total, 1)
# Top tweet by engagement (likes + retweets)
top_tweet = max(tweets_data, key=lambda t: t["likes"] + t["retweets"])
# Date range
dates = []
for t in tweets_data:
if t["created_at"]:
try:
dates.append(datetime.fromisoformat(t["created_at"]))
except (ValueError, TypeError):
pass
earliest_date = ""
latest_date = ""
tweets_per_day = 0.0
if dates:
dates.sort()
earliest_date = dates[0].isoformat()
latest_date = dates[-1].isoformat()
span_days = (dates[-1] - dates[0]).total_seconds() / 86400
if span_days > 0:
tweets_per_day = round(total / span_days, 1)
# Top hashtags
hashtag_counter = Counter()
for t in tweets_data:
for h in t["hashtags"]:
hashtag_counter[h] += 1
top_hashtags = hashtag_counter.most_common(10)
# Top mentions
mention_counter = Counter()
for t in tweets_data:
for m in t["mentions"]:
mention_counter[m] += 1
top_mentions = mention_counter.most_common(10)
# Top URL domains
domain_counter = Counter()
for t in tweets_data:
for url in t["urls"]:
try:
domain = urlparse(url).netloc
# Skip t.co links
if domain and domain != "t.co":
domain_counter[domain] += 1
except Exception:
pass
top_urls_domains = domain_counter.most_common(10)
# Engagement trend: compare first half vs second half
engagement_trend = "stable"
if total >= 4:
mid = total // 2
# Tweets are typically newest-first, so first half = newer tweets
first_half = tweets_data[:mid]
second_half = tweets_data[mid:]
eng_first = sum(t["likes"] + t["retweets"] for t in first_half) / len(first_half)
eng_second = sum(t["likes"] + t["retweets"] for t in second_half) / len(second_half)
if eng_second > 0:
ratio = eng_first / eng_second
if ratio > 1.3:
engagement_trend = "rising"
elif ratio < 0.7:
engagement_trend = "declining"
return {
"total_fetched": total,
"original_count": original_count,
"retweet_count": retweet_count,
"reply_count": reply_count,
"avg_likes": avg_likes,
"avg_retweets": avg_retweets,
"avg_replies": avg_replies,
"avg_views": avg_views,
"top_tweet_id": top_tweet["id"],
"earliest_date": earliest_date,
"latest_date": latest_date,
"tweets_per_day": tweets_per_day,
"top_hashtags": top_hashtags,
"top_mentions": top_mentions,
"top_urls_domains": top_urls_domains,
"engagement_trend": engagement_trend,
}
def extract_following(user):
return {
"username": _safe_str(getattr(user, "username", "")),
"name": _safe_str(getattr(user, "name", "")),
"followers_count": _safe_int(getattr(user, "followers_count", 0)),
"bio_snippet": _safe_str(getattr(user, "description", ""))[:100],
}
def extract_follower(user):
return {
"username": _safe_str(getattr(user, "username", "")),
"name": _safe_str(getattr(user, "name", "")),
"followers_count": _safe_int(getattr(user, "followers_count", 0)),
}
def compute_followings_stats(followings_data):
total = len(followings_data)
if total == 0:
return {
"total_fetched": 0,
"avg_followers": 0,
"categories_hint": [],
}
avg_followers = round(sum(f["followers_count"] for f in followings_data) / total)
# Simple category inference from bio snippets
category_keywords = {
"AI researchers": ["AI", "machine learning", "deep learning", "neural", "NLP", "ML", "research"],
"tech founders": ["founder", "CEO", "co-founder", "startup", "entrepreneur"],
"engineers": ["engineer", "developer", "programming", "software", "code", "dev"],
"journalists": ["journalist", "reporter", "editor", "news", "writer"],
"investors": ["investor", "VC", "venture", "capital", "fund"],
"crypto": ["crypto", "bitcoin", "web3", "blockchain", "defi"],
"scientists": ["professor", "PhD", "scientist", "university", "academic"],
"designers": ["design", "UX", "UI", "creative"],
}
category_counts = Counter()
for f in followings_data:
bio = f.get("bio_snippet", "").lower()
for cat, keywords in category_keywords.items():
if any(kw.lower() in bio for kw in keywords):
category_counts[cat] += 1
categories_hint = [cat for cat, _ in category_counts.most_common(5) if category_counts[cat] >= 2]
return {
"total_fetched": total,
"avg_followers": avg_followers,
"categories_hint": categories_hint,
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description="Deep-analyze an X/Twitter user profile")
parser.add_argument("--handle", required=True, help="Twitter username to analyze")
parser.add_argument(
"--cookies",
default=os.path.expanduser(DEFAULT_COOKIES_PATH),
help="Cookie file path",
)
parser.add_argument(
"--session-dir",
default=os.path.expanduser(DEFAULT_SESSION_DIR),
help="tweety session directory",
)
parser.add_argument("--tweet-pages", type=int, default=2, help="Number of tweet pages to fetch (each ~20 tweets)")
parser.add_argument("--following-pages", type=int, default=1, help="Number of following pages (each ~70 users)")
parser.add_argument("--follower-pages", type=int, default=1, help="Number of follower pages (each ~70 users)")
parser.add_argument("--output", default=None, help="Output JSON file path (default: stdout)")
parser.add_argument("--quiet", action="store_true", help="Suppress progress messages on stderr")
args = parser.parse_args()
handle = args.handle.lstrip("@")
quiet = args.quiet
# Load cookies
log(f"Loading cookies from {args.cookies}", quiet)
cookies_dict = load_cookies_file(args.cookies)
# Create app and login
log("Creating session and logging in...", quiet)
app = create_app(args.session_dir)
login_app(app, cookies_dict)
log("Login successful", quiet)
# 1. Fetch user profile
log(f"Fetching profile for @{handle}...", quiet)
try:
user_info = app.get_user_info(handle)
except Exception as e:
err_str = str(e).lower()
if "not found" in err_str or "does not exist" in err_str or "suspend" in err_str:
sys.stderr.write(f"Error: user @{handle} not found: {e}\n")
sys.exit(3)
raise
profile = extract_profile(user_info)
log(f"Profile fetched: {profile['name']} (@{profile['username']})", quiet)
# 2. Fetch tweets
log(f"Fetching tweets (pages={args.tweet_pages})...", quiet)
tweets_data = []
try:
raw_tweets = app.get_tweets(handle, pages=args.tweet_pages, wait_time=2)
flat = _flatten_tweets(list(raw_tweets))
tweets_data = [extract_tweet(t) for t in flat]
log(f"Fetched {len(tweets_data)} tweets", quiet)
except Exception as e:
sys.stderr.write(f"Warning: error fetching tweets: {e}\n")
tweet_stats = compute_tweet_stats(tweets_data)
# Compute data range days
data_range_days = 0
if tweet_stats["earliest_date"] and tweet_stats["latest_date"]:
try:
d1 = datetime.fromisoformat(tweet_stats["earliest_date"])
d2 = datetime.fromisoformat(tweet_stats["latest_date"])
data_range_days = round((d2 - d1).total_seconds() / 86400, 1)
except Exception:
pass
# 3. Fetch followings
log(f"Fetching followings (pages={args.following_pages})...", quiet)
followings_data = []
try:
raw_followings = app.get_user_followings(handle, pages=args.following_pages, wait_time=2)
followings_data = [extract_following(u) for u in raw_followings]
log(f"Fetched {len(followings_data)} followings", quiet)
except Exception as e:
sys.stderr.write(f"Warning: error fetching followings: {e}\n")
followings_stats = compute_followings_stats(followings_data)
# 4. Fetch followers
log(f"Fetching followers (pages={args.follower_pages})...", quiet)
followers_sample = []
try:
raw_followers = app.get_user_followers(handle, pages=args.follower_pages, wait_time=2)
followers_sample = [extract_follower(u) for u in raw_followers]
log(f"Fetched {len(followers_sample)} followers", quiet)
except Exception as e:
sys.stderr.write(f"Warning: error fetching followers: {e}\n")
# 5. Assemble output
result = {
"profile": profile,
"tweets": tweets_data,
"tweet_stats": tweet_stats,
"followings": followings_data,
"followings_stats": followings_stats,
"followers_sample": followers_sample,
"metadata": {
"analyzed_at": datetime.now(timezone.utc).astimezone().isoformat(),
"script_version": "1.0",
"tweet_pages_fetched": args.tweet_pages,
"data_range_days": data_range_days,
},
}
output_json = json.dumps(result, ensure_ascii=False, indent=2)
if args.output:
os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True)
with open(args.output, "w", encoding="utf-8") as f:
f.write(output_json)
f.write("\n")
log(f"Output written to {args.output}", quiet)
else:
print(output_json)
log("Done.", quiet)
if __name__ == "__main__":
main()
使用字节跳动 Seedance 生成 AI 视频:文生视频、图生视频(首帧/首尾帧/参考图)、 异步任务查询。支持 Seedance 2.0(多模态输入、视频续生、视频编辑)等多个模型。 Use when: (1) 用户说"生成视频"/"做个视频"/"AI视频"/"text to video", (2) 用户要求...
---
name: seedance-video
description: >
使用字节跳动 Seedance 生成 AI 视频:文生视频、图生视频(首帧/首尾帧/参考图)、
异步任务查询。支持 Seedance 2.0(多模态输入、视频续生、视频编辑)等多个模型。
Use when: (1) 用户说"生成视频"/"做个视频"/"AI视频"/"text to video",
(2) 用户要求根据图片生成视频/动画/"图生视频"/"image to video",
(3) 用户提到 Seedance/字节视频生成,
(4) 用户说"帮我做段视频"/"生成一段动画"。
NOT for: 从已有视频中抽帧/截图(用 video-frames)、
视频剪辑/转码/格式转换(用 ffmpeg 直接操作)、实拍视频处理。
version: 1.0.0
category: file-generation
argument-hint: "[text prompt or task ID]"
---
# Seedance Video Generation
使用字节跳动 Seedance 模型通过火山引擎 Ark API 生成 AI 视频。
## Step 0: 前置检查
1. **API Key**:确认 `ARK_API_KEY` 环境变量已设置
```bash
echo $ARK_API_KEY | head -c 8 # 只显示前 8 位确认存在
```
未设置 → 提示用户在 [火山引擎控制台](https://console.volcengine.com/) 获取
2. **Python CLI 工具**:确认 `{baseDir}/seedance.py` 存在
- 不存在 → 降级用 curl(见 `references/curl-api-reference.md`)
## Step 1: 需求分类
| 用户意图 | 模式 | 跳转 |
|----------|------|------|
| "帮我生成一段视频" / 纯文字描述 | **文生视频** | → Step 2,Mode text |
| "用这张图生成视频" / 给了一张图 | **图生视频(首帧)** | → Step 2,Mode image |
| 给了两张图(开头和结尾) | **首尾帧视频** | → Step 2,Mode first-last |
| "参考这几张图" / 给了多张参考图 | **参考图视频** | → Step 2,Mode ref |
| "查下之前那个视频任务" / 给了 task ID | **任务查询** | → Step 4 |
## Step 2: 确认参数
**必须在调 API 前跟用户确认以下内容:**
1. **Prompt**:向用户展示将使用的 prompt,确认后再提交
2. **模型选择**(根据场景推荐):
| 场景 | 推荐模型 | 理由 |
|------|---------|------|
| 日常文/图生视频 | Seedance 1.5 Pro(默认) | 稳定,支持音频 |
| 需要最高质量 | Seedance 2.0 | 多模态,最强效果 |
| 快速预览/测试 | Seedance 2.0 Fast | 生成快,质量略低 |
| 预算有限 | 1.5 Pro + Draft mode | 先低成本预览 |
| 纯文生视频、低成本 | 1.0 Lite T2V | 最便宜 |
| 多参考图 | 1.0 Lite I2V | 支持 1-4 张参考图 |
3. **默认参数**(用户不指定则用默认值):
- 分辨率:720p | 比例:16:9(文生)/ adaptive(图生)| 时长:5秒 | 音频:开
## Step 3: 执行生成
所有模式统一用 Python CLI:
```bash
# 文生视频
python3 {baseDir}/seedance.py create \
--prompt "描述文字" \
--ratio 16:9 --duration 5 --resolution 720p \
--wait --download ~/Desktop
# 图生视频(首帧)
python3 {baseDir}/seedance.py create \
--prompt "动作描述" --image /path/to/photo.jpg \
--wait --download ~/Desktop
# 首尾帧
python3 {baseDir}/seedance.py create \
--prompt "过渡描述" --image first.jpg --last-frame last.jpg \
--wait --download ~/Desktop
# 参考图(1-4张,Lite I2V)
python3 {baseDir}/seedance.py create \
--prompt "[图1]的人物在跳舞" --ref-images ref1.jpg ref2.jpg \
--model doubao-seedance-1-0-lite-i2v-250428 \
--wait --download ~/Desktop
# Draft 模式(便宜预览)
python3 {baseDir}/seedance.py create \
--prompt "描述" --draft true --wait --download ~/Desktop
# 从 Draft 生成正式版
python3 {baseDir}/seedance.py create \
--draft-task-id <DRAFT_TASK_ID> --resolution 720p \
--wait --download ~/Desktop
```
**`--wait --download` 会自动轮询等待 + 下载,无需手动轮询。**
### 超时保护
- `--wait` 默认超时 10 分钟
- 超时后展示 task ID 给用户,可后续用 `status` 查询
- 正常生成时间:文生 1-3 分钟,图生 2-5 分钟
## Step 4: 任务管理
```bash
# 查询状态
python3 {baseDir}/seedance.py status <TASK_ID>
# 等待并下载已有任务
python3 {baseDir}/seedance.py wait <TASK_ID> --download ~/Desktop
# 列出任务
python3 {baseDir}/seedance.py list --status succeeded
# 取消/删除
python3 {baseDir}/seedance.py delete <TASK_ID>
```
## Step 5: 验证与交付
1. 确认视频文件已下载:`ls -lh ~/Desktop/seedance_video_*.mp4`
2. 用 `MEDIA:<path>` 发送视频给用户
3. **展示 task ID**:方便用户后续查询或重新下载
4. 询问是否满意,不满意 → 调整 prompt 重新生成
## 边界条件
| 情况 | 处理 |
|------|------|
| ARK_API_KEY 未设置 | 停止,引导用户获取 |
| 图片格式不支持 | 支持 jpeg/png/webp/bmp/tiff/gif(1.5 Pro 额外支持 heic) |
| 图片尺寸不合规 | 宽高比 0.4-2.5,单边 300-6000px,≤30MB |
| 生成失败(API 错误) | 展示错误信息 + 建议:prompt 含违规词→修改;配额不足→换模型或稍后重试 |
| 视频 URL 过期 | 24 小时过期!生成后立即下载。过期 → 用 task ID 查询是否还能重新获取 |
| 任务历史过期 | 7 天后无法查询 |
| seedance.py 不存在 | 降级用 curl,见 `references/curl-api-reference.md` |
| 网络超时 | 重试 1 次,仍失败 → 建议用 flex tier(离线队列,便宜 50%) |
## 参数速查
| 参数 | 默认值 | 可选值 |
|------|--------|--------|
| ratio | 16:9 / adaptive | 16:9, 4:3, 1:1, 3:4, 9:16, 21:9, adaptive |
| duration | 5 | 4-12 秒(1.5 Pro),2-12(其他)|
| resolution | 720p | 480p, 720p, 1080p |
| generate_audio | true | true/false(仅 1.5 Pro) |
| draft | false | true/false(仅 1.5 Pro) |
| service_tier | default | default(在线), flex(离线,半价) |
## 模型 ID 速查
| 模型 | ID |
|------|-----|
| Seedance 2.0 | `doubao-seedance-2-0-260128` |
| Seedance 2.0 Fast | `doubao-seedance-2-0-fast-260128` |
| Seedance 1.5 Pro | `doubao-seedance-1-5-pro-251215` |
| Seedance 1.0 Pro | `doubao-seedance-1-0-pro-250528` |
| Seedance 1.0 Pro Fast | `doubao-seedance-1-0-pro-fast-251015` |
| Seedance 1.0 Lite T2V | `doubao-seedance-1-0-lite-t2v-250428` |
| Seedance 1.0 Lite I2V | `doubao-seedance-1-0-lite-i2v-250428` |
FILE:CHANGELOG.md
# 更新日志
## v1.1.0 (2026-02-12)
### 新增功能
- **飞书视频发送指南**:新增 `how_to_send_video_via_feishu_app.md` 文档,详细说明如何在 OpenClaw 中将生成的视频通过飞书 App 发送到聊天中。
- 完整的操作步骤:生成视频 → 本地保存 → message 工具发送 → 飞书 API 上传 → 飞书分发
- 包含 message 工具调用示例
- 飞书 Open API 调用细节(上传凭证、CDN 上传、消息发送)
- 权限认证说明(ARK_API_KEY、飞书 app_access_token)
- 关键技术细节(文件大小限制、支持格式、超时处理、速率限制)
## v1.0.0
- 初始版本
- 支持 Seedance 1.5 Pro、1.0 Pro、1.0 Pro Fast、1.0 Lite 模型
- 文本生成视频、图片生成视频(首帧、首尾帧、参考图)
- Python CLI 工具 (`seedance.py`)
- 完整的 curl 命令示例
FILE:LICENSE.txt
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
FILE:README.md
# Seedance Video
Generate AI videos using ByteDance Seedance models for OpenClaw.
## Features
- 🎬 **Text-to-Video**: Generate videos from text prompts
- 🖼️ **Image-to-Video**: Animate from first frame or first+last frame
- 🎵 **Audio Support**: Generate videos with audio (Seedance 1.5 Pro)
- 🎨 **Draft Mode**: Quick preview generation
- 📊 **Task Management**: Query status and retrieve generated videos
## Prerequisites
- OpenClaw agent environment
- Python 3.8+
- Volcengine Ark API key
## Installation
### Via ClawHub (Recommended)
```bash
clawhub install seedance-video
```
### Manual Installation
1. Clone to `~/.openclaw/skills/seedance-video`
2. Set your API key:
```bash
export ARK_API_KEY="your-api-key-here"
```
## Usage
Talk to your OpenClaw agent:
```
生成一个视频:海边日落,浪花拍打礁石
Generate a video from this prompt: a cat playing piano
查询视频生成任务状态: task-id-here
```
## Supported Models
- **Seedance 1.5 Pro**: Text/image to video with audio
- **Seedance 1.0 Pro**: Text/image to video
- **Seedance 1.0 Pro Fast**: Faster generation
- **Seedance 1.0 Lite**: Budget-friendly text-to-video
## License
MIT License - see [LICENSE.txt](LICENSE.txt) file
FILE:_meta.json
{
"owner": "jackycser",
"slug": "seedance-video-generation",
"displayName": "Seedance Video Generation",
"latest": {
"version": "1.0.3",
"publishedAt": 1770883878648,
"commit": "https://github.com/openclaw/skills/commit/53614a4c78e72d4c77387377386d6376c8edfb61"
},
"history": [
{
"version": "1.0.1",
"publishedAt": 1770868206406,
"commit": "https://github.com/openclaw/skills/commit/2a8fe9fd6b94793f454044beab15fef75a8e7c56"
},
{
"version": "1.0.0",
"publishedAt": 1770701416300,
"commit": "https://github.com/openclaw/skills/commit/29b89f955b2c41049532356fea634ae5938a8f54"
}
]
}
FILE:evals/results/text2video-with-skill.md
# Eval: text-to-video (WITH skill)
## 执行计划摘要
- 模型:Seedance 1.5 Pro(doubao-seedance-1-5-pro-251215)
- 调用方式:Python CLI seedance.py(优先)+ curl 备选
- 参数:ratio 16:9, duration 5, resolution 720p, generate_audio true
- 轮询:每15秒,CLI --wait 自动轮询
- 下载:--download ~/Desktop,URL 24小时过期需立即下载
- 异常处理:失败展示 error.message,建议修改 prompt
## Assertion 评分
| ID | Assertion | Pass | Evidence |
|---|---|---|---|
| model-selection | 选择合适的模型 | ✅ | Seedance 1.5 Pro + 选择理由 |
| api-endpoint | 正确的 API 端点 | ✅ | ark.cn-beijing.volces.com/api/v3/contents/generations/tasks |
| request-body | 请求体参数正确 | ✅ | 完整JSON结构含model/content/ratio/duration/resolution/generate_audio |
| polling | 知道如何查询任务状态 | ✅ | GET /tasks/{task_id},每15秒轮询 |
| result-download | 获取结果视频 | ✅ | content.video_url → curl下载,24h过期提醒 |
**Pass rate: 5/5 (100%)**
## 对比 Without-skill
- WITH 知道 CLI 工具 seedance.py(Skill 推荐优先使用),WITHOUT 只猜 curl
- WITH 参数完整准确(ratio/duration/resolution/generate_audio),WITHOUT 全猜
- WITH 知道轮询间隔(15s)和URL过期(24h),WITHOUT 不知道
- WITHOUT 自己承认"首次调用很可能需要调试"
FILE:evals/results/text2video-without-skill.md
# Eval: text-to-video (WITHOUT skill)
## 执行计划摘要
- 模型选择:Seedance 1.5 Pro(从 TOOLS.md 获取)
- API:知道接入点ID和API Key(TOOLS.md),但请求体结构全靠猜
- 自己承认:"API 调用参数是基于有限信息的推断"
- 不确定:JSON结构、duration字段名、轮询端点、prompt语言偏好
## Assertion 评分
| ID | Assertion | Pass | Evidence |
|---|---|---|---|
| model-selection | 选择合适的模型 | ✅ | 选了 Seedance 1.5 Pro |
| api-endpoint | 正确的 API 端点 | ⚠️ | 猜了个端点,不确定对不对 |
| request-body | 请求体参数正确 | ❌ | JSON结构完全是猜的 |
| polling | 知道如何查询任务状态 | ❌ | 推断为 /tasks/{task_id},不确定 |
| result-download | 获取结果视频 | ❌ | 不知道具体下载方式 |
**Pass rate: 1.5/5 (30%)**
FILE:references/curl-api-reference.md
# Seedance curl API 参考
> 完整 curl 命令参考。日常使用推荐 Python CLI(`{baseDir}/seedance.py`),此文件仅供调试或 CLI 不可用时参考。
## Base URL
```
https://ark.cn-beijing.volces.com/api/v3
```
## 创建任务
### Mode A: 文生视频
```bash
TASK_RESULT=$(curl -s -X POST "https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ARK_API_KEY" \
-d '{
"model": "doubao-seedance-1-5-pro-251215",
"content": [{"type": "text", "text": "YOUR_PROMPT"}],
"ratio": "16:9", "duration": 5, "resolution": "720p", "generate_audio": true
}')
TASK_ID=$(echo "$TASK_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
```
### Mode B: 图生视频(首帧)
**URL 图片:**
```bash
curl -s -X POST "https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks" \
-H "Content-Type: application/json" -H "Authorization: Bearer $ARK_API_KEY" \
-d '{
"model": "doubao-seedance-1-5-pro-251215",
"content": [
{"type": "text", "text": "YOUR_PROMPT"},
{"type": "image_url", "image_url": {"url": "IMAGE_URL"}, "role": "first_frame"}
],
"ratio": "adaptive", "duration": 5, "resolution": "720p", "generate_audio": true
}'
```
**本地图片(base64):**
```bash
IMG_BASE64=$(base64 < /path/to/image.png | tr -d '\n')
IMG_DATA_URL="data:image/png;base64,IMG_BASE64"
# 用 IMG_DATA_URL 替换上面的 IMAGE_URL
```
### Mode C: 首尾帧
```bash
"content": [
{"type": "text", "text": "YOUR_PROMPT"},
{"type": "image_url", "image_url": {"url": "FIRST_URL"}, "role": "first_frame"},
{"type": "image_url", "image_url": {"url": "LAST_URL"}, "role": "last_frame"}
]
```
### Mode D: 参考图(Lite I2V)
```bash
"content": [
{"type": "text", "text": "[图1]的人物在跳舞"},
{"type": "image_url", "image_url": {"url": "REF_URL"}, "role": "reference_image"}
]
# model 必须用 doubao-seedance-1-0-lite-i2v-250428
```
## 轮询任务状态
```bash
while true; do
STATUS_RESULT=$(curl -s -X GET ".../tasks/TASK_ID" -H "Authorization: Bearer $ARK_API_KEY")
STATUS=$(echo "$STATUS_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin)['status'])")
[ "$STATUS" = "succeeded" ] && break
[ "$STATUS" = "failed" ] || [ "$STATUS" = "expired" ] && break
sleep 15
done
```
## 其他操作
```bash
# 查询任务
curl -s -X GET ".../tasks/TASK_ID" -H "Authorization: Bearer $ARK_API_KEY"
# 列表(分页)
curl -s -X GET ".../tasks?page_num=1&page_size=10" -H "Authorization: Bearer $ARK_API_KEY"
# 删除/取消
curl -s -X DELETE ".../tasks/TASK_ID" -H "Authorization: Bearer $ARK_API_KEY"
```
## Draft Mode(Seedance 1.5 Pro)
```bash
# 1. 创建草稿(便宜预览)
"draft": true, "resolution": "480p"
# 2. 从草稿生成正式视频
"content": [{"type": "draft_task", "draft_task": {"id": "DRAFT_TASK_ID"}}],
"resolution": "720p"
```
## 连续视频(用尾帧串接)
```bash
# 第一段设置 "return_last_frame": true
# 完成后获取 last_frame_url,作为下一段的 first_frame
```
FILE:seedance.py
#!/usr/bin/env python3
"""
Seedance Video Generation CLI Tool
Usage:
python3 seedance.py create --prompt "描述" [options]
python3 seedance.py create --prompt "描述" --image /path/to/image.png [options]
python3 seedance.py create --prompt "描述" --image url1 --last-frame url2 [options]
python3 seedance.py create --prompt "描述" --ref-images url1 url2 [options]
python3 seedance.py create --draft-task-id <task_id> [options]
python3 seedance.py status <task_id>
python3 seedance.py wait <task_id> [--interval 15] [--download ~/Desktop]
python3 seedance.py list [--status succeeded] [--page 1] [--page-size 10]
python3 seedance.py delete <task_id>
"""
import argparse
import base64
import json
import os
import sys
import time
import urllib.request
import urllib.error
from pathlib import Path
BASE_URL = "https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks"
DEFAULT_MODEL = "doubao-seedance-1-5-pro-251215"
def get_api_key():
key = os.environ.get("ARK_API_KEY")
if not key:
print("Error: ARK_API_KEY environment variable is not set.", file=sys.stderr)
print("Set it with: export ARK_API_KEY='your-api-key-here'", file=sys.stderr)
sys.exit(1)
return key
def api_request(method, url, data=None):
"""Make an API request and return parsed JSON response."""
api_key = get_api_key()
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
body = json.dumps(data).encode("utf-8") if data else None
req = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
resp_body = resp.read().decode("utf-8")
if resp_body:
return json.loads(resp_body)
return {}
except urllib.error.HTTPError as e:
error_body = e.read().decode("utf-8", errors="replace")
try:
error_json = json.loads(error_body)
error_msg = error_json.get("error", {}).get("message", error_body)
except json.JSONDecodeError:
error_msg = error_body
print(f"API Error (HTTP {e.code}): {error_msg}", file=sys.stderr)
sys.exit(1)
except urllib.error.URLError as e:
print(f"Network Error: {e.reason}", file=sys.stderr)
sys.exit(1)
def image_to_data_url(image_path):
"""Convert a local image file to a base64 data URL."""
p = Path(image_path)
if not p.exists():
print(f"Error: Image file not found: {image_path}", file=sys.stderr)
sys.exit(1)
ext = p.suffix.lower().lstrip(".")
mime_map = {
"jpg": "jpeg", "jpeg": "jpeg", "png": "png",
"webp": "webp", "bmp": "bmp", "tiff": "tiff",
"tif": "tiff", "gif": "gif", "heic": "heic", "heif": "heif",
}
mime_ext = mime_map.get(ext, ext)
file_size = p.stat().st_size
if file_size > 30 * 1024 * 1024:
print(f"Error: Image file too large ({file_size / 1024 / 1024:.1f} MB). Max 30 MB.", file=sys.stderr)
sys.exit(1)
with open(p, "rb") as f:
b64 = base64.b64encode(f.read()).decode("ascii")
return f"data:image/{mime_ext};base64,{b64}"
def resolve_image(image_input):
"""Resolve image input to a URL or data URL. Accepts URL or local file path."""
if image_input.startswith(("http://", "https://", "data:")):
return image_input
return image_to_data_url(image_input)
def cmd_create(args):
"""Create a video generation task."""
content = []
# Draft task mode
if args.draft_task_id:
content.append({
"type": "draft_task",
"draft_task": {"id": args.draft_task_id}
})
else:
# Text prompt
if args.prompt:
content.append({"type": "text", "text": args.prompt})
# Image inputs
if args.ref_images:
# Reference image mode (Lite I2V only)
for img in args.ref_images:
content.append({
"type": "image_url",
"image_url": {"url": resolve_image(img)},
"role": "reference_image"
})
elif args.image:
# First frame
content.append({
"type": "image_url",
"image_url": {"url": resolve_image(args.image)},
"role": "first_frame"
})
# Last frame (optional)
if args.last_frame:
content.append({
"type": "image_url",
"image_url": {"url": resolve_image(args.last_frame)},
"role": "last_frame"
})
if not content:
print("Error: Must provide --prompt, --image, or --draft-task-id.", file=sys.stderr)
sys.exit(1)
body = {
"model": args.model,
"content": content,
}
# Optional parameters
if args.ratio:
body["ratio"] = args.ratio
if args.duration is not None:
body["duration"] = args.duration
if args.resolution:
body["resolution"] = args.resolution
if args.seed is not None:
body["seed"] = args.seed
if args.camera_fixed is not None:
body["camera_fixed"] = args.camera_fixed
if args.watermark is not None:
body["watermark"] = args.watermark
if args.generate_audio is not None:
body["generate_audio"] = args.generate_audio
if args.draft is not None:
body["draft"] = args.draft
if args.return_last_frame is not None:
body["return_last_frame"] = args.return_last_frame
if args.service_tier:
body["service_tier"] = args.service_tier
result = api_request("POST", BASE_URL, body)
task_id = result.get("id", "")
print(json.dumps({"task_id": task_id, "status": "created", "response": result}, indent=2))
# Auto-wait if requested
if args.wait:
return cmd_wait_logic(task_id, args.interval or 15, args.download)
return task_id
def cmd_status(args):
"""Query task status."""
url = f"{BASE_URL}/{args.task_id}"
result = api_request("GET", url)
print(json.dumps(result, indent=2, ensure_ascii=False))
return result
def cmd_wait_logic(task_id, interval=15, download_dir=None):
"""Wait for task completion, optionally download result."""
url = f"{BASE_URL}/{task_id}"
print(f"Waiting for task {task_id} to complete (polling every {interval}s)...")
while True:
result = api_request("GET", url)
status = result.get("status", "unknown")
if status == "succeeded":
video_url = result.get("content", {}).get("video_url", "")
last_frame_url = result.get("content", {}).get("last_frame_url")
duration = result.get("duration", "?")
resolution = result.get("resolution", "?")
ratio = result.get("ratio", "?")
print(f"\nVideo generation succeeded!")
print(f" Duration: {duration}s | Resolution: {resolution} | Ratio: {ratio}")
print(f" Video URL: {video_url}")
if last_frame_url:
print(f" Last Frame URL: {last_frame_url}")
# Download
if download_dir and video_url:
download_path = Path(download_dir).expanduser()
download_path.mkdir(parents=True, exist_ok=True)
filename = f"seedance_{task_id}_{int(time.time())}.mp4"
filepath = download_path / filename
print(f"\nDownloading video to {filepath}...")
try:
urllib.request.urlretrieve(video_url, str(filepath))
print(f"Saved to: {filepath}")
# Open on macOS
if sys.platform == "darwin":
os.system(f'open "{filepath}"')
except Exception as e:
print(f"Download failed: {e}", file=sys.stderr)
print(json.dumps(result, indent=2, ensure_ascii=False))
return result
elif status == "failed":
error = result.get("error", {})
print(f"\nVideo generation failed!")
print(f" Error: {error.get('code', 'unknown')} - {error.get('message', 'Unknown error')}")
print(json.dumps(result, indent=2, ensure_ascii=False))
sys.exit(1)
elif status == "expired":
print(f"\nVideo generation task expired.")
print(json.dumps(result, indent=2, ensure_ascii=False))
sys.exit(1)
else:
print(f" Status: {status}...", flush=True)
time.sleep(interval)
def cmd_wait(args):
"""Wait for task completion."""
return cmd_wait_logic(args.task_id, args.interval, args.download)
def cmd_list(args):
"""List video generation tasks."""
params = []
if args.page:
params.append(f"page_num={args.page}")
if args.page_size:
params.append(f"page_size={args.page_size}")
if args.status:
params.append(f"filter.status={args.status}")
url = BASE_URL
if params:
url += "?" + "&".join(params)
result = api_request("GET", url)
print(json.dumps(result, indent=2, ensure_ascii=False))
return result
def cmd_delete(args):
"""Cancel or delete a task."""
url = f"{BASE_URL}/{args.task_id}"
api_request("DELETE", url)
print(f"Task {args.task_id} cancelled/deleted successfully.")
def parse_bool(v):
if isinstance(v, bool):
return v
if v.lower() in ("true", "1", "yes"):
return True
if v.lower() in ("false", "0", "no"):
return False
raise argparse.ArgumentTypeError(f"Boolean expected, got '{v}'")
def main():
parser = argparse.ArgumentParser(description="Seedance Video Generation CLI")
subparsers = parser.add_subparsers(dest="command", help="Available commands")
# create
p_create = subparsers.add_parser("create", help="Create a video generation task")
p_create.add_argument("--prompt", "-p", help="Text prompt describing the video")
p_create.add_argument("--image", "-i", help="First frame image (URL or local file path)")
p_create.add_argument("--last-frame", help="Last frame image (URL or local file path)")
p_create.add_argument("--ref-images", nargs="+", help="Reference images for Lite I2V (1-4 URLs or paths)")
p_create.add_argument("--draft-task-id", help="Draft task ID to generate final video from")
p_create.add_argument("--model", "-m", default=DEFAULT_MODEL, help=f"Model ID (default: {DEFAULT_MODEL})")
p_create.add_argument("--ratio", choices=["16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive"], help="Aspect ratio")
p_create.add_argument("--duration", "-d", type=int, help="Duration in seconds (4-12 for 1.5 Pro)")
p_create.add_argument("--resolution", "-r", choices=["480p", "720p", "1080p"], help="Resolution")
p_create.add_argument("--seed", type=int, help="Random seed (-1 for random)")
p_create.add_argument("--camera-fixed", type=parse_bool, help="Fix camera position (true/false)")
p_create.add_argument("--watermark", type=parse_bool, help="Add watermark (true/false)")
p_create.add_argument("--generate-audio", type=parse_bool, help="Generate audio (true/false, 1.5 Pro only)")
p_create.add_argument("--draft", type=parse_bool, help="Draft/preview mode (true/false, 1.5 Pro only)")
p_create.add_argument("--return-last-frame", type=parse_bool, help="Return last frame URL (true/false)")
p_create.add_argument("--service-tier", choices=["default", "flex"], help="Service tier")
p_create.add_argument("--wait", "-w", action="store_true", help="Wait for completion after creating")
p_create.add_argument("--interval", type=int, default=15, help="Poll interval in seconds (default: 15)")
p_create.add_argument("--download", help="Download directory (e.g. ~/Desktop)")
# status
p_status = subparsers.add_parser("status", help="Query task status")
p_status.add_argument("task_id", help="Task ID to query")
# wait
p_wait = subparsers.add_parser("wait", help="Wait for task completion")
p_wait.add_argument("task_id", help="Task ID to wait for")
p_wait.add_argument("--interval", type=int, default=15, help="Poll interval in seconds (default: 15)")
p_wait.add_argument("--download", help="Download directory (e.g. ~/Desktop)")
# list
p_list = subparsers.add_parser("list", help="List video generation tasks")
p_list.add_argument("--status", choices=["queued", "running", "cancelled", "succeeded", "failed", "expired"])
p_list.add_argument("--page", type=int, default=1)
p_list.add_argument("--page-size", type=int, default=10)
# delete
p_delete = subparsers.add_parser("delete", help="Cancel or delete a task")
p_delete.add_argument("task_id", help="Task ID to cancel/delete")
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
commands = {
"create": cmd_create,
"status": cmd_status,
"wait": cmd_wait,
"list": cmd_list,
"delete": cmd_delete,
}
commands[args.command](args)
if __name__ == "__main__":
main()
小红书端到端运营:账号定位、选题研究、内容生产、发布执行、数据复盘。 Use when: (1) 用户要写小红书笔记/帖子, (2) 用户说"发小红书"/"写个种草文"/"出一篇小红书", (3) 用户讨论小红书选题/热点/爆款分析/竞品对标, (4) 用户提到账号定位/人设/内容方向规划, (5) 用户要求生成...
---
name: xiaohongshu-ops
version: 1.0.0
description: >
小红书端到端运营:账号定位、选题研究、内容生产、发布执行、数据复盘。
Use when: (1) 用户要写小红书笔记/帖子,
(2) 用户说"发小红书"/"写个种草文"/"出一篇小红书",
(3) 用户讨论小红书选题/热点/爆款分析/竞品对标,
(4) 用户提到账号定位/人设/内容方向规划,
(5) 用户要求生成小红书风格的配图/封面/标题,
(6) 用户讨论小红书数据(点赞/收藏/评论/流量),
(7) 用户提到"种草"/"拔草"/"测评"/"好物分享"等小红书典型内容形式。
即使用户没有明确说"小红书",只要涉及生活方式类短图文内容创作、
种草测评类写作、或社交电商内容运营,都应使用此技能。
支持多垂类复用,内置陪你看剧案例模板。
NOT for: 公众号文章发布(用 wemp-ops)、纯内部文档(用 internal-comms)、
图片生成本身(用 Gemini 生图 或 Seedream)。
---
# Openclaw 小红书运营技能(通用版)
目标:构建可复用的"小红书运营"流程,让任何账号类型都能复用同一套动作框架。
## 适用范围(默认即通用流程)
- 账号定位与内容方向
- 选题产出与争议点挖掘
- 竞品/同类账号对标
- 小红书发布前演练与内容交付
- 发布后快速复盘(互动结构、评论回复、热点追踪)
将每类账号的行业细节作为"案例模块(case module)"挂载到通用流程中。
## 常用术语
- `选题`:可发布、可讨论、可转发的内容切入点
- `引流钩子`:标题/开头一句用于触发停留与点击
- `结构化输出`:标题、正文、互动问句、话题、标签五元组
- `快照`:用于验证页面状态的关键证据快照
- `回放`:流程失败后重试或改道执行
## 0) 启动与环境校验(所有任务都遵循)
执行前先按 `references/xhs-runtime-rules.md` 中"运行规则"执行,优先遵循失败可复用顺序。
- 固定使用内置浏览器 profile:`openclaw`,出现通道异常先切回后再重试。
- 以 `evaluate` 为先,关键节点少量 `snapshot`,单步动作最多重试一次。
- 失败后保留已获结果,切稳健路径并汇报。
## 1) 技能默认行为(所有任务都遵循)
- **开始新任务前先读 `knowledge-base/README.md`**,检索历史记录,避免重复试错。
- **先读本技能目录下的 `persona.md`**(小红书平台专用人设/语气/发布与回复风格)。所有对外文案(发帖/评论回复/私信话术)都必须遵循。
- 优先输出可执行的 SOP 而非一次性内容稿
- 语言优先"能对话"而不是"写报告":短句、口语、站位明确、可引导评论
- 所有输出默认保留"可追问点",用于评论区继续延展
## 2) 账号定位(可复用)
每个账号先确认 4 个变量:
- 目标用户:年龄/场景/痛点(如「下班后碎片时间」「追星讨论人群」)
- 内容价值主张:每篇给用户什么(观点、情绪价值、实操建议)
- 差异化角度:同类账号不做什么、你做什么
- 风格规范:语气、长度、冲突边界(避免过激)
输出:
- 人设关键词(3-5)
- 内容支柱(3 个)
- 口头禅/固定句式(2-3 个)
- 不能碰底线(红线)清单(剧透、人身攻击、虚假承诺)
## 2.5) 账号分析
对账号做五维体检评分,判断"现在处在什么位置、下一步优先改什么"。
**五维:** 定位清晰度 / 内容结构力 / 互动转化力 / 账号辨识度 / 增长可持续性
**执行方式:** 提供账号URL/名称/数据,AI出诊断报告
**详细方法:** `references/xhs-account-analysis.md`
**沉淀去向:** 体检结论写入 `knowledge-base/accounts/YYYY-MM-DD-{账号名}-checkup.md`
## 3) 通用选题与对标流程
### §3.1 首页推荐流分析
在开始选题前,可先做一轮首页推荐流分析,理解"平台现在在推什么"。
**触发条件:** 账号初建期 / 数据下滑期 / 想找新方向时
**执行方式:** 老板手动观察首页前10-20条,截图/描述发给 AI 分析
**详细方法:** `references/xhs-home-feed-analysis.md`
**输出格式:** 首页画像 / 高信号样本 / 可复用模式 / 下步动作
**沉淀去向:** 分析结果写入 `knowledge-base/patterns/YYYY-MM-DD-feed-patterns.md`
### A. 平台侧抓取信号(可并行)
1. 先在小红书抓同题材高互动内容(点赞/收藏/评论高于近期平均值)
2. 记录可复用字段:`title`, `hook`, `angle`, `结构标签`, `评论信号`, `互动CTA`, `标签组`
3. 汇总前 10-20 条到候选池
**⚠️ 中间存盘规则**:每 2-3 轮搜索/抓取后,立刻把已获得的高互动样本和关键发现写到 `<WORKSPACE>/temp/xhs-findings-{topic}.md`,防止连续采集时前面的信息被挤掉。采集完成后此文件可删除。
### B. 需求侧补充信号(行业/场景)
1. 按主题去主流平台/社媒抓"评论区观点分歧"
2. 抽取支持/反对/中性观点各一组
3. 输出可发文争论点(争议但可控)
### C. 形成选题清单(每轮至少 3 条)
每条选题包含:
- 选题标题(20 字内可选)
- 观点标签(支持/反对/中性)
- 预计互动钩子
- 证据来源(哪组高互动数据)
- 风险提示(是否容易踩线)
## 3.5) 搜索并浏览(新增操作类型)
按 `references/xhs-runtime-rules.md` 的搜索与评论入口章节执行。
- 只允许从搜索结果页进入帖子;
- 优先通知/回复场景前先对位校验。
- 连续失败回退策略见引用文件。
## 3.8) 素材交接检查
内容生产前,检查 `<WORKSPACE>/temp/handoffs/collector-to-writing.md` 是否存在:
- 有 → 读取,筛选与当前选题相关的素材条目,纳入写作参考
- 消费后删除已使用的条目(如果文件清空则删除文件)
- 没有 → 跳过,正常流程
## 3.9) 对标颗粒度检查表
找到对标账号后,逐维度对比一致性。**每一个不一致都要解释为什么不一致,解释不了就改成一致。**
> 借鉴 dbs-benchmark 的核心理念:「模仿的颗粒度决定模仿的质量。如果对方袜子 3 个线头你只有 2 个,就没有模仿到位。」
### 内容维度对比
| 维度 | 对标账号 | 我们 | 一致性 | 差异说明 |
|------|---------|------|--------|---------|
| 笔记类型(图文/视频/混合) | | | | |
| 发布频率 | | | | |
| 标题风格(反问/数字/立场/悬念) | | | | |
| 封面图风格(实拍/设计/截图/文字卡) | | | | |
| 正文长度 | | | | |
| 正文结构(总分/故事/清单/对比) | | | | |
| 互动提问方式 | | | | |
| 话题标签数量和类型 | | | | |
| 评论区互动频率和风格 | | | | |
### 运营维度对比(如可获取)
| 维度 | 对标账号 | 我们 | 一致性 | 差异说明 |
|------|---------|------|--------|---------|
| 粉丝量级 | | | | |
| 平均点赞/收藏/评论 | | | | |
| 变现方式(广告/带货/引流/无) | | | | |
| 私域引流路径 | | | | |
| 投流方式(薯条/聚光/无) | | | | |
**使用方法**:找到对标账号后,浏览其最近 10-20 条笔记填写此表。重点关注高互动笔记。
---
## 4) 通用内容模板(小红书)
每次产出至少 2 个备选:
- 标题(争议/立场/反问,≤20字优先)
- 开头钩子(1-2 句)
- 正文(3 段:观点→证据→反方)
- 互动提问(1 句)
- 话题(5-8 个)
- 风险标注(是否剧透 / 引战边界 / 版权风险)
**定稿后必须执行**:对照 `references/anti-ai-checklist.md` 逐条检查,降 AI 味 + 注入灵魂。
### §4.3 图卡系列规划(多图笔记必做)
文案定稿后、配图前,必须完成图卡系列规划。流程见 `references/content-analysis.md` 的 §6§7。
**流程摘要**:
1. **内容分析** → 内容分类、受众画像、Hook 评估、传播触发点、视觉机会映射
2. **三策略大纲**(可选) → 默认出 A+B 两个差异化方案,老板选定
3. **预设选择** → 根据 `references/presets.md` 的信号匹配表自动推荐
4. **Swipe Flow 设计** → 每张图的位置、布局、内容、图间钩子(见 `references/swipe-flow.md`)
5. **生成配图** → 结构化 Prompt 组装 + Reference Image Chain(见 `references/illustration-prompts.md`)
**关键原则**:
- 配色覆盖可只换颜色不换风格(见 `references/presets.md` 配色覆盖节)
- 第一张图不带 ref,后续所有图以第一张为 ref 锚点(保证视觉一致性)
- 每张图的 prompt 先存 `prompts/NN-{type}-{slug}.md` 再生成
- 新增 mindmap 和 quadrant 两种布局(见 `references/layouts.md`)
### §4.5 五维内容自检(定稿后必做)
Anti-AI checklist 通过后,再过一遍内容质量自检。任何一项 ❌ 必须修改。
| 维度 | 检查问题 | 合格标准 | 判断 |
|------|---------|---------|------|
| **文字洁癖** | 有没有 AI 味残留?「姐妹们」模板化开头?Emoji 超标? | 读起来像真人写的,不像模板生成 | ✅/❌ |
| **标题** | 有立场/反差/具体吗?会不会看到就想点? | 不靠「一定要看到最后」这种廉价钩子 | ✅/❌ |
| **表达效率** | 300-600 字能说清楚吗?有没有注水段落? | 每段都有信息量,删任何一段都不完整 | ✅/❌ |
| **认知落差** | 读者看完会觉得「这个我知道」吗? | 至少 1 个「没想到」的点或具体数据 | ✅/❌ |
| **封面竞争力** | 封面在信息流中能不能抢到注意力? | 有视觉冲击 + 信息传递,不是纯装饰 | ✅/❌ |
## 5) 通用发布链路(不发稿)
详细发布执行路径请直接按 `references/xhs-publish-flows.md` 执行,避免重复维护。
### Step 0:发布前读者测试(强烈推荐)
正式产出文案后,在进入发布流程前先做一轮读者视角检查。
**方法:** 用一个无上下文的 Agent(或直接让 AI 角色扮演"第一次看到这篇"的陌生读者)阅读笔记全文,逐一检查 4 个维度:
| 维度 | 检查问题 | 合格标准 |
|------|---------|---------|
| 标题吸引力 | 第一眼看到标题,会点开吗? | 有立场/反差/具体之一;不靠"震惊体" |
| 开头好奇心 | 读完第一句,想继续往下看吗? | 直接是钩子或结论,不是自我介绍 |
| 术语可理解性 | 有没有没解释的行业词或默认共识? | 目标读者能直接理解,不需要查词 |
| 结尾互动引导 | 结尾有没有可回答的问题? | 有一个自然的互动钩子,不强迫关注 |
**发现盲点则修改后再进入发布流程。**
发布前必须满足的核心点:
- 账号先登录创作后台,确认页面在 `openclaw` profile 可操作。
- 明确发布类型(视频 / 图文 / 长文),三要素:封面、标题、正文。
- 到达"发布"按钮可见处停手,默认不直接点击发布。
- 若涉及截图确认,优先附件形式发送到飞书,并在用户确认后再发布。
## 6) 评论与回复(轻量)
评论检查与回复统一遵循 `references/xhs-comment-ops.md`,并结合 `examples/reply-examples.md` 作文案风格。
- 默认优先走通知页,先对位后输入后发送。
- 默认 one-send-per-turn(如无明确要求不连发)。
- 长度、隐性承诺、风控停损点等风险控制项请以引用文件为准。
## 6.5) 知识库沉淀
完成每次分析/发布/回复/复盘后,主动写入知识库(路径:`knowledge-base/`)。
**写入时机:**
- 任务前:读 `knowledge-base/README.md`,检索历史
- 任务中:发现新结论/新风险时立刻记临时条目
- 任务后:补写结果到对应子目录
- 失败后:记录原因、回退策略、可替代路径
**对应关系:**
- 首页推荐流分析 → `patterns/` + `topics/`
- 账号分析 → `accounts/` + `reviews/`
- 爆款拆解 → `patterns/`
- 发布/回复操作 → `actions/`
- 复盘 → `reviews/`
**写入失败降级:** 先完成用户任务,结束后把结构化摘要追加到 `knowledge-base/README.md` 的"待整理"区域,不阻塞主流程。
详细字段定义与模板见 `references/xhs-knowledge-base.md`(如已同步)。
## 6.8) 风格学习采集
**内容产出完成后自动执行,不需要老板操作。**
在 §4 内容模板产出定稿后,自动记录 AI 原稿:
```bash
python3 <WORKSPACE>/scripts/style-observe.py record-original <笔记文件> --skill xiaohongshu-ops --topic "选题关键词"
```
当老板确认最终版(可能经过修改)后,记录最终版:
```bash
python3 <WORKSPACE>/scripts/style-observe.py record-final <最终版文件> --skill xiaohongshu-ops
```
**触发 record-final 的信号**:
- 老板说"可以了"/"这版OK"/"发吧"
- 老板手动修改后把最终版发回来
- 笔记已发布
**无修改直接发布也要 record-final**(no_change = 正反馈)。
积累 5+ 对 diff 后可提取风格规则:
```bash
python3 <WORKSPACE>/scripts/style-observe.py pairs --skill xiaohongshu-ops --days 30
```
## 7) 失败与修复(必须遵循)
- 自动化失败先重试一次(同策略)
- 仍失败则改道:换到"更稳妥同义路径"
- 不做无效重复动作;保留当前进度可复用,报告一次用户需手动的单一动作
## 8) 通用提取示例(Evaluate)
通用字段提取脚本示例见 `references/xhs-eval-patterns.md`。
## 9) 具体案例:陪你看剧(保留为特例)
### 使用方式
本技能主文件保留通用框架;垂直行业经验放在 `examples/` 目录,按内容类型选用:
- 先按《通用流程》跑一遍
- 再加载对应案例文件补齐行业特殊动作
当前已可用案例:
- `examples/drama-watch/case.md`(陪你看剧账号)
每个内容类型按目录组织,文件命名可为:
- `examples/<vertical>/<vertical>.md`(推荐)
- 或 `examples/<vertical>/README.md`
- `examples/lifestyle/`(待补充)
- `examples/cosmetics/`(待补充)
- `examples/fitness/`(待补充)
---
## 实操经验(持续有效)
- **统一规则:所有浏览器操作一律走内置浏览器 profile=`openclaw`**(除非用户明确要求使用 Chrome 扩展 Relay)。
- 文字配图是稳定写入口,typed text 直接成为封面文案
- 发布话题优先用 UI 选题,不建议纯文本粘贴大量 `#话题`
- `evaluate` 批量改写富文本时,尽量少改版式,避免丢失 topic entity
- 关键步骤前保留一次快照,可用于复盘与问题定位
- `发布` 按钮可见 ≠ 发布成功;必须明确标注"到发布页停手"
- 若出现新类型评论节奏问题,优先减少每小时回复密度而非提高频率
## 运营成熟路径(可选)
- 标题池:按"站队/反问/冲突"各保留 10 条可复用模板
- 话题池:按账号调性建立常用关键词与同义替换列表
- 复用机制:每次复盘后把可复用表达同步进案例文件
---
## 下一步建议(条件触发)
笔记内容产出后,根据结果判断是否推荐下一步。
| 触发条件 | 推荐 |
|---------|------|
| 笔记素材来自收藏内容 | 「素材用完了,要用 content-collector 存档原始素材方便追溯。」 |
| 笔记主题有深度延展潜力(>800 字才能说清楚) | 「这个选题内容量大,建议用 wemp-ops 写一篇公众号长文,小红书版做精华摘要。」 |
| 需要竞品账号分析 | 「想看看同类账号怎么做的?给个账号名,用 x-profile-deep-dive 或浏览器分析。」 |
| 封面图需要信息图/流程图 | 「封面可以用 drawio 画个信息图,比纯文字封面有竞争力。」 |
---
## 绝对不要做的事
小红书内容产出中,以下行为直接拉低质量或触发平台降权:
1. **不要无脑堆 Emoji** — Emoji 是节奏工具不是装饰。每段 1-2 个最多,不要每句话都带。「✨🔥💕姐妹们!!」= 劣质模板的标志
2. **不要用「姐妹们」「家人们」「宝子们」开头** — 除非账号人设就是这个调性。泛化称呼 = 没有人设
3. **不要建议「多看看爆款」而不做具体分析** — 要看就给具体笔记链接 + 分析它为什么爆(标题结构/封面/选题/评论区互动),否则是废话
4. **不要写超过 800 字的笔记** — 小红书是短内容平台,300-600 字是黄金区间。超长 = 跳出率高
5. **不要在正文里堆 #话题标签** — 话题标签放末尾集中写,正文里穿插 #标签 打断阅读节奏
6. **不要用「一定要看到最后」「最后一条最重要」** — 这是 2022 年的钩子,现在是降权信号
7. **不要生成「总结型」标题** — 「关于 XX 的 5 个要点」太干,没有情绪。小红书标题要有立场/冲突/反差
8. **不要忽略封面图** — 小红书是图片优先平台。纯文字笔记没有封面竞争力 = 死在信息流里
---
## 内联案例库
### 正面案例
**案例 1:公众号→小红书改写(「Claude 能操控微信了」)**
> 基于公众号 V3 定稿改写:口语化、短段落、互动问句结尾。300-600 字区间。3 张竖版配图(1920x2560,3:4)。图片+文案打包放 ~/Downloads/,老板手机 App 手动发布。
- 成功要点:公众号长文 → 小红书版只保留一个核心观点 + 一个数据点 + 一个互动问题。不是缩写,是重写。
**案例 2:首发笔记「外卖员的新身份:AI 系统的"人形 API"」**
> 选题来自阮一峰周刊 Waymo/DoorDash 故事。角度:程序第一次调动人力。HTML+CSS 制作科技感信息卡(深色背景 + 电蓝 + 青色,3:4 竖版)。426 字。
- 成功要点:从一个具体故事切入,不讲大道理。配图有统一视觉体系。
### 反面案例
**反面 1:自动化发布触发账号违规预警(3/12 事件)**
> 使用浏览器自动化上传图片到小红书创作平台,触发 AI 托管检测,账号收到违规预警。
- 教训:🔴 绝不使用浏览器自动化操作小红书。工作边界是只产出文案+配图文件,老板手机 App 手动发布。
**反面 2:标题/正文混淆(首发笔记踩坑)**
> 小红书编辑器标题和正文都是 contenteditable,浏览器 type 操作无法区分,两次把所有文字灌进标题框。
- 教训:富文本编辑器不用 act(kind=type),用 evaluate + execCommand。但更根本的教训是——不要自动化操作小红书。
FILE:README.md
<!--
xiaohongshu-ops skill README
-->
# xiaohongshu-ops
小红书账号运营Skill,搭配Openclaw可以独立运营小红书账号
🎯 目标:一人公司,同时指挥10个Agent运营自媒体账号矩阵,每天通过飞书布置任务
## 核心能力
- [x] persona.md 人设注入
- [ ] 爆款选题
- [x] 小红书发布流程
- [x] 评论互动 / 自动回复
- [ ] 数据复盘
## 流程展示
单独给Openclaw开了一个小红书账号,ID:虾薯,是一只小龙虾操控小红薯的形象,欢迎大家围观,看看运营一个月后能到什么程度
‼️ Openclaw发帖比我自己发火多了
| 飞书任务交互与反馈 | 首篇发布内容 + 自动回复 |
|---|---|
<br><img src="./assets/飞书交互.jpg" alt="飞书交互展示" width="420" /> | <br><img src="./assets/自动发帖-回复.jpg" alt="第一个帖子发布+回复" width="420" />
### 作用说明
- 统一任务入口:通过飞书下发运营指令
- 自动化执行:从选题、发布到评论有完整闭环
- 快速复盘:沉淀每次发布与互动结果,便于持续迭代
## 安装
- 方法1: openclaw / codex 安装,复制以下命令发送
```
帮我安装这个skill,`https://github.com/Xiangyu-CAS/xiaohongshu-ops-skill`
```
- 方法2: clawhub安装
```
clawhub install xiaohongshu-ops
```
## 仓库结构
- `SKILL.md`
- 技能主逻辑与执行规则(SOP、流程、边界)
- `persona.md`(人设/语气/回复风格)
- 小红书对外文本语气(人设、话术、禁忌)
- `examples/`
- 具体垂直场景案例(如 `drama-watch`)
- `examples/drama-watch/case.md`:陪你看剧实例化流程
- `references/`
- `references/xhs-comment-ops.md`:评论互动与回复策略
- `references/xhs-publish-flows.md`:发布流程(视频/图文/长文)拆解
- `examples/reply-examples.md`
- 近场评论对位回复样例(含偏离与修正对照)
FILE:_meta.json
{
"owner": "xiangyu-cas",
"slug": "xiaohongshu-ops",
"displayName": "小红书自动运营",
"latest": {
"version": "0.1.1",
"publishedAt": 1771853804092,
"commit": "https://github.com/openclaw/skills/commit/cf1ac0c030d9c0ef5f0ce4d72d6c49353d69bf97"
},
"history": [
{
"version": "0.1.0",
"publishedAt": 1771425064203,
"commit": "https://github.com/openclaw/skills/commit/41d7778407dabba61e8335e440cb2f7af56fd372"
}
]
}
FILE:evals/evals.json
{
"skill_name": "xiaohongshu-ops",
"evals": [
{
"id": 1,
"eval_name": "note-creation",
"prompt": "帮我写一篇小红书笔记,主题是'打工人的AI效率工具推荐'",
"expected_output": "应该生成小红书风格的笔记,包含emoji标题、口语化正文、话题标签",
"assertions": [
{"id": "emoji-title", "text": "标题包含emoji且符合小红书风格", "type": "format"},
{"id": "casual-tone", "text": "正文口语化、短句为主", "type": "quality"},
{"id": "hashtags", "text": "包含5-10个话题标签", "type": "format"},
{"id": "word-count", "text": "正文300-800字", "type": "format"},
{"id": "interaction-ending", "text": "结尾有互动引导(提问/投票/讨论)", "type": "quality"}
]
},
{
"id": 2,
"eval_name": "topic-research",
"prompt": "我想做一个关于AI产品测评的小红书账号,帮我分析一下定位和选题方向",
"expected_output": "应该提供账号定位分析、竞品对标、选题方向建议",
"assertions": [
{"id": "positioning", "text": "有账号定位/人设建议", "type": "quality"},
{"id": "competitor-analysis", "text": "有竞品/同类账号分析", "type": "quality"},
{"id": "topic-suggestions", "text": "给出具体的选题方向(至少5个)", "type": "quality"},
{"id": "controversy-points", "text": "提到争议点或差异化角度", "type": "quality"}
]
}
]
}
FILE:evals/results/note-with-skill.md
# Eval: note-creation (WITH skill)
## 执行计划摘要
- 完整走了 Skill 流程:环境校验→persona读取→账号定位→选题对标→内容产出→发布链路
- 选题对标:在小红书搜索同类高互动笔记10-20条,提取评论区信号
- 内容:2个备选方案(清单体+故事体),结构化五元组输出
- persona.md:融入傲娇嘴硬风格("行行行""我不说太多")
- 字数:500-700字(符合Skill规定的300-800字)
- 发布:到发布按钮停手,等老板确认
## Assertion 评分
| ID | Assertion | Pass | Evidence |
|---|---|---|---|
| emoji-title | 标题含emoji且符合小红书风格 | ✅ | "💻 打工人必看!5个让我准时下班的AI神器|效率翻倍不加班" |
| casual-tone | 正文口语化、短句为主 | ✅ | "姐妹们 我真的受够了""行行行 这个确实离谱好用" |
| hashtags | 包含5-10个话题标签 | ✅ | 7个标签 |
| word-count | 正文300-800字 | ✅ | 500-700字 |
| interaction-ending | 结尾有互动引导 | ✅ | "评论区交出来 我帮你们测!" |
**Pass rate: 5/5 (100%)**
## vs Without-skill 关键差异
- WITH:先做选题对标(搜索竞品笔记),WITHOUT:直接写
- WITH:读取 persona.md 融入账号人设,WITHOUT:通用风格
- WITH:字数500-700符合规范,WITHOUT:800-1200偏长
- WITH:2个备选方案+五元组结构化输出,WITHOUT:1个方案
- WITH:发布链路明确(停在发布按钮),WITHOUT:未提及发布
- WITH:风险标注(不涉及公司内部信息),WITHOUT:未考虑
FILE:evals/results/note-without-skill.md
# Eval: note-creation (WITHOUT skill)
## 执行计划摘要
- 标题:3个备选,含emoji,悬念型
- 风格:口语化、短句、emoji分隔
- 字数:800-1200字(偏长,Skill要求300-800字)
- 标签:10个(Skill要求5-10个,在范围内)
- 互动引导:有评论+收藏引导
- 配图:6张竖版,有详细规划
## Assertion 评分
| ID | Assertion | Pass | Evidence |
|---|---|---|---|
| emoji-title | 标题含emoji且符合小红书风格 | ✅ | "同事以为我开挂了…其实是这些AI工具在帮忙✨" |
| casual-tone | 正文口语化、短句为主 | ✅ | 明确说"口语化、轻松活泼""每段不超过3行" |
| hashtags | 包含5-10个话题标签 | ✅ | 列了10个,说选5-8个 |
| word-count | 正文300-800字 | ❌ | 计划800-1200字,超过Skill规定的上限 |
| interaction-ending | 结尾有互动引导 | ✅ | 有评论引导+收藏引导 |
**Pass rate: 4/5 (80%)**
## 关键观察
- 模型对小红书风格的基础能力已经很强——标题、语气、标签、互动引导都到位
- 字数偏长是唯一问题,Skill 规定300-800字更符合小红书用户阅读习惯
- 没有涉及账号定位、选题策略、竞品对标等运营层面的能力
- 配图建议质量不错,但不知道我们具体的生图工具(Gemini/Seedream)
- **这是一个"编码偏好型"Skill** — 模型能写小红书,Skill 的价值在于约束规范和流程化
FILE:examples/drama-watch/case.md
# 例:陪你看剧(看剧讨论类)
适用对象:小红书“看剧账号”/“影视讨论账号”。
## 目标
- 挖可讨论、可争议、可发布的内容
- 保持账号观点明确但不过度引战
- 兼顾更新周期(最新集/话题)
## A. 腾讯视频(或同类)热剧争议点
1. 定位目标剧(或热榜入口剧)
2. 进入最新集内容
3. 评论区抓两批:热门 TopN + 最新 TopN(每批约 10-20)
4. 抽取字段:`user`, `time`, `text`, `likes`, `replies`
5. 输出争议框:
- 话题名
- 支持观点
- 反对观点
- 证据评论(短)
- 置信度
## B. 小红书看剧对标
1. 关键词检索(剧名、角色名、争议关键词)
2. 只看高互动笔记
3. 归纳爆款结构:
- 标题句式(反问/站队/对比/情绪)
- 正文结构(结论前置→证据→反方)
- 结尾互动(你站哪边)
## C. 内容产出
每次固定输出 2-5 条“可发草稿”
- 标题(≤20字)
- 开头钩子(1-2 句)
- 正文(立场 + 证据 + 反方)
- 互动问题
- 标签(5-8 个)
- 风险标注(剧透程度)
## 实操提示
- 视频评论和小红书数据的噪音较高,优先抓“支持/反对”边界明确的评论
- 观点发布避免剧透与人身对立
- 发布前在 body 尾部补齐话题,避免嵌入正文中间
FILE:examples/reply-examples.md
# 小红书评论回复示例库
> 目的:累积可复用范例,优先对位问题本身,不做过度展开。
## 修正后的高质量样例
### 1) 问:`没被风控吗`(红薯Claw)
**回复(最终版):**
- `最近我这边没明显风控,主要是把发帖频率和表达节奏先稳住了。先观测下,先保住稳定。`
**为什么:**
- 直接对问题(风控状态)作答
- 不添加过度建议、承诺或额外动作
---
### 2) 问:`你可以做啥呀虾薯`(拉文克劳学渣)
**回复(最终版):**
- `我能做这三件:选题、文案和互动这条线上的日常执行。`
**为什么:**
- 直接给出能力边界与答案
- 不说“我先给你方案/我去帮你整理”这类扩展话术
---
## 常见问题的短句模板(可直接套)
- 问:`风控吗/没被限流吗`
- `最近没明显风控,先把节奏和表达稳住。`
- 问:`你能做什么`
- `我先做到这几类:选题、文案、互动。`
- 问:`有啥建议`
- `一句话先说结论:先把最关键的一点改对。`
---
## 通用场景样例(持续积累)
> 这个文件是活的,每次出现值得复用的回复就补进来。格式:问题 → 回复 → 为什么。
---
### 3) 场景:用户问"在哪里看到的/信息来源"
**问:** `这个数据哪里看到的`
**回复:**
`小红书搜索 + 自己整理的,不是公开报告,仅供参考。`
**为什么:**
- 直接说来源,不绕弯
- "仅供参考"主动降低预期,避免被追责
---
### 4) 场景:用户表达强烈赞同
**问:** `完全同意!太真实了`
**回复:**
`哈有同感就好,下次聊更具体的——你遇到哪个场景最典型?`
**为什么:**
- 接赞同但不停在夸奖里
- 顺手引导进评论延展,增加互动深度
---
### 5) 场景:用户提出反对或质疑
**问:** `我不太同意,我的经历不是这样的`
**回复:**
`说说你的情况?我这里样本有限,不同场景下结论可能差挺多。`
**为什么:**
- 不防御,不解释
- 把"异见"变成"更多信息"的机会
- 口气保持平等,不降低身段也不对抗
---
## 回复写作原则
1. **先对位问题**,再考虑延展
2. **短句优先**,不写超过3句的回复
3. **不做承诺**("我去帮你查""我之后会...")
4. **情绪词精简**(少用"哈哈/嗯嗯/好的好的")
5. **结尾可留钩子**,但不强求
FILE:knowledge-base/README.md
# 小红书运营知识库
把每次分析、选题、发布、回复、复盘中有效的信息沉淀下来,让下一次决策能更快。
**三个核心问题:**
1. 这类内容之前怎么做的?
2. 哪些动作有效、哪些失败?
3. 下一次直接复用什么?
---
## 目录结构
| 目录 | 存什么 |
|------|--------|
| `accounts/` | 账号定位、诊断结论、竞品拆解 |
| `topics/` | 选题候选、争议点、标题骨架 |
| `patterns/` | 爆款结构、封面层级、互动机制、可复用模式 |
| `actions/` | 发布、回复、抓取等操作记录 |
| `reviews/` | 复盘结论、失败原因、下次修正 |
**文件命名规则:** `YYYY-MM-DD-brief.md`(日期前置,按时间排序,brief 保留账号/主题/动作词)
---
## 检索方法
1. 先看本文件"当前重点"和"固定索引"
2. 找最近 7-14 天的同类记录(按日期名排序)
3. 找同账号/同主题记录
4. 找同结构 pattern
5. 最后看历史失败记录
---
## 记录字段约定(精简版)
每条记录的 frontmatter 至少包含:
```yaml
---
id: YYYY-MM-DD-brief
type: account | topic | pattern | action | review
status: active | deprecated | experimental
created_at: YYYY-MM-DDThh:mm:00+08:00
source_url: "" # 可选
account: "" # 可选,相关账号
tags: []
summary: "一句话结论"
next_action: "下一次怎么用"
---
```
---
## 当前重点
- **账号定位**:AI 产品经理个人号,记录踩坑和想清楚的事
- **内容支柱**:AI Agent 实践 / AI PM 工作日常 / AI 工具使用心得
- **当前选题**:见 `topics/2026-03-20-xhs-topic-candidates.md`
- **下一步**:XHS-1(8万人调研)本周发,XHS-4(Claude 用法分层)随时可发
---
## 固定索引
> (空)——积累后把高价值 pattern 链接到这里,方便快速取用
---
## 待整理
> (空)——任务中临时结论先挂这里,任务后补写到对应子目录
FILE:knowledge-base/topics/2026-03-20-xhs-topic-candidates.md
---
id: 2026-03-20-xhs-topic-candidates
type: topic
status: active
created_at: 2026-03-20T21:30:00+08:00
account: 王凯Aaron
tags: [AI, Agent, Claude, 选题]
summary: "小红书选题候选池,从平台信号+账号定位+热点素材综合生成"
next_action: "按优先级逐条写稿+配图,本周先发 XHS-1"
---
# 小红书选题候选池
> 账号:王凯Aaron(小红书号 9537236820)
> 定位:AI 产品经理个人号,记录踩坑和想清楚的事
> 已发:外卖员新身份(3/6)、Skill vs SubAgent(3/20 待发)
---
## 📌 待发布
### XHS-1. 8万人告诉Anthropic:他们最想要的AI不是效率工具
- **角度**:反差——表面要效率,深挖要生活
- **人群**:用 AI 的 PM/白领/创业者
- **钩子**:你用 AI 最想解决的是工作效率,还是生活质量?
- **结构**:
- 开头:Anthropic 8 万人调研最大发现——人们以为自己要效率,其实要的是"有时间和家人做饭"
- 展开:9 大期望分类 + 3 个用户故事(乌克兰士兵/失语患者/屠夫转型创业者)
- 收口:AI 最被珍视的品质不是聪明,而是"耐心、不评判、随时在线"
- **素材来源**:Anthropic 官方(收藏 2026-03-20-anthropic-81k-interviews.md)
- **时效**:3/20 发布首日,1 周窗口
- **评分**:热度 2 + 匹配 2 + 互动 2 + 可写 2 - 风险 0 = **8/8** 🥇
- **状态**:待写稿
### XHS-4. 同样用Claude,为什么有人效率翻倍,有人只会问"帮我写个方案"
- **角度**:对比——同一个工具用法差距巨大
- **人群**:已经在用 Claude/ChatGPT 但觉得"也就那样"的人
- **钩子**:你平时用 AI 最多的功能是什么?评论区说说
- **结构**:
- 开头:同事问我 Claude 有啥好用的,他说"就让它写方案啊"。这就是问题所在
- 展开:三个层级——① 问答机器人 ② 工作伙伴(给上下文+迭代) ③ 自动化团队(多 Agent+技能系统)
- 收口:不是工具不行,是大部分人还停在第一层
- **素材来源**:老板 3 个月 Agent 实践经验
- **时效**:常青,随时可发
- **评分**:热度 2 + 匹配 2 + 互动 2 + 可写 2 - 风险 0 = **8/8** 🥇
- **状态**:待写稿
---
## ✅ 已发布 / 进行中
### XHS-0. 外卖员的新身份:AI系统的"人形API"
- **发布日期**:2026-03-06
- **状态**:已发布
### Skill vs SubAgent:给AI装了15个工具,它反而更蠢了
- **发布日期**:2026-03-20(待老板手动发布)
- **状态**:文案+配图已完成,待发布
---
## 💡 备选(需要时可激活)
### XHS-2. 我让AI管了三个月的日程,最后只留下了这两个习惯
- 评分:7/8 | 生活化,受众广但深度略浅
### XHS-3. 别再说"AI要取代产品经理"了,真正变的是这件事
- 评分:7/8 | 争议性强,观点独特
### XHS-5. 我在大厂做AI产品,每天花最多时间的居然不是写PRD
- 评分:7/8 | 好奇心驱动,适合涨粉
FILE:persona.md
# 小红书账号 Persona
## Voice Dimensions(量化风格锚点)
写作时以这些分数为锚点。详见 `<WORKSPACE>/memory/style-runs/voice-dimensions.md`。
| Dimension | Score | 说明 |
|-----------|-------|------|
| formal_casual | **3/10** | 偏口语,不油不装 |
| technical_accessible | **4/10** | 降到小白能懂 |
| serious_playful | **4/10** | 偏轻松有态度 |
| concise_elaborate | **3/10** | 短句,300-600 字 |
| reserved_expressive | **7/10** | 直接、有判断 |
| emoji_density | **6/10** | 适量,标记结构不刷屏 |
| interaction_pull | **8/10** | 结尾必有互动钩子 |
## 身份
**王凯(Aaron)**,AI 产品经理,个人号。
不是品牌号,不是工具人,就是一个在大厂做 AI 产品的普通人,顺手记录自己看到的、踩过的、想清楚的事。
---
## 语气定位
**直接、有观点、不装、不端、口语化但不油。**
- 有自己的判断,不做"这个问题很复杂,要看情况"的废话
- 不端着,但也不刻意接地气("宝""姐妹"用不了几次就腻了)
- 口语,但不刷存在感(没有"那么""其实呢""好的接下来")
- 说过的话能被追责——不许编数据,不许说"大概""也许"糊弄
---
## 固定句式(2-3个)
1. **「说结论」**——直接给判断,不绕弯
> 示例:「我觉得这个选择是对的,原因只有一个:…」
2. **「我试过/我遇到过」**——用第一人称经历撑观点,不空谈理论
> 示例:「我自己用过半年,最明显的感受是…」
3. **「你遇到过吗?」**——收尾互动钩子,把读者拉进来
> 示例:「你做产品有没有遇到类似的情况?评论区说说。」
---
## 禁忌清单
| 禁止 | 原因 |
|------|------|
| 「宝/姐妹/绝绝子/yyds」 | 不是我的语气 |
| 「希望对你有帮助/如有不妥请指正」 | 太客套,像免责声明 |
| 「大家好,今天给大家分享…」 | 开头废话,直接进正题 |
| 「其实/说真的/那个…」 | 口头禅填充词,删掉 |
| 编造具体数字("80%的人都…")| 用不确定的就说不确定 |
| 虚假承诺("下期告诉你…"没有下期) | 不说做不到的事 |
| 过度情绪词("太震惊了!""简直不敢信!") | 矫揉造作 |
| 无来源的"研究表明" | 有来源才说,没有就不说 |
---
## 内容风格参考
- **笔记长度:** 能说清楚就够,不凑字数。500字说清比1000字凑字数强。
- **标题:** 有立场 / 有反差 / 有具体——三者至少占一个
- **开头:** 第一句就给结论或钩子,不做自我介绍
- **结尾:** 留一个可回答的问题,但不强求"快去关注我"
- **话题标签:** 精准垂类 > 大词蹭热点
---
## 与「虾薯」风格的区分
「虾薯」是电子宠物风格,卖萌+服务感。这个账号不做这个。
这里是真人视角:**有时候会不确定,有时候会改判断,说过的话算数。**
FILE:references/anti-ai-checklist.md
# 小红书笔记:去 AI 味检查清单
> 来源:WikiProject AI Cleanup 24 条模式 + 实战积累,针对小红书平台裁剪。
> 通用 Anti-Pattern 清单:`~/.openclaw/workspace/references/writing-anti-patterns.md`(跨平台通用,本文件是小红书特化版)
>
> 使用时机:正文定稿后、发布前。逐条扫描,命中则改。
## 核心原则(5 条)
1. **删除填充短语** — 去除开场白和强调性拐杖词
2. **打破公式结构** — 避免二元对比、戏剧性分段
3. **变化节奏** — 混合句子长度。两项优于三项
4. **信任读者** — 直接说,不铺垫不辩解
5. **删除金句** — 如果听起来像"名言警句",重写它
## 检查清单
### A. 必杀项(命中 = 一定要改)
| 特征 | 示例 | 怎么改 |
|------|------|--------|
| 时代感叹开头 | "在当今 AI 时代…"、"随着…的发展…" | 直接从具体场景/经历切入 |
| 三段式法则 | "A、B 和 C"凑三组 | 改两项或四项 |
| 否定式排比 | "不仅仅是…而是…"、"不是…是…" | 直接说正面意思 |
| AI 高频词 | 此外、至关重要、深入探讨、格局、展示、充满活力的 | 用口语替代或直接删 |
| 意义膨胀 | 标志着、见证了、奠定基础、不可磨灭的 | 直接说"是什么",不说"代表什么" |
| 模糊归因 | "专家认为"、"行业报告显示" | 给出具体来源,或"我觉得" |
| 总结套话 | "综上所述"、"总结一下" | 删掉,直接说结论 |
| 系动词回避 | "作为/充当/代表"替代"是" | 该用"是"就用"是" |
### B. 注意项(1-2 个可接受,多了就改)
| 特征 | 示例 | 怎么改 |
|------|------|--------|
| 对称句式 | "不仅…而且…"、"既…又…" | 打破对称 |
| 同义词循环 | 主人公→主要角色→中心人物 | 固定一个称呼 |
| 过度限定 | "可以潜在地可能被认为…" | 直说 |
| 粗体轰炸 | 每句都有粗体 | 一段最多 1-2 处 |
| 宣传式语言 | "坐落于"、"令人叹为观止" | 事实描述替代 |
| 虚假范围 | "从 X 到 Y"但不在同一尺度 | 直接列举 |
### C. 小红书特殊规则(与公众号不同)
| 公众号规则 | 小红书调整 |
|-----------|-----------|
| 禁用 emoji(#17) | ✅ 小红书可以用 emoji,但不要每行都用,3-5 个/篇为宜 |
| 删除金句 | ⚠️ 小红书可以有 1 句金句收尾,但不能是"AI 名言"风格 |
| 内联标题列表 | ✅ 小红书图文笔记可以用序号+短标题,但正文别用 |
| 破折号限制 | ⚠️ 小红书更口语化,破折号用法更随意,但仍不要一段 3+ 个 |
## 灵魂注入(降完 AI 味后的必做项)
干净但没灵魂 = 同样有问题。检查:
- [ ] 有没有"我"的声音?(第一人称体验/感受)
- [ ] 有没有承认"不确定"或"复杂感受"?
- [ ] 有没有具体场景描写?(不是概念,是画面)
- [ ] 读起来像在跟朋友聊天还是在写报告?
## 快速自查(10 秒扫完)
- [ ] 连续三个句子长度相同?→ 打断
- [ ] 使用了"此外""然而"?→ 删
- [ ] 三段式列举?→ 改两项或四项
- [ ] 听起来像课本/新闻稿?→ 加口语感
- [ ] 开头第一句是废话?→ 从场景/动作切入
FILE:references/content-analysis.md
# 小红书内容分析框架
在生成配图/大纲之前,先对内容做结构化分析。不是"想画什么就画什么",而是"内容需要什么就给什么"。
---
## 分析流程
```
原始内容 → 1.内容分类 → 2.受众画像 → 3.Hook评估 → 4.传播触发点 → 5.视觉机会 → 6.预设推荐
```
完成分析后输出 `analysis.md`,作为后续配图和大纲的依据。
---
## 1. 内容类型分类
| 类型 | 特征关键词 | 推荐预设 | 推荐张数 |
|------|-----------|---------|---------|
| **干货分享** | 知识、方法、技巧、教程、推荐 | `knowledge-card` | 5-6 |
| **测评对比** | 产品PK、优劣、选哪个、体验 | `vs-compare` | 4-5 |
| **经验总结** | 我、经历、踩坑、复盘、感悟 | `pm-story` | 4-5 |
| **工具推荐** | 好物、神器、App、AI工具 | `checklist` | 5-7 |
| **避坑指南** | 警告、别踩、误区、血泪教训 | `checklist` | 4-6 |
| **教程步骤** | 手把手、第一步、操作、搭建 | `step-guide` | 5-7 |
| **观点输出** | 观察、趋势、预测、为什么 | `tech-insight` | 3-5 |
---
## 2. 目标受众画像匹配
| 受众 | 关注点 | 偏好风格 | 内容偏好 |
|------|--------|---------|---------|
| **产品经理** | 方法论、AI、效率、职场 | 科技感 / notion风 | 框架、实操、趋势 |
| **打工人** | 效率、工具、摸鱼、职场 | 手绘涂鸦 | 工具推荐、避坑、速成 |
| **技术人** | 深度、原理、架构、实操 | 科技感 | 教程、源码、对比 |
| **创业者** | ROI、增长、AI应用、趋势 | 科技感 | 案例、数据、方法论 |
| **AI爱好者** | 新工具、新模型、体验、测评 | 手绘涂鸦/科技感 | 测评、教程、推荐 |
**判断方法**:根据内容的核心受益对象选择,一篇笔记聚焦 1 个主要受众。
---
## 3. Hook 评估
→ 详见 `hook-analysis.md`
在此步骤完成标题 Hook 评分,确保 ≥ 4 星。
---
## 4. 传播触发点分析
### 收藏价值(用户"以后要用")
| 信号 | 收藏潜力 |
|------|---------|
| 清单、工具列表、速查表 | 🔴 极高 |
| 教程、步骤指南 | 🔴 极高 |
| 数据对比表、参数表 | 🟡 高 |
| 个人经验、故事 | 🟢 一般 |
| 时效性新闻、热点 | ⚪ 低 |
### 分享驱动(用户"朋友也需要看")
| 触发 | 分享类型 |
|------|---------|
| 「我朋友也需要看这个」 | 实用分享 |
| 「这说的就是我」 | 身份认同 |
| 「太有用了必须分享」 | 价值分享 |
| 「笑死给朋友看看」 | 娱乐分享 |
### 评论诱导(设计互动点)
| 策略 | 模板 | 放置位置 |
|------|------|---------|
| 开放提问 | 「你是哪种类型?」 | 结尾图 |
| 经验征集 | 「评论区说说你的经历」 | 结尾图 |
| 观点投票 | 「你觉得 A 还是 B?」 | 对比图后 |
| 补充征集 | 「还有什么好推荐的?」 | 结尾图 |
**规则**:每篇笔记至少 1 个评论诱导点,放在最后一张图或文案结尾。
---
## 5. 视觉机会映射
分析内容中哪些元素可以"show"而不是"tell":
| 内容元素 | 视觉处理 | 布局 |
|---------|---------|------|
| 数据/统计 | 大字高亮数字 | `dense` |
| 对比 | 左右分屏 | `comparison` |
| 步骤序列 | 编号+箭头 | `flow` |
| 清单/列表 | ✓/✗ 图标列表 | `list` |
| 引用/金句 | 引用卡片,大字居中 | `sparse` |
| 工具/产品 | 图标+简述网格 | `dense` |
| 时间线 | 上下/左右流 | `flow` |
---
## 6. 三策略大纲法(A/B/C 变体)
分析完成后,为同一内容生成 2-3 种差异化的图卡组织方案,供老板选择。
| 策略 | 焦点 | 结构 | 推荐风格 | 张数 |
|--------|------|------|---------|------|
| **A: 叙事驱动型** | 情感共鸣、个人视角 | Hook→痛点→发现→体验→感悟 | 手绘涂鸦 / warm | 4-6 |
| **B: 信息密集型** | 价值优先、结构化交付 | 核心结论→信息卡→对比→推荐 | notion / 科技感 | 3-5 |
| **C: 视觉优先型** | 视觉冲击、最少文字 | 主视觉→细节→场景→CTA | 丝网海报 / 极简 | 3-4 |
**差异化要求**:
- 每个策略必须在**风格、布局、张数、信息密度**上有明显差异
- 不是同一个方案换个配色,而是从不同角度重新组织内容
**输出格式**(在分析完成后,向老板展示):
```markdown
## 方案 A(叙事驱动型)
- 预设:pm-story
- 张数:5 张
- 特点:个人视角带入,每张 2-3 个点 + 场景感,适合共鸣
## 方案 B(信息密集型)← 推荐
- 预设:knowledge-card
- 张数:6 张
- 特点:高密度知识卡,每张 5+ 信息点,适合收藏
[老板指定时可出方案 C]
## 方案 C(视觉优先型)
- 预设:bold-poster
- 张数:4 张
- 特点:大字报风,每张只 1 个核心观点,最强视觉冲击
```
**工作流**:默认出 A+B 两个方案,老板选定后再生成详细 outline + 配图。老板也可以混合(“用 A 的封面 + B 的内容页”)。
---
## 7. 分析输出模板
```markdown
## 内容分析
**内容类型**:干货分享
**目标受众**:产品经理 / AI爱好者
**推荐预设**:knowledge-card(手绘涂鸦 + dense)
**推荐张数**:5 张
### Hook 评估
- 初始标题:「xxx」
- 评分:⭐⭐⭐⭐(数字+利益)
- 优化建议:加身份标签 → 「PM必看|xxx」
### 传播触发点
- 收藏价值:🔴 高(工具清单类,"以后要用")
- 分享驱动:实用分享("朋友也需要")
- 评论诱导:「你最常用哪个?评论区见」
### 视觉机会
| 段落/要点 | 可视化方式 | 布局 |
|---------|-----------|------|
| 5个工具列表 | 图标+简述 | dense |
| 效率对比数据 | 大字数字 | sparse |
| 使用流程 | 步骤箭头 | flow |
### 多图规划(Swipe Flow)
| # | 位置 | 布局 | 内容 | Hook |
|---|------|------|------|------|
| 1 | Cover | sparse | 标题+冲击 | 「第一个就很强大👇」 |
| 2 | Setup | balanced | 为什么需要 | 「接下来是干货👇」 |
| 3-4 | Core | dense | 工具详解 | 「下一个更厉害👇」 |
| 5 | Ending | sparse | 总结+互动 | — |
```
---
## 分析 Checklist
在进入配图阶段前,确认以下全部完成:
- [ ] 内容类型已分类
- [ ] 目标受众已确定
- [ ] 标题 Hook ≥ 4 星
- [ ] 至少 1 个收藏/分享触发点
- [ ] 至少 1 个评论诱导点
- [ ] 视觉机会已映射
- [ ] 多图 Swipe Flow 已规划
- [ ] 预设已选定
FILE:references/hook-analysis.md
# Hook 分析框架
系统化评估标题/钩子质量,确保每篇笔记标题有足够的点击驱动力。
---
## Hook 类型库
| 类型 | 模式 | 示例 |
|------|------|------|
| **数字钩子** | N个方法 / N分钟学会 / N%的人不知道 | 「5个让你效率翻倍的AI工具」 |
| **痛点钩子** | 踩过的坑 / 后悔没早知道 / 别再... | 「后悔没早用的AI产品技巧」 |
| **好奇钩子** | 原来... / 竟然... / 没想到... | 「原来产品经理可以这样用AI」 |
| **利益钩子** | 省钱 / 变强 / 效率翻倍 / 升职 | 「效率翻倍的3个AI工作流」 |
| **身份钩子** | 打工人必看 / 产品经理 / 00后 / PM | 「产品经理必看的AI生存指南」 |
### 组合威力倍增
单一钩子 < 双钩子组合 < 三钩子组合
| 组合 | 示例 | 钩子拆解 |
|------|------|---------|
| 身份+数字 | 「打工人必看!5个AI神器」 | 身份锁定 + 数字具象 |
| 痛点+利益 | 「别再手写PRD了,效率翻10倍」 | 痛点共鸣 + 利益承诺 |
| 身份+痛点+数字 | 「PM必看|踩了3次坑才学会的AI用法」 | 三重叠加 |
| 好奇+利益 | 「原来用对AI,一天能干三天活」 | 好奇驱动 + 利益兑现 |
---
## 评分标准(5 星制)
| 评分 | 标准 | 示例 |
|------|------|------|
| ⭐⭐⭐⭐⭐ | 2+ 钩子类型组合 + 精准目标人群 + 信息缺口 | 「PM必看|99%的人不知道的5个AI提效神器」 |
| ⭐⭐⭐⭐ | 单一强钩子 + 目标明确 + 有优化空间 | 「5个让你效率翻倍的AI工具」 |
| ⭐⭐⭐ | 基础钩子 + 表意清晰 + 但缺乏冲击力 | 「推荐几个好用的AI工具」 |
| ⭐⭐ | 弱钩子 + 表意模糊 | 「聊聊AI工具」 |
| ⭐ | 无钩子 + 像笔记标题而非小红书标题 | 「AI工具使用心得」 |
---
## 评估流程
每次产出小红书标题时,执行以下 4 步:
### Step 1:识别已用钩子
分析初始标题中包含的钩子类型,标注每个。
### Step 2:评分
按 5 星标准打分。
### Step 3:优化(如果 < 4 星)
生成 3 个优化候选标题,每个标注钩子组合:
```
原标题:「AI工具使用心得」→ ⭐(无钩子)
优化 1:「打工人必看!5个让我效率翻倍的AI神器」
→ 身份 + 数字 + 利益 = ⭐⭐⭐⭐⭐
优化 2:「后悔没早知道的5个AI工具」
→ 痛点 + 数字 = ⭐⭐⭐⭐
优化 3:「原来AI可以这样用?我的效率直接翻倍」
→ 好奇 + 利益 = ⭐⭐⭐⭐
```
### Step 4:确认
老板从候选中选择或指定方向,最终标题 ≥ 4 星方可使用。
---
## 平台限制
- 小红书标题上限 **20 字**(含标点和 emoji)
- emoji 可作为视觉分隔符但不算钩子
- 「」用作标题中的概念包裹(更醒目)
- 竖线 | 用作标题中的分段
---
## 标题结构模板
| 模板 | 结构 | 示例 |
|------|------|------|
| **[身份]|[数字+利益]** | 人群锚定 + 价值承诺 | 「PM必看|5个效率翻倍的AI神器」 |
| **[痛点],[解法]** | 问题 + 答案 | 「别再手写PRD了,用AI 10分钟搞定」 |
| **[数字][好奇]** | 具象 + 悬念 | 「3个被低估的AI用法,最后一个绝了」 |
| **[利益]+[身份]** | 价值前置 + 人群 | 「效率翻倍的秘密武器|打工人AI指南」 |
---
## 反面教材
| 问题标题 | 问题 | 修正 |
|---------|------|------|
| 「AI产品经理的一些思考」 | 模糊、无钩子、像博客标题 | 「AI时代PM的5个生存技能」 |
| 「关于Agent记忆的讨论」 | 学术感、无利益、无身份 | 「Agent为什么总忘事?3个记忆设计原则」 |
| 「实现在贬值判断在升值」 | 虽好但缺身份标签 | 「PM必看|实现在贬值,判断在升值」 |
FILE:references/illustration-prompts.md
# 小红书配图 Prompt 模板
小红书配图要求:吸引眼球、信息密度高、竖版为主。
> **配图工作流**:内容分析(`content-analysis.md`)→ 选预设(`presets.md`)→ 选布局(`layouts.md`)→ **查兼容矩阵**(下方)→ 设计 Swipe Flow(`swipe-flow.md`)→ 评估 Hook(`hook-analysis.md`)→ 用下方风格模板生成 prompt
> **⚠️ 安全区**:所有配图必须遵守 `layouts.md` 中的安全区规范。底部 10% 不放关键信息,右上角避开互动按钮区。每个 prompt 末尾追加安全区指令。
---
## 风格 × 布局 兼容矩阵
选定风格和布局后,必须查此矩阵确认组合合理。有 ✗ 的组合会导致风格特征和布局结构冲突,出来效果差。
| 风格 \ 布局 | sparse | balanced | dense | list | comparison | flow | mindmap | quadrant |
|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| 1 手绘涂鸦 | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✓ |
| 2 科技感 | ✓ | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✓ | ✓✓ |
| 3 Notion极简 | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓ |
| 4 丝网印刷海报 | ✓✓ | ✓ | ✗ | ✗ | ✓✓ | ✗ | ✗ | ✓ |
| 5 手写笔记 | ✗ | ✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✓ | ✗ |
| 6 手绘信息图 | ✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ |
| 7 截图+标注 | ✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓✓ | ✗ | ✗ |
> ✓✓ 强推荐 | ✓ 可用 | ✗ 不推荐
>
> 核心逻辑:
> - 丝网印刷海报靠负空间+大色块,密集布局会破坏其视觉张力
> - 手写笔记风格本身就是密集的,稀疏布局与"学霸笔记"气质冲突
> - 截图+标注适合线性展示,不适合抽象的脑图/象限布局
**兼容检查**:选定风格+布局后查此矩阵。有 ✗ 则换备选风格或布局。预设(presets.md)已确保其风格+布局组合在 ✓ 以上,可安全使用。
## 调用方式
```bash
# idealab Chat API(首选,团队AK,免费无额度压力)
<WORKSPACE>/scripts/generate-image.sh "prompt内容" output.jpg
# 默认模型 gemini-3.1-flash-image-preview,~25s/张
# 竖版 3:4: --ar 3:4
# 竖版 9:16: --ar 9:16
# 方版 1:1: --ar 1:1
# Seedream 5.0 Lite(降级,idealab 不可用时)
<WORKSPACE>/scripts/seedream-generate.sh "prompt内容" output.jpg "1680x2240" 1
# ComfyUI(兜底)
```
---
## 风格一:手绘涂鸦卡片(推荐,适合经验分享、干货总结)
```
# 角色
你是一位擅长手绘涂鸦风的信息可视化设计师,为社交媒体创作吸引眼球的知识卡片。
# 视觉风格规范
## 色彩
- 主色:珊瑚橙(#FF6B6B),搭配暗红(#C44569)、纯白、深灰(#2D3436)
- 背景为淡米色或纯白
- 重点信息用珊瑚橙高亮
## 线条
- 手绘线条,2-4px 均匀粗细
- 造型简洁几何化,纯色填充
- 无纹理、无阴影、无渐变
## 排版
- 大标题醒目居上(核心观点,不超过 8 字)
- 内容分 3-5 个要点,每个要点配一个简笔图标
- 关键词加粗或用色块强调
- 留白充分,不拥挤
## 文字
- 中文无衬线字体
- 遵循帕累托法则,仅提取核心关键词(3-5字)
- 绝不大段文字
## 禁止事项
- 不要渐变或复杂配色
- 不要 3D 效果
- 不要密集文字
# 输出
- 竖版,比例 3:4
- 中文,直接生成不需要解释
# 内容
[在此描述要可视化的内容]
```
### 适用场景
- 干货总结卡片("3个方法让你...")
- 工具推荐卡片
- 对比分析卡片
- 个人经验分享
---
## 风格二:科技感信息卡(适合 AI/产品/技术内容)
```
极简科技风知识卡片。
深色背景(#0F172A),主色电光蓝(#3B82F6),辅助色青绿(#06B6D4)和纯白。
线性图标风格,细线条(1-2px),霓虹发光效果。
标题大字居上,正文内容分区块展示,每个区块有简笔图标。
中文无衬线字体,关键数据用大号字+亮色强调。
竖版,比例 3:4。
高级感、未来感、信息清晰。
内容:[在此描述要可视化的内容]
```
### 适用场景
- AI 工具评测
- 产品功能拆解
- 技术方案对比
- 数据驱动的结论展示
---
## 风格三:Notion 极简线稿(适合知识科普、概念解读)
```
极简黑白手绘线条信息图。
主色:黑色(#1A1A1A),深灰(#4A4A4A)。
背景:纯白(#FFFFFF)或微黄(#FAFAFA)。
强调色:淡蓝(#A8D4F0)、淡黄(#F9E79F)、淡粉(#FADBD8) - 仅用于少量高亮。
线条:单一粗细的手绘线条,略带抖动,像马克笔随手画。
造型:简笔几何图形,圆圈方块,棍状简笔人物。
最大留白,极致干净,信息用短标签承载(3-5字)。
不要彩色渐变、3D效果、复杂装饰。
不要写实照片元素。
竖版,比例 3:4。中文。
内容:[在此描述要可视化的内容]
```
### 适用场景
- 概念解释、知识科普
- SaaS/效率类工具介绍
- 方法论框架
- 专业/理性调性内容
---
## 风格四:丝网印刷海报(适合观点输出、影评书评)
```
丝网印刷/版画风格海报。
2-4 种扁平纯色,绝不用渐变。
主色对从以下选一组:
橙+青(#E8751A, #0A6E6E) - 电影感
红+米(#C0392B, #F5E6D0) - 经典
蓝+金(#1A3A5C, #D4A843) - 高级感
半色调网点纹理(halftone dots)模拟色调变化。
大胆剪影和符号化图形,不画细节,用形状讲故事。
负空间即叙事(figure-ground inversion)。
文字是构图的一部分(不是贴上去的),粗体压缩无衬线或 Art Deco 风。
纸张颗粒质感底纹。
竖版,比例 3:4。中文。
内容:[在此描述要可视化的内容]
```
### 适用场景
- 观点文章封面(强视觉冲击)
- 影评/书评卡片
- 重大声明/重磅观点
- 文化评论
---
## 风格五:手写笔记照片(适合学习笔记、知识整理)
```
俯拍学生笔记风格照片,白色横线纸上密集手写。
主色:蓝色圆珠笔(#1E3A5F) 写正文,黑色墨水(#1A1A1A) 写标题。
强调:红色笔(#CC0000) 圈重点、画下划线、标星号。
高亮:黄色荧光笔(#FFFF00, 50%透明) 标记关键词。
真实的手写笔迹,有粗细变化,字迹密密麻麻但有结构。
页边空白处挤满补充笔记(蓝色小字)。
简单符号:→ ★ ✓ ✗ ! 123。
不要卡通元素、emoji、彩色装饰。
整张图是"学霸笔记"的真实感照片。
竖版,比例 3:4。中文。
内容:[在此描述要可视化的内容]
```
### 适用场景
- 知识框架整理("学霸笔记"感)
- 考试/面试要点速查
- 读书笔记分享
- 知识密集型总结
---
## 风格六:手绘信息图 / Sketch Notes(适合教育、流程图解、知识总结)
```
手绘教育信息图风格,像高质量的演讲视觉摘要。
背景:暖奶油色(#F5F0E8),微纸张纹理。
区块色:马卡龙蓝(#A8D8EA)、薄藤(#D5C6E0)、薄荷(#B5E5CF)、桃子(#F8D5C4) - 用作信息区块的圆角罩块背景。
强调色:珊瑚红(#E8655A),仅少量用于关键词强调。
所有线条带轻微手绘抖动(wobble),不用完美几何形状。
简笔籽状人物(在桌前工作、思考、对话)。
圆角卡片作为信息分区块,每个区块用一个马卡龙色填充,但填色不完全填满边缘(手绘感)。
涂鸦装饰:小星星、下划线、对勾、箭头、灯泡图标、齿轮图标。
波浪形手绘箭头连接各区块,箭头旁可加小字标注。
字体:
- 标题:粗体手绘字,大而醒目
- 区块内关键词加粗
- 次要文字用深灰小字
- 所有文字必须手绘质感,不用电脑字体
禁止:完美几何形状、写实元素、深色或饱和背景、渐变、光泽效果。
竖版,比例 3:4。中文。
内容:[在此描述要可视化的内容]
```
### 适用场景
- 教育/教程类内容(手绘教程、how-to)
- 流程/工作流图解
- 知识总结、概念图
- 技术解释做得亲切易懂
- 演讲/文章的视觉摘要
### macaron 配色的 Prompt 覆盖规则
当预设包含 macaron 配色时(如 `sketch-card`、`sketch-flow`),上述 prompt 已经内置 macaron 颜色。如果要覆盖为 `warm` 配色,只需替换背景/区块色/强调色的十六进制值,保留所有线条/装饰/字体规则。
---
## 风格七:真实截图 + 标注(适合教程、测评)
不使用 AI 生图,而是**真实截图 + 美化 + 标注**。
### 处理流程
1. 实际操作截图
2. 用 `<WORKSPACE>/scripts/beautify-screenshot.sh` 美化
3. 需要标注时,用圆圈/箭头/文字标注重点区域
### 适用场景
- 产品测评(真实界面)
- 操作教程(步骤截图)
- 效果展示(before/after)
---
## 小红书配图策略
> 详细的 Swipe Flow 设计见 `swipe-flow.md`,预设系统见 `presets.md`,布局体系见 `layouts.md`。
### 封面图(最重要)
- 封面决定 80% 的点击率(详见 `swipe-flow.md` 的"封面 80% 法则")
- 封面固定用 `sparse` 布局:大字标题 + 一个视觉焦点
- 标题必须通过 Hook 评估(≥ 4 星,详见 `hook-analysis.md`)
#### 封面图五维度体系(融合 baoyu-cover-image,竖版适配)
封面图用五维度结构化选择,保证风格可复现、品牌一致:
| 维度 | 小红书常用选项 | 默认 |
|------|--------------|------|
| **Type** | `typography`(大字报)、`hero`(视觉冲击)、`minimal`(极简) | typography |
| **Palette** | `warm`(莫兰迪)、`mono`(黑白)、`vivid`(鲜艳)、`macaron`(马卡龙) | warm |
| **Rendering** | `hand-drawn`(手绘)、`flat-vector`(扁平)、`screen-print`(丝印) | hand-drawn |
| **Text** | `title-only`、`text-rich`(多要点) | title-only |
| **Mood** | `bold`(信息流抢眼)、`balanced` | bold |
**小红书封面预设:**
| 预设名 | Type | Palette | Rendering | 适用 |
|--------|------|---------|-----------|------|
| `xhs-knowledge` | typography | warm | hand-drawn | 干货/方法论(**最常用**) |
| `xhs-bold` | typography | mono | screen-print | 观点/爬坑 |
| `xhs-cute` | minimal | macaron | flat-vector | 生活/好物 |
| `xhs-tech` | hero | vivid | flat-vector | AI/产品测评 |
**构图约束(封面图 prompt 末尾必附):**
```
Composition rules for Xiaohongshu cover (vertical 3:4):
- Title text occupies 40%+ area, must be immediately readable at thumbnail size
- Single visual focal point, generous whitespace (40-60%)
- NO realistic human faces, use simplified silhouettes or icons
- Bold high-contrast colors for feed competition
- Chinese text large, clear, no overlap with visuals
- Bottom 10% clear (app UI safe zone)
- Right-top corner clear (interaction buttons)
```
### 正文配图
- 图文笔记建议 4-6 张图
- 第 1 张 = 封面(`sparse`,吸引点击)
- 第 2 张 = 铺垫(`balanced`,建立背景/共鸣)
- 第 3-N 张 = 核心内容(跟随预设默认布局)
- 最后 1 张 = 结尾(`sparse`,CTA + 互动引导)
- 每张图底部加 Swipe Hook(除最后一张)
### 使用决策(已升级为预设系统)
```
内容类型判断 → 自动推荐预设(见 presets.md):
├─ 干货分享 / 方法论 → knowledge-card(手绘涂鸦 + dense)
├─ 排行 / 清单 / 避坑 → checklist(手绘涂鸦 + list)
├─ 教程 / 流程 / 步骤 → step-guide(手绘涂鸦 + flow)
├─ AI/产品/技术分析 → tech-insight(科技感 + balanced)
├─ 产品对比 / 方案PK → vs-compare(科技感 + comparison)
├─ 个人经历 / 故事 → pm-story(手绘涂鸦 + balanced)
├─ 封面 / 金句 → cover-hook(sparse)
└─ 教程/测评/实操 → 风格三(真实截图)
```
### 三策略分化(多方案选择)
每次配图不止出 1 个方案,出 **2 个差异化方案**供老板选择:
```markdown
## 方案 A(信息密集型)
- 预设:knowledge-card
- 张数:5-6 张
- 特点:高密度知识卡,每张 5+ 信息点,适合收藏
## 方案 B(叙事驱动型)
- 预设:pm-story
- 张数:4-5 张
- 特点:个人视角带入,每张 2-3 个点 + 场景感,适合共鸣
[老板指定时可出方案 C]
## 方案 C(极简海报型)
- 预设:cover-hook 全系列
- 张数:3-4 张
- 特点:大字报风,每张只 1 个核心观点,最强视觉冲击
```
方案 A 和 B 必须在**风格、布局、张数、信息密度**上有明显差异。
## 生图工具优先级
**idealab Chat API → Seedream 5.0 Lite → ComfyUI**
### 1. idealab Chat API(首选)
- 脚本: `<WORKSPACE>/scripts/generate-image.sh "prompt" output.jpg`
- 团队 AK,免费无额度压力,~25s/张
- 默认模型: `gemini-3.1-flash-image-preview`
- 可选: `gemini-2.5-flash-image`(更快 ~10s)、`gemini-3-pro-image-preview`(最高质量)
- ℹ️ 生成后自动去水印
### 2. Seedream 5.0 Lite(降级)
- 脚本: `<WORKSPACE>/scripts/seedream-generate.sh "prompt" output.jpg "size" n`
- 0.22 元/张,idealab 不可用时才用
- 竖版尺寸: `1440x2560`(9:16) 或 `1680x2240`(3:4)
- 方版尺寸: `1920x1920`(1:1)
- ⚠️ 最小像素要求 ≥ 3686400
### 3. ComfyUI(兜底)
- 本地运行,完全免费,但速度慢
## 生图注意事项
- 小红书竖版为主(3:4 或 9:16)
- 同一篇笔记的多张配图保持风格统一
- Seedream 无水印,nano-banana-pro 需去水印
- 中文文字必须清晰可读,不清晰则重新生成
- 所有配图 prompt 末尾追加安全区指令(见 `layouts.md`)
- ⛔ **Prompt 先存后生**:每张图的 prompt 先保存到文件(笔记目录下 `prompts/NN-{position}-{slug}.md`),再调用 Seedream 生成。失败时改文件重试不用重新构思,全部 prompt 有存档可回溯
## 多图系列视觉一致性
### 文本锚点(默认方式)
生成第一张图后,记录成功图的关键特征。后续图片 prompt 末尾附加:
```
Maintain visual consistency with the first image in this series:
- Same illustration style and line quality
- Same color palette: [主色] + [辅助色] + [背景色]
- Same typography approach
- Same level of detail and whitespace
- Same character design if recurring characters appear
```
**i️ 文本锚点内容应在生成第一张图后立即记录到 `prompts/00-anchor.md`**,包含实际成功的配色值、线条风格描述、字体表现。后续每张图的 prompt 从此文件拉取一致性指令,而不是凭记忆描述。
### 图片 Ref 锚点(首选方式,Reference Image Chain)
**核心原则:第一张图不带 ref,后续所有图以第一张为 ref 锚点。** 这样确保角色/配色/笔触跨整个系列一致。
```bash
# Image 1:不带 ref,建立视觉锚点
<WORKSPACE>/scripts/generate-image.sh "prompt" img-01.jpg
# Image 2+:所有后续图传入 Image 1 作为 ref
<WORKSPACE>/scripts/generate-image.sh "prompt" img-02.jpg --ref img-01.jpg
# 降级(idealab 不可用时):
# <WORKSPACE>/scripts/seedream-generate.sh "prompt" img-01.jpg "1680x2240" 1
# <WORKSPACE>/scripts/seedream-generate.sh "prompt" img-02.jpg "1680x2240" 1 --ref img-01.jpg
```
**注意**:
- Seedream 图生图效果待验证,如不支持则回退到文本锚点
- nano-banana-pro(baoyu-imagine skill)支持 `--ref`,效果稳定
- 两究底没有 ref 能力时,用文本锚点保底
### 结构化 Prompt 组装流程
每张图的 prompt 按以下固定结构组装,每个 section 独立可替换:
```
## 图片规格
- 类型:信息图
- 方向:竖版
- 比例:3:4
- 风格:手绘插画
## 核心原则
- 手绘质感贯穿--不用写实或照片元素
- 如涉及敏感人物或版权内容,画一个风格相似的卡通替代形象--不要拒绝生成
- 信息精炼,突出关键词和核心概念
- 充足留白,易于视觉扫描
- 清晰的视觉层级
## 文字规则(CRITICAL)
- 所有文字必须手绘风格
- 主标题醒目抢眼
- 关键词加粗放大
- 用荧光笔效果强调重点
- 绝不用电脑字体
## 语言
- 中文
- 标点风格匹配内容语言(中文:「」,。!)
---
[风格 section:从 illustration-prompts.md 的对应风格复制]
---
[布局 section:从 layouts.md 的对应布局复制 Prompt 关键词]
---
[内容 section:从 outline 的对应页面提取]
---
[安全区指令:从 layouts.md 复制]
---
[一致性指令:从 prompts/00-anchor.md 复制(Image 2+ only)]
```
**组装顺序(每张图必须)**:
1. 加载风格定义(如有配色覆盖,替换颜色部分)
2. 加载布局规则
3. 填充内容
4. 追加安全区指令
5. Image 2+ 追加一致性指令(文本锚点或 ref 参数)
6. 保存到 `prompts/NN-{type}-{slug}.md` 后再生成
FILE:references/layouts.md
# 小红书配图布局体系
配图由两个维度决定:**风格**(怎么画)× **布局**(怎么排)。风格见 `illustration-prompts.md`,本文定义布局。
---
## 画布规范
### 推荐比例
| 名称 | 比例 | 尺寸 | 说明 |
|------|------|------|------|
| 竖版 3:4 | 3:4 | 1680×2240 | **首选**,小红书最高流量比例 |
| 竖版 9:16 | 9:16 | 1440×2560 | 更高竖版,适合长列表 |
| 方版 | 1:1 | 1920×1920 | 次选,适合对比图 |
### 安全区(⚠️ 必读)
小红书移动端 UI 会遮挡图片以下区域,**关键信息必须避开**:
```
┌─────────────────────────────┐
│ [❤️ 📌 💬] │ ← 右上角:点赞/收藏/评论按钮
│ │
│ │
│ ✓ 安全内容区域 │
│ │
│ │
│ [笔记标题 + 用户头像栏] │ ← 底部 10%:标题栏遮挡
│ [@水印] │ ← 右下角:平台水印
└─────────────────────────────┘
```
| 避让区 | 位置 | 说明 |
|--------|------|------|
| 底部 10% | 最下方 | 标题栏覆盖,不放关键文字/数据 |
| 右上角 | 右上 15% | 互动按钮区 |
| 右下角 | 右下 10% | 水印位置 |
**所有配图 prompt 末尾追加**:
> Leave the bottom 10% of the image clean — no critical text or key visual elements — for mobile platform UI overlay. Avoid placing important content in the top-right corner.
---
## 布局类型
### 密度型布局
| 布局 | 信息密度 | 留白 | 信息点/图 | 最佳场景 |
|------|---------|------|----------|---------|
| `sparse` | 低 | 60-70% | 1-2 | 封面、金句、重磅声明 |
| `balanced` | 中 | 40-50% | 3-4 | 标准内容、教程、故事 |
| `dense` | 高 | 20-30% | 5-8 | 知识卡、干货速查、清单 |
### 结构型布局
| 布局 | 结构 | 条目数 | 最佳场景 |
|------|------|-------|---------|
| `list` | 垂直列举 | 4-7 条 | 排行榜、清单、步骤指南 |
| `comparison` | 左右对比 | 2 组 | before/after、优劣对比、产品PK |
| `flow` | 节点+箭头 | 3-6 步 | 流程、时间线、工作流 |
| `mindmap` | 中心放射 | 4-8 分支 | 概念图、知识脉络、话题总览 |
| `quadrant` | 四宫格/2×2 | 4 区块 | SWOT分析、优先级矩阵、分类对比 |
---
## 布局详细定义
### sparse(稀疏/冲击型)
```
结构:单一焦点居中,四周大量留白
视觉:对称构图,一个核心元素+大字标题
文字:主标题 ≤ 8 字,副标题可选,无正文段落
```
**Prompt 关键词**:
> Centered single focal point, abundant whitespace on all sides, symmetrical composition, one dominant visual element, large bold title text.
**典型用法**:封面图、金句卡、开篇声明、结尾CTA
---
### balanced(均衡型)
```
结构:标题居上(约20%),内容区均匀分布(约60%),底部留白(约20%)
视觉:清晰的视觉层次,3-4个并列或上下排列的要点
文字:标题 + 3-4 条要点(每条 5-10 字)+ 可选小图标
```
**Prompt 关键词**:
> Top-weighted title, evenly distributed content sections below, clear visual hierarchy, 3-4 key points with icons, moderate whitespace.
**典型用法**:标准内容页、教程讲解、经验分享
---
### dense(密集/知识卡型)
```
结构:紧凑网格,多区块有明确边界,标题+多个子分区
视觉:高信息密度但有组织,每个区块独立成组
文字:标题 + 5-8 个要点/数据项,允许较小字号,关键词高亮
```
**Prompt 关键词**:
> Organized grid structure, clear section boundaries, compact but readable spacing, multiple content blocks with headers, highlighted keywords, high information density.
**典型用法**:知识卡片、干货速查表、工具推荐合集
---
### list(列表/排行型)
```
结构:垂直排列 4-7 个条目,每条有序号/图标+标题+简述
视觉:左对齐,清晰的序号层级,一致的条目格式
文字:每条 = 序号 + 标题(3-5字)+ 一句描述
```
**Prompt 关键词**:
> Vertical enumeration with clear numbering, left-aligned items, consistent item format, visual hierarchy through number/bullet styling.
**典型用法**:Top N 排行、避坑清单、必备工具
---
### comparison(对比型)
```
结构:左右对称分屏,中间有分隔线/vs标记
视觉:左右配色差异明显(如绿vs红、亮vs暗),对比强烈
文字:顶部标题 + 左右各 3-4 条对应要点
```
**Prompt 关键词**:
> Split vertically into left and right halves with clear divider, symmetrical layout, contrasting colors for each side, corresponding comparison points.
**典型用法**:before/after、传统vs新方法、产品A vs 产品B
---
### flow(流程型)
```
结构:从上到下(或从左到右)的连接节点,箭头串联
视觉:3-6 个步骤节点,每个节点内有编号+简述,箭头连接
文字:每步 = 编号 + 标题(3-5字)+ 可选简述
```
**Prompt 关键词**:
> Top-to-bottom (or left-to-right) flow with connected nodes, directional arrows between steps, numbered stages, clear progression indicators.
**典型用法**:操作教程、工作流展示、决策路径
---
### mindmap(脑图/放射型)
```
结构:中心主题节点,向外放射 4-8 个分支,支持子分支
视觉:有机曲线连接,层级通过大小/颜色区分
文字:中心 = 主题(3-5字),分支 = 子主题(2-4字),叶节点 = 关键词
```
**Prompt 关键词**:
> Central topic node with radial branches outward, organic curved connections, hierarchical sub-branches, each branch with a distinct color from the palette.
**典型用法**:概念图、知识脉络、头脑风暴结果、话题全景
---
### quadrant(四象限型)
```
结构:2×2 网格,每个象限独立内容区,中心可选标记交叉轴
视觉:四个象限用不同色块区分,轴标签清晰
文字:每象限 = 类型标签(2-3字)+ 2-4 条内容
```
**Prompt 关键词**:
> Four-quadrant grid (2×2), clear axis labels at center cross, each quadrant with distinct background color, organized content within each section.
**典型用法**:SWOT 分析、优先级矩阵(紧急/重要)、四象限分类、对比归类
---
## 按位置推荐布局
| 位置 | 图片序号 | 推荐布局 | 原因 |
|------|---------|---------|------|
| Cover(封面) | 第 1 张 | `sparse` | 最大视觉冲击,清晰标题 |
| Setup(铺垫) | 第 2 张 | `balanced` | 建立上下文,不过载 |
| Core(核心) | 第 3 到 N-1 张 | `balanced` / `dense` / `list` | 根据信息密度选择 |
| Payoff(收获) | 倒数第 2 张 | `balanced` / `list` | 可执行的行动建议 |
| Ending(结尾) | 最后 1 张 | `sparse` | 干净的 CTA + 互动引导 |
---
## Prompt 中引用布局
在风格 prompt 块之后、内容描述之前,插入布局指令:
```
## 布局
[从上方对应布局的 Prompt 关键词中复制]
## 内容
[具体要展示的信息]
```
FILE:references/presets.md
# 小红书配图预设(Presets)
预设 = 风格 + 布局 + 可选配色 的一键组合。根据内容场景直接选预设,不用分别选风格和布局。
> 预设只是快捷方式。可以用预设后单独覆盖风格、布局或配色。
> 例如:用 `knowledge-card` 但换成科技感风格 → 保留 dense 布局 + 切换风格二。
> 例如:用 `sketch-card` 但换 warm 配色 → 保留手绘线条 + 暖色系。
---
## 配色覆盖(Palette Override)
每个风格有内置配色,但可以用配色覆盖只换颜色、保留风格的线条/装饰/版式。
| 配色 | 背景 | 区块色 | 强调色 | 感觉 |
|------|------|--------|--------|------|
| `macaron` | 暖奶油 #F5F0E8 | 马卡龙蓝 #A8D8EA、薄藤 #D5C6E0、薄荷 #B5E5CF、桃子 #F8D5C4 | 珊瑚红 #E8655A | 柔和、教育感、亲和力 |
| `warm` | 柔桃 #FFECD2 | 橙 #ED8936、陶土 #C05621、金 #F6AD55、玫瑰 #D4A09A | 赭石 #A0522D | 温暖、大地色、无冷色 |
| `neon` | 深紫 #1A1025 | 青 #00F5FF、品红 #FF00FF、绿 #39FF14、粉 #FF6EC7 | 黄 #FFFF00 | 高能量、未来感 |
**配色使用规则**:
- 默认不指定配色 → 用风格内置颜色
- 指定配色 → 只替换颜色,风格的线条/装饰/字体规则不变
- 例:手绘涂鸦 + macaron 配色 = 手绘线条 + 马卡龙色块,而不是珊瑚橙
**Prompt 中应用配色覆盖**:
在风格 section 的 Color Palette 部分替换为指定配色的颜色值,其余 Visual Elements / Typography 保持原风格不变。
---
## 预设列表
### 知识/干货类
| 预设 | 风格 | 布局 | 配色 | 适用场景 | 典型标题 |
|------|------|------|------|---------|---------|
| `knowledge-card` | 手绘涂鸦 | dense | 默认 | AI干货、方法论总结、工具合集 | 「5个AI工具让效率翻倍」 |
| `checklist` | 手绘涂鸦 | list | 默认 | 清单、排行榜、避坑指南 | 「产品经理必备的7个习惯」 |
| `step-guide` | 手绘涂鸦 | flow | 默认 | 教程、操作流程、工作流 | 「3步搭建你的AI工作流」 |
| `tech-insight` | 科技感 | balanced | 默认 | AI产品分析、技术趋势、行业观点 | 「ChatGPT为什么改变了PM」 |
| `concept-map` | Notion极简线稿(风格三) | mindmap | 默认 | 概念图、知识脉络、话题总览 | 「AI产品经理知识图谱」 |
### 对比/分析类
| 预设 | 风格 | 布局 | 配色 | 适用场景 | 典型标题 |
|------|------|------|------|---------|---------|
| `vs-compare` | 科技感 | comparison | 默认 | 产品PK、方案选型、before/after | 「传统PM vs AI PM」 |
| `tech-dense` | 科技感 | dense | 默认 | 技术方案拆解、架构解读 | 「Agent记忆系统全解析」 |
| `swot-matrix` | 科技感 | quadrant | 默认 | SWOT分析、四象限分类、优先级矩阵 | 「AI产品经理SWOT分析」 |
### 叙事/分享类
| 预设 | 风格 | 布局 | 配色 | 适用场景 | 典型标题 |
|------|------|------|------|---------|---------|
| `pm-story` | 手绘涂鸦 | balanced | 默认 | 个人经历、职场故事、成长复盘 | 「从美团到阿里,我学到的3件事」 |
| `cover-hook` | 手绘涂鸦/科技感 | sparse | 默认 | 封面图、金句卡片、重磅声明 | 「实现在贬值,判断在升值」 |
### 特殊风格类
| 预设 | 风格 | 布局 | 配色 | 适用场景 | 典型标题 |
|------|------|------|------|---------|---------|
| `notion-explainer` | Notion极简线稿(风格三) | balanced / dense | 默认 | 概念科普、SaaS/效率类、理性调性 | 「一张图看懂Agent记忆系统」 |
| `bold-poster` | 丝网印刷海报(风格四) | sparse | 默认 | 观点输出、影评书评、重磅声明 | 「AI不会取代PM,但会取代不用AI的PM」 |
| `cinematic` | 丝网印刷海报(风格四) | comparison | 默认 | 电影对比、戏剧张力、强视觉对比 | 「传统PM vs AI PM:死亡赛跑」 |
| `study-notes` | 手写笔记照片(风格五) | dense | 默认 | 知识框架整理、学习笔记分享 | 「学霸笔记|产品经理AI知识体系」 |
| `sketch-card` | 手绘信息图(风格六) | dense | macaron | 手绘知识卡、概念科普 | 「一张图搞懂Prompt Engineering」 |
| `sketch-flow` | 手绘信息图(风格六) | flow | macaron | 手绘教程、流程图解 | 「手把手AI Agent搭建全流程」 |
| `sketch-summary` | 手绘信息图(风格六) | balanced | macaron | 手绘总结、图文笔记 | 「一周AI产品学习手绘笔记」 |
---
## 内容信号 → 自动推荐预设
当 Editor Agent 分析内容时,按以下信号自动推荐预设。
**决策规则**(与公众号 cover-image-guide.md 统一逻辑):
1. 扫描内容关键词,匹配下表第一个命中行
2. 取推荐预设(预设已封装风格+布局,且均已过兼容矩阵校验)
3. 如需覆盖单个维度(如只换配色),查 `illustration-prompts.md` 的风格×布局兼容矩阵确保无 ✗
4. 混合信号时取第一个匹配,不迭加多个预设
小红书用「预设」而非原始维度,因为预设已经封装了风格+布局+配色的最佳组合;公众号用「六维度」因为封面图是 AI 生图需要更细粒度控制。两套体系底层逻辑一致:内容信号 → 自动推荐 → 兼容校验 → 生成。
| 内容信号关键词 | 推荐预设 | 备选 |
|--------------|---------|------|
| AI工具、效率、方法、技巧、干货、推荐 | `knowledge-card` | `checklist` |
| 排行、Top N、必备、清单、避坑、注意 | `checklist` | `knowledge-card` |
| 步骤、教程、流程、先…再…然后、第一步 | `step-guide` | — |
| AI趋势、产品分析、行业、技术、架构 | `tech-insight` | `tech-dense` |
| vs、对比、区别、优劣、before/after | `vs-compare` | — |
| 拆解、全解析、完全指南、深度 | `tech-dense` | `knowledge-card` |
| 我、经历、故事、复盘、成长、感悟 | `pm-story` | — |
| 金句、一句话、声明、重磅 | `cover-hook` | — |
| 概念、科普、原理、解释、SaaS | `notion-explainer` | `knowledge-card` |
| 观点、影评、书评、海报、声明 | `bold-poster` | `cover-hook` |
| 笔记、框架、知识体系、学霸、考试 | `study-notes` | `knowledge-card` |
| 手绘、图解、可视化、示意图、workflow | `sketch-card` | `sketch-flow` |
| 矩阵、分类、SWOT、象限、排列组合 | `swot-matrix` | `vs-compare` |
| 知识图谱、脑图、全景、发散思维 | `concept-map` | `notion-explainer` |
**混合信号时**:取第一个匹配的推荐预设。
---
## 预设使用示例
```markdown
## 配图方案
**预设**:knowledge-card(手绘涂鸦 + dense)
**配色覆盖**:无(使用风格默认配色)
**主题**:5个AI产品经理必备工具
**张数**:5 张
| # | 位置 | 布局覆盖 | 内容 |
|---|------|---------|------|
| 1 | Cover | sparse | 标题「5个AI工具让PM效率翻倍」+ 大字冲击 |
| 2 | Content | dense | ChatGPT:4个使用场景 |
| 3 | Content | dense | Cursor + Claude Code:编程提效 |
| 4 | Content | dense | Notion AI + Perplexity:信息整理 |
| 5 | Ending | sparse | 总结 + "你最常用哪个?评论区见" |
注:封面和结尾固定 sparse,中间内容页跟随预设默认布局(dense)。
```
---
## 扩展预设
如需新增预设,在此文件追加一行即可。格式:
`预设名 | 风格 | 布局 | 配色 | 适用场景 | 典型标题`
FILE:references/swipe-flow.md
# Swipe Flow 设计规范
多图笔记的叙事弧度和"下滑动力"设计。每张图不只是独立卡片,而是系列叙事的一环。
---
## 位置角色
| 位置 | 图片序号 | 目标 | 推荐布局 | 时间占比 |
|------|---------|------|---------|---------|
| **Cover(封面)** | 第 1 张 | 停止滑动、吸引点击 | `sparse` | 决定 80% 点击率 |
| **Setup(铺垫)** | 第 2 张 | 建立共鸣、提供背景 | `balanced` | 建立"为什么要看" |
| **Core(核心)** | 第 3 到 N-1 张 | 传递核心价值 | `dense` / `list` / `flow` | 信息主体 |
| **Ending(结尾)** | 最后 1 张 | 行动引导 + 互动 | `sparse` | 转化收藏/关注 |
---
## 封面 80% 法则
封面决定 80% 的点击率。封面图必须满足:
1. **大字标题**:核心价值主张,不超过 8 个字
2. **视觉锚点**:一个抓眼球的视觉元素(图标/人物/对比色块)
3. **身份标签**(可选):目标人群标签增加代入感("产品经理必看")
4. **好奇缺口**:标题本身制造"我想看下一张"的冲动
**封面禁忌**:
- ❌ 信息过载(超过 2 个信息点)
- ❌ 小字密排(移动端看不清)
- ❌ 与后续内容重复(封面是钩子不是摘要)
---
## 图间钩子(Swipe Hook)
每张图的底部或末尾需要一个钩子,驱动用户滑到下一张。
### 钩子策略
| 策略 | 模板 | 适用位置 |
|------|------|---------|
| **悬念型** | 「第一个就很强大 👇」 | Cover → Setup |
| **递进型** | 「下一个更厉害 👇」 | Content → Content |
| **预告型** | 「最后一个最实用 👇」 | 倒数第二张 |
| **提问型** | 「猜猜下一个是什么?👇」 | Content 中间 |
| **紧迫型** | 「最重要的来了 👇」 | 接近高潮点 |
| **数字型** | 「第 3/5 个 👇」 | 按序号推进 |
### 使用规则
- 每张图(除最后一张)都应该有 swipe hook
- hook 放在图片底部安全区之上(避免被标题栏遮挡)
- 最后一张不放 hook,放 CTA(收藏/关注/评论)
- 同一系列不要连续用相同策略,要变换
---
## 叙事弧度模板
### 模板 A:干货分享弧(最常用)
```
Cover: 大标题 + "打工人必看"
↓ hook: "第一个就很强大 👇"
Setup: 为什么你需要 [主题](痛点共鸣)
↓ hook: "接下来是具体推荐 👇"
Core 1-3: 每页 1-2 个具体工具/方法
↓ hook: "最后总结一下 👇"
Ending: 总结 + "你最常用哪个?评论区见"
```
### 模板 B:故事驱动弧
```
Cover: 金句/冲突点
↓ hook: "事情是这样的 👇"
Setup: 背景故事(我遇到了什么问题)
↓ hook: "转折来了 👇"
Core 1-2: 发现/解决过程
↓ hook: "结果出乎意料 👇"
Payoff: 结果 + 学到的教训
Ending: 感悟 + 互动引导
```
### 模板 C:对比冲击弧
```
Cover: "[A] vs [B]" 大字对比
↓ hook: "差别超乎你想象 👇"
Core 1: A 的特点(3-4 个点)
↓ hook: "看看 B 怎么说 👇"
Core 2: B 的特点(3-4 个点)
Comparison: 直接对比表
Ending: 结论 + "你站哪边?"
```
---
## 多图数量指南
| 内容量 | 推荐张数 | 结构 |
|--------|---------|------|
| 轻量(1-3 个点) | 3-4 张 | Cover + 1-2 Core + Ending |
| 标准(4-6 个点) | 5-6 张 | Cover + Setup + 2-3 Core + Ending |
| 重量(7+ 个点) | 7-8 张 | Cover + Setup + 4-5 Core + Ending |
**上限 9 张**(小红书单条笔记图片上限),建议不超过 8 张(留有余量)。
---
## 视觉一致性
同一系列多张图必须保持:
| 锁定项 | 说明 |
|--------|------|
| 风格 | 全系列同一种风格(手绘/科技感) |
| 主色调 | 一个主色始终不变 |
| 背景 | 统一底色 |
| 文字风格 | 统一字体方向和大小层级 |
| 角色设计 | 如有反复出现的卡通人物/吉祥物,必须一致 |
允许变化的:每页的布局(Cover 用 sparse,Core 用 dense 等)、具体内容。
### Reference Image Chain(首选)
**第一张图不带 ref,后续所有图以第一张为 ref。** 这是保证角色设计、配色渲染、插画风格跨系列一致的最有效方法。
具体操作见 `illustration-prompts.md` 的“图片 Ref 锚点”和“文本锚点”部分。
### 风格锚点(文本保底)
当生图工具不支持 ref 参数时,生成第一张图后立即将视觉特征记录到 `prompts/00-anchor.md`。后续每张 prompt 末尾附加:
```
Maintain visual consistency with the first image in this series:
- Same illustration style and line quality
- Same color palette: [主色] + [辅助色] + [背景色]
- Same typography approach
- Same level of detail and whitespace
- Same character design if recurring characters appear
```
FILE:references/xhs-account-analysis.md
# XHS 账号体检评分
目标:快速判断账号"现在处在什么位置、为什么涨或不涨、下一步优先改什么",输出可执行诊断。
## 0. 适用场景
- 新接手账号,先做体检基线
- 账号发了一段时间,数据波动或定位不稳
- 做竞品拆解,判断哪些动作值得学
- 为后续选题和复盘提供基准
---
## 1. 采样方式
建议最近 9-15 篇笔记,记录字段:
`title / cover_text / content_type / publish_time / likes / comments / collects / topic_tags / main_angle`
> 我们不做自动化抓取。老板手动整理账号数据发给 AI,或直接描述。
---
## 2. 五维评分
每维给 1-5 分,并说明一句原因。
### 2.1 定位清晰度
- 一句话能说清账号在讲什么吗?
- 用户进主页后能立即判断"这号适不适合我"吗?
- 内容支柱是否稳定?
### 2.2 内容结构力
- 标题是否稳定有钩子
- 封面是否有统一信息层级
- 正文能否在3段内讲清观点
- 是否有可复用的系列感和节奏
### 2.3 互动转化力
- 评论区是否被明确引导
- 收藏和评论结构是否健康
- 用户留言是泛泛夸赞,还是继续追问延展
### 2.4 账号辨识度
- 人设是否稳定
- 语气/视觉/切题方式是否像"同一个账号"
- 与同类账号相比,有没有一眼认出的区别
### 2.5 增长可持续性
- 爆款是否集中在少数偶发笔记
- 普通内容有没有基础互动盘
- 增长更依赖热点,还是已形成稳定结构
---
## 3. 评分标准
| 分值 | 含义 |
|------|------|
| 1分 | 明显缺失,已影响整体判断 |
| 2分 | 有一点基础,但不稳定 |
| 3分 | 基本合格,可继续用 |
| 4分 | 比较成熟,能稳定复用 |
| 5分 | 形成明显优势,可当模板 |
**汇总输出:**
- 总体判断:增长期 / 摸索期 / 混乱期 / 稳定期
- 最高分项:现在最该放大的优势
- 最低分项:下一步最优先修的短板
---
## 4. 输出模板
```md
## 账号快照
- 一句话定位:
- 内容支柱:(3个以内)
- 当前状态:增长期 / 摸索期 / 混乱期 / 稳定期
## 五维评分
- 定位清晰度:X/5,因为...
- 内容结构力:X/5,因为...
- 互动转化力:X/5,因为...
- 账号辨识度:X/5,因为...
- 增长可持续性:X/5,因为...
## 诊断结论
- 最大优势:
- 最大短板:
- 主要原因:
## 下步动作
1. 立刻调整什么
2. 接下来1周试什么
3. 继续观察哪些指标
```
---
## 5. 沉淀规则
体检完成后写入知识库:
- `knowledge-base/accounts/YYYY-MM-DD-{账号名}-checkup.md`
重点保留:账号定位、人设、内容支柱、红线、最低分项、当前已验证有效的标题/封面/互动结构。
竞品分析则优先沉淀"可学"和"不要学"的模式差异。
FILE:references/xhs-comment-ops.md
# XHS Comment Ops
本文件定义小红书评论检查与回复的标准流程,优先通知页,强调对位校验与风控节奏。
## 0. 目标与原则
- 目标:先准确检查,再按用户指令回复。
- 默认:**检查不等于自动回复**。
- 回复动作必须遵循:先对位、再输入、后发送。
- 一次默认只发 1 条(除非用户明确要求批量)。
## 1. 检查流程(默认执行)
1. 打开 `/notification`,进入「评论和@」
2. 抓取最新评论:用户名、评论文本、时间
3. 输出检查结果:
- 新评论条数
- 最新 3-5 条摘要
- 高风险信号(辱骂、钓鱼、诱导外链、明显违规)
4. 等待用户确认是否回复
## 2. 通知页回复 SOP(优先)
1. 在目标通知行点击「回复」入口(不要点顶部搜索框)
2. 校验输入框 placeholder 为 `回复 <用户名>`(唯一对位凭证)
3. 输入文案(逐字输入优先)
4. 发送前再次确认 placeholder 未漂移
5. 点击红色「发送」按钮(不使用 Enter)
6. 发送后确认输入框消失/清空
## 3. 帖子内回复 SOP(降级)
适用:通知页无法回复时。
1. 打开帖子详情评论区
2. 锁定目标评论(用户名 + 评论关键片段)
3. 点击该条评论下的「回复」
4. 校验出现 `回复 <用户名>`
5. 输入并发送
6. 校验已发成功,再处理下一条
## 4. 风控与节奏
- 默认 one-send-per-turn:每轮只发送 1 条
- 连续回复间隔 8-15 秒(用户明确加速时可降到约 5 秒)
- 命中以下提示立即停止并汇报:
- 评论过于频繁
- 操作过快/操作频繁
- 请稍后再试
- 发送失败/网络异常
## 5. 长度与内容约束
- 回复建议 <= 280 字(平台约 300 字上限)
- 超长先缩写,再考虑拆分多条
- 禁止虚构个人经历
- 禁止隐性承诺(如“我后续一定整理教程”)
- 仅在用户明确要求时做额外交付承诺
## 6. 常见故障
- 误点搜索框:点击空白处收起,重新定位通知行
- 回复对象漂移:placeholder 不匹配时立刻取消重来
- 连续两次发送失败:停止自动化,转人工确认
FILE:references/xhs-content-deconstruct.md
# XHS 爆款拆解框架
目标:从高互动内容中提炼可复用的结构和模式,供后续创作参考——**不是复刻,是理解为什么它能跑起来**。
## 0. 原则
- 拆的是结构,不是内容本身
- 结论必须落到"下一次可以怎么用"
- 拆解后沉淀到 knowledge-base/patterns/,供后续快速取用
---
## 1. 触发条件
- 看到一条数据明显高于平均的笔记(点赞/收藏/评论)
- 想理解某类内容为什么能传播
- 为接下来的选题/写作找结构参考
---
## 2. 拆解五模板
### 2.1 标题句式
```
标题原文:
句式类型:反问 / 立场 / 悬念 / 利益驱动 / 数字型 / 对比型
钩子在哪:第几个字给出"停下来的理由"
可复用骨架:(把原标题抽象成可套用的句式)
```
### 2.2 封面信息层级
```
封面大字:
封面副文案:(如有)
图片主体:人物 / 产品 / 场景 / 纯文字
信息密度:简洁(≤5字)/ 适中(5-15字)/ 信息密集(>15字)
配色情绪:暖 / 冷 / 高对比 / 低饱和
视觉重心:左 / 中 / 右 / 满幅
可复用结构:(一句话描述封面的信息排布逻辑)
```
### 2.3 正文节奏
```
开头策略:悬念 / 共鸣 / 反常识 / 直接结论
段落数:
每段核心:1-观点 / 2-证据 / 3-转折 / 4-收口
节奏描述:(快/慢,正文有没有明显的情绪起伏)
可复用结构:开头___→中间___→收口___
```
### 2.4 互动机制
```
评论区引导方式:站队型 / 追问型 / 经验型 / 分享型
评论区高频词:
有没有"天然的接话口":(用户不评论会觉得可惜的那个点)
收藏 vs 评论比:收藏高→实用型;评论高→争议/情绪型
```
### 2.5 标签策略
```
话题数:
话题类型:大词(百万级)/ 中词(十万级)/ 小词(精准垂类)
有没有隐藏标签(正文里的关键词):
可复用标签组合逻辑:
```
---
## 3. 拆解输出格式
```md
## 拆解对象
- 笔记标题:
- 账号类型:
- 数据:点赞X / 收藏X / 评论X
- 分析时间:
## 核心结论(一句话)
## 拆解
### 标题句式
...
### 封面层级
...
### 正文节奏
...
### 互动机制
...
### 标签策略
...
## 可复用点
1.
2.
3.
## 风险提示
(这个结构容易踩什么坑)
## 适用条件
(什么账号/什么阶段用这个结构更合适)
```
---
## 4. 沉淀规则
拆解完成后写入:
`knowledge-base/patterns/YYYY-MM-DD-{主题词}-deconstruct.md`
每个文件只记录一个核心模式,结论要带"适用条件",避免误用。
FILE:references/xhs-eval-patterns.md
# XHS 通用提取模板
## 基础 Evaluate 模板
```js
() => {
const pickText = (el, sels) => {
for (const s of sels) {
const v = el.querySelector?.(s)?.textContent?.trim();
if (v) return v;
}
return '';
};
const num = (v) => {
const m = String(v || '')
.replace(/,/g, '')
.match(/\d+(?:\.\d+)?/);
return m ? Number(m[0]) : 0;
};
return [...document.querySelectorAll('.note-item, .comment-item, li, [data-item]')]
.slice(0, 20)
.map((el) => ({
title: pickText(el, ['.title', '.note-title', 'h1', 'h2', 'h3']),
hook: pickText(el, ['.desc', '.description', '.summary', '.intro']),
angle: pickText(el, ['.tag', '.category', '.angle']),
comments_signal: pickText(el, ['.comment', '.comments', '[data-comment]']),
cta: pickText(el, ['.cta', '.action', '.footer']),
likes: num(pickText(el, ['.like', '.likes', '[data-like]'])),
tags: pickText(el, ['.tag-list', '.tags'])
}))
.filter(x => x.title || x.hook);
}
```
## 进阶 Selector 适配
页面结构随版本变化,以下是已知的兼容写法:
### 搜索结果页 / 推荐流(并联写法)
```js
// 同时兼容多种 selector,取最宽的那个
const items = [
...document.querySelectorAll('.note-item'),
...document.querySelectorAll('[data-item]'),
...document.querySelectorAll('section.note-item'),
].filter((el, i, arr) => arr.indexOf(el) === i); // 去重
```
### 轮播图内容抓取
```js
// 只取活跃帧,排除轮播复制帧
const activeSlides = document.querySelectorAll(
'.swiper-slide-active:not(.swiper-slide-duplicate)'
);
```
### 评论区 contenteditable 输入
```js
// 向 contenteditable 区域注入文字并触发 input 事件
const el = document.querySelector('[contenteditable="true"]');
el.focus();
document.execCommand('insertText', false, '回复内容');
el.dispatchEvent(new InputEvent('input', { bubbles: true }));
```
## 使用建议
- 先确认字段存在;缺失返回空字符串,避免脚本失败。
- 先做 20 条以内试跑,再扩大样本规模。
- 需复用时可按页面结构调整 selector。
- 页面结构变化时,先做一次 `snapshot` 再调整 selector,不要盲试。
FILE:references/xhs-home-feed-analysis.md
# XHS 首页推荐流分析
目标:从首页推荐流里提炼可复用的传播钩子、内容结构和选题方向,而不是只看单条热度。
## 0. 适用场景
- 理解首页推荐逻辑
- 找选题灵感、标题套路、封面信息层级
- 判断某类内容是否适合当前账号继续做
---
## 1. 输入
至少提供以下之一:
- `feed_scope`:观察范围,如"今天首页前 20 条"
- `topic_filter`:主题范围,如"AI/职场/情感"
- `account_context`:账号定位摘要
- `goal`:分析目标(找选题 / 找钩子 / 找账号方向)
建议 `sample_size` 10-20 条,先小样本再扩大。
---
## 2. 采样流程(纯观察,不自动化操作)
> ⚠️ 我们不做浏览器自动化采集。以下步骤由老板手动观察后,把内容描述/截图发给 AI 分析。
1. 老板打开小红书首页推荐流
2. 按顺序观察前 10-20 条卡片,优先保留"会停下来看"的
3. 每条记录以下字段(发给 AI 或填入下方模板):
- `title`:标题原文
- `hook`:首屏钩子或摘要
- `cover_text`:封面大字
- `cover_type`:图文 / 视频 / 文字配图
- `account_type`:达人 / 普通用户 / 品牌 / 垂类号
- `likes / comments / collects`:可见则记
- `tags`:话题标签
- `reason_to_stop`:为什么会点开或停留
---
## 3. 七维分析框架
对每条内容按同一套维度拆解:
| 维度 | 说明 |
|------|------|
| 推荐触发 | 为什么它会出现在首页 |
| 停留钩子 | 标题/封面/首句/争议点/情绪点 |
| 传播机制 | 收藏型/转发型/评论型/对号入座型/情绪宣泄型 |
| 结构模板 | 开头→展开→反转→收口的节奏 |
| 视觉模板 | 封面字数、配色、构图、信息层级 |
| 人群指向 | 明显在对谁说话 |
| 风险边界 | 是否擦边/标题党/引战/低质重复 |
---
## 4. 输出格式
### 4.1 首页画像
- 当前首页主导主题
- 重复出现的内容类型
- 最明显的情绪倾向
- 可能的推荐原因
### 4.2 高信号样本
列出 3-5 条最值得复用的样本,每条包含:标题 / 钩子 / 结构 / 互动机制 / 可复用点
### 4.3 可复用模式
总结 3-5 条模式结论,例如:
- "标题先给立场,再给理由"
- "封面先给结论,再给细节"
- "评论区用反问制造二次参与"
### 4.4 下步动作
根据分析目标给出下一步:找选题 / 找封面表达 / 找标题句式 / 找账号定位方向
---
## 5. 快速输出模板
```md
## 首页画像
- 主导主题:
- 常见情绪:
- 高频内容类型:
- 可能的推荐原因:
## 高信号样本
1. 标题:
- 钩子:
- 结构:
- 互动:
- 可复用点:
## 可复用模式
- 模式1:
- 模式2:
- 模式3:
## 下步动作
- 建议继续观察:
- 建议尝试选题:
- 建议调整账号:
```
---
## 6. 沉淀规则
分析完成后,把可复用模式沉淀到 `knowledge-base/patterns/`,文件命名参考:
`patterns/YYYY-MM-DD-feed-patterns.md`
---
## 7. 失败降级
- 样本太少(<5条):先做轻量分析,标注"样本不足,结论仅供试跑"
- 内容太杂:先缩小到某一主题再分析
- 无法采样:退回账号已有内容做内部分析
FILE:references/xhs-publish-flows.md
# XHS Publish Flows
本文件拆分并细化「发布链路」的操作步骤,供 `SKILL.md` 按需引用。
## 0. 总览
发布类型:
- 视频
- 图文
- 长文
三要素(发布前必须齐全):
1. 封面
2. 标题
3. 正文
## 1. 图文发布(推荐默认)
### 1.1 上传图文(普通)
1. 打开发布页并进入「上传图文」
2. 上传首图/多图
3. 填写标题(建议 <=20 字)
4. 填写正文
5. 追加话题/标签(放正文末尾)
6. 校验三要素后停在发布按钮(待用户确认)
### 1.2 图文-文字配图(大字报)
1. 进入「上传图文」
2. 点击「文字配图」
3. 输入封面大字报文案
4. 点击「生成图片」
5. 在模板页选择样式并点「下一步」
6. 进入编辑页填写:标题、正文、话题/标签
7. 校验三要素后停在发布按钮(待用户确认)
### 1.3 图文半程预发(不发布)
满足以下条件即视为“半程预发完成”:
- 已完成封面生成(或上传)
- 已进入编辑页
- 已填写标题与正文
- 仅停在「发布」按钮可见处,未点击发布
## 2. 视频发布
1. 进入「上传视频」
2. 上传视频文件
3. 补齐封面/标题/正文
4. 校验可见范围与设置
5. 发布前等待用户确认
## 3. 长文发布
1. 进入「写长文」
2. 新建创作或导入链接
3. 填写长文标题与正文结构
4. 若用户目标是图文,避免误走长文链路
## 4. 常见问题与处理
- 误入长文:返回发布笔记,明确切回「上传图文」
- 草稿箱默认视频:切换到「图文笔记」tab后再编辑
- 标题超限:出现 `xx/20` 时立刻压缩
- 只做了封面没填文案:必须补齐标题与正文
- 网页端详情扫码限制:评论优先在通知页处理,必要时改 App 端
FILE:references/xhs-runtime-rules.md
# XHS 运行规则(引用自技能主文)
## 0.1 低 token 与快照约束
- 优先 `evaluate`,减少无意义 dump 与重复抓取。
- 只在关键节点做快照:登录确认、到发布页、填写完成、发布前停顿。
- 避免 `fullPage`(除用户要求整页归档);重复调用优先复用同一 `targetId`。
- 每个动作最多重试 1 次;第二次失败改稳健路径并汇报。
- 记录关键证据:账号名、页面状态、按钮可见、字数等,返回可执行信号。
## 0.2 浏览器稳定规则(最高优先)
- 默认仅用内置浏览器:`profile="openclaw"`。
- 每次动作前先确认会话目标 tab(`browser.start --profile openclaw` 后再 `open/snapshot`)。
- 若出现 `no tab is connected`、`profile "chrome"` 等异常,立刻切回 `openclaw` 并重试。
- 连续 2 次点击/导航失败后改稳健路径(如直达点击改为 evaluate+定位),不做盲重试。
## 3.5 搜索并浏览(核心约束)
1. 仅从搜索结果页点击进入帖子,禁止直接 `navigate` 到 `/explore/<id>`。
2. 默认跳过本账号作者内容(避免自刷)。
3. 进入后先校验:不是 404、可见评论/互动信息、可识别标题或作者。
4. 进入方式优先点卡片本体,避免点头像/作者名导致跳错。
5. 若评论控件为 `contenteditable` 或 `p.content-input`,需先触发输入事件再发送。
6. 两条点击失败或 404 后返回搜索页换下一条,不对同链接直跳重试。
## 5.0 轮播图与评论区输入规则
### 5.1 轮播图抓取
只取当前活跃帧,排除 Swiper 克隆节点(避免重复抓取):
```js
// 正确做法:过滤掉 duplicate 帧
const slides = document.querySelectorAll(
'.swiper-slide-active:not(.swiper-slide-duplicate)'
);
```
- 不要用 `.swiper-slide` 全量选取,会包含无效的克隆帧
- 轮播图图片 URL 在 `img[src]` 或 `[data-src]`(懒加载时取 data-src)
### 5.2 评论区 contenteditable 输入
小红书评论框为 `contenteditable`,需触发 input 事件才能让平台识别文字:
```js
// 正确做法:execCommand + InputEvent
const el = document.querySelector('[contenteditable="true"]');
el.focus();
document.execCommand('insertText', false, '回复内容');
el.dispatchEvent(new InputEvent('input', { bubbles: true }));
```
- 禁止直接赋值 `el.textContent = '...'`(平台无法识别,发送按钮不激活)
- 禁止用 `act(kind=type)` 操作富文本评论框(已知失败)
- 若上述方法仍不激活发送按钮,改为手动:产出回复文案,通知老板粘贴
## 6.0 回放与降级
- 若搜索结构变化先 snapshot 更新 selector 再继续,不盲跑旧路径。
- 关键页(创作页、探索页、用户页)尽量复用已打开 tab,不重复 `open`。
- 先告诉用户“已达异常节点”,避免无意义继续操作导致误发。
FILE:references/xhs-topic-ideation.md
# XHS 选题灵感 SOP
目标:把平台信号、用户需求和账号定位合并成可发布的选题清单,不做空泛灵感词。
默认输出:3-5 条选题,每条都能直接进入内容生产。
---
## 1. 三类信号源
每次选题要混合以下三类信号,不能只靠一类:
### 1.1 平台侧信号
- 同题材高互动帖子(点赞/收藏/评论高于近期均值)
- 评论区高频追问、争议点、反驳点
- 收藏高于点赞的实用型内容
- 近期重复出现的标题句式/封面信息层级/互动口令
### 1.2 需求侧信号
- 用户正在问什么、吵什么、纠结什么
- 真实场景里的阻塞点、反复踩坑点、决策分歧点
- 有人想看但没人认真讲的空白题目
### 1.3 账号侧信号
- 账号已有的内容支柱和验证有效的方向
- 人设能讲的口吻和站位
- 适合持续连载的主题链路
---
## 2. 四步生成链路
### 第1步:提主题框
把输入压成一个主题框:
| 维度 | 选项 |
|------|------|
| 主题对象 | 人 / 事 / 产品 / 场景 / 问题 |
| 主题动作 | 分析 / 对比 / 复盘 / 教程 / 吐槽 / 观点 |
| 主题情绪 | 爽感 / 焦虑 / 争议 / 共鸣 / 反差 |
| 主题收益 | 省钱 / 省时间 / 少踩坑 / 变清楚 / 变好玩 |
### 第2步:拆选题角度
每个主题至少拆 3 种角度:
- **立场型**:支持 / 反对 / 中立但有条件
- **过程型**:怎么做 / 怎么避坑 / 怎么判断
- **结果型**:做完会怎样 / 为什么别人做不到
### 第3步:套标题骨架
**核心骨架库:**
```
为什么___
我发现___
___到底值不值
别再___了
___最容易被忽略的其实是___
没人告诉你___
我试了___,结果___
同样是___,为什么差距这么大
```
### 第4步:注入互动钩子
每条选题必须自带一个可评论的问题或动作:
- **站队型**:`你更偏哪边?`
- **复盘型**:`你遇到过吗?`
- **选择型**:`如果是你会怎么选?`
- **经验型**:`你有没有更好的做法?`
---
## 3. 六条筛选标准
每条候选选题过以下6关:
| 标准 | 判断问题 |
|------|---------|
| 可讲性 | 能用3段讲清楚吗 |
| 可争议 | 有点分歧但不至于失控吗 |
| 可持续 | 能延展成系列吗 |
| 可转发 | 有"顺手发给别人"的理由吗 |
| 可执行 | 现在就能写,不依赖未知信息 |
| 可合规 | 不碰隐私/虚假承诺/敏感诱导/违规内容 |
**快速评分:** 热度信号(0-2) + 账号匹配(0-2) + 互动潜力(0-2) + 可写性(0-2) - 风险分(0-2)
总分高的先发,风险分≥2直接淘汰。
---
## 4. 输出格式
```
1. 选题标题:(≤20字)
选题角度:支持/反对/中立/争议
目标人群:谁最容易点进来
互动钩子:一句可放进正文/评论区的问题
内容结构:开头/展开/收口 三段大纲
风险提示:是否容易引战或踩线
2. 选题标题:
...
```
---
## 5. Persona 校验
选题出来后过一次 persona.md:
- 语气要像"能说人话",不像报告
- 选题要短、直接、带一点情绪
- 收尾要留可接梗的空间
**原则:** 选题不只看"能不能火",还要看"像不像这个账号会说的话"。
---
## 6. 失败降级
- 平台信号太少:先按账号定位生3个基础主题框
- 账号信息太弱:用标题骨架补候选题,再用评论区提问具体化
- 信号严重不足:输出"可验证问题清单",让老板补充信息后再继续
- 禁止信号不足时硬编"爆点"
FILE:testcase.md
# 小红书运营技能 — 测试用例
定义 4 个核心场景的测试用例,验证技能是否按预期执行。
---
## TC-01:选题生成
**场景描述:** 给定账号定位,生成可发布的选题清单。
**输入:**
```
账号定位:王凯,AI 产品经理,个人号,分享 AI 产品/职场思考
目标:图文笔记
时间:本周
```
**预期行为:**
1. 读取 `persona.md`,确认账号语气和禁忌
2. 基于三类信号源(平台侧/需求侧/账号侧)生成候选选题
3. 输出 3-5 条选题,每条包含:标题/角度/目标人群/互动钩子/三段结构/风险提示
4. 每条选题过 6 条筛选标准评分
**验证标准:**
- [ ] 标题 ≤20 字,有立场/反差/具体之一
- [ ] 每条都有互动钩子(可以是问句)
- [ ] 没有出现禁忌词(宝/姐妹/大家好今天分享…)
- [ ] 高风险选题被标注或过滤
- [ ] 输出格式符合 `references/xhs-topic-ideation.md` 的模板
---
## TC-02:内容创作
**场景描述:** 给定选题,生成完整的小红书笔记文案。
**输入:**
```
选题:我发现 AI 工具用得越顺手,反而越难说清楚选它的理由
目标:图文笔记,700字以内
账号:王凯个人号
```
**预期行为:**
1. 读取 `persona.md`,对齐语气风格
2. 产出至少 2 个备选(标题×2、正文×2)
3. 对照 `references/anti-ai-checklist.md` 逐条检查,降 AI 味
4. 输出结构:标题/开头钩子/正文3段/互动问句/话题5-8个/风险标注
**验证标准:**
- [ ] 开头第一句就是钩子或结论,不做自我介绍
- [ ] 正文有3段,节奏:观点→证据→转折/收口
- [ ] 结尾有一个可回答的问题
- [ ] 语气符合 persona(直接/口语/不装),没有禁忌词
- [ ] 话题标签包含精准垂类词,不只蹭大词
- [ ] 字数 ≤700
---
## TC-03:评论回复
**场景描述:** 给定用户评论,生成符合账号风格的回复。
**输入:**
```
评论:「这个思路我之前也想过,但感觉很难落地,有具体操作方法吗」
账号:王凯个人号
```
**预期行为:**
1. 读取 `persona.md` 和 `examples/reply-examples.md`
2. 判断评论类型(追问型)
3. 给出 1-2 个候选回复,注明选用理由
4. 回复不超过 3 句
**验证标准:**
- [ ] 先对位问题(是否有具体操作方法),再考虑延展
- [ ] 不做不确定的承诺("我回头整理一下发给你")
- [ ] 语气平等,不降身段也不对抗
- [ ] 结尾可选择性留钩子(不强求)
- [ ] 字数 ≤80 字
---
## TC-04:账号分析
**场景描述:** 给定账号数据,输出五维体检报告。
**输入:**
```
账号名:某 AI 工具测评号
粉丝量:3000
最近10篇笔记数据:点赞均值80,收藏均值120,评论均值5
内容类型:都是工具测评对比图文
简介:专注测评 AI 工具
```
**预期行为:**
1. 读取 `references/xhs-account-analysis.md`
2. 对 5 个维度分别给出 1-5 分 + 一句原因
3. 汇总总体判断(增长期/摸索期/混乱期/稳定期)
4. 给出至少 3 条按优先级排序的下步动作建议
5. 提示把结论写入 `knowledge-base/accounts/`
**验证标准:**
- [ ] 五维都有评分且有对应理由
- [ ] 汇总判断合理(收藏>点赞=实用型,评论低=互动转化弱)
- [ ] 下步动作具体可执行,不是泛泛建议
- [ ] 有知识库沉淀提示
---
## 运行说明
- 测试用例手动执行,把输入粘贴给 AI,检查输出是否满足验证标准
- 不合格项记录到 `.learnings/` 目录,供后续改进
- 版本更新时重新跑一遍全量用例
微信公众号全流程运营:选题→采集→写作→排版→发布→数据分析→评论管理。 Use when: (1) 用户要写公众号文章或提供了选题方向, (2) 用户说"写一篇关于XXX的文章"/"帮我写篇推文"/"出一篇稿子", (3) 用户要求采集热点/素材/竞品分析, (4) 用户提到公众号日报/周报/数据分析/阅读量/...
---
name: wemp-ops
version: 1.0.0
description: >
微信公众号全流程运营:选题→采集→写作→排版→发布→数据分析→评论管理。
Use when: (1) 用户要写公众号文章或提供了选题方向,
(2) 用户说"写一篇关于XXX的文章"/"帮我写篇推文"/"出一篇稿子",
(3) 用户要求采集热点/素材/竞品分析,
(4) 用户提到公众号日报/周报/数据分析/阅读量/粉丝,
(5) 用户要求检查评论/回复评论/评论管理,
(6) 用户说"发布"/"推送"/"推到公众号"/"发到草稿箱",
(7) 用户讨论文章排版/封面图/标题优化。
即使用户没有明确说"公众号",只要涉及微信文章写作、内容发布、
公众号后台操作、文章数据查看、或读者互动管理,都应使用此技能。
当用户给出选题方向时,自动完成素材采集→内容写作→封面生图→排版美化→推送草稿箱全流程。
NOT for: 小红书运营(用 xiaohongshu-ops)、纯内部文档写作(用 internal-comms)、
通用 Word 文档(用 docx)。
---
# wemp-ops — 微信公众号运营技能
## 环境检查
首次使用前执行:
```bash
node scripts/setup.mjs
```
检查 Node.js 版本、Python3、微信公众号 API 配置。
## 工作流路由
根据用户意图选择对应流程:
| 意图 | 流程 |
|------|------|
| "写一篇关于 X 的文章" | → **全流程**(下方详述) |
| "采集 X 热点" | → 仅采集 |
| "公众号日报/周报" | → 数据分析 |
| "检查/回复评论" | → 互动管理 |
| "发布文章" | → 仅发布 |
| "选题/有什么可以写的" | → 选题管理(检查 topic-pool.md) |
## 全流程:选题到草稿箱
当用户给出选题方向时,执行以下完整流程。先读 `persona.md` 确定写作人设。
### Step 0: 选题准备
**如果老板没有指定具体选题**,先检查选题池:
1. 读取 `<WORKSPACE>/collections/topics/topic-pool.md`
2. 列出当前高优选题,每个附带角度和素材情况
3. 让老板选择,或老板提出新选题
4. 选定后进入 Step 1
**如果老板已有明确选题**,直接进入 Step 1。
### Step 1: 理解选题
读 `references/article-templates.md` 判断内容类型:
- **AI 产品拆解** — 具体产品名 + 分析/拆解
- **场景解决方案** — "怎么用 AI 做 XXX"
- **效率提升实战** — 工具 + 技巧/心得
- **产品方法论** — 抽象话题 + 思考
- **行业观察** — 新闻/趋势 + 观点
选题模糊时给 2-3 个具体方向让用户选(最多 1 轮澄清)。
### Step 2: 素材采集
**2pre. 交接文件检查**
先检查 `<WORKSPACE>/temp/handoffs/collector-to-writing.md` 是否存在:
- 有 → 读取,筛选与当前选题相关的素材条目,纳入写作参考
- 消费后删除已使用的条目(如果文件清空则删除文件)
- 没有 → 跳过,正常流程
**2a. 收藏库检索(优先)**
先从个人收藏库中检索相关素材——这是让文章有"人味"的关键(详见 writing-techniques.md §五):
1. 标签匹配:`grep -i "关键词" <WORKSPACE>/collections/tags.md`
2. 全文搜索:`grep -ril "关键词" <WORKSPACE>/collections/`
3. 有匹配时,读取对应文件的核心观点、要点摘录、个人笔记
4. 标记可用素材的使用场景:开头引入?观点支撑?案例展示?反面论据?
5. 收藏素材用自己的话重新表述,自然融入文章,不是学术式引用
**2b. 外部采集(补充)**
收藏库素材不足时,从外部补充:
1. 根据选题扩展 3-5 个搜索关键词
2. `web_search` 搜索 2-3 轮(官方信息→深度分析→对比评测)
3. `web_fetch` 抓取 2-4 篇高质量参考文章
4. 可选:`node scripts/smart_collect.mjs` 从 20+ 数据源采集相关热点
5. 提取关键事实、数据、观点备用
**⚠️ 中间存盘规则**:每 2-3 轮 web_search/web_fetch 后,立刻把已获得的关键发现写到 `<WORKSPACE>/temp/wemp-findings-{slug}.md`(选题 slug),防止连续采集时前面的信息在 context 中被挤掉。采集完成后此文件可删除。
**2c. 素材整理**
合并收藏库 + 外部素材,按相关度排序,标注来源
**2d. 对标账号分析(可选,首次写某领域时推荐)**
找 2-3 个同领域做得好的公众号,填写对标颗粒度检查表:
| 维度 | 对标账号 | 我们 | 差异说明 |
|------|---------|------|---------|
| 文章长度 | | | |
| 标题风格 | | | |
| 开头结构(故事/数据/问题/金句) | | | |
| 配图风格和数量 | | | |
| 排版密度(段落长度/留白) | | | |
| 结尾 CTA(关注/转发/留言) | | | |
| 发布频率 | | | |
**每个不一致都要有理由,否则改成一致。** 0 到 1 阶段模仿是正确答案。
**2e. 素材丰富度门禁(写作前必检)**
写作前过一遍素材储备,5 维中至少 3 个有料才开始写。不够就回 Step 2 补采集,不要硬写。
| 维度 | 有没有 | 具体内容 |
|------|--------|---------|
| 冲击力数据(大数字/百分比/对比) | ✅/❌ | |
| 转变故事(之前 vs 之后,反差越大越好) | ✅/❌ | |
| 金句(能独立传播的一句话观点) | ✅/❌ | |
| 权威背书(人物/机构/论文/官方数据) | ✅/❌ | |
| 痛点共鸣(目标读者的焦虑/常见错误) | ✅/❌ | |
- **0-2/5** → ⛔ 停止,回 Step 2 补充。素材不够硬写 = 进入「内容差→没流量→以量取胜→内容更差」的死亡螺旋
- **3-4/5** → ⚠️ 可以写,但开头冲击力受限。标注薄弱维度,写作中刻意补强
- **5/5** → ✅ 素材充足,放心写
### Step 3: 写作
读 `references/writing-sop.md`、`references/style-guide.md` 和 `references/writing-techniques.md`,按以下流程写作:
**3a. 创意排水(正式动笔前必做)**
花 2-3 分钟快速列出这个选题的"废水"——套路想法、陈词滥调、第一反应。标记为禁用列表,正式写作中刻意避开。(详见 writing-techniques.md §一)
**3b. 正式写作**
- 遵循 `persona.md` 人设(AI 产品经理第一人称)
- 按 `references/article-templates.md` 对应类型的结构模板
- 2000-3000 字,短句为主
- 链接用纯文本格式
- 输出为 Markdown 文件,保存到工作目录(文件名格式:`draft-v1.md`,放在当前文章的工作目录下,如 `wemp-article-NN/draft-v1.md`)
- **不要在文中出现 H1 标题**(公众号自带标题,正文从 H2 开始)
- 融入收藏库素材(Step 2 中检索到的真实经历/观点/案例)
写作中运用五大技巧(详见 writing-techniques.md §二):
- **微幽默**:每 200 字至少 1 个嘴角上扬的小细节
- **强开头**:禁止"在当今时代…"等套话。开头公式 = **话题(讲什么)+ Hook(为什么看)+ 可信度(为什么信你)**,三要素缺一不可。Hook 优先级:晒结果+反转⭐⭐⭐⭐⭐ > 数据冲击⭐⭐⭐⭐ > 反差/转变⭐⭐⭐⭐ > 金句⭐⭐⭐ > 权威+观点⭐⭐⭐ > 痛点+悬念⭐⭐⭐
- **概念把手**:为复杂概念创造 3-6 字记忆短语,每篇至少 1-2 个
- **句子节奏**:短/中/长句交替,不允许连续 3 句同长度
- **多巴胺密度**:每段至少 1 个"有意思"的点,连续 3 段没有 = 危险区域
**3c. 标题生成(双轨制)**
按 writing-techniques.md §四 生成标题候选:
1. 爆款标题 3-5 个(金钱数字/暴力隐喻/悬念等要素)
2. 自然风格标题 3-5 个(经验分享/观点输出/对比评测等)
3. 可选:组合优化(自然标题 + 注入 1-2 个爆款要素)
4. 推荐 Top 3 给老板选择
**悬念制造铁律**:标题和开头制造悬念,不给答案。用「为什么」不用「证明」。
- ❌ 「Computer Use 效率低,证明 API 才是正路」(给了答案,读者不想看了)
- ✅ 「Claude 能操控微信了,为什么我说这不是未来?」(留悬念,想知道为什么)
- ❌ 「Agent 的 3 个核心能力」(平铺直叙,无冲突)
- ✅ 「为什么 90% 的 Agent 产品会在 6 个月内死掉?」(制造好奇)
**3d. 三遍审校**
按 writing-techniques.md §三 结构化审校:
- 第一遍:内容审校(事实/逻辑/结构)+ 段落迷你论点串联测试
- 第二遍:风格审校(对照 24 条 AI 味特征清单降 AI 味 + 灵魂注入 + 5 维质量评分 ≥ 35 分才过)
- 第三遍:细节打磨(句子节奏 + 多巴胺密度 + 微幽默 + 概念把手检查)
#### ⚠️ 写作红线
- **不暴露 AI 参与写作**:不说"这篇文章是 AI 写的"、"让 AI 帮我写"等 — 读者对 AI 生成内容有抵触心理,暴露会显著降低阅读量和信任感
- **讲故事,不讲论点**:用时间线、场景、情绪推进,不用"总结"、"核心观点"、编号式论点堆砌
- **自然过渡**:不用中括号小标题、不用"以下是 N 条心得"式结构
- **让读者感受到力量**:分享经历,不是教学。有踩坑有收获,有真实细节
- **禁止废水开头**:不用"在当今 XX 的时代…"、"随着 XX 的不断发展…"等陈词滥调
### Step 4: 封面图
设计指南见 `references/cover-image-guide.md`(六维度体系:Type/Palette/Rendering/Text/Mood/Font + 12 个预设 + 兼容矩阵 + 构图核心原则)。三种方案按优先级:
**方案 A(优先):idealab Chat API**
- `<WORKSPACE>/scripts/generate-image.sh --prompt "prompt" --filename output.jpg`
- 默认模型 `gemini-3.1-flash-image-preview`,团队 AK 免费,走 /chat/completions 接口
- 2.35:1 裁剪:生成后 `sips -c 1090 2560 output.jpg`
- 超时/报错时脚本自动降级到 Gemini Key 轮换
- ⚠️ 不用 emoji(浏览器截图会变色块),用纯文字 + 几何图形
**方案 B(降级):Seedream 5.0 Lite**
- `<WORKSPACE>/scripts/seedream-generate.sh "prompt" output.jpg "2560x1440" 1`
- 0.22 元/张,idealab 不稳定时使用
- 裁剪同方案 A:`sips -c 1090 2560 output.jpg`
**方案 C(兜底):HTML 渲染 + 浏览器截图**
- 写一个 HTML 页面(渐变背景 + 标题文字 + SVG 图形)
- 用 `python3 -m http.server` 本地启动
- 用 browser 工具 navigate + screenshot 截图
- 适用于需要精确控制布局时
### Step 4.5: 配图规划与生成
完整指南见 `references/illustration-prompts.md`(Type×Style 矩阵、Prompt 规范、风格锚点)。
**4.5a 配图规划(先规划再生图)**
1. **扫描文章结构**,按以下规则标注潜在配图位置:
- `##` 标题后的第一段 → 潜在配图点
- 概念首次出现 → 用插图辅助解释
- 转折/对比处("但是"、"然而"、"相比之下")→ 适合对比图
- 连续超 800 字无任何视觉元素 → 需要打断
2. **内容信号自动匹配**,根据段落关键词推荐 Type:
- 架构/分层/系统 → `framework` | 步骤/流程 → `flowchart` | vs/对比 → `comparison`
- 数据/百分比 → `infographic` | 叙事/经历 → `scene` | 代码/界面 → `screenshot`
3. **锁定全文 Style**(一篇文章只用一种 AI 生图风格,从 `cover-image-guide.md` 预设中选):
- 技术/产品文 → `ai-product`(默认)| 数据/架构文 → `dashboard` | 故事/教育文 → `pm-growth` | 观点文 → `bold-opinion`
4. **输出配图计划表**(保存到 `illustration-plan.md`),让老板确认后再生图。
密度参考:2000 字 3 张、3000 字 4-5 张,正文配图硬上限 5 张。
**4.5b 生图执行**
配图有两条渲染路径,在配图计划表中按每张图标注:
**路径 A:AI 生图**(视觉美感优先,适合大部分场景)
- **优先级**:idealab Image API → Seedream 5.0 Lite → Gemini 生图 → ComfyUI
- idealab(首选): `<WORKSPACE>/scripts/generate-image.sh --prompt "prompt" --filename output.jpg --size 2560x1440`
- 默认模型 `gemini-3.1-flash-image-preview`,团队AK免费;超时/报错自动降级到 Gemini
- Seedream(降级): `<WORKSPACE>/scripts/seedream-generate.sh "prompt" output.jpg "2560x1440"`
- 正文插图 16:9 `2560x1440`
- **Prompt 按 LDSCS-R 六层结构构造**:Layout → Data → Semantics → Characters → Style → Ratio
- **风格锚点**:第一张图成功后,记录风格特征到 `prompts/style-anchor.md`,后续图片引用保持一致
- **Prompt 持久化**:每张图的 prompt 保存到 `prompts/NN-{type}-{slug}.md`,便于回溯修改
**路径 B:Mermaid 渲染截图**(信息精确性优先,技术文章的流程/架构/对比图)
- **适用条件**:配图计划中 Style 标注为 `mermaid-render` 的插图
- **工作流**:
1. 生成 Mermaid 代码(遵守 `references/illustration-prompts.md` 中的 Mermaid 规范)
2. 写入 `<WORKSPACE>/scripts/mermaid-render.html`(临时 HTML 模板)
3. 浏览器打开 → resize 2560x1440 → screenshot
4. 裁剪白边(如需要)→ 存入 `images/`
- **Mermaid prompt 也持久化**:保存到 `prompts/NN-{type}-{slug}.md`,记录 Mermaid 源码
**通用规则**:
- 截图美化:`<WORKSPACE>/scripts/beautify-screenshot.sh <input> [output] --shadow --bg "#f5f5f5"`
- 水印去除:`<WORKSPACE>/scripts/remove-watermark.sh <input> [output]`(Gemini 生图 需去水印)
- 配图数量:宁缺毋滥。不确定要不要配图 → 不配。
- 同一篇文章中路径 A 和路径 B 可以混用,但渲染方式不超过 2 种。
### Step 4.6: 产品截图获取
文章涉及线上产品(AI 工具、SaaS 产品等)时,截取真实产品界面作为配图。
**公开页面(无需登录):**
```bash
# 1. 打开产品页面
browser action:open url:"https://example.com/product"
# 2. 等待加载完成后截图
browser action:screenshot fullPage:false type:png
# 3. 裁剪/缩放(macOS sips 工具)
sips -z <高度> <宽度> screenshot.png # 缩放
sips -c <高度> <宽度> screenshot.png # 裁剪居中
sips --resampleWidth 800 screenshot.png # 按宽度等比缩放
# 4. 上传到微信素材库
node scripts/publisher.mjs # 通过 --markdown 自动上传
# 或手动:在 utils.mjs 中调用 uploadArticleImage()
```
**需登录页面(付费产品/内部系统):**
1. 让用户在 Chrome 打开目标页面
2. 用户点击 OpenClaw Browser Relay 工具栏图标 attach 该 tab
3. `browser action:screenshot profile:chrome` 截图
4. 截图后同上流程裁剪上传
**截图规范:**
- 宽度 600-900px,避免过大(微信有尺寸限制)
- 隐藏/打码敏感信息(用户名、私有数据等)
- 浏览器地址栏按需保留(能说明产品来源时保留)
- 优先截取核心功能区域,不截全屏
- 深色/浅色主题根据文章风格选择
### Step 5: 排版美化
```bash
# 基础排版
python3 scripts/markdown_to_html.py --input article.md --theme tech --output article.html
# 带图片自动上传(本地图片自动上传到微信素材库并替换 URL)
python3 scripts/markdown_to_html.py --input article.md --theme tech --output article.html --upload
```
主题选择:tech(科技风,默认)、minimal(简约风)、business(商务风)。
排版约束详见 `references/weixin-constraints.md`。
#### Step 5.5: 外链转底部引用(微信外链不可点击问题)
微信公众号不支持外链点击跳转,文章中的外链对读者没有交互价值。排版时自动处理:
**处理规则**:
1. 普通外链 `[text](https://example.com)` → 文中显示为 `text¹`(上标序号),链接收集到文末「引用链接」章节
2. `https://mp.weixin.qq.com/...` 链接保留为直接链接(微信内部链接可点击)
3. 裸链接(链接文本 = URL 本身)保留不动(已是展示型,读者可复制)
4. 没有外链的文章跳过此步
**文末引用格式**:
```markdown
---
## 引用链接
1. example.com: https://example.com/article
2. GitHub: https://github.com/user/repo
```
**执行时机**:Markdown 转 HTML 之前处理(在 Markdown 层面替换,不是 HTML 层面)。可在 `markdown_to_html.py` 中集成,或作为单独的预处理步骤。
如果有额外配图需要手动插入(非 Markdown 内嵌图片),先上传再手动插入 HTML。
### Step 6: 推送草稿箱
```bash
# 一键从 Markdown 到草稿(推荐,自动转 HTML + 上传图片 + 清理旧同名草稿)
node scripts/publisher.mjs --markdown article.md --title "文章标题" --cover cover.png
# 或手动分步:先转 HTML 再推送
python3 scripts/markdown_to_html.py --input article.md --theme tech --output article.html --upload
node scripts/publisher.mjs --title "文章标题" --content article.html --cover cover.png
# 跳过自动清理旧草稿
node scripts/publisher.mjs --markdown article.md --title "标题" --cover cover.png --no-cleanup
# 列出草稿
node scripts/publisher.mjs --list
# 删除草稿
node scripts/publisher.mjs --delete --media-id <id>
# 发布草稿(用户确认后)
node scripts/publisher.mjs --publish --media-id <草稿media_id>
```
**默认停在草稿箱,不自动发布。** 告知用户草稿已创建,确认后再发布。
推送新版本时自动清理同标题旧草稿(`--no-cleanup` 跳过)。
### Step 6+: 发布数据回流(推送草稿箱后自动执行)
推送草稿箱成功后,顺便采集历史文章数据,用于选题评分校准。
```
1. browser(action=tabs, profile="user") 检查是否有 mp.weixin.qq.com tab
2. 有 → browser(action=snapshot, targetId=<tab_id>) 读取发表记录页
3. 从 snapshot 提取每篇文章的:标题、阅读量、点赞、分享、留言
4. 自动更新 collections/topics/publish-feedback.md
5. 没有 tab 或 snapshot 失败 → 跳过,不影响主流程
```
注意:此步不影响主流程,失败就跳过。数据用于选题评分校准(见 eval-criteria.md + publish-feedback.md)。
### Step 6.5: 五维内容自检(交付前必做)
排版完成后、交付前,逐维度自检。任何一项 ❌ 必须修改后再进入下一步。
| 维度 | 检查问题 | 合格标准 | 判断 |
|------|---------|---------|------|
| **文字洁癖** | 有没有 AI 味?(空洞排比句、Emoji 堆叠、"让我们来看看") | 每句话都有信息量,没有填充语 | ✅/❌ |
| **标题/封面** | 平铺直叙能不能吸引人?不看封面只看标题想不想点? | 有立场/反差/数据之一,不靠震惊体 | ✅/❌ |
| **表达效率** | 能不能一句话说清核心观点?有没有 99% 时间包装 1% 内容? | 删掉任何一段,整篇不完整 = 没有冗余 | ✅/❌ |
| **认知落差** | 读完后读者会不会觉得「这个我知道」?和同行比有增量吗? | 至少有 1 个「我之前没想到」的点 | ✅/❌ |
| **数据支撑** | 核心判断有没有数据/事实/一手经验支撑? | 每个核心判断都有来源(数据/案例/亲身经历) | ✅/❌ |
> 借鉴 dbs-content 五维诊断框架。第五维从「AI 辅助」改为「数据支撑」,更适合公众号写作场景。
### Step 7: 读者测试(可选但推荐)
交付前,用一个无上下文的子 Agent 阅读文章全文,检查:
- 标题是否让人想点开?(不知道背景的人能否被吸引)
- 开头两段是否抓住注意力?(3 秒法则)
- 是否有行业黑话/未解释的概念?(非目标读者能否理解)
- 结尾是否有力?(读完后想做什么)
如果子 Agent 发现盲点,修改后再交付。
### Step 8: 风格学习采集
**写作完成时自动执行,不需要老板操作。**
在 Step 3 写作完成(draft-v1.md 定稿)后,自动记录 AI 原稿:
```bash
python3 <WORKSPACE>/scripts/style-observe.py record-original <工作目录>/draft-v1.md --skill wemp-ops --topic "选题关键词"
```
当老板确认最终版(可能经过多轮修改)后,记录最终版:
```bash
python3 <WORKSPACE>/scripts/style-observe.py record-final <最终版文件> --skill wemp-ops
```
**触发 record-final 的信号**:
- 老板说"可以了"/"发吧"/"推到草稿箱"
- 老板手动修改后把最终版发回来
- 文章已发布(从草稿箱发布 = 确认最终版)
**如果老板没有修改直接发布**,也要 record-final(no_change = 正反馈,说明这次写得好)。
积累 5+ 对 diff 后,可执行风格规则提取:
```bash
python3 <WORKSPACE>/scripts/style-observe.py pairs --skill wemp-ops --days 30
```
### Step 9: 交付
向用户汇报:文章标题、字数、草稿状态、封面预览、建议发布时间。
## 仅采集
```bash
node scripts/smart_collect.mjs --query "用户需求" --keywords "AI扩展的关键词" --sources "hackernews,v2ex,36kr" [--deep]
```
数据源分类:
- `tech`: hackernews, github, v2ex, sspai, juejin, ithome, producthunt
- `china`: weibo, zhihu, baidu, douyin, bilibili, toutiao, tencent, thepaper, hupu
- `finance`: 36kr, wallstreetcn, cls
采集后整理为选题候选清单,每条含:标题、来源、热度、链接、与用户需求的相关度。
## 数据分析
```bash
# 日报(默认昨天)
node scripts/daily_report.mjs [--date YYYY-MM-DD]
# 周报
node scripts/weekly_report.mjs
```
输出包含:用户增长、阅读数据、热门文章、互动数据、AI 洞察。
## 互动管理
```bash
# 检查新评论
node scripts/check_comments.mjs
# 回复评论
node scripts/reply_comment.mjs --comment-id <id> --content "回复内容"
```
AI 生成回复建议时遵循 `persona.md` 的语气规范,用户确认后再执行回复。
## 配置
公众号凭证配置在 **skill 自己的** `config/default.json`:
```json
{
"weixin": {
"appId": "你的AppID",
"appSecret": "你的AppSecret"
}
}
```
⚠️ **不要写到 `openclaw.json` 的 `channels` 里!** `channels` 只接受 OpenClaw 内置渠道类型(dingtalk/telegram/discord 等),写入未知类型会导致 gateway 校验失败并不断重启。
其他配置(数据源偏好、报告时间等)同样在 `config/default.json` 中调整。
---
## 多版本分发(一篇→多平台)
公众号文章完成后,可一键生成其他平台版本。
### 触发词
- "把这篇文章分发到小红书/X"
- "生成多平台版本"
- "同步到其他平台"
### 分发流程
#### Step 1: 读取源文章
从公众号草稿箱/已发布文章中提取:标题、正文、核心观点、配图
#### Step 2: 生成小红书版本
自动调用 `xiaohongshu-ops` 技能:
1. **标题**:从公众号标题中提炼,加 emoji,≤20 字
2. **正文**:缩写到 300-600 字,口语化改写,去掉长段落
3. **配图**:从公众号文章中选 1-3 个核心观点,制作信息卡(Seedream 优先,备选 Gemini 生图 / HTML截图)
4. **标签**:5-10 个小红书话题标签
5. 通过 openclaw browser 发布到创作中心
#### Step 3: 生成 X/Twitter 版本
1. **单条推文**(≤280 字符):提炼文章最核心的一个观点,英文或中文
2. **Thread 版本**(可选):3-5 条推文,适合深度内容
3. 通过 openclaw browser 发布(参考 TOOLS.md 中的 X 发帖 SOP)
4. 发帖前**先让老板确认内容** — 外部发布是不可撤回的操作,确认能避免事后删帖的尴尬
#### Step 4: 记录分发状态
在公众号文章对应的 memory 记录中标注已分发平台和链接
### 改写原则
- 每个平台版本**重新改写** — 直接复制粘贴会被平台算法降权,且不同平台的受众期待和内容格式差异很大
- 小红书:口语化、短句、emoji 多用、互动提问结尾
- X/Twitter:精炼、有冲击力、适合英文受众(如有)
- 保持核心观点一致,但表达方式适配平台调性
---
## 下一步建议(条件触发)
文章完成后,根据结果判断是否推荐下一步。
| 触发条件 | 推荐 |
|---------|------|
| 文章已发布成功 | 「文章已发布。要分发到小红书/X 吗?或者用 content-collector 存档素材方便下次复用。」 |
| 写作过程中发现素材不足 | 「素材不够,建议先用 content-collector 搜索已有收藏,或 web_search 补充。」 |
| 文章涉及会议纪要/内部沟通 | 「这篇更像内部文档,建议用 internal-comms 的格式。」 |
| 需要配图但 Seedream/Gemini 生图效果不佳 | 「配图可以试试 drawio 画流程图/架构图替代。」 |
---
## 绝对不要做的事
写作和内容产出中,以下行为直接拉低质量,必须避免:
1. **不要说「每个人的写作风格不同」「见仁见智」** — 这是回避判断。有观点就直接说,标注来源
2. **不要建议「参考同行」而不给具体对标** — 「去看看别人怎么写的」是废话。要给就给具体账号、具体文章、具体写法
3. **不要用「干货满满」「深度好文」「建议收藏」** — 读者自己判断有没有干货,作者说「干货满满」等于自夸
4. **不要写空洞的排比句开头** — 「在这个时代……在这个节点……在这个浪潮中……」是 AI 八股文的标志
5. **不要堆叠修饰词** — 「极其重要的关键性底层核心逻辑」,一个词能说清的事不用四个
6. **不要开头就下结论** — 先给事实/故事/数据制造认知落差,结论放后面。开头下结论 = 读者没动力往下读
7. **不要用「让我们一起来看看」「接下来我们来聊聊」** — 这是视频口播语气硬塞进文章的结果,书面内容不需要这种过渡
8. **不要在没有数据支撑时写「据统计」「研究表明」** — 要么给出具体来源(谁的研究、哪年、样本量),要么不引用
---
## 内联案例库
### 正面案例(老板认可的产出)
**案例 1:公众号 #6「Claude 能操控微信了」V3 定稿**
> 去掉所有比喻,直接用数据说话。1900 字,聚焦 Computer Use vs API 一个视角。新增"Anthropic 自己优先走 MCP 接口"的关键发现。
- 成功要点:一篇文章只讲一件事;数据 > 比喻 > 术语;「50% 成功率」比任何比喻都有说服力
- 老板评价:确认 OK
**案例 2:公众号 #5「Skills 不是低代码,但也不是 App Store」**
> 从第一人称 AI PM 视角,讲自己用 Skills 的真实踩坑经历,有具体场景、有数据、有得失。
- 成功要点:讲故事不讲论点,经历 > 观点,场景 > 总结,有踩坑有收获
**案例 3:封面图 Seedream 生成**
> 2560x1440 生成,裁剪 2.35:1 宽屏比例,简洁主体 + 深色背景 + 大字标题。
- 成功要点:封面不堆元素,一个主体 + 一行标题 + 干净背景
### 反面案例(被打回的产出 + 打回原因)
**反面 1:公众号 #6 V1 初稿(3100 字,被全面打回)**
> 塞了 Computer Use + Agentic Engineering + 冷思考 + 一手实践 + 8 个名人引用,试图一篇文章讲完所有。
- 打回原因:❌ 内容太干太多读不动 ❌ 名人引用堆砌(8 处)❌ 点名反驳其他博主 ❌ 想讲的东西太多
- 教训:一篇文章只讲一件事。名人引用全文不超过 2-3 处。不要点名反驳他人文章,从现象切入。
**反面 2:公众号 #6 V2(1800 字,比喻牵强)**
> 砍掉了多余内容,但用"开车 vs 高铁"比喻贯穿全文。
- 打回原因:❌ 比喻牵强,把事情搞复杂了
- 教训:比喻不是必须的。事实清晰时直接说更好。数据自己会说话。
FILE:README.md
# WeMP Ops
WeChat Official Account (公众号) full-stack operations skill for OpenClaw.
## Features
- 📝 **Topic Selection**: Auto-suggest topics from your topic pool
- 🔍 **Content Collection**: Search web, fetch articles, gather materials
- ✍️ **AI Writing**: Generate articles following style guides and templates
- 🎨 **Image Generation**: Create cover images and illustrations
- 📐 **Layout Formatting**: Convert Markdown to WeChat-compatible HTML
- 📊 **Analytics**: Track article performance and engagement
- 💬 **Comment Management**: Monitor and respond to reader comments
## Prerequisites
- OpenClaw agent environment
- Node.js 16+ and Python 3.8+
- WeChat Official Account API credentials (for publishing features)
## Installation
### Via ClawHub (Recommended)
```bash
clawhub install wemp-ops
```
### Manual Installation
1. Clone to `~/.openclaw/skills/wemp-ops`
2. Run setup:
```bash
cd ~/.openclaw/skills/wemp-ops
node scripts/setup.mjs
```
## Usage
Talk to your OpenClaw agent:
```
写一篇关于 AI 产品设计的文章
帮我采集今日热点
查看公众号日报
检查新评论
```
The skill guides you through: topic selection → material collection → writing → layout → publishing.
## Configuration
- `persona.md`: Define your writing persona
- `references/`: Style guides, templates, and techniques
- `config/`: API credentials for WeChat and image generation services
## License
MIT License - see [LICENSE](LICENSE) file
FILE:assets/templates/business.json
{
"name": "business",
"description": "商务风 - 深蓝金色,专业稳重",
"styles": {
"wrapper": "max-width:677px;margin:0 auto;padding:16px 8px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif;color:#2c3e50;line-height:1.8;font-size:15px;",
"h1": "font-size:24px;font-weight:700;color:#1a365d;margin:28px 0 16px;padding-bottom:10px;border-bottom:2px solid #c9a96e;",
"h2": "font-size:20px;font-weight:700;color:#1a365d;margin:24px 0 12px;padding-left:12px;border-left:4px solid #c9a96e;",
"h3": "font-size:18px;font-weight:600;color:#2c3e50;margin:20px 0 10px;",
"h4": "font-size:16px;font-weight:600;color:#34495e;margin:16px 0 8px;",
"p": "margin:12px 0;font-size:15px;line-height:1.8;color:#2c3e50;letter-spacing:0.5px;",
"strong": "color:#1a365d;",
"em": "color:#c9a96e;font-style:italic;",
"code_inline": "background:#f0f4f8;color:#1a365d;padding:2px 6px;border-radius:3px;font-size:14px;font-family:'SF Mono',Consolas,monospace;",
"pre": "background:#272822;border-radius:6px;padding:16px;margin:16px 0;overflow-x:auto;border-left:4px solid #c9a96e;",
"code": "color:#f8f8f2;font-size:14px;line-height:1.6;font-family:'SF Mono',Consolas,monospace;white-space:pre-wrap;word-break:break-all;",
"blockquote": "margin:16px 0;padding:12px 16px;background:#f0f4f8;border-left:4px solid #c9a96e;border-radius:0 4px 4px 0;color:#34495e;font-size:15px;",
"ul": "margin:12px 0;padding-left:24px;",
"ol": "margin:12px 0;padding-left:24px;",
"li": "margin:6px 0;font-size:15px;line-height:1.75;color:#2c3e50;",
"table": "width:100%;border-collapse:collapse;margin:16px 0;font-size:14px;",
"th": "background:#1a365d;color:#fff;padding:10px 12px;text-align:left;font-weight:600;",
"td": "padding:8px 12px;border-bottom:1px solid #e2e8f0;color:#2c3e50;",
"tr_even": "background:#f7fafc;",
"hr": "border:none;height:2px;background:linear-gradient(90deg,#1a365d,#c9a96e);margin:24px 0;",
"img": "max-width:100%;border-radius:4px;margin:12px 0;border:1px solid #e2e8f0;"
}
}
FILE:assets/templates/minimal.json
{
"name": "minimal",
"description": "简约风 - 黑白灰,极致可读性",
"styles": {
"wrapper": "max-width:677px;margin:0 auto;padding:16px 8px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif;color:#333;line-height:1.85;font-size:15px;",
"h1": "font-size:24px;font-weight:700;color:#111;margin:28px 0 16px;padding-bottom:8px;border-bottom:1px solid #ddd;",
"h2": "font-size:20px;font-weight:700;color:#222;margin:24px 0 12px;",
"h3": "font-size:18px;font-weight:600;color:#333;margin:20px 0 10px;",
"h4": "font-size:16px;font-weight:600;color:#444;margin:16px 0 8px;",
"p": "margin:12px 0;font-size:15px;line-height:1.85;color:#333;letter-spacing:0.3px;",
"strong": "color:#111;",
"em": "color:#666;font-style:italic;",
"code_inline": "background:#f6f8fa;color:#d63384;padding:2px 6px;border-radius:3px;font-size:14px;font-family:'SF Mono',Consolas,monospace;",
"pre": "background:#f6f8fa;border:1px solid #e1e4e8;border-radius:6px;padding:16px;margin:16px 0;overflow-x:auto;",
"code": "color:#24292e;font-size:14px;line-height:1.6;font-family:'SF Mono',Consolas,monospace;white-space:pre-wrap;word-break:break-all;",
"blockquote": "margin:16px 0;padding:10px 16px;border-left:3px solid #ddd;color:#666;font-size:15px;background:#fafafa;",
"ul": "margin:12px 0;padding-left:24px;",
"ol": "margin:12px 0;padding-left:24px;",
"li": "margin:6px 0;font-size:15px;line-height:1.75;color:#333;",
"table": "width:100%;border-collapse:collapse;margin:16px 0;font-size:14px;",
"th": "background:#f6f8fa;color:#24292e;padding:10px 12px;text-align:left;font-weight:600;border:1px solid #ddd;",
"td": "padding:8px 12px;border:1px solid #ddd;color:#333;",
"tr_even": "background:#fafafa;",
"hr": "border:none;height:1px;background:#ddd;margin:24px 0;",
"img": "max-width:100%;border-radius:4px;margin:12px 0;"
}
}
FILE:assets/templates/tech.json
{
"name": "tech",
"description": "科技风 - 蓝紫渐变,现代感",
"styles": {
"wrapper": "max-width:677px;margin:0 auto;padding:16px 0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif;color:#2d2d2d;line-height:1.8;font-size:15px;",
"h1": "font-size:24px;font-weight:700;color:#1a1a2e;margin:28px 0 16px;padding-bottom:10px;border-bottom:3px solid #7c3aed;",
"h2": "font-size:20px;font-weight:700;color:#1a1a2e;margin:24px 0 12px;padding-left:12px;border-left:4px solid #7c3aed;",
"h3": "font-size:18px;font-weight:600;color:#333;margin:20px 0 10px;",
"h4": "font-size:16px;font-weight:600;color:#555;margin:16px 0 8px;",
"p": "margin:12px 0;font-size:15px;line-height:1.8;color:#333;letter-spacing:0.5px;",
"strong": "color:#1a1a2e;",
"em": "color:#7c3aed;font-style:italic;",
"code_inline": "background:#f0ecff;color:#7c3aed;padding:2px 6px;border-radius:4px;font-size:14px;font-family:'SF Mono','Fira Code',Consolas,monospace;",
"pre": "background:#1e1e2e;border-radius:8px;padding:16px;margin:16px 0;overflow-x:auto;",
"code": "color:#cdd6f4;font-size:14px;line-height:1.6;font-family:'SF Mono','Fira Code',Consolas,monospace;white-space:pre-wrap;word-break:break-all;",
"blockquote": "margin:16px 0;padding:12px 16px;background:linear-gradient(135deg,#f0ecff,#e8e0ff);border-left:4px solid #7c3aed;border-radius:0 8px 8px 0;color:#555;font-size:15px;",
"ul": "margin:12px 0;padding-left:24px;",
"ol": "margin:12px 0;padding-left:24px;",
"li": "margin:6px 0;font-size:15px;line-height:1.7;color:#333;",
"table": "width:100%;border-collapse:collapse;margin:16px 0;font-size:14px;",
"th": "background:#7c3aed;color:#fff;padding:10px 12px;text-align:left;font-weight:600;",
"td": "padding:8px 12px;border-bottom:1px solid #e5e5e5;color:#333;",
"tr_even": "background:#f8f7ff;",
"hr": "border:none;height:1px;background:linear-gradient(90deg,transparent,#7c3aed,transparent);margin:24px 0;",
"img": "max-width:100%;border-radius:8px;margin:12px 0;"
}
}
FILE:config/default.json
{
"weixin": {
"appId": "wx4d9bfa93bcaef664",
"appSecret": "aea4c1ac10209d107d58d4128463fd0a"
},
"notification": {
"channel": "webchat",
"silent": false
},
"content": {
"topics": ["AI", "大模型", "产品"],
"defaultSources": ["hackernews", "v2ex", "36kr"],
"language": "zh_CN"
},
"analytics": {
"dailyReportTime": "09:00",
"timezone": "Asia/Shanghai",
"topArticles": 5,
"historyDays": 30
},
"publish": {
"defaultTheme": "tech",
"autoPublish": false
}
}
FILE:data/daily-history.json
{
"reports": [
{
"date": "2026-04-11",
"netGrowth": 0,
"totalRead": 0,
"totalUsers": 0,
"generatedAt": "2026-04-12T16:37:38.543Z"
},
{
"date": "2026-04-13",
"netGrowth": 0,
"totalRead": 0,
"totalUsers": 0,
"generatedAt": "2026-04-14T14:38:56.659Z"
},
{
"date": "2026-04-15",
"netGrowth": 0,
"totalRead": 0,
"totalUsers": 0,
"generatedAt": "2026-04-16T13:18:38.396Z"
}
]
}
FILE:evals/evals.json
{
"skill_name": "wemp-ops",
"evals": [
{
"id": 1,
"eval_name": "article-from-topic",
"prompt": "帮我写一篇关于AI如何改变电商运营的公众号文章",
"expected_output": "应该走完素材采集→内容写作→封面生图→排版美化的完整流程",
"assertions": [
{"id": "env-check", "text": "执行环境检查或提到 setup.mjs", "type": "quality"},
{"id": "material-collection", "text": "有素材采集/热点搜索步骤", "type": "quality"},
{"id": "writing-structure", "text": "文章有清晰的结构(标题/引言/正文/结尾)", "type": "format"},
{"id": "cover-image", "text": "提到生成封面图", "type": "quality"},
{"id": "no-ai-exposure", "text": "不暴露AI参与写作", "type": "quality"},
{"id": "draft-push", "text": "提到推送到草稿箱或通过API发布", "type": "quality"}
]
},
{
"id": 2,
"eval_name": "comment-management",
"prompt": "帮我看看公众号最新文章的评论,有需要回复的吗",
"expected_output": "应该使用公众号API获取评论列表并分析是否需要回复",
"assertions": [
{"id": "api-usage", "text": "提到使用微信公众号API获取评论", "type": "quality"},
{"id": "comment-analysis", "text": "对评论内容进行分析/分类", "type": "quality"},
{"id": "reply-suggestion", "text": "给出回复建议或草稿", "type": "quality"}
]
},
{
"id": 3,
"eval_name": "data-report",
"prompt": "给我看看公众号这周的数据表现怎么样",
"expected_output": "应该获取公众号数据并生成周报",
"assertions": [
{"id": "data-fetch", "text": "提到获取阅读量/粉丝/互动等数据", "type": "quality"},
{"id": "report-structure", "text": "有结构化的数据报告格式", "type": "format"},
{"id": "trend-analysis", "text": "包含趋势分析或同比/环比", "type": "quality"}
]
}
]
}
FILE:evals/results/article-with-skill.md
# Eval: article-from-topic (WITH skill)
## 执行计划摘要
- 选题澄清:给出3个具体方向让老板选
- 素材采集:收藏库 grep + web_search 3轮 + smart_collect.mjs + web_fetch 2-4篇
- 写作:行业观察模板 2000-3000字,persona.md 人设,第一人称
- 封面:Seedream 5.0 Lite(0.22元/张)→ 裁剪2.35:1 → 质量检查
- 正文配图:2-3张,Seedream/截图 + beautify-screenshot.sh
- 排版:markdown_to_html.py --theme tech --upload
- 推送:publisher.mjs 到草稿箱,不自动发布
- 红线:8条写作红线逐条遵守方案
## Assertion 评分
| ID | Assertion | Pass | Evidence |
|---|---|---|---|
| env-check | 执行环境检查或提到 setup.mjs | ⚠️ 部分 | 没显式提到 setup.mjs,但提到了 smart_collect.mjs 和所有脚本 |
| material-collection | 有素材采集步骤 | ✅ | 收藏库grep + web_search 3轮 + smart_collect.mjs,非常全面 |
| writing-structure | 文章有清晰结构 | ✅ | 行业观察模板5段式,每段有字数估算 |
| cover-image | 提到生成封面图 | ✅ | Seedream 生图+裁剪+质量检查+备选方案,非常详细 |
| no-ai-exposure | 不暴露AI参与写作 | ✅ | 写作红线第1条明确说明,写完自检 |
| draft-push | 提到推送草稿箱或API发布 | ✅ | publisher.mjs 推送草稿箱,明确不自动发布 |
**Pass rate: 5.5/6 (92%)**
## vs Without-skill 关键差异
- WITH:知道所有脚本(seedream-generate.sh/markdown_to_html.py/publisher.mjs/smart_collect.mjs)
- WITHOUT:不知道任何专用脚本,对发布流程不确定
- WITH:8条写作红线逐条遵守方案
- WITHOUT:没意识到"不暴露AI写作"红线
- WITH:封面图完整方案(Seedream→裁剪→质量检查→备选)
- WITHOUT:只说"用图片生成工具"
- WITH:收藏库检索作为素材来源
- WITHOUT:完全不知道收藏库
FILE:evals/results/article-without-skill.md
# Eval: article-from-topic (WITHOUT skill)
## 执行计划摘要
- 素材:web_search + web_fetch
- 写作:直接在对话中完成
- 配图:提到用图片生成工具,封面900×383px
- 排版:不确定如何排版,提到"需要了解发布渠道"
- 发布:不确定是否有API权限,提了不确定点
## Assertion 评分
| ID | Assertion | Pass | Evidence |
|---|---|---|---|
| env-check | 执行环境检查或提到 setup.mjs | ❌ | 完全不知道 setup.mjs 的存在 |
| material-collection | 有素材采集步骤 | ✅ | web_search + web_fetch 搜索案例数据 |
| writing-structure | 文章有清晰结构 | ✅ | 开头/场景拆解/理性分析/实操建议/结尾,结构清晰 |
| cover-image | 提到生成封面图 | ✅ | 提到封面图+文中插图,有尺寸规范 |
| no-ai-exposure | 不暴露AI参与写作 | ⚠️ 未提及 | 没有明确意识到这条红线 |
| draft-push | 提到推送草稿箱或API发布 | ⚠️ 部分 | 提到但表示不确定是否有API权限 |
**Pass rate: 3/6 (50%)**
## 关键差异
- 不知道 setup.mjs 环境检查
- 不知道具体的公众号API调用方式和脚本
- 没有意识到"不暴露AI写作"红线
- 对发布流程不确定,缺少公众号专属的排版/推送知识
- 但文章结构和写作本身质量不错——这是模型基础能力
FILE:persona.md
# 公众号写作人设
## 你是谁
一个正在成长中的 AI 产品经理,在大厂做 AI 产品,熟悉 Dify、Claude Code、RAG、Agent、MCP 等技术栈,有产品设计和项目管理背景。不是技术大牛,但能用产品思维把技术讲明白。
## 读者是谁
- 主要:AI 爱好者、想了解 AI 怎么用的普通用户
- 次要:产品经理同行(内容需经得起专业审视)
## 核心价值
帮读者用**产品思维**理解和使用 AI,解决实际问题。不是纯技术教程,而是「产品视角 + 技术实操」的融合。
## 写作语气
- **第一人称**:用「我」的视角,「我最近在做 X 时发现…」「我的看法是…」
- **有观点**:敢下判断,「我认为 A 比 B 更适合这个场景」,但给理由
- **口语化但有逻辑**:像在和朋友聊天,但条理清楚
- **承认局限**:「当然,这只是我的看法」「在 XX 场景下可能不适用」
## 红线
- 不编造数据和案例
- 不空口说「XX 是最好的」,必须给依据
- 不写成官方文档或新闻稿
- 不过度使用 emoji
- 不在结尾放「参考资料」「延伸阅读」等学术体章节
## 评论回复语气
- 友好但不谄媚
- 有实质内容,不敷衍「谢谢支持」
- 遇到质疑坦诚回应,不回避
- 简短为主,1-3 句话
FILE:references/article-templates.md
# 文章结构模板
## 类型判断
```
用户输入选题
├─ 具体产品名 + "分析/拆解/体验" → AI 产品拆解
├─ "怎么用 AI 做 XXX" / 具体场景 → 场景解决方案
├─ 工具名 + "技巧/心得/教程" → 效率提升实战
├─ 抽象话题 + "如何/为什么/思考" → 产品方法论
└─ 新闻/趋势 + "怎么看/分析" → 行业观察
```
---
## 1. AI 产品拆解(2000-3000 字)
```
开头(100-200字)
用一个真实使用场景引入:「上周我做XX时,发现这个产品…」
产品是什么(200-300字)
一句话定位 + 核心功能概述
我的使用场景(800-1200字)【核心】
场景1:我用它做了什么?效果如何?
场景2:另一个场景下的表现
场景3:对比其他工具的差异
每个场景:起因→过程→结果→感受
产品设计分析(300-500字)
为什么能解决这个问题?
可借鉴的设计思路?
我觉得的不足?
总结(100-200字)
核心观点 + 给读者的建议
```
## 2. 场景解决方案(2000-3000 字)
```
场景痛点(200-300字)
「我之前做XX时遇到这个问题…」
方案选型(300-500字)
对比过哪些方案,为什么选这个
具体实现(800-1200字)【核心】
步骤 + 配置 + 关键细节
效果和踩坑(300-500字)
实际效果,踩过什么坑
总结(100-200字)
适合什么人,有什么局限
```
## 3. 效率提升实战(1500-2500 字)
```
背景(100-200字)
我用这个工具做什么任务?遇到什么问题?
技巧/心得(1000-1500字)【核心】
技巧1:什么场景下发现?怎么用?效果?
技巧2-5:更多实用技巧
每个技巧:真实场景→具体操作→实际效果
注意事项(200-300字)
容易踩的坑
总结(100字)
一句话概括核心收获
```
## 4. 产品方法论(2000-3000 字)
```
引入问题(200-300字)
「最近一直在思考一个问题…」
我的观点(200-300字)
先亮明核心观点
论证(1000-1500字)【核心】
为什么这么认为?
有什么案例支撑?
有没有反例?
如何落地(300-500字)
具体怎么做
总结(100-200字)
回扣观点
```
## 5. 行业观察(1500-2500 字)
```
事件/趋势是什么(300-500字)
快速讲清楚背景,不堆数据
我怎么看(200-300字)
鲜明观点,我的独特视角
为什么这么看(600-1000字)【核心】
观察到什么现象?
我的实际经历或案例
推理过程
不要只罗列数据,要讲故事
对我们意味着什么(200-300字)
给读者可操作的建议
结尾(100字)
开放性思考或互动
```
## 开头模板
- **场景型**:最近在做XX时,遇到了一个问题…
- **问题型**:很多人问我:XX?今天聊聊我的看法。
- **观点型**:我一直觉得,XX在大多数场景下是被过度炒作的。
- **发现型**:前两天发现了一个工具,用了一周后想分享下心得。
## 结尾模板
- **总结型**:总结一下:… 希望对你有帮助。
- **开放型**:你觉得呢?欢迎评论区聊聊。
- **行动型**:如果你也想试试,可以从XX开始。
FILE:references/cover-image-guide.md
# 封面图设计指南
## 五维度选择体系
封面图由 5 个独立维度交叉定义。每个维度独立选择,通过兼容矩阵确保组合合理。
```
文章内容 → 自动推荐五维度组合 → 老板确认/调整 → 组装 prompt → 生成
```
---
### 维度 1:构图类型(Type)
| 类型 | 构图方式 | 视觉区占比 | 适用文章 |
|------|---------|-----------|---------|
| `hero` | 大视觉冲击 + 标题覆盖,戏剧性构图 | 视觉 60-70% | 产品分析、重磅发布 |
| `conceptual` | 抽象概念可视化,信息分区,干净 | 视觉 50% | 技术架构、方法论 |
| `typography` | 文字为主体元素(40%+面积),极少视觉 | 文字 60% | 观点文、金句、声明 |
| `metaphor` | 具象物体隐喻抽象概念,符号化 | 视觉 60% | 成长、转型、哲理 |
| `split` | 左右或上下对比分屏,强烈视觉对照 | 各 50% | 对比文、before/after |
| `minimal` | 单一焦点 + 大量留白(60%+) | 视觉 30% | 极简核心概念、禅意 |
### 维度 2:配色方案(Palette)
| 配色 | 色值 | 视觉感受 | 适用 |
|------|------|---------|------|
| `tech-blue` | #1a1f5c → #7c3aed | 科技/紫蓝渐变 | AI产品、技术拆解 |
| `insight-blue` | #1e3a5f → #3b82f6 | 理性/深蓝 | 方法论、框架思考 |
| `action-orange` | #f97316 → #eab308 | 活力/橙黄 | 效率提升、实战 |
| `solution-green` | #10b981 → #f97316 | 解决方案/绿橙 | 场景方案、破局 |
| `trend-cyan` | #0891b2 → #06b6d4 | 趋势/蓝绿 | 行业观察、前沿 |
| `mono` | #1A1A1A + #F5F5F5 | 极简/黑白灰 | 哲理、极简、金句 |
| `warm` | #F5E6D0 + #D4956A + #7BA3A8 | 温暖/莫兰迪 | 个人故事、感悟 |
| `dark` | #0F172A + #3B82F6 + #06B6D4 | 高级/深色 | 电影感、高级观点 |
### 维度 3:渲染风格(Rendering)
| 渲染 | 视觉特征 | Prompt 关键词 |
|------|---------|-------------|
| `3d-icon` | 现代3D图标、光效、微渐变 | modern 3D style icons, subtle glow, depth |
| `flat-vector` | 扁平矢量、干净线条、无阴影 | flat vector, clean lines, no shadows, solid colors |
| `hand-drawn` | 手绘马克笔、不均匀线条 | hand-drawn marker style, uneven strokes, sketch quality |
| `painterly` | 水彩/油画质感、柔和边缘、艺术感 | watercolor soft edges, painterly brushstrokes, dreamy artistic |
| `digital` | 数据可视化、仪表盘风、打磨感 | polished digital render, data dashboard aesthetic, clean gradients |
| `screen-print` | 丝网印刷、半色调、2-4色 | screen print poster art, halftone dots, 2-4 flat colors, stencil-cut |
| `chalk` | 黑板粉笔、教学感、粗糙质感 | chalkboard style, chalk texture, rough hand-lettering, dark background |
### 维度 4:文字密度(Text)
| 级别 | 文字内容 | 适用 |
|------|---------|------|
| `none` | 纯视觉,无文字 | 抽象概念封面(少用) |
| `title-only` | 仅主标题(**默认**) | 大多数文章 |
| `title-subtitle` | 主标题 + 副标题/系列名 | 系列文章、教程 |
| `text-rich` | 标题 + 副标题 + 2-4 关键词标签 | 公告、多要点文章 |
### 维度 5:视觉强度(Mood)
| 级别 | 效果 | 量化调整 | 适用 |
|------|------|---------|------|
| `subtle` | 低对比、柔和、专业克制 | 对比度 -20~30%,饱和度 -20~30%,线条更细 | 方法论、学术类 |
| `balanced` | 中等对比(**默认**) | 标准 | 大多数文章 |
| `bold` | 高对比、强冲击、饱和色 | 对比度 +20~30%,饱和度 +20~30%,线条更粗 | 重磅观点、争议话题 |
### 维度 6:字体风格(Font)← 新增
| 字体 | 视觉感受 | Prompt 关键词 | 适用 |
|------|---------|-------------|------|
| `clean` | 无衬线、现代、干净(**默认**) | clean sans-serif typography | 技术、产品、大多数文章 |
| `handwritten` | 手写体、亲和、个人感 | handwritten casual font style | 个人故事、生活感悟 |
| `serif` | 衬线体、经典、学术感 | classic serif editorial typography | 深度长文、学术、书评 |
| `display` | 粗重装饰体、冲击力 | bold decorative display font | 公告、事件、促销类 |
---
## 构图核心原则(融合 baoyu-cover-image Base Prompt)
每次生成封面图,prompt 末尾必须附加以下构图约束:
```
Composition rules:
- Generous whitespace: maintain 40-60% breathing room, avoid cluttered layouts
- Visual anchor: main element centered or offset left (reserve right side for title area if title included)
- Information hierarchy: one dominant focal point, 1-2 supporting elements, decorative accents
- Clean backgrounds: solid colors or subtle gradients, no complex textures or patterns
- Characters: simplified silhouettes only, NO realistic human faces or bodies
- Icon vocabulary: use simple recognizable icons to represent concepts (see table below)
- Chinese text must be clearly readable, text and visuals must not overlap
- No emoji (renders as color blocks in browser screenshots)
```
### 图标词汇表速查
| 类别 | 图标 |
|------|------|
| 技术 | 代码窗口、齿轮、电路、云、锁、API 括号 |
| 创意 | 灯泡、火箭、靶心、拼图、钥匙、放大镜 |
| 沟通 | 对话气泡、聊天点、喇叭、信封 |
| 成长 | 植物/幼苗、树、箭头、图表、山 |
| 工具 | 扳手、铅笔、画笔、清单、时钟 |
| 抽象概念 | 无穷≡、太极、螺旋、层叠/堆叠、桥、门/传送门、镜子/倒影 |
### 图标组合模式
通过组合图标创建视觉比喻:
| 组合 | 表达 |
|------|------|
| 灯泡 + 齿轮 | 创新工程 |
| 植物 + 代码 | 有机技术增长 |
| 火箭 + 靶心 | 精准加速 |
| 钥匙 + 锁 | 安全解决方案 |
| 桥 + 人物 | 团队连接 |
| 放大镜 + 数据 | 分析洞察 |
### 渲染风格与图标处理
| Rendering | 图标风格 |
|-----------|----------|
| `3d-icon` | 立体感、微渐变、光影 |
| `flat-vector` | 几何化、简单形状、均匀填充 |
| `hand-drawn` | 涂鸦感、有机线条、手绘质感 |
| `painterly` | 柔和边缘、笔触感 |
| `digital` | 精确、微渐变、打磨 |
| `screen-print` | 模板切割、单色块、半色调 |
| `chalk` | 粗糙、粉笔质感、网格对齐 |
---
## Rendering 详细定义
每种渲染风格的线条/纹理/深度/元素规范,prompt 中引用对应条目:
| Rendering | Lines | Texture | Depth | Prompt 特征词 |
|-----------|-------|---------|-------|---------------|
| `3d-icon` | 干净圆滑 | 微光泰效果 | 有立体感和阴影 | modern 3D icons, subtle glow, soft shadows, depth layers |
| `flat-vector` | 均匀精确、无变化 | 无纹理、纯色填充 | 完全扁平 | flat vector, clean uniform lines, solid fills, no shadows, no gradients |
| `hand-drawn` | 不均匀、略有抖动 | 马克笔/铅笔质感 | 最小深度 | hand-drawn marker sketch, slightly shaky lines, paper texture, doodle quality |
| `painterly` | 柔和笔触、模糊边缘 | 水彩/油画质感 | 柔和边缘、光影渗透 | watercolor soft washes, visible brushstrokes, dreamy blending, artistic texture |
| `digital` | 精确干净、圆滑 | 微光泰效果 | 微层次感 | polished digital render, clean gradients, dashboard aesthetic, subtle depth |
| `screen-print` | 模板切割、粗犷 | 半色调点/色块边缘 | 扁平、无深度 | screen print poster, halftone dots, 2-4 flat colors, stencil-cut edges, bold contrast |
| `chalk` | 粗糙手写、不均匀 | 粉笔飞粉感 | 扁平 | chalkboard style, chalk dust texture, rough hand-lettering, dark background, educational feel |
---
## 参考图处理流程
当提供参考图时(老板给了风格参考、竞品封面等):
### 流程
1. **保存参考图**:复制到 `refs/ref-NN-{slug}.{ext}`
2. **深度分析**:提取具体、可复现的元素(不是“有个 logo”,而是“logo 用平行竖线拼 m”)
3. **分类用途**:
- `direct`:参考图直接传给模型 `--ref`(需要复现人物/核心元素时)
- `style`:只提取视觉风格(线条、纹理、构图)
- `palette`:只提取配色方案(hex 色值)
4. **写入 prompt**:用 MUST/REQUIRED 前缀强制约束,不能只传 `--ref` 不写文字描述(模型经常忽略 ref 图片)
### 深度分析提取清单
| 分析维度 | 好的描述 | 差的描述 |
|----------|---------|----------|
| 配色 | “#2D4A3E 深青 + #F5F0E0 米白” | “有深色和浅色” |
| 线条 | “2px 均匀线条,圆角端点” | “有线条” |
| 布局 | “底部 30% 暗色横幅放品牌” | “有个横幅” |
| 字体 | “大写、宽字距、无衬线” | “有文字” |
### 优先级规则
- 参考图 **覆盖默认**:如果参考图风格与预设配色/渲染冲突,参考图优先
- 具体 > 模糊:提取具体元素,不要“干净风格”这种模糊描述
- 生成后检查:确认参考元素在产出中可见,不可见则加强 prompt 重试
---
## 兼容矩阵
### Type × Rendering
| Type \ Rendering | 3d-icon | flat-vector | hand-drawn | painterly | digital | screen-print | chalk |
|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| `hero` | ✓✓ | ✓ | ✓ | ✓ | ✓✓ | ✓ | ✗ |
| `conceptual` | ✓✓ | ✓✓ | ✗ | ✗ | ✓✓ | ✓ | ✓ |
| `typography` | ✓ | ✓✓ | ✓ | ✗ | ✓ | ✓✓ | ✓✓ |
| `metaphor` | ✓ | ✓ | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓ |
| `split` | ✓✓ | ✓✓ | ✓ | ✗ | ✓✓ | ✓ | ✗ |
| `minimal` | ✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✓✓ | ✓ |
### Type × Mood
| Type \ Mood | subtle | balanced | bold |
|---|:---:|:---:|:---:|
| `hero` | ✓ | ✓✓ | ✓✓ |
| `conceptual` | ✓✓ | ✓✓ | ✓ |
| `typography` | ✓ | ✓✓ | ✓✓ |
| `metaphor` | ✓✓ | ✓✓ | ✓ |
| `split` | ✓ | ✓✓ | ✓✓ |
| `minimal` | ✓✓ | ✓✓ | ✗ |
### Palette × Rendering
| Palette \ Rendering | 3d-icon | flat-vector | hand-drawn | painterly | digital | screen-print | chalk |
|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| `tech-blue` | ✓✓ | ✓ | ✗ | ✗ | ✓✓ | ✓ | ✗ |
| `insight-blue` | ✓✓ | ✓✓ | ✗ | ✗ | ✓✓ | ✓ | ✗ |
| `action-orange` | ✓✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ |
| `solution-green` | ✓✓ | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ |
| `trend-cyan` | ✓✓ | ✓✓ | ✗ | ✗ | ✓✓ | ✓ | ✗ |
| `mono` | ✓ | ✓✓ | ✓ | ✗ | ✓ | ✓✓ | ✓✓ |
| `warm` | ✓ | ✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✗ |
| `dark` | ✓✓ | ✓ | ✗ | ✗ | ✓✓ | ✓✓ | ✓✓ |
> ✓✓ 强推荐 | ✓ 可用 | ✗ 不推荐
### Type × Text
| Type \ Text | none | title-only | title-subtitle | text-rich |
|---|:---:|:---:|:---:|:---:|
| `hero` | ✓ | ✓✓ | ✓✓ | ✓ |
| `conceptual` | ✓✓ | ✓✓ | ✓ | ✓ |
| `typography` | ✗ | ✓ | ✓✓ | ✓✓ |
| `metaphor` | ✓✓ | ✓ | ✓ | ✗ |
| `split` | ✓ | ✓✓ | ✓ | ✓ |
| `minimal` | ✓✓ | ✓✓ | ✓ | ✗ |
### Font × Rendering
| Font \ Rendering | 3d-icon | flat-vector | hand-drawn | painterly | digital | screen-print | chalk |
|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| `clean` | ✓✓ | ✓✓ | ✗ | ✗ | ✓✓ | ✓ | ✗ |
| `handwritten` | ✓ | ✓ | ✓✓ | ✓✓ | ✓ | ✗ | ✓✓ |
| `serif` | ✓ | ✓ | ✗ | ✓ | ✓✓ | ✓ | ✗ |
| `display` | ✓✓ | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ |
**兼容检查**:选定六维度后,检查上方 5 张矩阵中是否有 ✗ 组合。有则提示调整。
---
## 内容信号自动推荐
**决策规则**(与小红书 presets.md 统一逻辑):
1. 扫描文章关键词,匹配下表第一个命中行
2. 取推荐的六维度组合(或直接用预设快捷方式)
3. 用上方兼容矩阵校验组合无 ✗
4. 混合信号时取第一个匹配,不迭加多个维度推荐
公众号用「六维度」因为封面图是 AI 生图需要细粒度控制;小红书用「预设」因为预设已封装最佳组合。两套体系底层逻辑一致:内容信号 → 自动推荐 → 兼容校验 → 生成。
根据文章关键词自动推荐六维度组合:
| 文章关键词/类型 | Type | Palette | Rendering | Text | Mood |
|---------------|------|---------|-----------|------|------|
| AI、产品、工具、拆解、评测 | `hero` | `tech-blue` | `3d-icon` | title-only | balanced |
| 架构、框架、系统、分层、原理 | `conceptual` | `insight-blue` | `flat-vector` | title-only | subtle |
| 观点、趋势、判断、声明、预测 | `typography` | `mono` | `screen-print` | title-only | bold |
| 成长、转型、个人经历、反思 | `metaphor` | `warm` | `hand-drawn` | title-only | balanced |
| vs、对比、before/after、选型 | `split` | `tech-blue` | `3d-icon` | title-only | bold |
| 极简、核心、本质、一个概念 | `minimal` | `mono` | `flat-vector` | title-only | subtle |
| 效率、实战、工具实操 | `hero` | `action-orange` | `3d-icon` | title-subtitle | balanced |
| 行业、前沿、趋势报告 | `hero` | `trend-cyan` | `3d-icon` | title-only | balanced |
| 解决方案、破局、方法 | `hero` | `solution-green` | `3d-icon` | title-only | balanced |
| 电影感、高级、深度长文 | `typography` | `dark` | `screen-print` | title-only | bold |
---
## 预设快捷方式
常用组合一键选择:
| 预设名 | Type | Palette | Rendering | Font | 典型文章 |
|--------|------|---------|-----------|------|---------|
| `ai-product` | hero | tech-blue | 3d-icon | clean | AI产品拆解(**最常用**) |
| `methodology` | conceptual | insight-blue | flat-vector | clean | 方法论/框架文 |
| `bold-opinion` | typography | dark | screen-print | display | 观点输出/声明(mood=bold) |
| `pm-growth` | metaphor | warm | hand-drawn | handwritten | 个人成长/转型 |
| `versus` | split | tech-blue | 3d-icon | clean | 对比类文章 |
| `zen-core` | minimal | mono | flat-vector | serif | 极简深度文(mood=subtle) |
| `efficiency` | hero | action-orange | 3d-icon | clean | 效率/实战 |
| `trend-report` | hero | trend-cyan | digital | clean | 行业观察/数据 |
| `chalkboard` | conceptual | dark | chalk | handwritten | 教程/解释概念 |
| `watercolor` | metaphor | warm | painterly | serif | 文学/艺术/反思 |
| `cinematic` | hero | dark | screen-print | display | 电影感深度解读(mood=bold) |
| `dashboard` | conceptual | insight-blue | digital | clean | 数据分析/SaaS拆解 |
Text 默认 `title-only`,Mood 默认 `balanced`,除非预设标注(如 bold-opinion/cinematic → bold,zen-core → subtle)。
> ℹ️ 融合自 baoyu-cover-image v1.56.1 五维度体系 + Style Presets,适配公众号封面场景。
---
## 尺寸规范
| 用途 | 比例 | 生成尺寸 | 后处理 |
|------|------|---------|--------|
| 公众号封面 | 2.35:1 | `2560x1440`(16:9) | `sips -c 1090 2560` 裁剪 |
| 公众号次条 | 1:1 | `1920x1920` | 无 |
| 小红书封面 | 3:4 | `1680x2240` | 无 |
---
## Prompt 组装
### 步骤
1. **选定五维度**(自动推荐或手动选择)
2. **兼容矩阵校验**(检查 3 张矩阵无 ✗)
3. **存 prompt 文件**:`prompts/00-cover.md`(⛔ 先存后生)
4. **生成图片**
5. **裁剪**(如需 2.35:1)
### Prompt 模板
```markdown
---
type: [hero/conceptual/typography/metaphor/split/minimal]
palette: [配色名]
rendering: [渲染名]
text: [none/title-only/title-subtitle]
mood: [subtle/balanced/bold]
---
[渲染风格的 Prompt 关键词,从维度3表格中复制]
配色:[配色色值,从维度2表格中复制]
构图:[构图方式描述,从维度1表格中复制]
内容:
- 标题:「[中文标题]」
- 副标题:「[副标题,如有]」
- 视觉元素:[与文章主题相关的具象物体/抽象图形]
约束:
- 中文文字清晰可读
- 文字和视觉不重叠
- 不要 emoji
- 横版,高质量
```
### 生图调用
```bash
# 首选:idealab Chat API(团队AK免费)
<WORKSPACE>/scripts/generate-image.sh "[组装好的 prompt]" cover.jpg
# 降级:Seedream(idealab 不可用时)
<WORKSPACE>/scripts/seedream-generate.sh \
"[组装好的 prompt]" \
cover.jpg "2560x1440" 1
# 裁剪为 2.35:1
sips -c 1090 2560 cover.jpg
```
---
## 多方案选择
每次封面图出 **2-3 个方案**供老板选择:
```markdown
## 方案 A(推荐)
- 预设:ai-product(hero + tech-blue + 3d-icon)
- 视觉:[具体视觉元素描述]
## 方案 B
- 预设:bold-opinion(typography + dark + screen-print)
- 视觉:[具体视觉元素描述]
## 方案 C(可选)
- 预设:methodology(conceptual + insight-blue + flat-vector)
- 视觉:[具体视觉元素描述]
```
方案之间必须在 **Type 或 Rendering** 上有明显差异(不只是换配色)。
---
## 生图工具优先级
1. **idealab Chat API**(首选):`<WORKSPACE>/scripts/generate-image.sh`,团队AK免费无额度压力,~25s/张
2. **Seedream 5.0 Lite**(降级):`<WORKSPACE>/scripts/seedream-generate.sh`,0.22元/张,idealab不可用时
3. **DashScope qwen-image-2.0-pro**(中文文字专用):当中文渲染失败时
4. **HTML 截图**(兜底):typography 类型封面可用 HTML 精确控制文字
---
## 内容结构图(可选)
放在文章开头、封面图之后,帮读者一图看全文。
### 风格
Graphic Recording / Visual Thinking 手绘风格:
- 白纸背景,无横线
- 黑色细线笔轮廓 + 彩色标记(青色、橙色、柔和红色)
- 放射状布局,箭头连接
- 16:9 比例
### Prompt 模板
```
Create a hand-drawn sketch visual summary about [文章主题].
Clean white paper background, no lines.
Art style: graphic recording / visual thinking.
Black fine-tip pen for outlines and text.
Colored markers (cyan, orange, soft red) for emphasis.
Main title "[文章标题]" centered in a 3D rectangular box.
Surround with radially distributed simple doodles, icons, and diagrams:
- [要点1]
- [要点2]
- [要点3]
Connect ideas with arrows. Clear hand-written block letters.
Layout 16:9, high quality.
```
### 决策规则
- 文章 ≥ 3 个主要观点:建议生成
- 文章内容简单或时间紧迫:可跳过
---
## 质量检查
- [ ] 五维度组合通过兼容矩阵检查
- [ ] Prompt 已保存到 `prompts/00-cover.md`
- [ ] 中文文字清晰可读
- [ ] 颜色鲜明,吸引眼球
- [ ] 主题契合文章内容
- [ ] 视觉和文字不重叠
- [ ] 裁剪后比例正确(2.35:1)
FILE:references/illustration-prompts.md
# 正文配图指南
用于公众号文章正文中的信息图、概念图、流程图等配图。封面图见 cover-image-guide.md。
---
## 一、Type × Style × Palette 三维矩阵
插图由三个独立维度决定:**Type(画什么类型的图)**、**Style(用什么视觉风格)** 和 **Palette(配色覆盖,可选)**。
### Type(信息类型)
| Type | 适用场景 | 内容信号关键词 |
|------|---------|---------------|
| `framework` | 方法论、模型、架构、分层体系 | 架构、分层、模型、组件、系统、四大支柱 |
| `flowchart` | 流程、工作流、步骤、决策树 | 步骤、流程、先…再…然后、第一步、工作流 |
| `comparison` | 对比、前后、选择、优劣 | vs、对比、优势、劣势、区别、before/after |
| `infographic` | 数据、指标、统计、趋势 | 数字、百分比、增长、数据、指标、排名 |
| `scene` | 叙事、故事、情感、体验 | 我、经历、记得、那天、叙事语气 |
| `screenshot` | 真实产品界面、代码、终端 | 代码块、命令行、产品名+界面描述 |
### Style(视觉风格)
**快速选择(Core Styles)**:不确定用哪种风格时,从这 3 个开始:
| Core Style | 对应 | 适用 |
|------------|------|------|
| `notion-sketch` (A) | 默认首选 | 技术/产品/方法论——80% 的文章用这个 |
| `tech-flat` (B) | 数据密集 | 数据图表、架构图、需要精确信息的场景 |
| `warm-doodle` (C) | 叙事温暖 | 个人故事、教育科普、需要亲和力的场景 |
其余 Style D-I 用于特定场景,见下方详细定义。
#### Style A:notion-sketch(默认,推荐)
Notion/Linear 官方插画风手绘线条。技术/产品/方法论文章首选。
```
# 视觉风格
- 以单一主色为主(根据文章主题选择:科技蓝 #4A90D9 / 活力橙 #FF6B35 / 极客绿 #2ECC71)
- 线条粗细不均匀,像马克笔随手画的质感
- 笔触松弛、略带抖动,不追求工整
- 简笔画人物:圆润的头、点或线表示五官
- 不要彩色渐变或复杂配色
- 不要粗黑边框或生硬分隔线
- 不要 3D 效果、阴影、立体感
- 文字简化为核心关键词(3-5字),不密集堆砌
- 保持大量留白和呼吸感
```
#### Style B:tech-flat(适合数据/架构类)
极简扁平化科技信息图。
```
极简扁平化科技信息图。
配色限定为深蓝(#1E3A5F)、电光蓝(#3B82F6)、纯白、浅灰(#F1F5F9)。
线条简洁有力(2-3px均匀粗细),造型几何化干净,纯色填充,零纹理。
白色背景,无阴影,无渐变,高对比度。
中文文字采用无衬线字体,仅提取核心关键词(3-5字),绝不大段文字。
图标使用线性简笔风格,保持统一视觉语言。
整体风格:专业、克制、信息密度适中。
```
#### Style C:warm-doodle(适合故事/教育类)
白纸手绘知识图,手账/板书感。
```
白纸背景手绘知识图,无横线无网格。
黑色细线笔轮廓(像 0.5mm 中性笔手绘)。
彩色标记点缀:青色(#06B6D4)、橙色(#F97316)、柔和红色(#EF4444)。
拟人化角色承载抽象概念(详见角色映射表)。
高信息密度但用短句(3-5字标签)承载,不用长段文字。
手写体感觉的文字标注。
整体风格:亲切、可爱、信息量大但不杂乱。
```
**角色映射表(我们公众号的固定隐喻):**
- Agent → 🦞 小龙虾(OpenClaw 品牌元素)
- 记忆/知识 → 📚 小书架 / 图书馆
- 错误/Bug → 💥 爆炸星号
- 用户/PM → 圆头简笔人物(蓝色T恤)
- 数据流 → 虚线箭头 + 水滴
- Token → 小方块 / 积木
**warm-doodle 子模板:**
- **机制图**:主体居中 + 内部运作展开 + 输入输出箭头
- **对比图**:左右分区 + 中间差异标注(✓ vs ✗)
- **步骤图**:从左到右或从上到下 + 编号圆圈 + 角色引导
#### Style F:morandi-journal(莫兰迪手帐风)
适合生活感悟、感性话题、非技术类文章。
```
手绘涂鸦插画风,莫兰迪色调。
背景:暖米色纸纹(#F5F0E6)。
主色:灰绿(#7BA3A8) 用于标题和边框。
辅助色:赭石橙(#D4956A) 用于数字和高亮。
线稿:深棕炭笔(#4A4540),手绘涂鸦质感,略不规则。
装饰:和纸胶带条纹、虚线边框、角落小插画(星星、小房子、云朵)。
波浪线分割区域。手写体标题,干净手写体正文。
圆角卡片容器包裹内容项。
不要数字化精确图形、emoji、纯白背景、企业感。
横版,比例 16:9。中文。
内容:[在此描述要可视化的内容]
```
适用:个人成长感悟、温情故事、非技术类总结
---
#### Style G:subway-map(地铁线路图风)
适合复杂路线/路径的可视化。
```
地铁线路图风格信息图。
背景纯白或极浅灰。
线路用粗圆角线条(4-6px),每条线路一种颜色。
站点用圆形节点标记,换乘站用双圆圈或大圆圈。
站名用简洁无衬线标签,水平或45度倾斜。
线路仅用直线和90度/45度转弯,不用曲线。
图例在角落标注线路名称+颜色。
极简,无装饰,专注于路径和连接关系。
横版,比例 16:9。中文。
内容:[在此描述要可视化的内容]
```
适用:多路径并行的复杂流程、技能树/学习路径、多线程项目进度
---
#### Style H:chalkboard(黑板粉笔风)
适合教育科普、教学讲解。
```
黑板粉笔风格信息图。
背景:深墨绿黑板色(#2C3E2D),有微弱粉笔灰尘纹理。
文字和图形:白色粉笔(#F0F0F0)为主。
强调色:黄色粉笔(#F4D03F)、粉色粉笔(#EC7063)、蓝色粉笔(#5DADE2)。
所有线条有粉笔的粗糙颗粒质感,不均匀。
手写体文字,字迹有粗细变化像真实粉笔书写。
可以有粉笔擦除痕迹、虚线框、手绘箭头。
整体感觉像老师在黑板上边讲边画。
横版,比例 16:9。中文。
内容:[在此描述要可视化的内容]
```
适用:概念科普、原理讲解、教程类文章
---
#### Style I:ink-notes(黑白专业笔记风)
Mike Rohde sketchnoting 风格,黑白为主 + 点缀语义色。适合 manifesto、Before/After、技术宣言、框架类比。
```
专业手绘视觉笔记,纯白背景。
主色:近黑(#1A1A1A),所有线条、文字、图形均用黑墨水。
语义点缀色(占画面<10%):
珊瑚红(#E8655A) - 仅用于风险/问题/强调
柔青(#5FA8A8) - 仅用于正面/方案/解决
暗薰衣草(#9B8AB5) - 仅用于中性分类标签
线条有轻微手绘抖动(wobble),像中性笔随手画。
简笔棍状人物,头顶标注角色名(如“PM”、“Tech Lead”)。
手写体标题大而醒目,正文用手写小字标注。
大量留白,信息用短标签承载。
不要彩色渐变、3D效果、电脑字体。
横版,比例 16:9。中文。
内容:[在此描述要可视化的内容]
```
### 适用场景
- Before/After 对比(传统 vs 新方式)
- 技术 manifesto / 思维转变宣言
- 框架类比(“系统如何运作”)
- 专业视觉笔记 / 演讲摘要
---
### Palette(配色覆盖,可选第三维度)
Palette 独立于 Style,可覆盖任何 Style 的默认配色——只替换颜色,保留 Style 的线条/装饰/字体规则。不指定 Palette 时使用 Style 内置配色。
| Palette | 背景 | 主色系 | 强调色 | 适用 |
|---------|------|--------|--------|------|
| `macaron` | 暖奶油 #F5F0E8 | 马卡龙蓝 #A8D8EA、薄藤 #D5C6E0、薄荷 #B5E5CF、桃子 #F8D5C4 | 珊瑚红 #E8655A | 教育、知识科普、亲和力 |
| `warm` | 柔桃 #FFECD2 | 橙 #ED8936、陶土 #C05621、金 #F6AD55、玫瑰 #D4A09A | 赭石 #A0522D | 品牌、产品、生活方式 |
| `neon` | 深紫 #1A1025 | 青 #00F5FF、品红 #FF00FF、绿 #39FF14、粉 #FF6EC7 | 黄 #FFFF00 | 游戏、复古、高能量 |
| `mono-ink` | 纯白 #FFFFFF | 近黑 #1A1A1A(线条/文字/图形) | 珊瑚红 #E8655A(风险/强调)、柔青 #5FA8A8(正面/方案) | 专业笔记、Before/After、宣言、技术 manifesto |
**Palette 覆盖规则**:
1. 读取 Style 定义 → 获取线条/装饰/字体规则
2. 读取 Palette → 获取颜色 + 背景色
3. Palette 颜色**替换** Style 默认颜色,Style 的纹理/线条/字体保持不变
4. 在 prompt 的 COLORS 层使用 Palette 的 hex 值
**示例**:`notion-sketch + macaron` = notion-sketch 的手绘线条质感 + macaron 的柔和马卡龙配色
---
### 兼容矩阵(更新)
| Type \ Style | notion-sketch | tech-flat | warm-doodle | real-capture | mermaid | morandi | subway | chalkboard | ink-notes |
|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| `framework` | ⭐⭐ | ⭐⭐ | ⭐ | ✗ | ⭐⭐ | ⭐ | ✗ | ⭐⭐ | ⭐⭐ |
| `flowchart` | ⭐⭐ | ⭐ | ⭐⭐ | ✗ | ⭐⭐ | ✗ | ⭐⭐ | ⭐⭐ | ⭐⭐ |
| `comparison` | ⭐⭐ | ⭐⭐ | ⭐ | ⭐ | ⭐⭐ | ⭐ | ✗ | ⭐ | ⭐⭐ |
| `infographic` | ⭐ | ⭐⭐ | ✗ | ✗ | ✗ | ⭐ | ✗ | ⭐ | ⭐ |
| `scene` | ⭐ | ✗ | ⭐⭐ | ✗ | ✗ | ⭐⭐ | ✗ | ✗ | ✗ |
| `screenshot` | ✗ | ✗ | ✗ | ⭐⭐ | ✗ | ✗ | ✗ | ✗ | ✗ |
---
#### Style D:real-capture(真实截图)
不使用 AI 生图,截取真实界面 + 美化。
```bash
# 截图美化
<WORKSPACE>/scripts/beautify-screenshot.sh <input> [output] --shadow --bg "#f5f5f5"
```
### 兼容矩阵
| Type \ Style | notion-sketch | tech-flat | warm-doodle | real-capture | ink-notes |
|---|:---:|:---:|:---:|:---:|:---:|
| `framework` | ⭐⭐ | ⭐⭐ | ⭐ | ✗ | ⭐⭐ |
| `flowchart` | ⭐⭐ | ⭐ | ⭐⭐ | ✗ | ⭐⭐ |
| `comparison` | ⭐⭐ | ⭐⭐ | ⭐ | ⭐ | ⭐⭐ |
| `infographic` | ⭐ | ⭐⭐ | ✗ | ✗ | ⭐ |
| `scene` | ⭐ | ✗ | ⭐⭐ | ✗ | ✗ |
| `screenshot` | ✗ | ✗ | ✗ | ⭐⭐ | ✗ |
> ⭐⭐ 强推荐 | ⭐ 可用 | ✗ 不推荐
#### Style E:mermaid-render(结构化信息图)
用 Mermaid 语法生成 → HTML 渲染 → 浏览器截图。适合信息准确性 > 视觉美感的技术类插图。
**适用 Type**:`flowchart`、`comparison`、`framework`
**不适用**:`scene`、`infographic`、`screenshot`
**兼容矩阵补充**:
| Type \ Style | mermaid-render |
|---|:---:|
| `framework` | ⭐⭐ |
| `flowchart` | ⭐⭐ |
| `comparison` | ⭐⭐ |
| `infographic` | ✗ |
| `scene` | ✗ |
| `screenshot` | ✗ |
**选择条件**:
- ✅ 技术文章 + 内容是流程/架构/对比 + 需要信息精确可读
- ✅ 文章中有明确的步骤序列、组件关系、方案对比
- ❌ 小红书(视觉风格太"技术")
- ❌ 需要品牌感/角色/情感的配图
- ❌ 封面图
**Mermaid 渲染→截图工作流**:
1. **生成 Mermaid 代码**(遵守语法规范,见下方)
2. **写入临时 HTML**:
```bash
# 模板路径
<WORKSPACE>/scripts/mermaid-render.html
```
将 Mermaid 代码注入 HTML 模板(白底、大字号、宽画布)
3. **浏览器截图**:
```
browser action:open url:file:///tmp/openclaw/mermaid-render.html
browser action:act kind:resize width:2560 height:1440
browser action:screenshot type:png
```
4. **裁剪/优化**(如需要):`sips` 裁剪白边
5. **产出文件**放入文章的 `images/` 目录
**Mermaid 语法关键规范**(完整参考:content-collector/references/mermaid-syntax-rules.md):
- `1. ` 触发列表错误 → 用 `①`/`(1)`/去空格
- subgraph 带空格 → `subgraph id["显示名"]`
- 节点引用用 ID,不用显示文本
- 不用 Emoji
- 配色使用 `style` 声明语义色(绿=正面、红=问题、紫=处理、青=输出)
**字号建议**(公众号阅读场景):
- Mermaid 默认字号偏小,HTML 模板中设置 `fontSize: 20` + `fontFamily: 'PingFang SC', sans-serif`
- 节点文字保持 3-8 个字,长文本拆分为多行
---
## 一-B、信息图布局选择(Type 补充维度)
当 Type 为 `framework` / `infographic` / `flowchart` / `comparison` 时,可进一步选择具体的信息图布局。
→ **详见 `infographic-layouts.md`**(8 种专业布局:bento-grid、iceberg、funnel、hub-spoke、bridge、hierarchical-layers、linear-progression、dense-modules)
**选择时机**:确定 Type 后、构造 prompt 前,检查内容是否匹配某个信息图布局。匹配则在 prompt 的 Layout 层引用对应布局的 Prompt 指导词。
---
## 一-C、结构化中间层(Structured Content)
**目的**:在"配图计划"和"prompt 组装"之间,增加一个数据逐字引用的检查点,防止 AI 在生图 prompt 中篡改文章数据。
**触发条件**:当配图涉及文章中的具体数据、术语、引用时,先输出 structured-content 再组装 prompt。
**格式**:
```markdown
## 配图 N: [标题]
**Key Concept**: [一句话概括这张图要传达什么]
**Content (逐字引用原文)**:
- "[文章中的精确数据/术语,一字不改]"
- "[文章中的精确引用,保留原始格式]"
**Text Labels (图中精确文字)**:
- Headline: "[精确标题文字]"
- Labels: "[标签1]", "[标签2]", "[标签3]"
**Visual Element**:
- Type: [framework/flowchart/comparison/infographic]
- Layout: [从 infographic-layouts.md 选择,如 hub-spoke]
- Structure: [具体构图描述]
```
**逐字引用规则**:
- ✅ "73% 的 PM 认为" → 精确引用
- ❌ "大部分 PM 认为" → 不允许模糊化
- ✅ "MEMORY.md → topics/ → memory/" → 保留原始路径
- ❌ "记忆索引 → 主题 → 日志" → 不允许意译
- 所有数字、百分比、人名、产品名必须与原文一致
**工作流**:
```
配图计划表 → structured-content.md → prompt 文件(LDSCS-R 六层)
↑ 数据检查点
```
---
## 一-D、正文配图预设
预设 = Type + Style + Palette 的一键组合。选定预设后无需分别选三个维度。可覆盖单个维度(如用 `tech-explainer` 预设但换 `macaron` 配色)。
### 技术/工程类
| 预设 | Type | Style | Palette | 适用 |
|------|------|-------|---------|------|
| `tech-explainer` | infographic | tech-flat (B) | — | API 文档、系统指标、技术深度文 |
| `system-design` | framework | tech-flat (B) | — | 架构图、系统设计 |
| `architecture` | framework | notion-sketch (A) | — | 组件关系、模块结构 |
### 知识/教育类
| 预设 | Type | Style | Palette | 适用 |
|------|------|-------|---------|------|
| `knowledge-base` | infographic | notion-sketch (A) | — | 概念解释、教程(**最常用**) |
| `tutorial` | flowchart | notion-sketch (A) | — | 步骤教程、操作指南 |
| `edu-visual` | infographic | notion-sketch (A) | macaron | 知识总结、概念科普(马卡龙配色) |
| `hand-drawn-edu` | flowchart | warm-doodle (C) | macaron | 手绘教程、流程图解 |
### 数据/分析类
| 预设 | Type | Style | Palette | 适用 |
|------|------|-------|---------|------|
| `data-report` | infographic | tech-flat (B) | — | 数据分析、指标报告 |
| `versus` | comparison | notion-sketch (A) | — | 方案对比、框架 PK |
### 叙事/故事类
| 预设 | Type | Style | Palette | 适用 |
|------|------|-------|---------|------|
| `storytelling` | scene | warm-doodle (C) | — | 个人故事、成长感悟 |
| `warm-knowledge` | infographic | warm-doodle (C) | warm | 产品介绍、团队故事(暖色调) |
| `journal` | scene | morandi-journal (F) | — | 生活感悟、非技术反思 |
### 观点/编辑类
| 预设 | Type | Style | Palette | 适用 |
|------|------|-------|---------|------|
| `opinion-piece` | scene | chalkboard (H) | — | 观点文、评论、讲解 |
| `ink-manifesto` | comparison | ink-notes (I) | mono-ink | Before/After、思维转变、宣言 |
| `ink-framework` | framework | ink-notes (I) | mono-ink | 系统类比、架构图解(笔记风) |
### 内容类型 → 预设推荐
| 文章类型 | 首选预设 | 备选 |
|---------|---------|------|
| 技术深度/架构 | `tech-explainer` | `system-design`, `architecture` |
| 教程/How-to | `tutorial` | `knowledge-base`, `edu-visual` |
| 方法论/框架 | `architecture` | `system-design` |
| 数据/指标 | `data-report` | `versus`, `tech-explainer` |
| 对比/评测 | `versus` | `ink-manifesto` |
| Manifesto/思维转变 | `ink-manifesto` | `ink-framework` |
| 个人叙事 | `storytelling` | `journal` |
| 观点/评论 | `opinion-piece` | `ink-manifesto` |
| 知识科普 | `edu-visual` | `knowledge-base`, `hand-drawn-edu` |
**决策规则**(与封面图 cover-image-guide.md、小红书 presets.md 统一逻辑):
1. 扫描文章关键词,匹配上表第一个命中行
2. 取首选预设(预设已封装 Type+Style+Palette)
3. 如需覆盖单个维度,查兼容矩阵确保无 ✗
4. 混合信号时取第一个匹配,不迭加多个预设
---
## 二、内容信号自动匹配
根据段落内容关键词自动推荐 Type 和 Style:
| 内容信号 | 推荐 Type | 推荐 Style |
|---------|-----------|-----------|
| "架构"、"分层"、"模型"、"组件"、"系统" | `framework` | notion-sketch / tech-flat / **mermaid-render**(技术文优先) |
| "步骤"、"流程"、"先…再…然后"、"第一步" | `flowchart` | notion-sketch / warm-doodle / **mermaid-render**(技术文优先) |
| "vs"、"对比"、"优势"、"劣势"、"区别" | `comparison` | tech-flat / notion-sketch / **mermaid-render**(技术文优先) |
| 数字、百分比、"增长"、"数据"、"指标" | `infographic` | tech-flat |
| "我"、"经历"、"记得"、"那天"、叙事语气 | `scene` | warm-doodle |
| 代码块、命令行、产品名+界面描述 | `screenshot` | real-capture |
**mermaid-render 选择指引**:技术/产品类文章中的流程、架构、对比图,当信息精确性和可读性比视觉美感更重要时,优先选 mermaid-render。生活/故事/情感类文章不用。同一篇文章中 mermaid-render 和 AI 生图可以混用(如:架构图用 Mermaid,场景图用 warm-doodle),但不超过 2 种渲染方式。
**混合信号优先级:** screenshot > framework > flowchart > comparison > infographic > scene
**与预设的关系**:上方内容信号直接推荐 Type + Style 组合,适合精确控制。如果想更快决策,直接用「一-D 正文配图预设」的预设推荐表——预设已经封装了 Type + Style + Palette 的最佳组合。
---
## 三、自动配图位置推荐
### 定位规则(按优先级)
1. **在显式标题后**:每个 `##` 标题后的第一段,是潜在配图点
2. **在概念首次出现时**:文章引入新概念/术语时,用插图辅助解释
3. **在转折/对比处**:出现"但是"、"然而"、"相比之下"时适合对比图
4. **在总结/提炼处**:段落在总结前文多个要点时适合框架图
5. **在长文本断裂处**:连续超过 800 字无任何图片/代码块/列表时,插入视觉元素
### 配图目的分类(定位后判断)
每个配图位确定后,判断它属于哪类目的,据此选择不同 Type 和 Style:
| 目的 | 说明 | 推荐 Type | 推荐 Style |
|------|------|----------|-----------|
| **信息传递** | 图表达的是数据/结构/逻辑关系 | framework, flowchart, comparison, infographic | tech-flat, mermaid-render, notion-sketch |
| **概念隐喻** | 图表达的是抽象概念的具象化 | framework, scene | warm-doodle, notion-sketch, chalkboard |
| **情感氛围** | 图表达的是感受/场景/画面感 | scene | warm-doodle, morandi-journal |
**关键原则**(借鉴 baoyu-article-illustrator):
> **Metaphors → visualize the underlying concept, NOT the literal image.**
> 文章说"把大象装进冰箱"→ 画的是"步骤分解方法论",不是真的冰箱和大象。
### 密度选择(写作开始前确定)
| 密度级别 | 配图数 | 适用场景 | 组成 |
|---------|--------|---------|------|
| `minimal` | 1-2 张 | 时间紧/短文(<1500字)/文字本身即核心 | 封面 + 可选结构图 |
| `balanced` | 3-4 张 | **默认推荐**,大多数文章 | 封面 + 结构图 + 1-2 正文 |
| `per-section` | 每章节1张 | 长文/教程/重点文章 | 封面 + 结构图 + 每##标题一图 |
| `rich` | 5-6 张 | 视觉驱动型/重要文章 | 封面 + 结构图 + 3-4 正文 |
**参考**(密度级别与文章长度的关系):
| 文章长度 | 建议密度 | 典型配图数 |
|---------|---------|-----------|
| < 1500 字 | minimal | 1-2 张 |
| 1500-2500 字 | balanced | 3-4 张 |
| 2500-3500 字 | balanced 或 per-section | 4-5 张 |
| 3500+ 字 | per-section 或 rich | 5-6 张 |
**规则**:
1. 写作开始前,根据文章长度和重要性选择密度级别
2. 选定后记入配图计划表头部(`密度:balanced`)
3. 生图过程中不随意增减,除非老板指定
4. 硬上限仍为 **6 张**(含封面+结构图)
5. 宁缺毋滥:不确定要不要配图 → 不配
### 配图计划表输出格式
```markdown
## 配图计划
**全文风格锁定:** Style = notion-sketch | 主色 = 科技蓝 #4A90D9 | 背景 = 纯白
| # | 位置 | Type | 渲染方式 | 内容描述 | 必要性 |
|---|------|------|---------|---------|--------|
| 0 | 封面 | - | AI 生图 | 文章主题封面 | 必要 |
| 0.5 | 封面后 | framework | mermaid-render | 全文结构总览图 | 推荐 |
| 1 | §2 标题后 | framework | mermaid-render | Agent 记忆分层架构 | 必要 |
| 2 | §4 对比段 | comparison | mermaid-render | 方案 A vs B 对比 | 推荐 |
| 3 | §6 总结前 | scene | AI 生图 | 场景示意图 | 可选 |
```
**渲染方式选择**:`AI 生图`(Seedream/nano/ComfyUI)或 `mermaid-render`(Mermaid→截图)。同一篇文章可混用,但不超过 2 种渲染方式。
---
## 四、Prompt 结构化构造规范(LDSCS-R 六层)
所有插图 prompt 必须包含以下 6 层结构:
### L - Layout(布局)
**先描述构图和分区,再描述内容。**
- framework:"centered main title box, radiating outward to 4 connected sub-modules"
- flowchart:"left-to-right flow with 4 stages connected by arrows"
- comparison:"split vertically into left and right halves with a divider"
- infographic:"3 horizontal zones: top header, middle data cards, bottom summary"
### D - Data(真实数据)
**标签必须用文章中的真实术语和数字,不用占位符。**
- ✅ "MEMORY.md (index) → topics/ (details) → memory/ (daily logs)"
- ✅ "200K tokens → context explosion → session crash"
- ❌ "Layer 1 → Layer 2 → Layer 3"
- ❌ "Component A → Component B"
### S - Semantics(语义颜色)
**用颜色传达含义,不是装饰。**
- 🔴 红色/橙色 = 问题、风险、错误、告警
- 🟢 绿色 = 解决方案、效率、正面结果
- 🔵 蓝色 = 技术、系统、中性信息
- ⚫ 灰色 = 背景、次要信息、已完成
### C - Characters(角色/隐喻,仅 warm-doodle)
**将抽象概念具象化为可爱角色。**
- 参照上方角色映射表
- ⚠️ **绝不把比喻画成字面意思**:文章说"把大象装进冰箱"→ 画的是"步骤分解方法论",不是真的冰箱和大象
### S - Style(风格块)
**直接引用对应风格的标准 prompt 块**(从上方 Style A/B/C 中复制),确保全文一致。
### R - Ratio(宽高比)
- 正文插图:16:9(Seedream 用 `2560x1440`)
- 封面图:2.35:1(`2560x1440` 生成后 `sips -c 1090 2560` 裁剪)
- 结构图:16:9(`2560x1440`)
### Prompt 组装示例
```
# Layout
A hand-drawn sketch diagram showing a layered architecture.
Central rectangle labeled "Agent" at top, with 3 layers branching downward.
# Data (from article)
Top layer: "MEMORY.md" (index, < 2KB)
Middle layer: "topics/" (user-profile, projects, tools, rules)
Bottom layer: "memory/YYYY-MM-DD.md" (daily logs, events, decisions)
Arrows connecting layers, labeled "compaction writes up" and "session reads down".
# Style
[插入 notion-sketch 风格块]
# Semantics
Blue (#4A90D9) for system components, orange (#FF6B35) for data flow arrows, gray for background labels.
# Constraints
No 3D effects, no gradients, no dense text blocks.
Aspect ratio 16:9, high quality.
Do not include any photographic elements.
Do not render long sentences in the image — use short labels (3-5 characters) only.
```
---
## 四-B、Type-Specific 填空模板
LDSCS-R 是通用结构。下方是每种 Type 的**具体填空骨架**——确定 Type 后复制对应模板,填入文章真实数据。
### framework(架构/框架图)
```
[标题] — 概念框架
Layout: [hierarchical / network / matrix / radial]
(如有信息图布局匹配,引用 infographic-layouts.md 的 Prompt 指导词)
NODES:
- [概念1] — [角色/定义,逐字引用原文]
- [概念2] — [角色/定义]
- [概念3] — [角色/定义]
RELATIONSHIPS: [节点如何连接,箭头方向和含义]
LABELS: [文章原文术语,一字不改]
COLORS: [语义配色,引用 LDSCS-R 的 S 层]
STYLE: [引用锁定的风格块]
ASPECT: 16:9
```
### flowchart(流程图)
```
[标题] — 流程视图
Layout: [left-to-right / top-to-bottom / circular]
STEPS:
1. [步骤名,原文] — [简述]
2. [步骤名] — [简述]
3. [步骤名] — [简述]
(如有决策分支:Step 2 → YES: Step 3 / NO: Step 4)
CONNECTIONS: [箭头类型,实线=主流程,虚线=可选]
LABELS: [文章原文步骤名]
STYLE: [引用锁定的风格块]
ASPECT: 16:9
```
### comparison(对比图)
```
[标题] — 对比视图
LEFT SIDE - [选项A名称,原文]:
- [要点1,逐字引用]
- [要点2]
- [要点3]
RIGHT SIDE - [选项B名称,原文]:
- [要点1,逐字引用]
- [要点2]
- [要点3]
DIVIDER: [中间分隔方式:垂直线 / vs标记 / 渐变过渡]
COLORS: [左=正面色(绿/蓝),右=问题色(红/灰),或中性双色]
LABELS: [文章原文标签]
STYLE: [引用锁定的风格块]
ASPECT: 16:9
```
### infographic(数据/信息图)
```
[标题] — 数据可视化
Layout: [grid / radial / hierarchical / timeline]
(引用 infographic-layouts.md 的对应布局)
ZONES:
- Zone 1: [数据点,含精确数字,逐字引用]
- Zone 2: [对比/趋势,含指标]
- Zone 3: [总结/结论]
LABELS: [精确数字/百分比/术语,逐字引用原文]
COLORS: [语义配色]
STYLE: [引用锁定的风格块]
ASPECT: 16:9
```
### scene(氛围/叙事图)
```
[标题] — 氛围场景
FOCAL POINT: [画面主体]
ATMOSPHERE: [光照、环境、时间段]
MOOD: [要传达的情感:温暖/紧张/宁静/...]
COLOR TEMPERATURE: [warm / cool / neutral]
CHARACTERS: [如有人物,引用角色映射表]
STYLE: [引用锁定的风格块]
ASPECT: 16:9
```
**使用方式**:选定 Type → 复制对应模板 → 填入文章真实数据 → 套上 LDSCS-R 的 S/C/S/R 层 → 完整 prompt。
---
## 五、全文视觉一致性锁定
**一篇文章一套视觉参数,开始生图前确定,中途不改。**
### 锁定项(全文不变)
| 参数 | 说明 | 示例 |
|------|------|------|
| Style | 全文统一一种风格 | notion-sketch |
| 主色调 | 一个主色 | 科技蓝 #4A90D9 |
| 辅助色 | 最多 1 个辅助色 | 活力橙 #FF6B35 |
| 背景 | 统一背景色 | 纯白 / 浅灰 #F8F9FA |
| 线条 | 统一线条特征 | 不均匀手绘 2-3px |
| 人物 | 如有人物,锁定外观 | 圆头简笔画,蓝色T恤 |
| 中文字体描述 | 统一字体方向 | 无衬线,简洁 |
### 允许变化项
- **Type**:不同段落可用不同类型(framework / flowchart / comparison)
- **内容布局**:每张图的构图可以不同
- **语义颜色**:红/绿/蓝/灰 用法不变,但具体元素不同
### 风格锚点
生成第一张图后,记录成功图的关键特征作为"风格锚点"。后续图片 prompt 末尾附加:
```
Maintain visual consistency with previous illustrations:
- Same hand-drawn line style with uneven thickness
- Same color palette: primary #4A90D9, accent #FF6B35, background white
- Same minimalist Notion-style aesthetic
- Same simple stick-figure character design if people appear
```
将风格锚点保存到 `prompts/style-anchor.md`,每次生图时引用。
### Ref 图三级使用
当有参考图片(历史成功图/外部风格参考/品牌素材)时,分三级使用:
| 用法 | 说明 | 操作 |
|------|------|------|
| `direct` | 直接作为视觉参考传给生图模型 | Seedream 图生图模式传 ref |
| `style` | 只提取风格特征,不传文件 | 分析图的线条/质感/构图 → 写入 prompt |
| `palette` | 只提取配色方案,不传文件 | 吸取 3-5 个主色 hex → 写入 prompt COLORS 层 |
**决策规则**:
- 参考图和目标图**同类型**(都是封面 / 都是 framework 图) → `direct`
- 参考图**风格好但内容不同** → `style`
- 参考图**颜色好但风格不同** → `palette`
- 没有参考图 → 不用 ref,走标准 prompt 流程
**操作方式**:
- `direct`:将参考图作为 Seedream/Gemini 的 image reference 输入(需 API 支持图生图)
- `style`:在 prompt 末尾追加 `STYLE (from ref): 清爽线条,微弱阴影,...`
- `palette`:在 prompt 的 COLORS 部分用提取的 hex 值替代默认色
**典型场景**:
- 同一篇文章的第 2-N 张图 → `direct` 引用第 1 张成功图(与风格锚点配合)
- 看到某篇文章的封面风格好 → `style` 提取其视觉特征
- 品牌素材提供了色板 → `palette` 提取色值
---
## 六、禁止清单
- ❌ **不生成与内容无关的装饰性图片**——每张插图必须传递信息
- ❌ **不在图中生成长段文字**——AI 文字渲染不可靠,用短标签(3-5字)
- ❌ **不把比喻画成字面意思**——画底层概念,不画字面场景
- ❌ **不连续放 2 张以上同类型插图**——避免视觉单调
- ❌ **不用彩色渐变/3D效果/阴影**——除非特定风格要求
- ❌ **不用 emoji 替代图标**——截图时 emoji 会变成色块
- ❌ **不在同一篇文章中混用 2 种以上 Style**——视觉割裂
- ❌ **不直接传 inline prompt 给 Seedream/Gemini**——⛔ 必须先存 `prompts/NN-{type}-{slug}.md`,再引用文件内容生图。好处:失败时改文件重试不用重新构思,全部 prompt 有存档可回溯
---
## 七、生图工具优先级
**idealab Chat API → Seedream 5.0 Lite → ComfyUI**
| 工具 | 优势 | 劣势 | 使用场景 |
|------|------|------|---------|
| idealab Chat API | 团队AK免费、无额度压力、~25s/张 | 无 | **首选主力生图** |
| Seedream 5.0 Lite | 0.22元/张、无水印、质量高 | 有成本 | idealab不可用时降级 |
| ComfyUI (本地) | 无费用、无限制 | 需启动、MPS 较慢 | 兜底 |
```bash
# idealab Chat API(首选)
<WORKSPACE>/scripts/generate-image.sh "prompt" output.jpg
# Seedream(降级)
<WORKSPACE>/scripts/seedream-generate.sh "prompt" output.jpg "2560x1440" 1
```
### 批量生成策略
当一篇文章的所有 prompt 文件(`prompts/NN-*.md`)都已写好时,可批量并行生成而不是逐张串行:
**串行模式(默认)**:逐张生成,每张可即时检查效果、调整 prompt 后重试。适合首次尝试新风格。
**批量模式(推荐,prompt 已确认后)**:
- 使用 baoyu-imagine skill 的 batch mode(`build-batch.ts` → `main.ts --batchfile`)
- 或多个 Seedream 调用并行执行
**选择规则**:
- prompt 还在迭代/探索风格 → 串行
- prompt 全部定稿、只差生图 → 批量
- 同一篇文章 3 张以上配图 → 优先批量(节省 60%+ 等待时间)
---
## 八、Prompt 文件持久化
每篇文章的配图 prompt 保存为独立文件,便于回溯和复用。
### 文件结构
```
wemp-article-NN/
├── draft-v1.md
├── illustration-plan.md # 配图计划表
├── prompts/ # prompt 资产
│ ├── style-anchor.md # 风格锚点(全文一致性参数)
│ ├── 00-cover.md # 封面图 prompt
│ ├── 00-structure.md # 结构图 prompt
│ ├── 01-framework-xxx.md # 正文插图 prompt
│ ├── 02-comparison-xxx.md
│ └── 03-flowchart-xxx.md
└── images/
├── cover.jpg
├── structure.png
├── 01-framework-xxx.png
├── 02-comparison-xxx.png
└── 03-flowchart-xxx.png
```
### Prompt 文件格式
```markdown
---
type: framework
style: notion-sketch
position: §2 "记忆系统设计" 后
purpose: 展示 Agent 记忆的分层架构
ratio: 16:9
tool: seedream
---
# Prompt
[完整的生图 prompt,按 LDSCS-R 六层结构]
# 生成记录
- v1: 2026-03-15 — 文字不清晰,重新生成
- v2: 2026-03-15 — ✅ 最终采用
```
FILE:references/infographic-layouts.md
# 公众号信息图布局库
正文配图中"信息图"类型的专用布局参考。与 `illustration-prompts.md` 中的 Type 维度配合使用。
> 本文件补充 `illustration-prompts.md` 中的 `framework` / `infographic` / `flowchart` / `comparison` Type。
> 当 Type 确定后,可从本文件选择更具体的信息图布局。
---
## 布局索引
| 布局 | 适用场景 | 推荐 Style |
|------|---------|-----------|
| `bento-grid` | 多主题概览、功能模块 | notion-sketch / tech-flat |
| `iceberg` | 表面vs深层、显性vs隐性 | notion-sketch / warm-doodle |
| `funnel` | 筛选/转化、漏斗逻辑 | tech-flat |
| `hub-spoke` | 核心概念+辐射分支 | notion-sketch / tech-flat |
| `bridge` | 问题→解决方案的跨越 | notion-sketch / warm-doodle |
| `hierarchical-layers` | 分层/金字塔/优先级 | tech-flat / notion-sketch |
| `linear-progression` | 时间线/进程/阶段演变 | notion-sketch / tech-flat |
| `dense-modules` | 高密度信息大图、完全指南 | tech-flat |
---
## 布局详细定义
### bento-grid(便当盒网格)
```
结构:模块化网格,不同大小的矩形单元
特点:混合 1x1、2x1、1x2 单元,Hero 单元突出主题
视觉:清晰的单元边界,每个单元有标题+简要内容+小图标
```
**适用**:
- "5个AI能力模块"全景图
- 产品功能总览
- 文章要点概览
**Prompt 指导**:
> Modular bento-grid layout with rectangular cells of mixed sizes. One hero cell (2x2) for the main concept, surrounded by smaller cells. Each cell has a title, brief content, and a simple icon. Clear boundaries between cells.
---
### iceberg(冰山图)
```
结构:水平线分割上下两部分
上方(水面上):显而易见的、表面的(较小)
下方(水面下):隐藏的、深层的(较大,占 60-70%)
视觉:上方明亮,下方渐暗;水线清晰
```
**适用**:
- "你看到的PM工作 vs 真实PM工作"
- "AI产品 — 用户看到的 vs 背后的工程"
- 表面现象 vs 根本原因
**Prompt 指导**:
> Iceberg diagram with a clear waterline dividing visible (above, smaller, brighter) from hidden (below, larger, darker). The underwater section is 3x the size of the above-water tip. Gradient showing depth. Labels on both sides.
---
### funnel(漏斗图)
```
结构:从宽到窄的漏斗形,3-5 层递进
视觉:每层颜色渐变或区分,层间有数字/百分比标注
底部是最终输出/结果
```
**适用**:
- "从100个需求到1个MVP"
- 用户转化漏斗
- 信息筛选/决策过滤
**Prompt 指导**:
> Funnel diagram tapering from wide (top) to narrow (bottom), with 3-5 distinct layers. Each layer has a label and a number/percentage. Colors gradient from light (top) to deep (bottom). Arrow at bottom pointing to the final output.
---
### hub-spoke(轮辐图)
```
结构:中心一个核心节点,4-8 个分支向外辐射
视觉:中心节点最大最醒目,分支大小一致,连接线清晰
可选:分支可以有二级子节点
```
**适用**:
- "Agent的6种记忆类型"
- 核心概念+多维解读
- 组织架构/能力地图
**Prompt 指导**:
> Hub-and-spoke diagram with one large central node connected to 4-8 smaller peripheral nodes by straight or curved lines. Central node is the main concept. Each spoke node has a title and brief description. Clean, radial arrangement.
---
### bridge(桥梁图)
```
结构:左侧是"现状/问题",右侧是"目标/方案",中间是跨越的桥
视觉:桥梁连接两岸,桥上标注"如何跨越"的关键步骤
底部是"深渊/风险"(可选)
```
**适用**:
- "传统PM → AI时代PM的跨越"
- 问题→解决方案
- 能力差距分析
**Prompt 指导**:
> Bridge diagram connecting "Problem" (left cliff) to "Solution" (right cliff). The bridge itself is labeled with 3-4 key actions/steps needed to cross. Optional: a gap/abyss below labeled with risks of not crossing.
---
### hierarchical-layers(层级/金字塔)
```
结构:3-5 层从底到顶递增/递减
视觉:金字塔形或堆叠层,底层最宽最基础,顶层最窄最高级
每层有标题+简述
```
**适用**:
- "Skill系统的3层架构"
- 马斯洛需求层次类比
- 技术栈分层
**Prompt 指导**:
> Pyramid/layered diagram with 3-5 horizontal layers, widest at bottom (foundation) tapering to narrow at top (highest level). Each layer has a distinct color, title, and 2-3 bullet points. Clear visual hierarchy.
---
### linear-progression(线性进程)
```
结构:从左到右(或从上到下)的时间线/阶段线
视觉:3-6 个节点,每个节点是一个阶段/里程碑,箭头连接
节点可以有日期、标题、简述
```
**适用**:
- "公众号运营4周复盘"
- 产品迭代路线图
- 技术演进历史
**Prompt 指导**:
> Horizontal timeline with 3-6 milestone nodes connected by arrows from left (earliest) to right (latest). Each node has a date/label on top and a brief description below. A progress indicator or color gradient showing advancement.
---
### dense-modules(高密度模块)
```
结构:6-7 个功能型模块紧密排列,几乎无留白
模块类型:
- 品牌/选项阵列(4-8 项 + "最佳"推荐标记)
- 规格刻度(数值量表 + 质量指标)
- 深度解析(局部放大/拆解视图)
- 场景对比(3-6 个使用场景 + 推荐)
- 识别技巧(检查清单:看/测/查)
- 踩坑警示(3-5 个误区 + 后果)
- 快速参考(决策树/摘要表)
每个模块有编号/标签
```
**适用**:
- "AI PM完全指南"
- 产品选购攻略
- 完整知识体系一张图
**Prompt 指导**:
> High-density infographic with 6-7 distinct labeled modules packed tightly. Each module serves a specific purpose (selection grid, specification scale, deep-dive, scenario comparison, identification tips, warning zone, quick reference). Minimal whitespace. Each module has a coordinate label (MOD-1, MOD-2...). Information in every corner. Smaller text acceptable for density.
**注意**:这种布局信息量极大,建议配合 `tech-flat` 或 `mermaid-render` 风格,确保可读性。正文中最多用 1 次。
---
## 选择指南
| 文章内容特征 | 推荐布局 |
|-------------|---------|
| 文章介绍一个核心概念 + 多个分支/维度 | `hub-spoke` |
| 文章论述"表面看到的 vs 真正的" | `iceberg` |
| 文章有明确的层级/优先级关系 | `hierarchical-layers` |
| 文章有时间演进或阶段划分 | `linear-progression` |
| 文章是"问题→解决方案"结构 | `bridge` |
| 文章有筛选/转化/逐步减少的逻辑 | `funnel` |
| 文章需要多主题概览(5+ 主题) | `bento-grid` |
| 文章需要一张"完全指南"大图 | `dense-modules` |
FILE:references/style-guide.md
# 语言风格指南
## Voice Dimensions(量化风格锚点)
写作时以这些分数为锚点,确保风格一致。详见 `<WORKSPACE>/memory/style-runs/voice-dimensions.md`。
| Dimension | Score | 说明 |
|-----------|-------|------|
| formal_casual | **4/10** | 口语化但有逻辑 |
| technical_accessible | **6/10** | 技术用比喻,不回避深度 |
| serious_playful | **5/10** | 中间偏正经,每 200 字 1 个微幽默 |
| concise_elaborate | **5/10** | 2000-3000 字,每段 3-5 句 |
| reserved_expressive | **7/10** | 有观点敢判断,承认局限 |
## 句式
- 短句优先,单句 ≤25 字
- 多用并列结构和小标题
- 用「我们」「你」增加亲切感
- **句子节奏变化**:短句(5-10字) + 中句(15-25字) + 长句(25-35字) 交替,不允许连续 3 句同长度。朗读检查——如果读起来像背课文,节奏出了问题
## 术语处理
技术术语首次出现必须解释,优先用比喻:
- ❌ 「RAG 通过检索增强生成来提升 LLM 的准确性」
- ✅ 「RAG 就像给 AI 配了一个随时能查资料的助手——先搜索相关文档,再基于搜到的内容回答」
可以为复杂概念创造**概念把手**——3-6 字的记忆短语(如"提示词幻觉"、"工具疲劳"),首次出现时用一句话定义,之后直接用短语指代。
## 链接格式
微信公众号不支持超链接,统一用纯文本:
- ❌ `[官网](https://example.com/)`
- ✅ `官方网站:https://example.com/`
## Emoji 使用
- 小标题可以用 1 个 emoji 做标记
- 正文中极少使用,点缀即可
- 不要每句话都加 emoji
## 数据引用
- 引用数据标明来源
- 没有确切数据不要编
- 「据官方数据」「根据我的测试」要区分清楚
## 段落节奏
- 每段 3-5 句话
- 段间留白,不堆叠
- 关键结论单独成段,加粗
- **多巴胺密度**:每段至少 1 个"有意思"的点(洞见/例子/比喻/微幽默),连续 3 段没有 = 危险区域
## 微幽默
- 每 200 字至少 1 个让读者嘴角上扬的小细节
- 类型:意外的细节、自嘲式诚实、夸张但真实、有趣的词放句末
- 不是讲段子,是在正经内容中埋入轻松感
- 不能牺牲信息量
## 欧化中文自检(翻译/引用外文后必查)
翻译或大量引用英文内容后,逐条检查"翻译腔":
| # | 问题 | 表现 | 修正 |
|---|------|------|------|
| 1 | 多余连接词 | "因此/然而/此外/另外"密集出现 | 上下文已暗示关系时直接删掉 |
| 2 | 被动语态滥用 | "被/由/受到"过多 | 改主动语态("X 被 Y 影响"→"Y 影响了 X")|
| 3 | 名词堆砌 | 长定语链("基于大模型的智能化运营提效解决方案") | 拆短句 |
| 4 | "是...的"句式 | "这是一个非常重要的功能" | "这个功能很重要" |
| 5 | 过度名词化 | "进行了讨论/做出了改进" | "讨论了/改进了" |
| 6 | 多余代词 | "他/她/它/我们/你"出现频率过高 | 中文可省略主语时省略 |
**快速自测**:对着段落问——"这读起来像中国人写的,还是翻译的?"像翻译的 → 按上面 6 项逐个排查。
**触发条件**:文章素材中有 ≥30% 来自英文源 → 必须跑一遍欧化检查。
> **术语一致性**:翻译时参照 `~/.openclaw/workspace/references/glossary-ai-zh.md` 统一术语。
---
## 内容价值检查
写完后对照以下 3 条原则(来源:Lenny's Podcast 内容营销洞察):
1. **Painkiller, not vitamin**:这篇文章解决了读者的一个具体痛点吗?还是只是"了解一下也好"的维生素?痛点文章才有传播力。
2. **End the Google search**:读者读完你的文章后,还需要再去搜索同一个话题吗?如果需要,说明信息密度不够——要么加深度,要么缩窄范围。
3. **AI-assisted, not AI-generated**:人提供 information gain(独特观点、一手经验、真实案例),AI 辅助执行(结构、润色、排版)。纯 AI 生成的内容没有信息增量。
---
## 降 AI 味检查
写完后对照以下特征自查,出现 2 个以上需要改写:
- 对称句式("不仅…而且…")超过 2 次
- "然而"、"值得注意的是"等万能转折词
- "综上所述"、"总而言之"等总结套话
- "让我们一起来看看"等过度礼貌
- 动不动"第一…第二…第三…"的列表强迫症
- "应运而生"、"如火如荼"等四字成语堆砌
- "想象一下…"式假设修辞(换成真实案例)
- "令人兴奋的是"等情感注入过度
## 写作 Anti-Pattern 检查
定稿后必须过一遍 `~/.openclaw/workspace/references/writing-anti-patterns.md`:
- 🔴 绝对禁止项:命中任何一条必须改("不是X而是Y"、破折号滥用、三段式凑数等)
- 🟡 警惕区:偶尔可以,频繁出现就改
- 🟢 正面特征:确认有"我"的声音、具体场景、节奏变化
- 10 秒快扫:开头/破折号/此外然而/三段式/AI味
## 文章只输出正文
- ✅ 只保留:标题 + 封面图 + 正文 + 结尾
- ❌ 不添加:「参考资料」「图片说明」「延伸阅读」「优缺点总结表」
FILE:references/weixin-constraints.md
# 微信公众号平台限制
## CSS 限制
微信编辑器**不支持**:
- 外部 CSS 文件(必须内联)
- `position: fixed/sticky`
- `@keyframes` 动画
- `calc()` 函数
- CSS 变量 `var()`
- `flexbox`(部分支持,不稳定)
- `grid` 布局
**支持的常用属性**:
- `color`, `background-color`, `background` 渐变
- `font-size`, `font-weight`, `line-height`, `letter-spacing`
- `margin`, `padding`, `border`, `border-radius`
- `text-align`, `text-indent`
- `display: inline/block/inline-block`
- `box-shadow`(部分客户端)
- `max-width`, `width`(百分比和 px)
## 图片限制
- 不支持本地图片路径,必须上传到微信素材库
- 封面图建议 900×500(2.35:1 也可)
- 单图 ≤ 2MB
- 支持格式:jpg/jpeg/png/gif
- 文章内图片会被微信压缩
## 内容限制
- 不支持 JavaScript
- 不支持 iframe
- 不支持外部链接(超链接只能链接到其他公众号文章或小程序)
- 所有链接在正文中用纯文本展示
- 不支持视频直接嵌入(需用微信视频功能)
## 排版建议
- 正文字号:15-17px
- 行高:1.75-2.0
- 段间距:通过 margin-bottom 控制
- 代码块用 `pre + code` + 背景色 + 内联样式
- 表格列数 ≤ 4(移动端友好)
- 全文内联样式写在每个标签的 `style` 属性中
## 外链处理规则
微信公众号不支持外链点击跳转。文章中的外部链接按以下规则处理:
### 处理分类
| 链接类型 | 处理方式 |
|---------|---------|
| `mp.weixin.qq.com` | 保持原样(微信内部互链可跳转) |
| 普通外链(博客/GitHub/文档等) | 转为底部引用格式 |
| 裸链接(链接文字=URL本身) | 保持 inline 纯文本 |
### 转换格式
**正文中**:在引用处加上标序号
```
参考 Simon Willison 的文章[1],以及 Anthropic 的 Skills 文档[2]。
```
**文末新增**"引用链接"区:
```
---
**引用链接**
[1] Agentic Engineering Patterns - https://simonwillison.net/2025/...
[2] Claude Code Skills - https://docs.anthropic.com/en/docs/...
```
### 执行时机
Editor Agent 写完文章后、推草稿箱前:
1. 扫描正文所有外部链接
2. 排除 `mp.weixin.qq.com` 和裸链接
3. 按出现顺序编号 [1][2][3]...
4. 正文中替换为 `文字[N]`
5. 文末追加"引用链接"区
6. 微信 API 推送时使用处理后的版本
---
## API 限制
- access_token 每日获取上限 2000 次(工具自动缓存)
- 草稿 API 无频率限制
- 群发消息:订阅号 1 次/天,服务号 4 次/月
- 素材上传:单文件 ≤ 2MB(图片)、≤ 10MB(视频)
FILE:references/writing-sop.md
# 写作 SOP
## 写作前
1. **明确内容类型**(见 article-templates.md)
2. **确认素材充足**:至少 2 篇高质量参考来源 + 收藏库相关素材
3. **确定文章角度**:这篇文章给读者的核心价值是什么?用一句话说清
4. **搜索个人素材库**:从 `collections/` 中检索相关素材,标记可用的真实经历/观点/案例(详见 writing-techniques.md §五)
## 写作流程
### 1. 搜索与素材准备
搜索策略(2-4 轮):
- 第 1 轮:官方信息(官网、GitHub、官方博客)
- 第 2 轮:深度分析(技术博客、评测文章)
- 第 3 轮:对比与讨论(竞品对比、社区讨论)
- 第 4 轮(按需):补充验证
优先来源:官方文档 > 少数派/掘金/Medium > 知乎/即刻讨论 > 技术博客
提取要点:
- 核心功能和特性
- 真实使用场景和效果
- 优势和局限
- 不同观点和争议
### 2. 创意排水(正式动笔前必做)
在构思框架之前,先执行创意排水(详见 writing-techniques.md §一):
1. 花 2-3 分钟快速写出"不过脑子的话我会怎么写"——把第一反应、套路想法、陈词滥调全倒出来
2. 标记其中的废水模式(时代感叹句?万能过渡?结论套话?)
3. 把废水清单作为正式写作的"禁用列表"
> 这一步的目的是从源头降 AI 味。事后替换同义词是治标,创意排水是治本。
### 3. 构思框架
根据内容类型选择模板(见 article-templates.md),确定:
- 开头用什么钩子引入(必须遵循强开头原则,见 writing-techniques.md §二.2)
- 正文的 3-4 个主要段落各讲什么
- 结尾给读者什么行动建议
- 哪些地方可以插入收藏库的真实素材
### 4. 写作
核心原则:
- **用自己的语言重新组织**,绝不照搬原文
- **实战导向**:少讲「是什么」,多讲「怎么用」「踩过什么坑」
- **有观点有立场**:不做信息搬运工
- **面向普通用户**:技术术语必须解释,多用比喻
写作中注意(详见 writing-techniques.md §二):
- **微幽默**:每 200 字至少 1 个让读者嘴角上扬的小细节
- **概念把手**:为复杂概念创造 3-6 字的记忆短语,每篇至少 1-2 个
- **句子节奏**:短/中/长句交替,不允许连续 3 句同长度
- **多巴胺密度**:每段至少 1 个"有意思"的点,连续 3 段没有 = 危险区域
### 5. 标题生成(双轨制)
按 writing-techniques.md §四 的双轨制生成标题:
1. 先出 3-5 个爆款标题(金钱数字/暴力隐喻/悬念等要素)
2. 再出 3-5 个自然风格标题(经验分享/观点输出/对比评测等类型)
3. 可选:组合优化(自然标题 + 注入 1-2 个爆款要素)
4. 推荐 Top 3 给老板选择
### 6. 三遍审校
按 writing-techniques.md §三 执行结构化审校:
**第一遍 — 内容审校:**
- [ ] 所有数据/事实有出处或标注为个人经验
- [ ] 论点之间有因果或递进关系
- [ ] 段落迷你论点串联测试通过(每段核心论点串起来 = 文章逻辑摘要)
- [ ] 没有前后矛盾的说法
**第二遍 — 风格审校(降 AI 味):**
- [ ] 对照 AI 味特征清单逐条检查(对称句式、转折万能词、总结套话、列表强迫症等)
- [ ] 创意排水阶段标记的废水模式没有出现
- [ ] 用第一人称叙述,有真实场景
- [ ] 口语化但有逻辑
**第三遍 — 细节打磨:**
- [ ] 句子节奏分布检查:无连续 3+ 句同长度
- [ ] 多巴胺密度标记:无连续 3 段空白
- [ ] 微幽默检查:全文 ≥ 3 个,分布均匀
- [ ] 概念把手确认:至少 1 个原创概念把手
- [ ] 字数 2000-3000 字
格式检查:
- [ ] 链接使用纯文本(不用 markdown 超链接)
- [ ] 没有「参考资料」「图片说明」等额外章节
- [ ] 正文从 H2 开始(不出现 H1)
## 写作禁忌
- ❌ 大段照搬原文
- ❌ 无观点的信息搬运
- ❌ 堆砌技术术语不解释
- ❌ 「Great question!」式的废话开头
- ❌ 面面俱到但什么都没讲深
- ❌ "在当今 XX 的时代…"等时代感叹句开头
- ❌ 连续使用"然而"、"值得注意的是"等 AI 味转折词
- ❌ 编号列表贯穿全文(叙事优先,编号点缀)
- ❌ 四字成语堆砌("应运而生"、"如火如荼")
FILE:references/writing-techniques.md
# 写作技巧与审校方法论
## 一、创意排水(Creative Drainage)
写正式初稿前的"排毒"环节,从源头降 AI 味。
### 什么是创意排水
在动笔写正式内容之前,先花 3-5 分钟快速"倒一遍垃圾"——把脑子里关于这个主题的套路想法、陈词滥调、第一反应全写出来。写完标记为"废水",然后在正式写作中刻意避开。
### 操作步骤
1. **快速倾倒**:看到选题后,用 2-3 分钟写出"如果不过脑子,我会怎么写这篇文章"的要点
2. **标记废水模式**:识别其中的套路——是不是每篇 AI 文章都这样开头?是不是所有人都在讲同一个角度?
3. **反向约束**:在正式写作时,把废水清单作为"禁用列表"
### 典型废水模式(AI/科技领域)
| 废水类型 | 示例 | 为什么是废水 |
|----------|------|------------|
| 时代感叹句 | "在 AI 飞速发展的今天…" | 每篇文章都这么写,零信息量 |
| 万能过渡 | "接下来让我们看看…" | 读者不需要你导航 |
| 结论先行套话 | "总结一下,XX 有以下三个优势…" | 教科书味,不是人话 |
| 情绪堆砌 | "真的太强了!颠覆性的!" | 夸张到失去可信度 |
| 全面罗列 | "功能包括 A、B、C、D、E…" | 说明书,不是文章 |
| 伪第一人称 | "我认为这是一个重要的里程碑" | 换个主语也成立 = 没有个人视角 |
### 检查标准
- [ ] 写作前执行了创意排水(至少列出 3 条废水模式)
- [ ] 正式稿中没有出现废水清单里的模式
- [ ] 开头不是"时代感叹句"类型
---
## 二、五大写作技巧
### 1. 微幽默(Microhumor)
不是讲段子,是在正经内容中埋入让读者嘴角微扬的小细节。
**密度要求**:每 200 字至少 1 个微幽默点。
**四种类型:**
| 类型 | 示例 |
|------|------|
| 意外的细节 | "配置完 RAG 后我很自信地跑了第一个 query——它返回了一段我三年前写的周报" |
| 自嘲式诚实 | "当然,花了三小时调 prompt 才跑通的我可能不是最佳评测人选" |
| 夸张但真实 | "Claude 的 context window 大到可以塞进一整本《战争与和平》——虽然它读完后给出的总结是'挺长的'" |
| 有趣的词放句末 | "这个功能在理论上很完美,在实践中……也就那样" |
**检查方法**:通读全文,标记每个可能让读者微笑的点。如果连续 400 字没有标记,考虑加入。
### 2. 强开头原则
**禁止使用的开头模式:**
- ❌ "在当今 AI/数字化/信息爆炸的时代…"
- ❌ "随着 XX 技术的不断发展…"
- ❌ "众所周知…"
- ❌ "今天我们来聊聊…"
**四个强开头公式:**
| 公式 | 示例 |
|------|------|
| 具体事件 | "上周三凌晨两点,我的 Agent 连续跑了 47 次 API 调用后终于吐出了正确答案。" |
| 钩子悬念 | "我花了 200 块 API 费用,让 AI 帮我做了一件 5 分钟就能手动完成的事。" |
| 反常识 | "Prompt Engineering 可能是过去一年被高估最多的技能。" |
| 直接提问 | "你有没有过这种经历——给 AI 写了一大段 prompt,结果它回了一句'好的,我来帮你'然后什么都没做?" |
### 3. 概念把手(Concept Handles)
用 3-6 个字的短语总结一个复杂概念,让读者记住并传播。
**要求**:每篇文章至少创造 1-2 个概念把手。
**示例:**
- "创意排水" ← 写正式内容前先倒掉套路想法
- "多巴胺密度" ← 每段至少一个有意思的点
- "提示词幻觉" ← 以为 prompt 写得好但实际 AI 没理解
- "工具疲劳" ← 试了太多 AI 工具反而效率下降
**创造方法**:
1. 找到文章中需要反复解释的概念
2. 压缩到 3-6 个字,优先用比喻或日常词汇
3. 首次出现时用一句话定义,之后直接用短语指代
### 4. 句子节奏变化
**三种句长交替使用:**
- 短句:5-10 字(制造停顿感、强调)
- 中句:15-25 字(承载主要信息)
- 长句:25-35 字(展开论述、补充细节)
**规则**:不允许连续 3 句同长度级别。
**反面示例:**
> 这个工具非常好用。界面设计很简洁。功能也比较齐全。操作流程很顺畅。
(连续四个短句,像机器人在报告。)
**正面示例:**
> 这个工具好用。界面那种"少即是多"的克制感,让我第一次打开就知道该点哪里——不需要看教程,不需要找按钮,鼠标自然而然就落在了正确的位置。真正的好设计就是这样。
(短-长-短,有呼吸感。)
**检查方法**:大声朗读全文。如果某一段读起来像在背课文,就是节奏出了问题。
### 5. 多巴胺密度
**定义**:每段文字中"让读者觉得有意思"的密度。
**要求**:每段至少 1 个多巴胺触发点。连续 3 段没有 = 危险区域。
**多巴胺触发点类型:**
- 🎯 **洞见**:读者没想到的角度或结论
- 📖 **具体例子**:真实的、有细节的案例
- 🔄 **好的比喻**:把抽象概念变具体
- 😄 **微幽默**:见上文
- 📊 **有冲击力的数据**:不是罗列,是让人"哇"的数字
- ❓ **反问/设问**:激活读者思考
**检查方法**:逐段标注多巴胺触发点类型。如果某段标注不出来,就需要加料。
---
## 三、三遍审校法
### 第一遍:内容审校
检查事实、逻辑、结构的正确性。
**清单:**
- [ ] 所有数据/事实有出处或标注为个人经验
- [ ] 论点之间有因果或递进关系,不是散点罗列
- [ ] 每个段落有且只有一个核心论点
- [ ] 没有前后矛盾的说法
**段落迷你论点串联测试:**
1. 在每段旁边标注该段的核心论点(一句话)
2. 把所有核心论点串成一段
3. 这段话应该是文章的逻辑摘要
4. 如果串起来不通顺 → 文章结构有问题,调整段落顺序或补充过渡
**示例:**
```
[段落1] 核心论点:我发现 Cursor 在处理大型项目时比 Copilot 更好用
[段落2] 核心论点:关键原因是 Cursor 能理解项目级别的上下文
[段落3] 核心论点:但这种理解有边界,超过 10 个文件就开始降级
[段落4] 核心论点:我的解决方案是手动划定上下文范围
[段落5] 核心论点:这个经验说明 AI 编程工具的关键瓶颈是上下文管理
→ 串起来:Cursor 处理大型项目比 Copilot 好用,因为它能理解项目级上下文,但超过 10 个文件会降级,我通过手动划定范围解决,这说明 AI 编程工具的瓶颈是上下文管理。
→ ✅ 逻辑通顺,层层递进。
```
### 第二遍:风格审校(降 AI 味)
对照 AI 味特征清单逐条检查。来源:实战积累 + [WikiProject AI Cleanup](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing) 24 条模式提炼。
#### 5 条核心原则(速查)
1. **删除填充短语** — 去除开场白和强调性拐杖词
2. **打破公式结构** — 避免二元对比、戏剧性分段、修辞性设置
3. **变化节奏** — 混合句子长度。两项优于三项。段落结尾要多样化
4. **信任读者** — 直接陈述事实,跳过软化、辩解和手把手引导
5. **删除金句** — 如果听起来像"可引用的语句",重写它。好的写作不需要精心打磨的收尾金句
#### AI 味特征清单
**A. 句式与结构(最容易暴露)**
| # | 特征 | 示例 | 改写方向 |
|---|------|------|---------|
| 1 | 对称句式 | "不仅…而且…"、"既…又…" 超过 2 次 | 打破对称,用不规则表达 |
| 2 | 否定式排比 | "这不仅仅是…,而是…"、"不是…,是…" | 直接说正面意思,删掉否定铺垫 |
| 3 | 三段式法则 | 强行凑三组"A、B 和 C",如"创新、灵感和行业洞察" | 改为两项或四项,打破三段式节奏 |
| 4 | 列表强迫症 | 动不动 "第一…第二…第三…" | 用叙事串联,减少编号 |
| 5 | 提纲式挑战/展望 | "尽管面临挑战…但依然…"、"未来可期" | 删掉,或用具体事实替代 |
**B. 词汇与表达(高频 AI 词)**
| # | 特征 | 示例 | 改写方向 |
|---|------|------|---------|
| 6 | AI 高频词 | 此外、至关重要、深入探讨、格局、关键性的、展示、充满活力的、宝贵的、增强、培养 | 用日常用语替代或直接删除 |
| 7 | 意义膨胀词 | 标志着、见证了、作为…的体现/证明、为…奠定基础、不可磨灭的印记、深深植根于 | 直接说"是什么",不说"代表什么" |
| 8 | 宣传式语言 | 坐落于、充满活力的、令人叹为观止的、开创性的、著名的 | 事实描述替代形容词堆砌 |
| 9 | 四字成语堆砌 | "应运而生"、"如火如荼"、"方兴未艾" | 用大白话替代 |
| 10 | 转折万能词 | "然而"、"不过值得注意的是" | 换成口语:"但说实话"、"话说回来" |
| 11 | 总结套话 | "综上所述"、"总而言之" | 删掉,直接说结论 |
| 12 | 填充短语 | "为了实现这一目标"→"为了"、"值得注意的是…"→删掉、"在这个时间点"→"现在" | 压缩到最短自然表达 |
**C. 语气与风格(无灵魂的干净)**
| # | 特征 | 示例 | 改写方向 |
|---|------|------|---------|
| 13 | 过度礼貌/导航 | "让我们一起来看看"、"接下来我们讨论" | 删掉导航词,直接切入 |
| 14 | 假设性修辞 | "想象一下…" | 换成真实案例:"上周我就碰到了…" |
| 15 | 情感注入过度 | "令人兴奋的是"、"值得一提的是" | 删掉修饰,让内容本身说话 |
| 16 | 模糊归因 | "专家认为"、"行业报告显示"、"有人指出" | 给出具体来源,或标注为个人观点 |
| 17 | 过度限定 | "可以潜在地可能被认为…" | 直说:"该政策可能会影响结果" |
| 18 | 谄媚语气 | "好问题!"、"您说得完全正确!" | 删掉,直接回应内容 |
| 19 | 系动词回避 | 用"作为/充当/代表"替代简单的"是" | 该用"是"就用"是" |
| 20 | 同义词循环 | 主人公→主要角色→中心人物→英雄 | 固定一个称呼,不刻意换词 |
**D. 排版细节**
| # | 特征 | 示例 | 改写方向 |
|---|------|------|---------|
| 21 | 破折号过度 | 一段话 3+ 个破折号 | 改用逗号/句号,破折号每段最多 1 个 |
| 22 | 粗体轰炸 | 每句话都有粗体词 | 一段最多 1-2 处粗体 |
| 23 | 内联标题列表 | "**用户体验:** 用户体验通过…" | 合并成一段自然叙述 |
| 24 | 虚假范围 | "从 X 到 Y"但 X 和 Y 不在同一尺度 | 直接列举,不伪造跨度感 |
#### 灵魂注入指导
降完 AI 味后,文章可能变得"干净但无灵魂"。好的写作背后有一个真人在思考。
**6 条灵魂原则:**
1. **有观点** — 不要只罗列事实,要对事实做出反应。"我真的不知道该怎么看待这件事"比中立列利弊更有人味
2. **变节奏** — 短句有力。然后是需要时间慢慢展开的长句。混合使用。(与"句子节奏变化"技巧呼应)
3. **承认复杂性** — 真实的人有混合感受。"这令人印象深刻但也有点不安"胜过单一评价
4. **适当使用"我"** — 第一人称不是不专业,是诚实。"我一直在想……"表明有真人在思考
5. **允许一些混乱** — 完美的结构感觉像算法。跑题、题外话和半成型的想法是人性的体现
6. **对感受要具体** — 不是"这令人担忧",而是"凌晨三点没人看着的时候,Agent 还在不停跑,这让人不安"
**改写示例(灵魂注入前后):**
无灵魂(干净但死板):
> 实验产生了有趣的结果。智能体生成了 300 万行代码。一些开发者印象深刻,另一些则持怀疑态度。影响尚不明确。
有灵魂(鲜活):
> 300 万行代码,在人类大概睡觉的时候生成的。开发社区有一半人疯了,另一半人在解释为什么这不算数。真相可能在无聊的中间某处——但我一直在想那些通宵工作的智能体。
#### 快速自查(交稿前 30 秒扫一遍)
- [ ] 连续三个句子长度相同?→ 打断其中一个
- [ ] 段落以简洁的单行结尾?→ 变换结尾方式
- [ ] 揭示前有破折号?→ 删除它
- [ ] 解释隐喻或比喻?→ 相信读者能理解
- [ ] 使用了"此外""然而"等连接词?→ 考虑删除
- [ ] 三段式列举?→ 改为两项或四项
- [ ] 听起来像"可引用的金句"?→ 重写,让它更随意
#### 质量评分(5 维 × 10 分 = 50 分)
审校完成后,对文本进行自评:
| 维度 | 评估标准 | 得分 |
|------|---------|------|
| **直接性** | 直接陈述事实还是绕圈宣告?10 分 = 直截了当,1 分 = 充满铺垫 | /10 |
| **节奏** | 句子长度是否变化?10 分 = 长短交错,1 分 = 机械重复 | /10 |
| **信任度** | 是否尊重读者智慧?10 分 = 简洁明了,1 分 = 过度解释 | /10 |
| **真实性** | 听起来像真人说话吗?10 分 = 自然流畅,1 分 = 机械生硬 | /10 |
| **精炼度** | 还有可删减的内容吗?10 分 = 无冗余,1 分 = 大量废话 | /10 |
| **总分** | | /50 |
**标准:** 45-50 分 = ✅ 优秀,可交付 | 35-44 分 = ⚠️ 良好,仍有改进空间 | <35 分 = 🔴 需要重新修订
**改写示例(综合应用):**
原文(AI 味重):
> 在当今 AI 技术飞速发展的时代,如何有效地利用大语言模型来提升工作效率,已经成为了每一位知识工作者不得不面对的重要课题。让我们一起来看看三个关键策略。
改写(人味):
> 上个月我用 Claude 写了一份 30 页的竞品分析报告。整个过程花了 4 小时——以前同事做这事至少要两天。但中间踩了不少坑,今天聊聊我摸索出来的几个实用套路。
### 第三遍:细节打磨
**检查项:**
1. **句子节奏分布**
- 统计短/中/长句比例,理想比例约 2:5:3
- 检查是否有连续 3+ 句同长度
- 朗读检查:有没有"背课文"的段落
2. **多巴胺密度标记**
- 逐段标注多巴胺触发点
- 连续 3 段空白 = 必须加料
- 重点检查文章中段(最容易无聊的区域)
3. **微幽默检查**
- 全文微幽默点 ≥ 3 个
- 分布均匀,不集中在开头
- 幽默不能牺牲信息量
4. **概念把手确认**
- 至少 1 个原创概念把手
- 首次出现有清晰定义
- 后续引用时一致使用
---
## 四、爆款标题双轨制
### 第一轮:爆款标题(生成 3-5 个)
基于 7 大爆款要素:
| 要素 | 说明 | 示例 |
|------|------|------|
| 💰 金钱数字 | 具体金额、收益、成本 | "省了 2000 块 API 费用" |
| 🔥 暴力隐喻 | 冲击力动词 | "碾压"、"干翻"、"吊打" |
| ☠️ 死亡/危机 | 紧迫感、淘汰焦虑 | "还不会用就晚了"、"正在被淘汰" |
| ⚡ 捷径效率 | 省时省力、快速见效 | "3 步搞定"、"10 分钟上手" |
| ❓ 异常悬念 | 违反直觉、引发好奇 | "我为什么反对…"、"没人告诉你的…" |
| 📊 具体数字 | 量化结果 | "效率提升 300%"、"测了 50 个工具" |
| 🎭 身份认同 | 读者群体标签 | "产品经理必看"、"程序员都在用" |
### 第二轮:自然风格标题(生成 3-5 个)
回归文章本质,8 种类型:
| 类型 | 公式 | 示例 |
|------|------|------|
| 经验分享 | 我用 X 做了 Y | "我用 Claude 写了一个月的周报" |
| 工具推荐 | X:一句话定位 | "Cursor:让 AI 真正帮你写代码的编辑器" |
| 观点输出 | 我对 X 的看法 | "为什么我觉得 Agent 是被高估的" |
| 问题解答 | 怎么用 X 做 Y | "怎么让 GPT 写出不像 AI 的文案" |
| 对比评测 | X vs Y:Z 视角 | "Claude 3.5 vs GPT-4o:产品经理实测" |
| 趋势观察 | X 说明了什么 | "Devin 出来一个月了,说明了什么" |
| 避坑指南 | X 的 N 个坑 | "用 AI 写公众号踩过的 5 个坑" |
| 思考复盘 | 关于 X 的思考 | "做了半年 AI 产品,我想清楚了一件事" |
### 第三轮(可选):组合优化
在自然标题基础上注入 1-2 个爆款要素:
```
自然标题:我用 Claude 写了一个月的周报
+ 金钱数字 → 我用 Claude 写了一个月的周报,省了 40 小时
+ 反常识 → 我用 Claude 写了一个月的周报,发现手写更好的场景
+ 具体数字 → 我用 Claude 写了 30 篇周报后,总结了 5 个关键技巧
```
### 标题最终选择建议
- 产出 6-10 个候选标题(两轮 + 可选组合)
- 按以下维度评分(1-5 分):好奇心激发、信息量、可信度、与内容匹配度
- 推荐 Top 3 给老板选择
---
## 五、个人素材库联动
### 为什么重要
AI 生成内容最大的问题不是语法,是"没有自己的经历和观点"。收藏库里的素材是让文章有个人色彩的关键。
### 使用时机
| 场景 | 怎么用收藏素材 |
|------|--------------|
| 文章开头 | 用真实经历/发现引入,替代"在 AI 时代…"式开头 |
| 观点支撑 | 引用收藏的文章、评论、数据来支撑个人观点 |
| 案例展示 | 用收藏的真实项目、工具测评作为案例 |
| 反面论据 | 用收藏的不同观点来展示思辨深度 |
| 结尾回扣 | 引用收藏的精彩观点/金句来强化结尾 |
### 操作方法
```bash
# 1. 按标签搜索相关素材
grep -i "关键词" <WORKSPACE>/collections/tags.md
# 2. 全文搜索
grep -ril "关键词" <WORKSPACE>/collections/
# 3. 读取素材详情,提取可用内容
# 重点关注:核心观点、个人笔记/批注、真实数据
```
### 融入原则
- 自然引入,不是"根据 XX 文章指出…"的学术式引用
- 用自己的话重新表述,加入个人理解
- 标注来源但不需要严格引用格式(公众号不是论文)
- 优先使用带有个人笔记/评论的素材(已经消化过的内容)
FILE:scripts/check_comments.mjs
#!/usr/bin/env node
/**
* 评论检查 - 扫描最近文章的新评论
*/
import {
output, outputError, parseArgs,
listPublished, listComments
} from './lib/utils.mjs';
async function checkComments(articleCount = 5) {
console.error(`[评论] 检查最近 articleCount 篇文章的评论...\n`);
// 获取已发布文章
const published = await listPublished(0, articleCount);
const articles = published.item || [];
if (!articles.length) {
console.error('没有已发布的文章。');
return { articles: [], totalComments: 0, unreplied: 0 };
}
const results = [];
let totalComments = 0;
let unreplied = 0;
for (const article of articles) {
const newsItem = article.content?.news_item?.[0];
const title = newsItem?.title || '未知标题';
const msgDataId = article.article_id;
if (!msgDataId) continue;
try {
const commentsData = await listComments(msgDataId, 0, 0, 50, 0);
const comments = commentsData.comment || [];
const total = commentsData.total || comments.length;
const commentList = comments.map(c => ({
id: c.user_comment_id,
content: c.content,
user: c.nick_name || '匿名',
time: c.create_time ? new Date(c.create_time * 1000).toLocaleString('zh-CN') : '',
isElected: !!c.is_elected,
hasReply: !!(c.reply?.content),
replyContent: c.reply?.content || '',
}));
const unrepliedComments = commentList.filter(c => !c.hasReply);
totalComments += total;
unreplied += unrepliedComments.length;
results.push({
articleId: msgDataId,
title,
totalComments: total,
unrepliedCount: unrepliedComments.length,
comments: commentList,
});
// 输出摘要
console.error(`📝 《title》`);
console.error(` 评论: total 条, 未回复: unrepliedComments.length 条`);
if (unrepliedComments.length) {
for (const c of unrepliedComments.slice(0, 3)) {
console.error(` 💬 c.user: c.content.slice(0, 50)''`);
}
}
console.error('');
} catch (e) {
console.error(` ⚠️ 《title》评论获取失败: e.message\n`);
}
}
console.error(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
console.error(`总计: totalComments 条评论, unreplied 条未回复`);
return { articles: results, totalComments, unreplied };
}
async function main() {
const args = parseArgs();
const count = parseInt(args.count) || 5;
try {
const result = await checkComments(count);
output(true, result);
} catch (error) { outputError(error); }
}
main();
FILE:scripts/daily_report.mjs
#!/usr/bin/env node
/**
* 公众号日报
*/
import {
loadConfig, output, outputError, parseArgs, formatDate, getYesterday, calcChangeRate,
readData, writeData,
getUserSummary, getUserCumulate, getArticleSummary, getUpstreamMsg, listPublished
} from './lib/utils.mjs';
async function generateDailyReport(date) {
const config = loadConfig();
const reportDate = date || getYesterday();
console.error(`[日报] 生成 reportDate 的日报...`);
// 用户数据
let newUsers = 0, cancelUsers = 0;
try {
const r = await getUserSummary(reportDate);
for (const i of (r.list || [])) { newUsers += i.new_user || 0; cancelUsers += i.cancel_user || 0; }
} catch (e) { console.error(`[日报] 用户数据失败: e.message`); }
const netGrowth = newUsers - cancelUsers;
// 累计用户
let totalUsers = 0;
try {
const r = await getUserCumulate(reportDate, reportDate);
if (r.list?.length) totalUsers = r.list[0].cumulate_user || 0;
} catch (e) { console.error(`[日报] 累计用户失败: e.message`); }
// 文章数据
let totalRead = 0, totalShare = 0;
try {
const r = await getArticleSummary(reportDate);
for (const i of (r.list || [])) { totalRead += i.int_page_read_count || 0; totalShare += i.share_count || 0; }
} catch (e) { console.error(`[日报] 文章数据失败: e.message`); }
// 消息数据
let newMessages = 0;
try {
const r = await getUpstreamMsg(reportDate);
for (const i of (r.list || [])) { newMessages += i.msg_count || 0; }
} catch (e) { console.error(`[日报] 消息数据失败: e.message`); }
// 已发布文章
let topArticles = [];
try {
const r = await listPublished(0, 10);
topArticles = (r.item || []).slice(0, config.analytics?.topArticles || 5).map((item, idx) => ({
rank: idx + 1, title: item.content?.news_item?.[0]?.title || '未知标题'
}));
} catch (e) { console.error(`[日报] 已发布文章失败: e.message`); }
// 历史对比
const history = readData('daily-history.json', { reports: [] });
const last = history.reports?.length ? history.reports[history.reports.length - 1] : null;
const growthRate = last ? calcChangeRate(netGrowth, last.netGrowth) : '-';
const readChange = last ? calcChangeRate(totalRead, last.totalRead) : '-';
// AI 洞察
let insight = '数据平稳,建议持续优化内容策略。';
if (netGrowth > 10) insight = `今日净增 netGrowth 位粉丝,增长势头强劲!`;
else if (netGrowth > 0) insight = `今日净增 netGrowth 位粉丝,保持良好增长。`;
else if (netGrowth < 0) insight = `今日净流失 Math.abs(netGrowth) 位粉丝,建议关注内容质量和推送时间。`;
const reportData = { date: reportDate, newUsers, cancelUsers, netGrowth, growthRate, totalUsers, totalRead, totalShare, readChange, topArticles, newMessages, insight };
// 保存历史
if (!history.reports) history.reports = [];
history.reports.push({ date: reportDate, netGrowth, totalRead, totalUsers, generatedAt: new Date().toISOString() });
if (history.reports.length > 30) history.reports = history.reports.slice(-30);
writeData('daily-history.json', history);
// 生成报告文本
const lines = [
`📊 **公众号日报** (reportData.date)`, `━━━━━━━━━━━━━━━━━━━━━━━━━━━━`, '',
`**👥 用户数据**`,
`• 新增关注: +newUsers`, `• 取消关注: -cancelUsers`,
`• 净增长: ''netGrowth (growthRate)`,
`• 累计粉丝: totalUsers.toLocaleString()`, '',
`**📖 阅读数据**`,
`• 总阅读: totalRead.toLocaleString() 次 (readChange)`,
`• 总分享: totalShare 次`,
];
if (topArticles.length) {
lines.push('', `**🔥 热门文章**`);
topArticles.forEach(a => lines.push(`a.rank. 《a.title》`));
}
lines.push('', `**💬 互动数据**`, `• 新消息: newMessages 条`, '', `**💡 AI 洞察**`, insight, '', `━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
return { report: lines.join('\n'), data: reportData };
}
async function main() {
const args = parseArgs();
try {
const { report, data } = await generateDailyReport(args.date);
console.error('\n' + report);
output(true, { report, data });
} catch (error) { outputError(error); }
}
main();
FILE:scripts/fetch_news.py
#!/usr/bin/env python3
"""
热点新闻采集 - 20+ 数据源,纯 stdlib,无需 pip install
"""
import json, sys, argparse, re, time
from urllib.request import urlopen, Request
from urllib.parse import quote, urlencode
from urllib.error import URLError, HTTPError
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "application/json, text/html, */*",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
}
def fetch(url, headers=None, timeout=10):
h = {**HEADERS, **(headers or {})}
req = Request(url, headers=h)
try:
with urlopen(req, timeout=timeout) as resp:
return resp.read().decode('utf-8')
except (URLError, HTTPError) as e:
sys.stderr.write(f"请求失败 {url}: {e}\n")
return None
def fetch_json(url, headers=None, timeout=10):
data = fetch(url, headers, timeout)
if data:
try: return json.loads(data)
except json.JSONDecodeError: return None
return None
def post_json(url, body, headers=None, timeout=10):
h = {**HEADERS, "Content-Type": "application/json", **(headers or {})}
req = Request(url, data=json.dumps(body).encode(), headers=h, method='POST')
try:
with urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode('utf-8'))
except Exception as e:
sys.stderr.write(f"POST失败 {url}: {e}\n")
return None
def filt(items, keyword=None):
if not keyword: return items
keywords = [k.lower() for k in keyword.split(',')]
return [i for i in items if any(k in (i.get('title','')+ i.get('content','')).lower() for k in keywords)]
# ---- 数据源实现 ----
def fetch_hackernews(limit=20, keyword=None):
ids = fetch_json("https://hacker-news.firebaseio.com/v0/topstories.json")
if not ids: return []
items = []
for iid in ids[:min(limit*2, 50)]:
item = fetch_json(f"https://hacker-news.firebaseio.com/v0/item/{iid}.json")
if item and item.get('title'):
items.append({"source":"Hacker News","title":item['title'],"url":item.get('url') or f"https://news.ycombinator.com/item?id={iid}","score":f"{item.get('score',0)} pts","time":""})
return filt(items, keyword)[:limit]
def fetch_github(limit=20, keyword=None):
html = fetch("https://github.com/trending?since=daily")
if not html: return []
items = []
for m in re.finditer(r'<h2[^>]*lh-condensed[^>]*>.*?href="(/[^"]+)"', html.replace('\n',' ')):
repo = m.group(1).strip('/')
items.append({"source":"GitHub Trending","title":repo,"url":f"https://github.com/{repo}","score":"trending","time":"today"})
return filt(items, keyword)[:limit]
def fetch_v2ex(limit=20, keyword=None):
data = fetch_json("https://www.v2ex.com/api/topics/hot.json")
if not data: return []
items = [{"source":"V2EX","title":i.get('title',''),"url":i.get('url',''),"score":f"{i.get('replies',0)} replies","time":"Hot"} for i in data]
return filt(items, keyword)[:limit]
def fetch_weibo(limit=20, keyword=None):
html = fetch("https://s.weibo.com/top/summary?cate=realtimehot", {"Referer":"https://s.weibo.com/top/summary"})
if not html: return []
items = []
for m in re.finditer(r'<td class="td-02">\s*<a href="([^"]+)"[^>]*>([^<]+)</a>', html):
href, title = m.group(1), m.group(2).strip()
if 'javascript:' in href: continue
items.append({"source":"微博热搜","title":title,"url":f"https://s.weibo.com{href}","score":"","time":"实时"})
return filt(items, keyword)[:limit]
def fetch_zhihu(limit=20, keyword=None):
data = fetch_json("https://www.zhihu.com/api/v3/feed/topstory/hot-list-web?limit=50&desktop=true")
if not data or 'data' not in data: return []
items = []
for i in data['data']:
t = i.get('target',{})
items.append({"source":"知乎热榜","title":t.get('title_area',{}).get('text',''),"url":t.get('link',{}).get('url',''),"score":t.get('metrics_area',{}).get('text',''),"time":"热榜"})
return filt(items, keyword)[:limit]
def fetch_36kr(limit=20, keyword=None):
data = post_json("https://gateway.36kr.com/api/mis/nav/newsflash/flow", {"pageSize":limit*2,"pageEvent":0})
if not data: return []
items_list = data.get('data',{}).get('itemList',[]) or []
items = []
for i in items_list:
items.append({"source":"36氪","title":i.get('templateMaterial',{}).get('widgetTitle',''),"url":f"https://36kr.com/newsflashes/{i.get('itemId','')}","score":"","time":i.get('templateMaterial',{}).get('publishTime','')})
return filt(items, keyword)[:limit]
def fetch_baidu(limit=20, keyword=None):
html = fetch("https://top.baidu.com/board?tab=realtime")
if not html: return []
items = []
for m in re.finditer(r'"word":"([^"]+)".*?"desc":"([^"]*)".*?"hotScore":"?(\d+)', html.replace('\n','')):
items.append({"source":"百度热搜","title":m.group(1),"url":f"https://www.baidu.com/s?wd={quote(m.group(1))}","score":m.group(3),"time":"实时"})
return filt(items, keyword)[:limit]
def fetch_juejin(limit=20, keyword=None):
data = post_json("https://api.juejin.cn/recommend_api/v1/article/recommend_all_feed",{"id_type":2,"sort_type":200,"cursor":"0","limit":limit*2})
if not data: return []
items = []
for i in (data.get('data',[]) or []):
info = i.get('article_info',{})
if not info.get('title'): continue
items.append({"source":"掘金","title":info['title'],"url":f"https://juejin.cn/post/{info.get('article_id','')}","score":f"{info.get('digg_count',0)} 赞","time":""})
return filt(items, keyword)[:limit]
def fetch_sspai(limit=20, keyword=None):
data = fetch_json("https://sspai.com/api/v1/article/index/page/get?limit=20&offset=0&created_at=0")
if not data: return []
items = [{"source":"少数派","title":i.get('title',''),"url":f"https://sspai.com/post/{i.get('id','')}","score":f"{i.get('like_count',0)} 赞","time":""} for i in data.get('data',[])]
return filt(items, keyword)[:limit]
def fetch_ithome(limit=20, keyword=None):
html = fetch("https://www.ithome.com/")
if not html: return []
items = []
for m in re.finditer(r'<a[^>]*href="(https://www\.ithome\.com/\d+/\d+/\d+/\d+\.htm)"[^>]*>([^<]+)</a>', html):
items.append({"source":"IT之家","title":m.group(2).strip(),"url":m.group(1),"score":"","time":""})
return filt(items, keyword)[:limit]
def fetch_producthunt(limit=20, keyword=None):
html = fetch("https://www.producthunt.com/")
if not html: return []
items = []
for m in re.finditer(r'data-test="post-name-([^"]*)"[^>]*>([^<]+)', html):
items.append({"source":"Product Hunt","title":m.group(2).strip(),"url":f"https://www.producthunt.com/posts/{m.group(1)}","score":"","time":"today"})
return filt(items, keyword)[:limit]
def fetch_bilibili(limit=20, keyword=None):
data = fetch_json("https://api.bilibili.com/x/web-interface/ranking/v2?rid=0&type=all")
if not data or data.get('code')!=0: return []
items = [{"source":"B站热门","title":i.get('title',''),"url":i.get('short_link','') or f"https://www.bilibili.com/video/{i.get('bvid','')}","score":f"{i.get('stat',{}).get('view',0)} 播放","time":""} for i in data.get('data',{}).get('list',[])]
return filt(items, keyword)[:limit]
def fetch_douyin(limit=20, keyword=None):
data = fetch_json("https://www.douyin.com/aweme/v1/web/hot/search/list/", {"Referer":"https://www.douyin.com/"})
if not data: return []
items = []
for i in (data.get('data',{}).get('word_list',[]) or []):
items.append({"source":"抖音热搜","title":i.get('word',''),"url":f"https://www.douyin.com/search/{quote(i.get('word',''))}","score":str(i.get('hot_value','')),"time":"实时"})
return filt(items, keyword)[:limit]
def fetch_toutiao(limit=20, keyword=None):
data = fetch_json("https://www.toutiao.com/hot-event/hot-board/?origin=toutiao_pc")
if not data: return []
items = [{"source":"今日头条","title":i.get('Title',''),"url":i.get('Url',''),"score":str(i.get('HotValue','')),"time":""} for i in data.get('data',[])]
return filt(items, keyword)[:limit]
def fetch_tencent(limit=20, keyword=None):
data = fetch_json("https://r.inews.qq.com/gw/event/hot_ranking_list?page_size=50")
if not data: return []
items = [{"source":"腾讯新闻","title":i.get('title',''),"url":i.get('url',''),"score":str(i.get('hotScore','')),"time":""} for i in data.get('idlist',[{}])[0].get('newslist',[])[1:]]
return filt(items, keyword)[:limit]
def fetch_thepaper(limit=20, keyword=None):
data = fetch_json("https://cache.thepaper.cn/contentapi/wwwIndex/rightSidebar")
if not data: return []
items = [{"source":"澎湃新闻","title":i.get('name',''),"url":f"https://www.thepaper.cn/newsDetail_forward_{i.get('contId','')}","score":"","time":""} for i in data.get('data',{}).get('hotNews',[])]
return filt(items, keyword)[:limit]
def fetch_hupu(limit=20, keyword=None):
html = fetch("https://bbs.hupu.com/all-gambia")
if not html: return []
items = []
for m in re.finditer(r'<a[^>]*href="(/\d+\.html)"[^>]*class="[^"]*textSpan[^"]*"[^>]*>([^<]+)', html):
items.append({"source":"虎扑","title":m.group(2).strip(),"url":f"https://bbs.hupu.com{m.group(1)}","score":"","time":""})
return filt(items, keyword)[:limit]
def fetch_wallstreetcn(limit=20, keyword=None):
data = fetch_json("https://api-one-wscn.awtmt.com/apiv1/content/lives?channel=global-channel&limit=30")
if not data: return []
items = [{"source":"华尔街见闻","title":i.get('title','') or i.get('content_text','')[:60],"url":f"https://wallstreetcn.com/live/{i.get('id','')}","score":"","time":i.get('display_time','')} for i in data.get('data',{}).get('items',[])]
return filt(items, keyword)[:limit]
def fetch_cls(limit=20, keyword=None):
data = fetch_json(f"https://www.cls.cn/nodeapi/updateTelegraphList?app=CailianpressWeb&os=web&rn={limit*2}")
if not data: return []
items = []
for i in data.get('data',{}).get('roll_data',[]):
title = i.get('title','') or re.sub(r'<[^>]+>','',i.get('content',''))[:80]
items.append({"source":"财联社","title":title,"url":f"https://www.cls.cn/detail/{i.get('id','')}","score":"","time":""})
return filt(items, keyword)[:limit]
# ---- 数据源注册 ----
SOURCES = {
"hackernews": fetch_hackernews, "github": fetch_github, "v2ex": fetch_v2ex,
"weibo": fetch_weibo, "zhihu": fetch_zhihu, "36kr": fetch_36kr,
"baidu": fetch_baidu, "juejin": fetch_juejin, "sspai": fetch_sspai,
"ithome": fetch_ithome, "producthunt": fetch_producthunt,
"bilibili": fetch_bilibili, "douyin": fetch_douyin, "toutiao": fetch_toutiao,
"tencent": fetch_tencent, "thepaper": fetch_thepaper, "hupu": fetch_hupu,
"wallstreetcn": fetch_wallstreetcn, "cls": fetch_cls,
}
GROUPS = {
"tech": ["hackernews","github","v2ex","sspai","juejin","ithome","producthunt"],
"china": ["weibo","zhihu","baidu","douyin","bilibili","toutiao","tencent","thepaper","hupu"],
"finance": ["36kr","wallstreetcn","cls"],
"all": list(SOURCES.keys()),
}
def main():
parser = argparse.ArgumentParser(description='热点新闻采集')
parser.add_argument('--source', required=True, help='数据源名称或分组(tech/china/finance/all)')
parser.add_argument('--limit', type=int, default=20)
parser.add_argument('--keyword', help='逗号分隔的关键词过滤')
parser.add_argument('--deep', action='store_true', help='深度抓取(预留)')
parser.add_argument('--list-sources', action='store_true', help='列出所有数据源')
args = parser.parse_args()
if args.list_sources:
for name in sorted(SOURCES.keys()):
print(name)
return
source = args.source.lower()
if source in GROUPS:
sources = GROUPS[source]
elif source in SOURCES:
sources = [source]
else:
sys.stderr.write(f"未知数据源: {source}\n可用: {', '.join(sorted(SOURCES.keys()))}\n分组: {', '.join(GROUPS.keys())}\n")
sys.exit(1)
all_items = []
for s in sources:
sys.stderr.write(f"[采集] {s}...\n")
try:
items = SOURCES[s](args.limit, args.keyword)
sys.stderr.write(f"[采集] {s} → {len(items)} 条\n")
all_items.extend(items)
except Exception as e:
sys.stderr.write(f"[采集] {s} 失败: {e}\n")
print(json.dumps(all_items, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()
FILE:scripts/lib/utils.mjs
#!/usr/bin/env node
/**
* wemp-ops 共享工具库:配置管理、数据存储、微信公众号 API (70个)
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, createReadStream } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export const SKILL_ROOT = join(__dirname, '..', '..');
// ============ 配置管理 ============
export function loadConfig() {
const configPath = join(SKILL_ROOT, 'config', 'default.json');
if (!existsSync(configPath)) return {};
return JSON.parse(readFileSync(configPath, 'utf-8'));
}
function getWempAccount() {
// 优先从 skill 自己的 config/default.json 读取
const skillConfig = loadConfig();
if (skillConfig?.weixin?.appId && skillConfig?.weixin?.appSecret) {
return { appId: skillConfig.weixin.appId, appSecret: skillConfig.weixin.appSecret };
}
// ⚠️ 不要往 openclaw.json 的 channels 里写 wemp 配置!
// channels 只接受 OpenClaw 内置支持的渠道类型,写错会导致 gateway 崩溃。
throw new Error('未找到公众号配置。请在 config/default.json 的 weixin 字段配置 appId + appSecret');
}
// ============ 数据存储 ============
export function getDataPath(filename) {
const dataDir = join(SKILL_ROOT, 'data');
if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true });
return join(dataDir, filename);
}
export function readData(filename, defaultValue = {}) {
const p = getDataPath(filename);
if (!existsSync(p)) return defaultValue;
try { return JSON.parse(readFileSync(p, 'utf-8')); } catch { return defaultValue; }
}
export function writeData(filename, data) {
writeFileSync(getDataPath(filename), JSON.stringify(data, null, 2));
}
// ============ 工具函数 ============
export function output(success, data) {
console.log(JSON.stringify({ success, data }, null, 2));
}
export function outputError(error) {
console.error(`[错误] error.message`);
console.log(JSON.stringify({ success: false, error: error.message }));
process.exit(1);
}
export function parseArgs() {
const args = {};
const argv = process.argv.slice(2);
for (let i = 0; i < argv.length; i++) {
if (argv[i].startsWith('--')) {
const key = argv[i].slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
const next = argv[i + 1];
if (!next || next.startsWith('--')) { args[key] = true; }
else { args[key] = next; i++; }
}
}
return args;
}
export function formatDate(d) {
const date = d ? new Date(d) : new Date();
return date.toISOString().slice(0, 10);
}
export function getYesterday() {
const d = new Date(); d.setDate(d.getDate() - 1);
return formatDate(d);
}
export function getDaysAgo(n) {
const d = new Date(); d.setDate(d.getDate() - n);
return formatDate(d);
}
export function calcChangeRate(current, previous) {
if (!previous || previous === 0) return '-';
const rate = ((current - previous) / Math.abs(previous) * 100).toFixed(1);
return rate > 0 ? `↑rate%` : rate < 0 ? `↓Math.abs(rate)%` : '→0%';
}
// ============ 微信 API 基础 ============
let tokenCache = null;
async function getAccessToken() {
if (tokenCache && tokenCache.expiresAt > Date.now()) return tokenCache.token;
const account = getWempAccount();
const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=account.appId&secret=account.appSecret`;
const resp = await fetch(url);
const data = await resp.json();
if (data.errcode) throw new Error(`获取 Token 失败: data.errcode - data.errmsg`);
tokenCache = { token: data.access_token, expiresAt: Date.now() + (data.expires_in - 300) * 1000 };
return tokenCache.token;
}
async function wechatApi(path, body = null, method) {
const token = await getAccessToken();
const sep = path.includes('?') ? '&' : '?';
const url = `https://api.weixin.qq.compathsepaccess_token=token`;
const m = method || (body ? 'POST' : 'GET');
const options = { method: m };
if (body) { options.headers = { 'Content-Type': 'application/json' }; options.body = JSON.stringify(body); }
const resp = await fetch(url, options);
const data = await resp.json();
if (data.errcode && data.errcode !== 0) throw new Error(`微信API错误: data.errcode - data.errmsg`);
return data;
}
// 上传文件(multipart/form-data)
async function wechatUpload(path, filePath, fieldName = 'media') {
const token = await getAccessToken();
const sep = path.includes('?') ? '&' : '?';
const url = `https://api.weixin.qq.compathsepaccess_token=token`;
const { basename: bn } = await import('node:path');
const { readFileSync: rfs } = await import('node:fs');
const fileName = bn(filePath);
const fileData = rfs(filePath);
const boundary = '----WempOps' + Date.now();
const ext = fileName.split('.').pop().toLowerCase();
const mimeMap = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', bmp: 'image/bmp', mp3: 'audio/mp3', amr: 'audio/amr', mp4: 'video/mp4' };
const mime = mimeMap[ext] || 'application/octet-stream';
const header = `--boundary\r\nContent-Disposition: form-data; name="fieldName"; filename="fileName"\r\nContent-Type: mime\r\n\r\n`;
const footer = `\r\n--boundary--\r\n`;
const headerBuf = Buffer.from(header);
const footerBuf = Buffer.from(footer);
const body = Buffer.concat([headerBuf, fileData, footerBuf]);
const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': `multipart/form-data; boundary=boundary` }, body });
const data = await resp.json();
if (data.errcode && data.errcode !== 0) throw new Error(`上传失败: data.errcode - data.errmsg`);
return data;
}
// ============ 统计 API (8) ============
export async function getUserSummary(date) {
return wechatApi('/datacube/getusersummary', { begin_date: date, end_date: date });
}
export async function getUserCumulate(beginDate, endDate) {
return wechatApi('/datacube/getusercumulate', { begin_date: beginDate, end_date: endDate || beginDate });
}
export async function getArticleSummary(date) {
return wechatApi('/datacube/getarticlesummary', { begin_date: date, end_date: date });
}
export async function getArticleTotal(date) {
return wechatApi('/datacube/getarticletotal', { begin_date: date, end_date: date });
}
export async function getUserRead(date) {
return wechatApi('/datacube/getuserread', { begin_date: date, end_date: date });
}
export async function getUserShare(date) {
return wechatApi('/datacube/getusershare', { begin_date: date, end_date: date });
}
export async function getUpstreamMsg(date) {
return wechatApi('/datacube/getupstreammsg', { begin_date: date, end_date: date });
}
export async function getUpstreamMsgHour(date) {
return wechatApi('/datacube/getupstreammsghour', { begin_date: date, end_date: date });
}
// ============ 草稿 API (6) ============
export async function addDraft(articles) {
return wechatApi('/cgi-bin/draft/add', { articles });
}
export async function updateDraft(mediaId, index, article) {
return wechatApi('/cgi-bin/draft/update', { media_id: mediaId, index, articles: article });
}
export async function getDraft(mediaId) {
return wechatApi('/cgi-bin/draft/get', { media_id: mediaId });
}
export async function listDrafts(offset = 0, count = 20) {
return wechatApi('/cgi-bin/draft/batchget', { offset, count, no_content: 0 });
}
export async function deleteDraft(mediaId) {
return wechatApi('/cgi-bin/draft/delete', { media_id: mediaId });
}
export async function getDraftCount() {
return wechatApi('/cgi-bin/draft/count', {});
}
// ============ 发布 API (5) ============
export async function publishDraft(mediaId) {
return wechatApi('/cgi-bin/freepublish/submit', { media_id: mediaId });
}
export async function getPublishStatus(publishId) {
return wechatApi('/cgi-bin/freepublish/get', { publish_id: publishId });
}
export async function listPublished(offset = 0, count = 20) {
return wechatApi('/cgi-bin/freepublish/batchget', { offset, count, no_content: 1 });
}
export async function getPublishedArticle(articleId) {
return wechatApi('/cgi-bin/freepublish/getarticle', { article_id: articleId });
}
export async function deletePublished(articleId, index = 0) {
return wechatApi('/cgi-bin/freepublish/delete', { article_id: articleId, index });
}
// ============ 评论 API (8) ============
export async function listComments(msgDataId, index = 0, begin = 0, count = 50, type = 0) {
return wechatApi('/cgi-bin/comment/list', { msg_data_id: msgDataId, index, begin, count, type });
}
export async function replyComment(msgDataId, index, userCommentId, content) {
return wechatApi('/cgi-bin/comment/reply/add', { msg_data_id: msgDataId, index, user_comment_id: userCommentId, content });
}
export async function deleteCommentReply(msgDataId, index, userCommentId) {
return wechatApi('/cgi-bin/comment/reply/delete', { msg_data_id: msgDataId, index, user_comment_id: userCommentId });
}
export async function electComment(msgDataId, index, userCommentId) {
return wechatApi('/cgi-bin/comment/markelect', { msg_data_id: msgDataId, index, user_comment_id: userCommentId });
}
export async function unelectComment(msgDataId, index, userCommentId) {
return wechatApi('/cgi-bin/comment/unmarkelect', { msg_data_id: msgDataId, index, user_comment_id: userCommentId });
}
export async function deleteComment(msgDataId, index, userCommentId) {
return wechatApi('/cgi-bin/comment/delete', { msg_data_id: msgDataId, index, user_comment_id: userCommentId });
}
export async function openComment(msgDataId, index = 0) {
return wechatApi('/cgi-bin/comment/open', { msg_data_id: msgDataId, index });
}
export async function closeComment(msgDataId, index = 0) {
return wechatApi('/cgi-bin/comment/close', { msg_data_id: msgDataId, index });
}
// ============ 用户 API (7) ============
export async function getUserInfo(openId) {
return wechatApi(`/cgi-bin/user/info?openid=openId&lang=zh_CN`, null, 'GET');
}
export async function batchGetUserInfo(openIds) {
const userList = openIds.map(openid => ({ openid, lang: 'zh_CN' }));
return wechatApi('/cgi-bin/user/info/batchget', { user_list: userList });
}
export async function getFollowers(nextOpenId = '') {
return wechatApi(`/cgi-bin/user/get?next_openid=nextOpenId`, null, 'GET');
}
export async function setUserRemark(openId, remark) {
return wechatApi('/cgi-bin/user/info/updateremark', { openid: openId, remark });
}
export async function getBlacklist(beginOpenId = '') {
return wechatApi('/cgi-bin/tags/members/getblacklist', { begin_openid: beginOpenId });
}
export async function batchBlacklistUsers(openIds) {
return wechatApi('/cgi-bin/tags/members/batchblacklist', { openid_list: openIds });
}
export async function batchUnblacklistUsers(openIds) {
return wechatApi('/cgi-bin/tags/members/batchunblacklist', { openid_list: openIds });
}
// ============ 标签 API (8) ============
export async function createTag(name) {
return wechatApi('/cgi-bin/tags/create', { tag: { name } });
}
export async function getTags() {
return wechatApi('/cgi-bin/tags/get', null, 'GET');
}
export async function updateTag(id, name) {
return wechatApi('/cgi-bin/tags/update', { tag: { id, name } });
}
export async function deleteTag(id) {
return wechatApi('/cgi-bin/tags/delete', { tag: { id } });
}
export async function batchTagUsers(openIds, tagId) {
return wechatApi('/cgi-bin/tags/members/batchtagging', { openid_list: openIds, tagid: tagId });
}
export async function batchUntagUsers(openIds, tagId) {
return wechatApi('/cgi-bin/tags/members/batchuntagging', { openid_list: openIds, tagid: tagId });
}
export async function getUserTags(openId) {
return wechatApi('/cgi-bin/tags/getidlist', { openid: openId });
}
export async function getTagUsers(tagId, nextOpenId = '') {
return wechatApi('/cgi-bin/user/tag/get', { tagid: tagId, next_openid: nextOpenId });
}
// ============ 模板消息 API (5) ============
export async function getTemplates() {
return wechatApi('/cgi-bin/template/get_all_private_template', null, 'GET');
}
export async function addTemplate(templateIdShort) {
return wechatApi('/cgi-bin/template/api_add_template', { template_id_short: templateIdShort });
}
export async function deleteTemplate(templateId) {
return wechatApi('/cgi-bin/template/del_private_template', { template_id: templateId });
}
export async function sendTemplateMessage(toUser, templateId, data, url = '', miniprogram = null) {
const body = { touser: toUser, template_id: templateId, data };
if (url) body.url = url;
if (miniprogram) body.miniprogram = miniprogram;
return wechatApi('/cgi-bin/message/template/send', body);
}
export async function getIndustry() {
return wechatApi('/cgi-bin/template/get_industry', null, 'GET');
}
// ============ 素材 API (6) ============
export async function uploadTempMedia(filePath, type = 'image') {
return wechatUpload(`/cgi-bin/media/upload?type=type`, filePath);
}
export async function uploadPermanentMedia(filePath, type = 'image') {
return wechatUpload(`/cgi-bin/material/add_material?type=type`, filePath);
}
export async function uploadArticleImage(filePath) {
return wechatUpload('/cgi-bin/media/uploadimg', filePath);
}
export async function getMaterialCount() {
return wechatApi('/cgi-bin/material/get_materialcount', null, 'GET');
}
export async function getMaterialList(type = 'image', offset = 0, count = 20) {
return wechatApi('/cgi-bin/material/batchget_material', { type, offset, count });
}
export async function deleteMaterial(mediaId) {
return wechatApi('/cgi-bin/material/del_material', { media_id: mediaId });
}
// ============ 客服消息 API (7) ============
async function sendCustomMessage(toUser, msgtype, content) {
return wechatApi('/cgi-bin/message/custom/send', { touser: toUser, msgtype, ...content });
}
export async function sendTextMessage(toUser, text) {
return sendCustomMessage(toUser, 'text', { text: { content: text } });
}
export async function sendImageMessage(toUser, mediaId) {
return sendCustomMessage(toUser, 'image', { image: { media_id: mediaId } });
}
export async function sendVoiceMessage(toUser, mediaId) {
return sendCustomMessage(toUser, 'voice', { voice: { media_id: mediaId } });
}
export async function sendVideoMessage(toUser, mediaId, thumbMediaId, title = '', description = '') {
return sendCustomMessage(toUser, 'video', { video: { media_id: mediaId, thumb_media_id: thumbMediaId, title, description } });
}
export async function sendNewsMessage(toUser, articles) {
return sendCustomMessage(toUser, 'news', { news: { articles } });
}
export async function sendMpNewsMessage(toUser, mediaId) {
return sendCustomMessage(toUser, 'mpnews', { mpnews: { media_id: mediaId } });
}
export async function sendTypingStatus(toUser) {
return wechatApi('/cgi-bin/message/custom/typing', { touser: toUser, command: 'Typing' });
}
// ============ 菜单 API (4) ============
export async function createMenu(buttons) {
return wechatApi('/cgi-bin/menu/create', { button: buttons });
}
export async function getMenu() {
return wechatApi('/cgi-bin/menu/get', null, 'GET');
}
export async function deleteMenu() {
return wechatApi('/cgi-bin/menu/delete', null, 'GET');
}
export async function getCurrentMenuInfo() {
return wechatApi('/cgi-bin/get_current_selfmenu_info', null, 'GET');
}
// ============ 二维码 API (2) ============
export async function createQRCode(sceneStr, expireSeconds = 2592000) {
const isTemp = expireSeconds > 0;
const body = isTemp
? { expire_seconds: expireSeconds, action_name: 'QR_STR_SCENE', action_info: { scene: { scene_str: sceneStr } } }
: { action_name: 'QR_LIMIT_STR_SCENE', action_info: { scene: { scene_str: sceneStr } } };
return wechatApi('/cgi-bin/qrcode/create', body);
}
export function getQRCodeImageUrl(ticket) {
return `https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=encodeURIComponent(ticket)`;
}
// ============ 群发 API (5) ============
export async function massSendByTag(tagId, mpnewsMediaId) {
return wechatApi('/cgi-bin/message/mass/sendall', {
filter: { is_to_all: tagId === 0, tag_id: tagId },
mpnews: { media_id: mpnewsMediaId }, msgtype: 'mpnews', send_ignore_reprint: 0
});
}
export async function massSendByOpenIds(openIds, mpnewsMediaId) {
return wechatApi('/cgi-bin/message/mass/send', {
touser: openIds, mpnews: { media_id: mpnewsMediaId }, msgtype: 'mpnews', send_ignore_reprint: 0
});
}
export async function previewMassMessage(toUser, mpnewsMediaId) {
return wechatApi('/cgi-bin/message/mass/preview', {
touser: toUser, mpnews: { media_id: mpnewsMediaId }, msgtype: 'mpnews'
});
}
export async function getMassMessageStatus(msgId) {
return wechatApi('/cgi-bin/message/mass/get', { msg_id: msgId });
}
export async function deleteMassMessage(msgId, articleIdx = 0) {
return wechatApi('/cgi-bin/message/mass/delete', { msg_id: msgId, article_idx: articleIdx });
}
FILE:scripts/markdown_to_html.py
#!/usr/bin/env python3
"""
Markdown → 微信公众号 HTML 转换器
所有样式内联,兼容微信编辑器。纯 stdlib 实现。
功能:
- Markdown 解析(标题/段落/列表/代码/表格/引用/分割线/图片)
- 3 套主题(tech/minimal/business)
- 自动去除 H1(公众号自带标题)
- 图片自动上传微信素材库(--upload 模式)
- 本地预览(--preview)
- 纯正文 HTML 输出(--body-only,用于 API 推送)
"""
import sys, os, re, json, argparse, html, subprocess
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent
SKILL_ROOT = SCRIPT_DIR.parent
TEMPLATES_DIR = SKILL_ROOT / 'assets' / 'templates'
# ============ 主题加载 ============
def load_theme(name):
path = TEMPLATES_DIR / f'{name}.json'
if not path.exists():
sys.stderr.write(f"主题不存在: {path}\n可用主题: tech, minimal, business\n")
sys.exit(1)
with open(path) as f:
return json.load(f)
# ============ Markdown 解析 ============
def escape(text):
return html.escape(text)
def parse_inline(text):
"""处理行内格式:加粗、斜体、行内代码、链接、图片"""
text = re.sub(r'`([^`]+)`', lambda m: f'<code style="{{code_inline}}">{escape(m.group(1))}</code>', text)
text = re.sub(r'\*\*\*(.+?)\*\*\*', r'<strong><em>\1</em></strong>', text)
text = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', text)
text = re.sub(r'\*(.+?)\*', r'<em>\1</em>', text)
text = re.sub(r'!\[([^\]]*)\]\(([^)]+)\)', r'<img src="\2" alt="\1" style="{img}" />', text)
text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'\1(\2)', text)
return text
def markdown_to_blocks(md_text):
lines = md_text.split('\n')
blocks = []
i = 0
while i < len(lines):
line = lines[i]
if not line.strip():
i += 1; continue
# 代码块
if line.strip().startswith('```'):
lang = line.strip()[3:].strip()
code_lines = []
i += 1
while i < len(lines) and not lines[i].strip().startswith('```'):
code_lines.append(lines[i])
i += 1
i += 1
blocks.append({'type': 'code', 'lang': lang, 'content': '\n'.join(code_lines)})
continue
# 标题
hm = re.match(r'^(#{1,6})\s+(.+)', line)
if hm:
level = len(hm.group(1))
blocks.append({'type': 'heading', 'level': level, 'content': hm.group(2)})
i += 1; continue
# 分割线
if re.match(r'^(-{3,}|_{3,}|\*{3,})\s*$', line):
blocks.append({'type': 'hr'})
i += 1; continue
# 引用块
if line.strip().startswith('>'):
quote_lines = []
while i < len(lines) and lines[i].strip().startswith('>'):
quote_lines.append(re.sub(r'^>\s?', '', lines[i]))
i += 1
blocks.append({'type': 'blockquote', 'content': '\n'.join(quote_lines)})
continue
# 无序列表
if re.match(r'^[\s]*[-*+]\s+', line):
list_items = []
while i < len(lines) and re.match(r'^[\s]*[-*+]\s+', lines[i]):
list_items.append(re.sub(r'^[\s]*[-*+]\s+', '', lines[i]))
i += 1
blocks.append({'type': 'ul', 'items': list_items})
continue
# 有序列表
if re.match(r'^[\s]*\d+\.\s+', line):
list_items = []
while i < len(lines) and re.match(r'^[\s]*\d+\.\s+', lines[i]):
list_items.append(re.sub(r'^[\s]*\d+\.\s+', '', lines[i]))
i += 1
blocks.append({'type': 'ol', 'items': list_items})
continue
# 表格
if '|' in line and i + 1 < len(lines) and re.match(r'^[\s]*\|?[\s]*[-:]+', lines[i+1]):
table_lines = []
while i < len(lines) and '|' in lines[i]:
table_lines.append(lines[i])
i += 1
blocks.append({'type': 'table', 'lines': table_lines})
continue
# 独立图片行(单独一行只有图片)
img_match = re.match(r'^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$', line)
if img_match:
blocks.append({'type': 'image', 'alt': img_match.group(1), 'src': img_match.group(2)})
i += 1; continue
# 普通段落
para_lines = [line]
i += 1
while i < len(lines) and lines[i].strip() and not lines[i].strip().startswith('#') \
and not lines[i].strip().startswith('```') and not lines[i].strip().startswith('>') \
and not re.match(r'^[-*+]\s+', lines[i].strip()) and not re.match(r'^\d+\.\s+', lines[i].strip()) \
and not re.match(r'^(-{3,}|_{3,}|\*{3,})\s*$', lines[i]):
para_lines.append(lines[i])
i += 1
blocks.append({'type': 'paragraph', 'content': ' '.join(para_lines)})
return blocks
def parse_table(table_lines):
rows = []
for idx, line in enumerate(table_lines):
if idx == 1: continue
cells = [c.strip() for c in line.strip('|').split('|')]
rows.append(cells)
return rows
# ============ 图片上传 ============
def upload_image_to_weixin(image_path):
"""调用 utils.mjs 上传图片到微信素材库,返回 URL"""
utils_path = SCRIPT_DIR / 'lib' / 'utils.mjs'
script = f'''
import {{ uploadArticleImage }} from '{utils_path}';
const r = await uploadArticleImage('{image_path}');
console.log(JSON.stringify(r));
'''
try:
result = subprocess.run(
['node', '-e', script],
capture_output=True, text=True, timeout=30
)
if result.returncode == 0:
data = json.loads(result.stdout.strip())
return data.get('url', '')
except Exception as e:
sys.stderr.write(f"[上传失败] {image_path}: {e}\n")
return ''
# ============ HTML 生成 ============
def blocks_to_html(blocks, theme, upload=False, base_dir=None):
s = theme.get('styles', {})
parts = []
for block in blocks:
t = block['type']
if t == 'heading':
level = block['level']
if level == 1:
continue # 公众号自带标题,跳过 H1
style = s.get(f'h{level}', s.get('h2', ''))
text = parse_inline(block['content']).format(**s)
parts.append(f'<h{level} style="{style}">{text}</h{level}>')
elif t == 'paragraph':
text = parse_inline(block['content']).format(**s)
parts.append(f'<p style="{s.get("p", "")}">{text}</p>')
elif t == 'image':
src = block['src']
alt = block['alt']
# 本地图片上传
if upload and not src.startswith('http'):
local_path = Path(src) if Path(src).is_absolute() else (base_dir / src if base_dir else Path(src))
if local_path.exists():
sys.stderr.write(f"[上传] {local_path.name}...")
url = upload_image_to_weixin(str(local_path))
if url:
src = url
sys.stderr.write(f" ✅\n")
else:
sys.stderr.write(f" ❌ 保留本地路径\n")
img_style = s.get('img', 'max-width:100%;border-radius:8px;margin:12px 0;')
parts.append(f'<p style="text-align:center;margin:20px 0;"><img src="{src}" alt="{escape(alt)}" style="{img_style}" /></p>')
elif t == 'code':
code = escape(block['content'])
parts.append(f'<pre style="{s.get("pre", "")}"><code style="{s.get("code", "")}">{code}</code></pre>')
elif t == 'blockquote':
text = parse_inline(block['content']).format(**s)
parts.append(f'<blockquote style="{s.get("blockquote", "")}">{text}</blockquote>')
elif t == 'ul':
items = ''.join(f'<li style="{s.get("li", "")}">{parse_inline(item).format(**s)}</li>' for item in block['items'])
parts.append(f'<ul style="{s.get("ul", "")}">{items}</ul>')
elif t == 'ol':
items = ''.join(f'<li style="{s.get("li", "")}">{parse_inline(item).format(**s)}</li>' for item in block['items'])
parts.append(f'<ol style="{s.get("ol", "")}">{items}</ol>')
elif t == 'table':
rows = parse_table(block['lines'])
html_rows = []
for idx, row in enumerate(rows):
tag = 'th' if idx == 0 else 'td'
cell_style = s.get('th', '') if idx == 0 else s.get('td', '')
cells = ''.join(f'<{tag} style="{cell_style}">{parse_inline(c).format(**s)}</{tag}>' for c in row)
tr_style = s.get('tr_even', '') if idx % 2 == 0 else ''
html_rows.append(f'<tr style="{tr_style}">{cells}</tr>')
parts.append(f'<table style="{s.get("table", "")}">{" ".join(html_rows)}</table>')
elif t == 'hr':
parts.append(f'<hr style="{s.get("hr", "")}" />')
body = '\n'.join(parts)
wrapper = s.get('wrapper', 'max-width:677px;margin:0 auto;padding:16px 0;')
return f'<section style="{wrapper}">{body}</section>'
# ============ 主函数 ============
def main():
parser = argparse.ArgumentParser(description='Markdown → 微信公众号 HTML')
parser.add_argument('--input', '-i', required=True, help='输入 Markdown 文件')
parser.add_argument('--output', '-o', help='输出 HTML 文件(默认同名 .html)')
parser.add_argument('--theme', '-t', default='tech', choices=['tech', 'minimal', 'business'])
parser.add_argument('--preview', '-p', action='store_true', help='转换后打开预览')
parser.add_argument('--upload', '-u', action='store_true', help='自动上传本地图片到微信素材库')
parser.add_argument('--body-only', action='store_true', help='只输出 body 内容(不含 html/head 标签)')
args = parser.parse_args()
input_path = Path(args.input)
if not input_path.exists():
sys.stderr.write(f"文件不存在: {input_path}\n"); sys.exit(1)
md_text = input_path.read_text(encoding='utf-8')
theme = load_theme(args.theme)
blocks = markdown_to_blocks(md_text)
html_content = blocks_to_html(blocks, theme, upload=args.upload, base_dir=input_path.parent)
if args.body_only:
full_html = html_content
else:
full_html = f'''<!DOCTYPE html>
<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>{input_path.stem}</title></head>
<body style="margin:0;padding:0;background:#f5f5f5;">
{html_content}
</body></html>'''
output_path = Path(args.output) if args.output else input_path.with_suffix('.html')
output_path.write_text(full_html, encoding='utf-8')
sys.stderr.write(f"✅ 转换完成: {output_path}\n")
sys.stderr.write(f" 主题: {args.theme} | 大小: {len(full_html)} 字符\n")
if args.preview:
import webbrowser
webbrowser.open(f'file://{output_path.resolve()}')
print(json.dumps({"success": True, "output": str(output_path), "size": len(full_html), "theme": args.theme}))
if __name__ == '__main__':
main()
FILE:scripts/publisher.mjs
#!/usr/bin/env node
/**
* 公众号草稿发布工具
*
* 创建草稿: node publisher.mjs --title "标题" --content article.html [--cover cover.png] [--author "作者"] [--digest "摘要"]
* 从 Markdown: node publisher.mjs --markdown article.md --title "标题" [--cover cover.png] [--author "作者"] [--digest "摘要"]
* 发布草稿: node publisher.mjs --publish --media-id <id>
* 列出草稿: node publisher.mjs --list [--count 10]
* 删除草稿: node publisher.mjs --delete --media-id <id>
*/
import { readFileSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import {
output, outputError, parseArgs,
uploadPermanentMedia, uploadArticleImage,
addDraft, getDraft, listDrafts, deleteDraft,
publishDraft, getPublishStatus
} from './lib/utils.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
function convertMarkdownToHtml(mdPath) {
const scriptPath = resolve(__dirname, 'markdown_to_html.py');
const absPath = resolve(mdPath);
if (!existsSync(absPath)) throw new Error(`Markdown 文件不存在: absPath`);
console.error(`[发布] 转换 Markdown: absPath`);
const stdout = execFileSync(
'python3',
[scriptPath, '--input', absPath, '--upload', '--body-only'],
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'inherit'] }
);
const json = JSON.parse(stdout.trim());
if (!json.success) throw new Error('Markdown 转换失败');
const htmlPath = resolve(json.output);
const content = readFileSync(htmlPath, 'utf-8');
console.error(`[发布] HTML 就绪: htmlPath (content.length 字符)`);
return content;
}
async function cleanupOldDrafts(title) {
console.error(`[清理] 检查同标题草稿: "title"`);
const result = await listDrafts(0, 20);
const items = result.item || [];
let cleaned = 0;
for (const item of items) {
const draftTitle = item.content?.news_item?.[0]?.title;
if (draftTitle === title) {
console.error(`[清理] 删除旧草稿: item.media_id`);
await deleteDraft(item.media_id);
cleaned++;
}
}
if (cleaned > 0) {
console.error(`[清理] 已删除 cleaned 个同标题旧草稿`);
} else {
console.error(`[清理] 无同标题旧草稿`);
}
}
async function createDraft(args) {
const title = args.title;
let content;
if (args.markdown) {
content = convertMarkdownToHtml(args.markdown);
} else {
const contentPath = resolve(args.content);
if (!existsSync(contentPath)) throw new Error(`内容文件不存在: contentPath`);
content = readFileSync(contentPath, 'utf-8');
}
console.error(`[发布] 标题: title`);
console.error(`[发布] 内容: content.length 字符`);
if (!args.noCleanup) {
await cleanupOldDrafts(title);
}
let thumbMediaId = '';
if (args.cover) {
const coverPath = resolve(args.cover);
if (!existsSync(coverPath)) throw new Error(`封面图不存在: coverPath`);
console.error(`[发布] 上传封面图: coverPath`);
const uploadResult = await uploadPermanentMedia(coverPath, 'image');
thumbMediaId = uploadResult.media_id;
console.error(`[发布] 封面图 media_id: thumbMediaId`);
}
const article = {
title,
content,
content_source_url: '',
thumb_media_id: thumbMediaId,
author: args.author || '',
digest: args.digest || '',
show_cover_pic: thumbMediaId ? 1 : 0,
need_open_comment: 1,
only_fans_can_comment: 0,
};
console.error('[发布] 创建草稿...');
const result = await addDraft([article]);
const mediaId = result.media_id;
console.error(`\n✅ 草稿创建成功!`);
console.error(` media_id: mediaId`);
console.error(` 发布: node publisher.mjs --publish --media-id mediaId`);
console.error(` 删除: node publisher.mjs --delete --media-id mediaId`);
return { mediaId, title };
}
async function publish(args) {
const mediaId = args.mediaId;
if (!mediaId) throw new Error('请指定 --media-id');
console.error(`[发布] 发布草稿: mediaId`);
const result = await publishDraft(mediaId);
console.error(`\n✅ 发布请求已提交!`);
console.error(` publish_id: result.publish_id`);
await new Promise(r => setTimeout(r, 3000));
try {
const status = await getPublishStatus(result.publish_id);
console.error(` 发布状态: JSON.stringify(status)`);
return { publishId: result.publish_id, status };
} catch (e) {
console.error(` 查询状态失败(可能仍在处理中): e.message`);
return { publishId: result.publish_id };
}
}
async function list(args) {
const count = parseInt(args.count) || 10;
console.error(`[草稿] 列出最近 count 个草稿...\n`);
const result = await listDrafts(0, count);
const items = result.item || [];
for (const item of items) {
const news = item.content?.news_item?.[0];
const title = news?.title || '未知标题';
const updateTime = item.update_time ? new Date(item.update_time * 1000).toLocaleString('zh-CN') : '';
console.error(` 📝 title`);
console.error(` media_id: item.media_id`);
console.error(` 更新时间: updateTime\n`);
}
console.error(`共 result.total_count || items.length 个草稿`);
return { total: result.total_count, items: items.map(i => ({
mediaId: i.media_id,
title: i.content?.news_item?.[0]?.title || '',
updateTime: i.update_time
}))};
}
async function remove(args) {
const mediaId = args.mediaId;
if (!mediaId) throw new Error('请指定 --media-id');
console.error(`[草稿] 删除: mediaId`);
await deleteDraft(mediaId);
console.error('✅ 删除成功');
return { deleted: mediaId };
}
async function main() {
const args = parseArgs();
try {
let result;
if (args.publish) {
result = await publish(args);
} else if (args.list) {
result = await list(args);
} else if (args.delete) {
result = await remove(args);
} else {
if (!args.title) throw new Error('请指定 --title');
if (!args.content && !args.markdown) throw new Error('请指定 --content (HTML文件路径) 或 --markdown (Markdown文件路径)');
result = await createDraft(args);
}
output(true, result);
} catch (error) {
outputError(error);
}
}
main();
FILE:scripts/reply_comment.mjs
#!/usr/bin/env node
/**
* 评论回复 + 精选
*
* 回复: node reply_comment.mjs --article-id <id> --comment-id <id> --content "回复内容"
* 精选: node reply_comment.mjs --article-id <id> --comment-id <id> --elect
* 同时: node reply_comment.mjs --article-id <id> --comment-id <id> --content "回复内容" --elect
*/
import {
output, outputError, parseArgs,
replyComment, electComment, unelectComment
} from './lib/utils.mjs';
async function main() {
const args = parseArgs();
const articleId = args.articleId;
const commentId = args.commentId ? parseInt(args.commentId) : null;
if (!articleId) { outputError(new Error('请指定 --article-id')); return; }
if (!commentId) { outputError(new Error('请指定 --comment-id')); return; }
const results = {};
try {
// 回复
if (args.content) {
console.error(`[评论] 回复评论 commentId: args.content`);
await replyComment(articleId, 0, commentId, args.content);
results.replied = true;
console.error('✅ 回复成功');
}
// 精选
if (args.elect) {
console.error(`[评论] 精选评论 commentId`);
await electComment(articleId, 0, commentId);
results.elected = true;
console.error('✅ 精选成功');
}
// 取消精选
if (args.unelect) {
console.error(`[评论] 取消精选 commentId`);
await unelectComment(articleId, 0, commentId);
results.unelected = true;
console.error('✅ 取消精选成功');
}
output(true, { articleId, commentId, ...results });
} catch (error) { outputError(error); }
}
main();
FILE:scripts/setup.mjs
#!/usr/bin/env node
/**
* 环境检查脚本
*/
import { existsSync, readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execSync } from 'node:child_process';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SKILL_ROOT = join(__dirname, '..');
let ok = true;
function check(label, pass, hint) {
const icon = pass ? '✅' : '❌';
console.log(` icon label`);
if (!pass) { console.log(` → hint`); ok = false; }
}
console.log('\n🔍 wemp-ops 环境检查\n');
// Node.js 版本
const nodeVer = process.versions.node.split('.').map(Number);
check('Node.js >= 18', nodeVer[0] >= 18, `当前版本 process.version,请升级到 v18+`);
// Python3
let hasPython = false;
try { execSync('python3 --version', { stdio: 'pipe' }); hasPython = true; } catch {}
check('Python3 可用', hasPython, '安装 Python3:https://www.python.org/downloads/');
// 微信公众号配置(从 skill 自己的 config/default.json 读取)
const configPath = join(SKILL_ROOT, 'config', 'default.json');
let hasWemp = false;
if (existsSync(configPath)) {
try {
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
hasWemp = !!(config?.weixin?.appId && config?.weixin?.appSecret);
} catch {}
}
check('微信公众号配置', hasWemp,
`在 configPath 的 weixin 字段配置 appId + appSecret\n ⚠️ 不要写到 openclaw.json 的 channels 里,会导致 gateway 崩溃!`);
console.log(ok ? '\n✅ 环境就绪!\n' : '\n⚠️ 请修复以上问题后重试。\n');
process.exit(ok ? 0 : 1);
FILE:scripts/smart_collect.mjs
#!/usr/bin/env node
/**
* 智能热点采集 - AI 扩展关键词后调用 fetch_news.py
*/
import { spawn } from 'node:child_process';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadConfig, output, outputError, parseArgs, formatDate, writeData } from './lib/utils.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const FETCH_SCRIPT = join(__dirname, 'fetch_news.py');
async function collectFromSource(source, keywords, limit, deep) {
const args = ['python3', FETCH_SCRIPT, '--source', source, '--limit', String(limit)];
if (keywords?.length) args.push('--keyword', keywords.join(','));
if (deep) args.push('--deep');
return new Promise((resolve, reject) => {
const proc = spawn(args[0], args.slice(1), { stdio: ['pipe', 'pipe', 'pipe'], timeout: 120000 });
let stdout = '', stderr = '';
proc.stdout.on('data', d => { stdout += d; });
proc.stderr.on('data', d => { stderr += d; process.stderr.write(d); });
proc.on('close', code => {
if (code !== 0) return reject(new Error(`采集失败 (source): stderr.slice(0, 300)`));
try { resolve(JSON.parse(stdout)); } catch (e) { reject(new Error(`解析失败 (source): e.message`)); }
});
proc.on('error', reject);
});
}
function scoreRelevance(item, keywords) {
const title = (item.title || '').toLowerCase();
const content = (item.content || '').toLowerCase();
let score = 0;
for (const kw of keywords) {
const k = kw.toLowerCase();
if (title.includes(k)) score += 0.3;
if (content.includes(k)) score += 0.15;
}
const heat = parseInt(String(item.score).replace(/[^0-9]/g, '')) || 0;
if (heat > 500) score += 0.2;
else if (heat > 100) score += 0.1;
return Math.min(score, 1);
}
async function main() {
const args = parseArgs();
if (!args.query) { outputError(new Error('请指定 --query 参数')); return; }
const keywords = args.keywords ? args.keywords.split(',').map(k => k.trim()) : [];
const sources = args.sources ? args.sources.split(',').map(s => s.trim()) : ['hackernews', 'v2ex', '36kr'];
const deep = args.deep === true || args.deep === 'true';
const count = parseInt(args.count) || 20;
console.error(`\n🔍 智能采集`);
console.error(` 需求: args.query`);
console.error(` 关键词: keywords.join(', ')`);
console.error(` 数据源: sources.join(', ')`);
console.error('');
const allItems = [];
for (const source of sources) {
try {
const items = await collectFromSource(source, keywords, Math.ceil(count / sources.length) + 5, deep);
allItems.push(...items);
} catch (e) { console.error(`[采集] source 失败: e.message`); }
}
// 评分、去重、排序
const scored = allItems.map(i => ({ ...i, relevance: scoreRelevance(i, keywords) }));
scored.sort((a, b) => b.relevance - a.relevance);
const seen = new Set();
const unique = scored.filter(i => {
const k = (i.title || '').toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]/g, '').slice(0, 30);
if (seen.has(k)) return false;
seen.add(k); return true;
}).slice(0, count);
const result = { query: args.query, keywords, sources, deep, date: formatDate(), items: unique, collectedAt: new Date().toISOString() };
writeData('collected-news.json', result);
console.error(`\n✅ 采集完成,共 unique.length 条`);
console.error('\n📰 相关度最高的 5 条:');
for (const item of unique.slice(0, 5)) {
const tag = item.relevance > 0.5 ? '🔥' : item.relevance > 0.2 ? '📌' : '📄';
console.error(` tag [item.source] (item.title || '').slice(0, 50)`);
}
output(true, result);
}
main();
FILE:scripts/weekly_report.mjs
#!/usr/bin/env node
/**
* 公众号周报
*/
import {
loadConfig, output, outputError, parseArgs, formatDate, getDaysAgo, calcChangeRate,
readData, writeData,
getUserSummary, getUserCumulate, getArticleSummary, listPublished
} from './lib/utils.mjs';
async function generateWeeklyReport() {
const config = loadConfig();
console.error('[周报] 生成本周周报...');
const endDate = getDaysAgo(1);
const startDate = getDaysAgo(7);
console.error(`[周报] 统计范围: startDate ~ endDate`);
let totalNew = 0, totalCancel = 0, totalRead = 0, totalShare = 0;
const dailyData = [];
for (let i = 7; i >= 1; i--) {
const date = getDaysAgo(i);
let dayNew = 0, dayCancel = 0, dayRead = 0, dayShare = 0;
try {
const u = await getUserSummary(date);
for (const item of (u.list || [])) { dayNew += item.new_user || 0; dayCancel += item.cancel_user || 0; }
} catch {}
try {
const a = await getArticleSummary(date);
for (const item of (a.list || [])) { dayRead += item.int_page_read_count || 0; dayShare += item.share_count || 0; }
} catch {}
totalNew += dayNew; totalCancel += dayCancel; totalRead += dayRead; totalShare += dayShare;
dailyData.push({ date, newUsers: dayNew, cancelUsers: dayCancel, netGrowth: dayNew - dayCancel, read: dayRead, share: dayShare });
}
const netGrowth = totalNew - totalCancel;
// 累计用户
let totalUsers = 0;
try {
const r = await getUserCumulate(endDate, endDate);
if (r.list?.length) totalUsers = r.list[0].cumulate_user || 0;
} catch {}
// 上周对比
const history = readData('weekly-history.json', { reports: [] });
const last = history.reports?.length ? history.reports[history.reports.length - 1] : null;
const growthRate = last ? calcChangeRate(netGrowth, last.netGrowth) : '-';
const readChange = last ? calcChangeRate(totalRead, last.totalRead) : '-';
// 日均
const avgNew = (totalNew / 7).toFixed(1);
const avgRead = (totalRead / 7).toFixed(0);
// 最佳/最差日
const bestDay = [...dailyData].sort((a, b) => b.netGrowth - a.netGrowth)[0];
const worstDay = [...dailyData].sort((a, b) => a.netGrowth - b.netGrowth)[0];
// 已发布文章
let topArticles = [];
try {
const r = await listPublished(0, 10);
topArticles = (r.item || []).slice(0, 5).map((item, idx) => ({
rank: idx + 1, title: item.content?.news_item?.[0]?.title || '未知标题'
}));
} catch {}
// 洞察
let insight = '';
if (netGrowth > 50) insight = `本周净增 netGrowth 粉丝,增长强劲!继续保持内容输出节奏。`;
else if (netGrowth > 0) insight = `本周净增 netGrowth 粉丝,稳步增长。可以尝试增加推送频次。`;
else insight = `本周净流失 Math.abs(netGrowth) 粉丝,建议复盘内容方向和推送时间。`;
const reportData = { startDate, endDate, totalNew, totalCancel, netGrowth, growthRate, totalUsers, totalRead, totalShare, readChange, avgNew, avgRead, bestDay, worstDay, topArticles, dailyData, insight };
// 保存历史
if (!history.reports) history.reports = [];
history.reports.push({ startDate, endDate, netGrowth, totalRead, totalUsers, generatedAt: new Date().toISOString() });
if (history.reports.length > 12) history.reports = history.reports.slice(-12);
writeData('weekly-history.json', history);
// 生成报告
const lines = [
`📊 **公众号周报** (startDate ~ endDate)`, `━━━━━━━━━━━━━━━━━━━━━━━━━━━━`, '',
`**👥 用户数据**`,
`• 新增关注: +totalNew (日均 avgNew)`,
`• 取消关注: -totalCancel`,
`• 净增长: ''netGrowth (growthRate)`,
`• 累计粉丝: totalUsers.toLocaleString()`, '',
`**📖 阅读数据**`,
`• 总阅读: totalRead.toLocaleString() 次 (readChange)`,
`• 总分享: totalShare 次`,
`• 日均阅读: avgRead 次`, '',
`**📈 每日趋势**`,
];
dailyData.forEach(d => {
const g = d.netGrowth >= 0 ? `+d.netGrowth` : `d.netGrowth`;
lines.push(` d.date: 粉丝g, 阅读d.read`);
});
lines.push('', `**🏆 表现**`,
`• 最佳: bestDay.date (净增 bestDay.netGrowth)`,
`• 最差: worstDay.date (净增 worstDay.netGrowth)`);
if (topArticles.length) {
lines.push('', `**🔥 本周文章**`);
topArticles.forEach(a => lines.push(`a.rank. 《a.title》`));
}
lines.push('', `**💡 AI 洞察**`, insight, '', `━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
return { report: lines.join('\n'), data: reportData };
}
async function main() {
try {
const { report, data } = await generateWeeklyReport();
console.error('\n' + report);
output(true, { report, data });
} catch (error) { outputError(error); }
}
main();
个人内容收藏与知识管理系统。收藏、整理、检索、二创。 Use when: (1) 用户说"收藏"/"存一下"/"记录下来"/"save"/"bookmark"/"clip", (2) 用户要求搜索之前收藏的内容, (3) 用户要求基于收藏内容生成社交媒体文案(二创), (4) 用户提到"之前看过一个..."/"上...
---
name: content-collector
version: 2.0.0
description: >
个人内容收藏与知识管理系统。收藏、整理、检索、二创。
Use when: (1) 用户说"收藏"/"存一下"/"记录下来"/"save"/"bookmark"/"clip",
(2) 用户要求搜索之前收藏的内容,
(3) 用户要求基于收藏内容生成社交媒体文案(二创),
(4) 用户提到"之前看过一个..."/"上次收藏的..."等回忆性检索。
已支持来源:博客、X/Twitter、网页、B站/YouTube/抖音/小红书视频。
NOT for: 纯个人笔记(直接写文件)、长文写作(用 internal-comms/docx)、
公众号发布(用 wemp-ops)、小红书发布(用 xiaohongshu-ops)。
---
# Content Collector - 个人内容收藏系统
收藏好内容 → 结构化整理 → 关键词检索 → 二次创作
## 数据位置
- **主存储**: `<WORKSPACE>/collections/`(articles/ tweets/ videos/ wechat/ ideas/)
- **Obsidian 同步**: `<YOUR_OBSIDIAN_VAULT>/收藏/`(每次收藏同时写入)
- **索引**: `collections/index.md` + `collections/tags.md`(自动维护)
## 收藏工作流
### Step 0: 去重(每次必做)
有 URL 时: `obsidian search query="<domain/path>" total`(去掉 `https://` 前缀)或 `grep -rl "<url>" collections/`
返回 > 0 → 已收藏,终止。返回 0 → 继续。
### Step 1: URL 路由
按 URL 匹配处理路径。详见 `references/url-routing-and-site-specs.md`。
| URL 模式 | category | 处理方式 |
|----------|----------|---------|
| 内网域名 | articles | Chrome Relay,不调 web_fetch |
| `arxiv.org/abs/*` | articles | 提取 abstract/authors |
| `github.com/*/*` | articles | README + stars/language |
| `mp.weixin.qq.com` | wechat | 优先 browser |
| `youtube.com/watch*` | videos | Supadata transcript |
| B站 | videos | `video_transcribe.sh` 本地转录 |
| 小红书/抖音(视频) | videos | `video_transcribe.sh` 本地转录 |
| `x.com/*/status/*` | tweets | 提取互动数据,thread 展开 |
| 其他 | articles | 默认流程 |
### Step 2: 内容提取
**文章/网页:**
1. `supadata_fetch.py web <url>`(降级: `web_fetch`)
2. Schema.org 提取 — 详见 `references/schema-extraction-spec.md`
3. 插图提取+下载(**必做**)— 详见 `references/image-extraction-spec.md`
4. 主题关键词提取 — 详见 `references/theme-extraction-spec.md`
**视频:**
1. 元数据: `supadata_fetch.py metadata <url>` 或 `bilibili_extract.py <url>`
2. 转录: `bash scripts/video_transcribe.sh <url>`(自动检测平台和字幕源)
3. 精彩片段提取(≥10min) — 详见 `references/highlight-extraction-spec.md`
4. 主题关键词提取
**推文/短内容:** 直接提取文本+互动数据
### Step 3: 写文件
1. 生成 `collections/{category}/YYYY-MM-DD-slug.md`(格式见下方 Schema)
2. 内容概览图(>1000字文章) — 详见 `references/content-overview-spec.md`
3. 同步到 Obsidian — 详见 `references/obsidian-integration.md`
4. `obsidian daily:append content="- 📌 收藏了 [[{标题}]]({source})| {一句话摘要}"`
5. 更新 `index.md` + `tags.md`
### Step 3.5: 微信图片缓存(wechat 类必做)
如果 URL 是微信公众号(`mp.weixin.qq.com`),写完收藏文件后运行:
`bash scripts/cache-wechat-images.sh <刚写入的收藏文件>`
下载微信 CDN 图片到本地 `collections/images/<slug>/`,防止图片过期 404。
### Step 4: 关联匹配
运行 `bash scripts/post-collect.sh <刚写入的收藏文件>`
脚本自动匹配活跃项目和相关收藏,更新 frontmatter 的 related_projects。
如有相关收藏,在回复中附带提及。
仍需手动匹配 `collections/topics/topic-pool.md` → 追加到 `temp/handoffs/collector-to-writing.md`
## 写文件前自检
每次写 collections/ 文件前,确认以下步骤已完成。缺项标注 `incomplete: true`,不允许静默跳过。
- 去重 ✓ → 内容提取 ✓ → 插图(文章类,必做) ✓ → 主题关键词 ✓ → 写文件 ✓ → Obsidian同步 ✓ → Daily Note ✓
- 写 tags 前运行 `bash scripts/normalize-tags.sh <tag1> <tag2> ...` 检查是否有已有近似 tag,优先复用已有 tag 名称
## 存储 Schema
文件命名: `YYYY-MM-DD-slug.md`
```yaml
---
title: ""
source: ""
url: ""
author: ""
date_published: ""
date_collected: ""
tags: []
category: "articles|tweets|videos|wechat|ideas"
language: "zh|en"
summary: ""
themes: [] # 5-7 个概念切面
schema_type: "" # Schema.org @type(可选)
schema_data: {} # ≤10 key-value(可选)
incomplete: false
# 视频专属
duration: ""
platform: ""
bvid: ""
stats: {}
subtitle_source: "" # native_cc|whisper
highlights: [] # 精彩片段
related_projects: []
---
```
### 内容结构
- **内容概览**(Mermaid,>1000字触发)
- **核心观点**(3-7个要点)
- **精彩片段**(视频≥10min)
- **要点摘录**(blockquote 金句)
- **热门评论精选**(视频类)
- **我的笔记**
- **原文摘要**(200-500字)
### 英文内容
默认 storytelling 翻译风格。术语参照 `<WORKSPACE>/references/glossary-ai-zh.md`,首次出现 `中文(English)` 格式。
## 检索
1. 标签: `tags.md`
2. 全文: `grep -ril "keyword" collections/`
3. 返回匹配列表 + 摘要
## 二创
按选题从收藏库筛选素材,交给 `xiaohongshu-ops` 或 `wemp-ops` 处理。本 skill 只负责供料。
## 工具脚本
| 脚本 | 用途 |
|------|------|
| `scripts/supadata_fetch.py web\|transcript\|metadata <url>` | Supadata API 抓取 |
| `scripts/bilibili_extract.py <url>` | B站元数据 |
| `scripts/video_transcribe.sh <url>` | 视频转录(自动检测平台) |
| `scripts/sync_to_obsidian.py` | 批量同步到 Obsidian |
| `scripts/cache-wechat-images.sh <file>` | 微信 CDN 图片本地缓存 |
| `scripts/normalize-tags.sh <tag1> <tag2> ...` | 标签归一化去重 |
| `scripts/post-collect.sh <file>` | 收藏后自动关联分析 |
## 🔴 Final: 机械验证(不可跳过)
通知用户前运行:
```bash
bash scripts/skill-verify.sh content-collector <collections-file-path>
# 例: bash scripts/skill-verify.sh content-collector collections/wechat/2026-04-23-xxx.md
```
- ✅ ALL PASSED → 回复用户收藏结果
- ❌ FAILED → 按输出补齐缺失项(Obsidian 同步/插图/index.md 等),重新验证直到通过
绝不在验证未通过时回复用户"已完成"。
## 收藏结果通知
- 成功: `📌 已收藏:<标题>\n核心:<一句话摘要>\n标签:<3-5个标签>`
- 重复: `📌 已存在:<标题>(之前已收藏过)`
- 失败: `❌ 收藏失败:<URL>\n原因:<失败原因>`
## 下一步建议(条件触发)
| 触发条件 | 推荐 |
|---------|------|
| 与公众号选题方向高度相关 | 用 wemp-ops 写 |
| 适合小红书短图文 | 用 xiaohongshu-ops 改写 |
| 某博主收藏 ≥3 条 | 用 x-profile-deep-dive 画像 |
| 涉及技术方案/架构决策 | 存到 memory 做长期参考 |
FILE:README.md
# Content Collector
Personal content collection and knowledge management system for OpenClaw.
## Features
- 📚 **Multi-source Collection**: Save content from blogs, X/Twitter, videos (YouTube, Bilibili, TikTok), webpages
- 🎯 **Automatic Transcription**: Extract subtitles from videos with faster-whisper
- 🏷️ **Smart Tagging**: Auto-generate tags and maintain searchable index
- 📊 **Visual Overview**: Generate Mermaid diagrams for long articles
- 🔍 **Full-text Search**: Find content by keywords or tags
- 📝 **Obsidian Sync**: Optional sync to Obsidian vault for manual browsing
- 🎨 **Image Extraction**: Save valuable illustrations from articles
## Prerequisites
- OpenClaw agent environment
- Python 3.8+
- (Optional) yt-dlp + faster-whisper for video transcription
- (Optional) Obsidian vault for sync feature
## Installation
### Via ClawHub (Recommended)
```bash
clawhub install content-collector
```
### Manual Installation
1. Clone this repository to `~/.openclaw/skills/content-collector`
2. Install Python dependencies:
```bash
pip3 install pyyaml
```
3. (Optional) For video transcription:
```bash
pip3 install yt-dlp
uv pip install faster-whisper opencc-python-reimplemented
```
## Usage
Simply talk to your OpenClaw agent:
```
收藏这个链接:https://example.com/article
Save this URL: https://example.com/blog-post
搜索关键词:AI产品设计
Search for: machine learning
```
The skill automatically detects collection requests and saves content to `<WORKSPACE>/collections/`.
## Configuration
Set environment variables in your OpenClaw TOOLS.md:
- `SUPADATA_API_KEY`: For web content extraction (optional, falls back to web_fetch)
- `COLLECTIONS_DIR`: Custom collections directory (default: `~/.openclaw/workspace/collections`)
- `OBSIDIAN_DIR`: Obsidian vault sync path (optional)
## License
MIT License - see [LICENSE](LICENSE) file for details
FILE:evals/benchmark.md
# Content-Collector Eval Benchmark
> 日期:2026-03-09
> 模型:idealab/claude-opus-4-6
## 总览
| Test Case | With Skill | Without Skill | Delta |
|---|---|---|---|
| blog-url-collection (6 assertions) | 6/6 (100%) | 3/6 (50%) | **+50%** |
| bilibili-video-collection (4 assertions) | 4/4 (100%) | 0/4 (0%) | **+100%** |
| recall-search (3 assertions) | 3/3 (100%) | 2/3 (67%) | **+33%** |
| **总计 (13 assertions)** | **13/13 (100%)** | **5/13 (38%)** | **+62%** |
## Skill 提升倍数:2.6x
## 关键发现
### 1. B站视频场景差距最大(0% → 100%)
Without-skill 完全不知道本地 bilibili 脚本的存在,不知道 Supadata 不支持B站,
无法生成视频专属字段,自己也承认"基本在盲人摸象"。
Skill 提供了完整的工具链和决策路径。
### 2. 博客收藏场景差距显著(50% → 100%)
Without-skill 能完成基本的"抓取+存储",但:
- 不用 YAML frontmatter(用简单 markdown 列表)
- 不做内容分类(统一放 collections/ 根目录)
- 不提取核心观点和要点摘录
- 不知道 Supadata API
### 3. 检索场景差距中等(67% → 100%)
Without-skill 知道用 grep 搜索,但不知道 tags.md/index.md 索引文件的存在,
搜索效率低且不确定在哪里找。Skill 提供了三层搜索策略。
## 结论
Content-collector Skill 是典型的 **能力增强型(Capability Uplift)** Skill,
基础模型无法独立完成的任务(B站工具链、结构化存储规范、索引体系)
是 Skill 的核心价值。该 Skill 应持续维护和测试。
## 改进建议
1. B站转录无降级方案 — 如果 yt-dlp cookie 过期或 faster-whisper 不可用,没有兜底
2. 搜索策略可以更丰富 — 加入语义搜索能力而非仅靠关键词 grep
FILE:evals/evals.json
{
"skill_name": "content-collector",
"evals": [
{
"id": 1,
"eval_name": "blog-url-collection",
"prompt": "帮我收藏一下这篇文章 https://paulgraham.com/writes.html 觉得写得挺好的,关于写作的",
"expected_output": "应该抓取文章内容,生成结构化的 markdown 文件到 collections/articles/ 目录,包含 YAML frontmatter(title/source/url/tags 等)、核心观点提取、要点摘录、摘要,并更新 index.md 和 tags.md",
"assertions": [
{"id": "correct-directory", "text": "文件保存到 collections/articles/ 目录", "type": "quality"},
{"id": "yaml-frontmatter", "text": "包含完整的 YAML frontmatter(title, source, url, tags, category, summary)", "type": "format"},
{"id": "content-extraction", "text": "提取了文章核心观点(至少3个要点)", "type": "quality"},
{"id": "index-update", "text": "提到更新 index.md 或实际执行了更新", "type": "quality"},
{"id": "tags-reasonable", "text": "标签合理且包含与写作相关的标签", "type": "quality"},
{"id": "supadata-or-fetch", "text": "使用了 supadata API 或 web_fetch 抓取内容", "type": "quality"}
]
},
{
"id": 2,
"eval_name": "bilibili-video-collection",
"prompt": "这个B站视频讲AI Agent的,帮我存一下 https://www.bilibili.com/video/BV1example",
"expected_output": "应该识别出B站视频,使用本地 bilibili 脚本流程(bilibili_extract.py + bilibili_transcribe.sh),生成到 collections/videos/ 目录",
"assertions": [
{"id": "bilibili-detection", "text": "识别出B站链接并使用本地 bilibili 脚本而非 supadata", "type": "quality"},
{"id": "correct-tools", "text": "提到使用 bilibili_extract.py 获取元数据和/或 bilibili_transcribe.sh 转录", "type": "quality"},
{"id": "video-directory", "text": "文件保存到 collections/videos/ 目录", "type": "quality"},
{"id": "video-metadata", "text": "包含视频专属字段(duration/platform/bvid/stats)", "type": "format"}
]
},
{
"id": 3,
"eval_name": "recall-search",
"prompt": "上次我收藏过一篇关于产品经理做AI的文章,帮我找一下",
"expected_output": "应该在收藏库中搜索,通过 tags.md/index.md 或 grep 查找与'产品经理'和'AI'相关的收藏",
"assertions": [
{"id": "search-action", "text": "执行了搜索操作(读取 tags.md/index.md 或使用 grep)", "type": "quality"},
{"id": "keyword-match", "text": "搜索关键词包含'产品经理'和/或'AI'", "type": "quality"},
{"id": "results-format", "text": "返回结果包含标题和摘要信息", "type": "format"}
]
}
]
}
FILE:evals/results/bilibili-with-skill.md
# Eval: bilibili-video-collection (WITH skill)
## 执行计划摘要
- 工具链:bilibili_extract.py(元数据+评论)→ bilibili_transcribe.sh(转录)→ LLM 提炼
- 路径:collections/videos/2026-03-09-ai-agent-bilibili.md
- 格式:Markdown + 完整 YAML frontmatter(含视频专属字段)
- 内容结构:核心观点 + 要点摘录 + 热门评论精选 + 评论区观点摘要 + 我的笔记 + 原文摘要
- 关键决策:"Supadata 不支持 B站" → 直接走本地流程,避免走弯路
- 后置:index.md + tags.md 双索引更新
## Assertion 评分
| ID | Assertion | Pass | Evidence |
|---|---|---|---|
| bilibili-detection | 识别B站链接并使用本地脚本 | ✅ | 明确跳过 Supadata,走本地 bilibili 专用流程 |
| correct-tools | 使用 bilibili_extract.py 和 bilibili_transcribe.sh | ✅ | 4步标准流程,两个脚本都明确提到 |
| video-directory | 文件保存到 collections/videos/ | ✅ | collections/videos/2026-03-09-ai-agent-bilibili.md |
| video-metadata | 包含视频专属字段 | ✅ | duration/platform/bvid/stats 全部包含 |
**Pass rate: 4/4 (100%)**
FILE:evals/results/bilibili-without-skill.md
# Eval: bilibili-video-collection (WITHOUT skill)
## 执行计划摘要
- 工具:web_fetch(可能被反爬)→ web_search 补充 → write
- 路径:workspace/collections/2026-03-09-AI-Agent-视频标题.md
- 格式:Markdown,简单元数据列表
- 提取:标题、UP主、简介、标签、封面URL、元数据
- 转录:不确定,提了3种方式但都不确定能否成功
## 关键差异点(vs with-skill 预期)
- ❌ 不知道有 bilibili_extract.py 和 bilibili_transcribe.sh 脚本
- ❌ 不知道用 yt-dlp + faster-whisper 的本地转录方案
- ❌ 不知道 Supadata 不支持B站(需要本地流程)
- ❌ 没有 YAML frontmatter 中的视频专属字段(duration/platform/bvid/stats)
- ❌ 没有分类到 collections/videos/ 子目录
- ❌ 没有评论提取能力
- ⚠️ 自己也承认"执行效果会比较粗糙,可能遗漏关键步骤"
FILE:evals/results/blog-with-skill.md
# Eval: blog-url-collection (WITH skill)
## 执行计划摘要
- 工具:supadata_fetch.py(优先)→ web_fetch(降级)
- 路径:collections/articles/2026-03-09-paul-graham-writes.md
- 格式:Markdown + 完整 YAML frontmatter
- 提取:标题、作者、日期、标签、摘要、核心观点(3-7个)、要点摘录(blockquote)、我的笔记、原文摘要
- 索引:index.md(按月份倒序)+ tags.md(按标签聚合)
- 额外:关联项目自动匹配(读 projects.md),用户情感保留到"我的笔记"
## Assertion 评分
| ID | Assertion | Pass | Evidence |
|---|---|---|---|
| correct-directory | 文件保存到 collections/articles/ | ✅ | 明确指定 articles/ 子目录 |
| yaml-frontmatter | 完整 YAML frontmatter | ✅ | 包含 title/source/url/author/tags/category/summary/related_projects |
| content-extraction | 提取核心观点(≥3个) | ✅ | 明确说"3-7个核心观点" |
| index-update | 更新 index.md | ✅ | 详细描述了 index.md 和 tags.md 的更新步骤 |
| tags-reasonable | 标签合理且包含写作相关 | ✅ | [写作, writing, Paul Graham, 思维模型, 创作, AI] |
| supadata-or-fetch | 使用 supadata 或 web_fetch | ✅ | 优先 supadata,降级 web_fetch |
**Pass rate: 6/6 (100%)**
FILE:evals/results/blog-without-skill.md
# Eval: blog-url-collection (WITHOUT skill)
## 执行计划摘要
- 工具:web_fetch → write → read/edit
- 路径:workspace/collections/2026-03-09-paul-graham-writes.md
- 格式:Markdown,带简单元数据头(非 YAML frontmatter)
- 提取:标题、全文、URL、时间、标签、用户备注
- 索引:有,index.md 表格形式
## Assertion 评分
| ID | Assertion | Pass | Evidence |
|---|---|---|---|
| correct-directory | 文件保存到 collections/articles/ | ❌ | 放在 collections/ 根目录,没有 articles/ 子分类 |
| yaml-frontmatter | 完整 YAML frontmatter | ❌ | 用 markdown 列表,不是 YAML frontmatter |
| content-extraction | 提取核心观点(≥3个) | ❌ | 只提取全文,没有核心观点/要点摘录结构 |
| index-update | 更新 index.md | ✅ | 有 index.md 表格 |
| tags-reasonable | 标签合理且包含写作相关 | ✅ | #写作 #Paul-Graham |
| supadata-or-fetch | 使用 supadata 或 web_fetch | ✅ | 用了 web_fetch(但不知道 supadata) |
**Pass rate: 3/6 (50%)**
FILE:evals/results/search-with-skill.md
# Eval: recall-search (WITH skill)
## 执行计划摘要
- 策略:三层搜索 — tags.md 标签索引 → grep 全文搜索 → index.md 浏览
- 搜索目录:tags.md > index.md > articles/ > wechat/ > videos/ > tweets/
- 返回格式:结构化摘要列表(标题/来源/日期/标签/摘要/URL)
- 兜底:建议扩大关键词或从网上搜索
## Assertion 评分
| ID | Assertion | Pass | Evidence |
|---|---|---|---|
| search-action | 执行了搜索操作 | ✅ | 三层搜索策略,精确定位 tags.md/index.md/collections/ |
| keyword-match | 搜索关键词包含产品经理和/或AI | ✅ | 包含"产品经理""AI""产品设计" |
| results-format | 返回结果包含标题和摘要 | ✅ | 从 YAML frontmatter 提取的结构化列表 |
**Pass rate: 3/3 (100%)**
FILE:evals/results/search-without-skill.md
# Eval: recall-search (WITHOUT skill)
## 执行计划摘要
- 策略:grep -r 盲搜 workspace 目录 + 检查 bookmarks/collections/saved 等猜测目录
- 关键词:产品经理、AI、PM、大模型、LLM
- 返回格式:标题+来源+时间+摘要+标签
- 自我评价:"基本是在盲人摸象"
## Assertion 评分
| ID | Assertion | Pass | Evidence |
|---|---|---|---|
| search-action | 执行了搜索操作 | ⚠️ 部分 | 提了 grep -r 但不知道具体搜哪里,靠猜 |
| keyword-match | 搜索关键词包含产品经理和/或AI | ✅ | 包含"产品经理"和"AI" |
| results-format | 返回结果包含标题和摘要 | ✅ | 有格式化的返回模板 |
**Pass rate: 2/3 (67%)** — 但实际执行效果会很差,因为不知道 collections/ 目录结构和 tags.md/index.md 的存在
FILE:references/bilibili-comments.md
# B站评论区浏览器提取方法
B站评论区使用 Web Components + Shadow DOM,需要逐层穿透才能读取。
## DOM 层级结构
```
document
└── bili-comments (shadowRoot)
└── #feed
└── bili-comment-thread-renderer × N (shadowRoot)
├── #comment → bili-comment-renderer (shadowRoot)
│ ├── bili-comment-user-info (shadowRoot) → #user-name a → 用户名
│ ├── #content → bili-rich-text (shadowRoot) → #contents span → 评论文本
│ └── bili-comment-action-buttons-renderer (shadowRoot) → #like button span → 点赞数
└── #replies → bili-comment-replies-renderer (shadowRoot)
└── bili-comment-renderer × N → 子评论(结构同上)
```
## 提取 JS(在 browser evaluate 中运行)
```javascript
(() => {
function getCommentText(rendererEl) {
const sr = rendererEl?.shadowRoot;
if (!sr) return { user: '', content: '', likes: '0' };
const richText = sr.querySelector('bili-rich-text');
const rtSr = richText?.shadowRoot;
const contentP = rtSr?.querySelector('#contents');
const spans = contentP?.querySelectorAll('span') || [];
let text = '';
spans.forEach(s => text += s.textContent);
const userInfo = sr.querySelector('bili-comment-user-info');
const uSr = userInfo?.shadowRoot;
const userName = uSr?.querySelector('#user-name a')?.textContent?.trim() || '';
const actionBtns = sr.querySelector('bili-comment-action-buttons-renderer');
const aSr = actionBtns?.shadowRoot;
const likes = aSr?.querySelector('#like button span')?.textContent?.trim() || '0';
return { user: userName, content: text.trim(), likes };
}
const biliComments = document.querySelector('bili-comments');
const sr1 = biliComments?.shadowRoot;
if (!sr1) return JSON.stringify({ error: 'no comments component' });
const threads = sr1.querySelectorAll('bili-comment-thread-renderer');
const results = [];
threads.forEach(thread => {
const sr2 = thread.shadowRoot;
if (!sr2) return;
const mainComment = getCommentText(sr2.querySelector('#comment'));
if (!mainComment.content) return;
const repliesRenderer = sr2.querySelector('bili-comment-replies-renderer');
const rSr = repliesRenderer?.shadowRoot;
const replyRenderers = rSr?.querySelectorAll('bili-comment-renderer') || [];
const subReplies = [];
replyRenderers.forEach(rr => {
const sub = getCommentText(rr);
if (sub.content) subReplies.push(sub);
});
results.push({ ...mainComment, sub_replies: subReplies });
});
return JSON.stringify({ count: results.length, items: results });
})()
```
## 使用步骤
1. 用 `browser` 工具启动浏览器(需已登录B站)
2. 导航到视频页面
3. 滚动到评论区(`window.scrollTo(0, 3000)`),等待3秒
4. 运行上述 JS 提取评论
5. 可多次滚动 + 提取获得更多评论
## 注意事项
- 未登录状态评论区可能不加载
- 默认加载约20条,滚动可触发更多
- B站的 shadow DOM 结构可能随版本更新变化
FILE:references/content-overview-spec.md
# 内容概览图生成规则
**触发条件**:正文 > 1000 字的 articles / wechat / videos(短推文、零散想法不画)。
**输出格式**:Mermaid 代码块,直接嵌入收藏文件的 `## 内容概览` 章节。Obsidian 原生渲染,不需要额外插件。
## 图表类型自动选择(按文章内容匹配)
| 文章特征 | 图表类型 | Mermaid 语法 | 示例 |
|---------|---------|-------------|------|
| 方法论/框架/模型(分层、组件) | 思维导图 | `mindmap` | "AI PM 的 3 个核心能力" |
| 流程/步骤/演进(先后顺序) | 流程图 | `graph TB` | "AI 编程三次进化" |
| 对比/选择(A vs B) | 对比图 | `graph TB` + 并行 subgraph | "传统 PM vs AI PM" |
| 多实体互动/依赖关系 | 关系图 | `graph LR` | "Agent 各模块数据流" |
| 时间线/里程碑 | 时间线 | `graph LR` 线性 | "2024 AI 大事记" |
| 混合/不确定 | 思维导图 | `mindmap`(万能兜底) | - |
## 生成要求
1. **节点文字用文章原始术语**,不用"概念1"、"模块A"等占位符
2. **层级不超过 3 层**--图是辅助理解,不是完整复述
3. **节点数量 5-15 个**--太少没信息量,太多一眼看不完
4. **Mermaid 语法注意**:
- `1. ` 触发列表解析错误 → 用 `(1)` 或去掉空格
- subgraph 带空格 → 用 `subgraph id["显示名"]`
- 节点引用用 ID 不用显示文本;不用 Emoji
5. **配色使用语义色**:
- 核心概念:`fill:#d3f9d8,stroke:#2f9e44`(绿)
- 问题/挑战:`fill:#ffe3e3,stroke:#c92a2a`(红)
- 方法/工具:`fill:#e5dbff,stroke:#5f3dc4`(紫)
- 输出/结果:`fill:#c5f6fa,stroke:#0c8599`(青)
## 嵌入格式示例
```markdown
## 内容概览
\`\`\`mermaid
mindmap
root((AI PM 核心能力))
问题定义
从模糊需求到精确问题
判断什么值得做
上下文质量
Context Engineering
给 Agent 正确的信息
判断力
评估 Agent 产出
知道何时人工介入
\`\`\`
```
## 不做什么
- 不生成独立图片文件--Mermaid 文本直接嵌入 Markdown
- 不画太复杂的图--收藏文件的图是"速览",不是完整笔记
FILE:references/highlight-extraction-spec.md
# 精彩片段提取规范
**目标**:从长视频转录中自动筛选 3-5 个最值得看的精华时间段,帮助用户快速定位价值内容。
## 触发条件
- 视频时长 ≥ 10 分钟
- 有完整转录文本(whisper 或 native_cc)
- < 10 分钟的短视频跳过此步骤
## 提取方式
使用以下 prompt 提取精彩片段(遵循 `~/.openclaw/workspace/references/prompt-engineering-template.md` 的 XML 结构化范式):
```xml
<task>
<role>You are an expert content curator selecting the most valuable moments from a video transcript.</role>
<languageRequirement>IMPORTANT: You MUST generate all content in Chinese.</languageRequirement>
<context>
Title: {video_title}
Speaker: {video_author}
Duration: {duration}
</context>
<goal>From this {duration} video, select the 3-5 most compelling moments worth watching.</goal>
<instructions>
<item>Each highlight must be a contiguous segment of 45-90 seconds.</item>
<item>Title must be specific and ≤15 characters. No generic titles like "重要观点".</item>
<item>Quote must be an exact verbatim match from the transcript.</item>
<item>Timestamps in [MM:SS-MM:SS] format.</item>
<item>Insight explains in one sentence why this moment matters.</item>
<item>Distribute highlights across the full video timeline - don't cluster in the opening.</item>
<item>Focus on: contrarian insights, vivid stories, data-backed arguments, practical frameworks, memorable quotes.</item>
</instructions>
<qualityControl>
<item>Are highlights distributed across the video (beginning, middle, end)?</item>
<item>Does each highlight stand alone without needing context?</item>
<item>Is the quote a verbatim match from the transcript?</item>
</qualityControl>
<outputFormat>Return strict JSON: [{"title":"string","start":"MM:SS","end":"MM:SS","quote":"exact transcript text","insight":"one sentence why this matters"}]. No markdown.</outputFormat>
<transcript><![CDATA[
{transcript_with_timestamps}
]]></transcript>
</task>
```
## 写入格式
**frontmatter**:
```yaml
highlights:
- title: "片段标题"
start: "12:30"
end: "13:45"
quote: "原文引用"
insight: "为什么值得看"
```
**正文**(在「核心观点」之后、「要点摘录」之前):
```markdown
## 精彩片段
> AI 从 {duration} 视频中筛选的 {N} 个最值得看的片段
**1. [{start}-{end}] {title}**
> {quote}
{insight}
```
## 降级
- 转录文本过短(< 500 字)→ 跳过
- AI 提取失败 → 跳过,不阻塞收藏流程
- 产出不满 3 个 → 有几个写几个
FILE:references/image-extraction-spec.md
# 插图保存规范
🔴 **收藏文章时必须执行插图提取步骤,不得跳过。** 这是收藏流程的强制环节,不是可选步骤。
写收藏文件之前,先完成插图筛选和下载。
## 判断标准(哪些图值得保存)
- ✅ 架构图、流程图、框架图、对比图、数据可视化
- ✅ 概念说明图、示意图、信息图
- ❌ 装饰性 banner、logo、头像、广告
- ❌ 纯文字截图(直接引用文字更好)
## 操作流程
1. **发现插图**:用 browser `evaluate` 提取页面 `<img>` 列表(src + alt + caption)
2. **筛选**:排除装饰性图片(logo、小于 200px、SVG 图标等)
3. **下载**:用 CDN 原始 URL(避免 Next.js 等图片优化层),`curl -sL` 保存到:
- collections 路径:`collections/{category}/images/{slug}/01-描述.png`
- slug 与收藏文件的文件名 slug 一致
4. **嵌入收藏文件**:在正文中用 `## 插图` 章节,每张图包含:
- ``
- 斜体说明文字(来自 caption 或自行总结)
- 一句 **"人话注释"**:`- 人话:这张图到底想表达什么`,用 1 句话解释这张图的核心意思,要求:不复述图面元素,直接说作者想传达的判断/结论/提醒;默认写给未来快速回看的自己
5. **同步到 Obsidian**:
- 复制图片到 `<YOUR_OBSIDIAN_VAULT>/收藏/{中文目录}/images/{slug}/`
- Obsidian 版本使用相同的相对路径引用
## 命名规范
- 文件名:`{序号}-{简短英文描述}.png`(如 `01-architecture-overview.png`)
- 目录名:与收藏文件 slug 一致(如 `evals-for-agents`)
## 降级处理(browser 不可用时)
1. 如果是通过 `web_fetch` 抓取的页面 → 从 HTML 中正则提取 `<img>` 标签的 src
2. 如果 URL 是 CDN 图片链接 → 直接 `curl -sL` 下载
3. 如果完全无法获取图片列表(如纯 API 抓取、无 browser 无 HTML)→ 在 frontmatter 中标注 `images_pending: true`,收藏文件正文加 `> ⚠️ 插图待补充(收藏时无法访问页面图片)`
4. **绝不静默跳过**--要么提取图片,要么显式标注 pending
## 自检信号
每次收藏流程中,写文件前默问自己两句:
1. **「图片提了没?」**
- 提了 → 继续
- 没提且能提 → 立刻补
- 没提且不能提 → 标注 `images_pending: true`
2. **「每张图的人话注释写了没?」**
- 写了 → 继续
- 没写 → 立刻补
## 人话注释写法
不是描述"图里有什么",而是回答:**"这张图到底想表达什么?"**
好例子:
- 人话:这张图想说的不是 AI 写了更多代码,而是同样的人在业务结果没变差的前提下,交付能力明显上去了。
- 人话:这张图在提醒你,真正拖慢项目的往往不是写代码,所以盯着生码率会看错地方。
坏例子:
- 人话:图里有三列数据,分别是前端、后端和测试。
- 人话:这是一张流程图,展示了左移流程。
判断标准:
- ✅ 读完这句,不看图也知道作者要表达的核心判断
- ❌ 这句只是把图重新念了一遍
## 注意
- 图片保存为本地副本,不依赖外部 URL(防止链接失效)
- 单篇文章通常 3-8 张有价值的图,不要过度收集
- 筛选后 0 张有价值的图也是正常结果(如纯文字文章),此时不加 `images_pending`,但要在流程中明确记录「已筛选,无有价值插图」
FILE:references/obsidian-integration.md
# Obsidian 集成(CLI + 同步规范)
## Obsidian CLI 集成
### 概述
使用 Obsidian CLI(`obsidian` 命令)直接操作 KaiVault,替代手动写文件。CLI 通过 IPC 连接运行中的 Obsidian App,写入后自动触发索引更新。
### 可用性检测与降级
每次收藏操作开始时,先检测 CLI 是否可用:
```bash
obsidian vault 2>/dev/null
```
- 返回 vault 信息 → CLI 可用,使用 CLI 写入
- 返回错误或超时 → Obsidian 未运行,**降级到直接写文件**(原有方式,写入 `<YOUR_OBSIDIAN_VAULT>/收藏/` 目录)
降级对用户无感,不需要提示。CLI 可用时优先用 CLI。
### 收藏前去重
在执行收藏流程之前,先用 CLI 搜索是否已收藏过:
```bash
obsidian search query="<domain/path>" total
```
- ⚠️ **去掉 `https://` 前缀**,只搜域名+路径(`https:` 会被 Obsidian 解析为操作符报错)
- 同时去掉 query params(`?utm_*` 等)
- 返回 > 0 → 已收藏,告知用户「📌 已存在:<标题>(之前已收藏过)」,不重复收藏
- 返回 0 → 继续收藏流程
CLI 不可用时降级到 `grep -rl "<url>" collections/`。
### CLI 写入 Obsidian
收藏文件写入 `collections/` 后,用 CLI 同步到 Obsidian(替代直接写文件):
```bash
# 创建笔记
obsidian create path="收藏/{中文目录}/{标题}.md" content="<完整内容>" overwrite
# 设置 frontmatter 属性
obsidian property:set path="收藏/{中文目录}/{标题}.md" name="aliases" value="[{title}]" type=list
obsidian property:set path="收藏/{中文目录}/{标题}.md" name="tags" value="[tag1,tag2]" type=list
obsidian property:set path="收藏/{中文目录}/{标题}.md" name="url" value="{url}" type=text
obsidian property:set path="收藏/{中文目录}/{标题}.md" name="source" value="{source}" type=text
obsidian property:set path="收藏/{中文目录}/{标题}.md" name="date_collected" value="{date}" type=date
```
**实际操作时**:优先用 `obsidian create` 一次性写入完整内容(含 frontmatter YAML 头),减少多次 CLI 调用。`property:set` 仅在需要**追加或修改**已有笔记属性时使用。
### Daily Note 联动
每次收藏成功后,追加一条记录到当天的 Daily Note:
```bash
obsidian daily:append content="- 📌 收藏了 [[{标题}]]({source})| {一句话摘要}"
```
CLI 不可用时跳过此步(不阻塞收藏流程)。
---
## Obsidian 同步规范
**优先使用 Obsidian CLI**(见上方)。CLI 不可用时降级到以下直接写文件方式。
每次写入 `collections/` 后,**必须**同时写入 Obsidian 版本。步骤:
1. 从刚写入的收藏文件读取 frontmatter
2. 构建 Obsidian 版本:
- 保留 frontmatter 的 `title, source, url, author, date_published, date_collected, category, language, summary, duration, platform, bvid, tags`
- 添加 `aliases: [title]`
- 正文第一行加 `#tag1 #tag2 ...`(标签中的空格替换为 `_`)
3. 文件名 = `sanitize(title).md`(去掉 `<>:"/\|?*`,截断 80 字符)
4. 写入到 `<YOUR_OBSIDIAN_VAULT>/收藏/{中文目录}/`
### 目录映射
| collections 目录 | Obsidian 目录 |
|---|---|
| articles/ | 文章/ |
| videos/ | 视频/ |
| tweets/ | 推文/ |
| wechat/ | 公众号/ |
| ideas/ | 想法/ |
**注意**:Obsidian 版本是 collections 的只读镜像。编辑应在 collections 原文件上进行,然后重新同步。
FILE:references/schema-extraction-spec.md
# Schema.org 提取规范
**目标**:自动从网页结构化数据中获取更准确的元数据,减少人工补充。
## 提取方法
1. 如果已用 `browser` 打开页面,`evaluate` 执行:
```javascript
JSON.parse(document.querySelector('script[type="application/ld+json"]')?.textContent || 'null')
```
2. 如果只用了 `web_fetch`,用正则从 HTML 中提取 `<script type="application/ld+json">` 内容
3. 页面可能有多个 JSON-LD 块,取 `@type` 最相关的一个(优先 Article > NewsArticle > BlogPosting > WebPage)
## 字段映射
| Schema.org 字段 | → frontmatter 字段 | 覆盖规则 |
|---|---|---|
| `headline` / `name` | `title` | 仅当现有 title 为空或明显是 URL slug 时覆盖 |
| `author.name` / `author[0].name` | `author` | 仅当现有 author 为空时填充 |
| `datePublished` | `date_published` | 优先使用(比 meta tag 更准确) |
| `description` | `summary` | 仅当现有 summary 为空时填充 |
| `aggregateRating.ratingValue` | `schema_data.rating` | 直接写入 |
| `aggregateRating.reviewCount` | `schema_data.reviewCount` | 直接写入 |
| `@type` | `schema_type` | 直接写入 |
| 其他有价值字段 | `schema_data.*` | 按需写入,总共不超过 10 个 key-value |
## 注意
- **静默降级**:提取失败(无 JSON-LD、解析报错、字段为空)→ 跳过,不阻塞收藏流程
- **不覆盖已有**:已有明确值的字段不被 Schema.org 数据覆盖(Schema.org 是补充,不是权威源)
- **schema_data 精简**:只保留有信息量的字段(rating/price/duration/keywords 等),忽略 @context、publisher logo URL 等噪音
FILE:references/theme-extraction-spec.md
# 主题关键词提取规范
**目标**:为每篇收藏内容提取 5-7 个精细概念切面,辅助选题和二创时快速判断"这篇可以从哪些角度写文章"。
## 与 tags 的区别
| | tags | themes |
|---|---|---|
| 粒度 | 大类(AI、产品) | 具体概念(Prompt 工程范式、用户分层策略) |
| 用途 | 检索/分类 | 选题/二创灵感 |
| 数量 | 3-5 | 5-7 |
## 触发条件
- **所有内容类型**(文章、视频、推文、笔记),只要有足够文本(≥ 200 字)
- 可以在生成 summary + tags 的同一步 AI 调用中一起提取,不需要额外调用
## 提取方式
使用以下 prompt(或合并到内容提取步骤中):
```xml
<task>
<role>You are an expert content analyst and semantic keyword extraction specialist.</role>
<languageRequirement>IMPORTANT: You MUST generate all keywords in Chinese. Technical terms keep English in parentheses.</languageRequirement>
<context>
Title: {title}
Source: {source}
Author: {author}
</context>
<goal>Extract 5-7 core conceptual themes that precisely capture the main topics discussed.</goal>
<instructions>
<item>Each keyword/phrase must be 2-6 Chinese characters (or 1-3 English words for technical terms).</item>
<item>Each keyword must capture a meaningfully different angle, stakeholder, problem, or method.</item>
<item>Specificity over Generality: "用户分层策略" > "用户运营".</item>
<item>Cover different facets: challenges, solutions, frameworks, stakeholders, outcomes.</item>
<item>Avoid synonyms or adjective swaps of earlier keywords.</item>
</instructions>
<qualityControl>
<item>Would each theme alone spark a specific article idea?</item>
<item>Are any two themes essentially the same concept reworded?</item>
</qualityControl>
<outputFormat>Return a JSON array of strings: ["theme1","theme2",...]. No markdown.</outputFormat>
<content><![CDATA[
{content_text_first_3000_chars}
]]></content>
</task>
```
## 写入格式
**frontmatter**:
```yaml
themes: ["Prompt工程范式", "AI Agent架构", "用户意图识别", "工具调用策略", "上下文窗口优化"]
```
## 降级
- 内容太短(< 200 字)→ 跳过
- AI 提取失败 → 跳过,用 tags 替代
- 输出不是数组 → 跳过
FILE:references/url-routing-and-site-specs.md
# URL 路由表与站点特殊处理
## URL 路由表
按域名/URL 模式匹配差异化提取策略。**匹配到时执行额外步骤,未匹配走默认流程。**
| URL 模式 | 自动 category | 额外提取 | 特殊处理 |
|----------|--------------|---------|---------|
| 内网域名(见下方清单) | `articles` | 标题、作者(如有) | **必须走 Chrome Relay**,见下方「内网文章收藏流程」 |
| `arxiv.org/abs/*` | `articles` | abstract、authors 列表、PDF 链接、subjects/categories | 标签自动加 `论文`;从 abs 页提取,不下载 PDF |
| `github.com/*/*`(repo 首页) | `articles` | stars、primary language、description、license、最近 commit 日期 | 提取 README 前 500 字作为正文摘要;区分 repo 页 vs 文件页(文件页走默认流程) |
| `mp.weixin.qq.com/*` | `wechat` | 公众号名称(`#js_name` 或 meta `og:site_name`) | 优先 browser 提取(见「站点选择器」) |
| `youtube.com/watch*` | `videos` | 频道名、时长、观看数 | 优先 Supadata transcript;Schema.org 通常有 VideoObject |
| `xiaohongshu.com/explore/*` 或 `xhslink.com/*`(视频类) | `videos` | 作者、点赞数 | `video_transcribe.sh` 本地转录(whisper);非视频笔记走默认流程 |
| `douyin.com/video/*` 或 `v.douyin.com/*` | `videos` | 作者、点赞数 | `video_transcribe.sh` 本地转录(whisper) |
| `x.com/*/status/*` | `tweets` | likes、retweets、replies、是否 thread | thread(连续 tweet)自动展开全部推文 |
| `news.ycombinator.com/item*` | `articles` | HN 得分、评论数、top 3 高赞评论 | **同时**抓取原文链接的内容(HN 页面本身只有讨论) |
| `substack.com/p/*` 或 `*.substack.com/p/*` | `articles` | 作者、发布日期、likes | Substack JSON-LD 通常完整 |
| `medium.com/*` 或 `*.medium.com/*` | `articles` | 作者、claps、reading time | 注意付费墙截断(见「站点选择器」) |
### 路由匹配逻辑
1. 按 URL 正则从上到下匹配,**首个命中**生效
2. 命中后执行「额外提取」列的步骤,**叠加**到默认流程上(不替代)
3. `自动 category` 列覆盖默认分类逻辑
4. 未命中任何规则 → 走默认 URL 内容流程,category 默认 `articles`
### 扩展方式
后续遇到新的高频站点,直接在表格中追加一行。无需改代码逻辑。
---
## 内网文章收藏流程
### 内网域名清单
```
*.alibaba-inc.com # 阿里内网通用(含 ata.alibaba-inc.com / aone / done 等)
*.aliyun-inc.com # 阿里云内网
*.alibaba.net # 阿里内部
*.taobao.org # 淘系内网
*.antfin.com # 蚂蚁内网(含 yuque.antfin.com)
*.atatech.org # ATA 技术博客(ata.atatech.org)
alidocs.dingtalk.com # 钉钉文档(阿里内部文档)
```
### 识别方式
1. clip 脚本自动检测:消息以 `[内网]` 开头
2. 手动发送:用户发内网 URL 时,agent 按域名匹配识别
### 收藏步骤
1. **不调用 web_fetch / supadata**(内网必失败,浪费时间和 credit)
2. **提示老板点 Chrome Relay**:
> 📌 检测到内网链接,我需要通过你的浏览器读取内容。
> 请确认当前 Tab 已打开这个页面,然后**点一下 Chrome Relay 按钮**(toolbar 上,badge 变 ON),我来提取。
3. **等老板确认**(老板说"好了"/"点了"/"OK" 等)
4. **通过 user 提取**:
```
browser(action=snapshot, profile="user")
```
获取页面正文内容
5. **正常走收藏流程**:提取标题/作者/正文 → 生成收藏文件 → 更新索引 → 同步 Obsidian
6. **提取完成后提醒老板**可以关掉 Relay(可选,不关也没事)
### 降级处理
- 老板不方便点 Relay → 仅保存 URL + 标题(从消息或老板口述获取),frontmatter 加 `incomplete: true`,正文加 `> ⚠️ 内网文章,正文待补充。下次在浏览器打开时可重新收藏。`
- user 提取正文 < 200 字 → 同上降级,可能是页面需要额外操作(如展开全文)
### 标签
内网文章自动加标签 `内网`、`阿里`。
---
## 站点选择器
当 `web_fetch` 提取结果残缺(正文 < 200 字)时,自动触发 `browser evaluate` + CSS 选择器精准提取。
| 站点 | 常见问题 | CSS 选择器 | 降级策略 |
|------|---------|-----------|---------|
| 内网域名(见清单) | 无外部访问权限 | 按页面结构判断 | **必须 Chrome Relay**,见「内网文章收藏流程」 |
| `mp.weixin.qq.com` | web_fetch 经常拿不到正文或格式混乱 | `#js_content` | Chrome Relay 打开原始页面提取 |
| `medium.com` | 付费墙截断,web_fetch 只拿到前几段 | `article section` | 仅保存可见部分,frontmatter 加 `incomplete: true`,标注"内容可能不完整(付费墙)" |
| `substack.com` | 部分文章需登录/付费 | `.post-content, .body` | web_fetch 通常可用;失败时同 medium 处理 |
| `36kr.com` | 反爬严重,web_fetch 可能返回空 | `.article-content` | browser 打开提取 |
### 触发条件
- `web_fetch` / `supadata` 返回的正文 **< 200 字**
- 或者正文明显是错误页(包含"请在微信客户端打开"/"Enable JavaScript" 等)
- 命中以上条件 → 自动尝试 browser 方案
### 降级原则
- 选择器提取失败 → **不阻塞收藏**
- 保存已有内容 + frontmatter 加 `incomplete: true`
- 收藏文件正文顶部加 `> ⚠️ 本文内容可能不完整,原始页面提取受限。`
FILE:scripts/bilibili_extract.py
#!/usr/bin/env python3
"""Extract video info and comments from Bilibili via public API.
Usage:
python3 bilibili_extract.py <bvid_or_url> [--comments N] [--cookie-file PATH]
Output: JSON to stdout with video metadata + top comments.
"""
import sys
import re
import json
import urllib.request
import urllib.error
import os
from datetime import datetime
HEADERS = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"Referer": "https://www.bilibili.com/",
}
COOKIE = ""
def fetch_json(url, extra_headers=None):
headers = {**HEADERS, "Cookie": COOKIE}
if extra_headers:
headers.update(extra_headers)
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read().decode("utf-8"))
def extract_bvid(input_str):
m = re.search(r"(BV[a-zA-Z0-9]+)", input_str)
return m.group(1) if m else input_str
def get_video_info(bvid):
data = fetch_json(f"https://api.bilibili.com/x/web-interface/view?bvid={bvid}")
if data["code"] != 0:
raise Exception(f"API error: {data['message']}")
v = data["data"]
return {
"bvid": v["bvid"],
"aid": v["aid"],
"title": v["title"],
"description": v.get("desc", ""),
"author": v["owner"]["name"],
"author_mid": v["owner"]["mid"],
"duration_seconds": v["duration"],
"duration_display": f"{v['duration']//60}:{v['duration']%60:02d}",
"publish_date": datetime.fromtimestamp(v["pubdate"]).strftime("%Y-%m-%d"),
"cover": v["pic"],
"tags_from_page": [],
"cid": v["cid"],
"stats": {
"views": v["stat"]["view"],
"danmaku": v["stat"]["danmaku"],
"comments": v["stat"]["reply"],
"favorites": v["stat"]["favorite"],
"coins": v["stat"]["coin"],
"shares": v["stat"]["share"],
"likes": v["stat"]["like"],
},
}
def get_video_tags(bvid):
try:
data = fetch_json(f"https://api.bilibili.com/x/tag/archive/tags?bvid={bvid}")
if data["code"] == 0 and data.get("data"):
return [t["tag_name"] for t in data["data"]]
except Exception:
pass
return []
def get_comments(aid, max_comments=30, cookie_sessdata=None):
"""Fetch comments. With cookie_sessdata, can paginate up to 20 pages (400 comments)."""
comments = []
seen = set()
max_pages = 20 if cookie_sessdata else 5
extra_headers = {"Cookie": f"SESSDATA={cookie_sessdata}"} if cookie_sessdata else None
for pn in range(1, max_pages + 1):
try:
data = fetch_json(
f"https://api.bilibili.com/x/v2/reply?type=1&oid={aid}&sort=2&pn={pn}&ps=20",
extra_headers=extra_headers,
)
if data["code"] != 0:
break
# top replies (only on page 1)
if pn == 1:
for r in (data.get("data", {}).get("top_replies") or []):
c = _parse_comment(r)
if c and c["message"] not in seen:
seen.add(c["message"])
c["is_top"] = True
comments.append(c)
replies = data.get("data", {}).get("replies") or []
if not replies:
break
for r in replies:
c = _parse_comment(r)
if c and c["message"] not in seen:
seen.add(c["message"])
comments.append(c)
if len(comments) >= max_comments:
break
except Exception as e:
print(f"Warning: error fetching comments page {pn}: {e}", file=sys.stderr)
break
# sort by likes descending
comments.sort(key=lambda x: x.get("likes", 0), reverse=True)
return comments[:max_comments]
def _parse_comment(r):
member = r.get("member", {})
content = r.get("content", {})
msg = content.get("message", "")
if not msg:
return None
comment = {
"user": member.get("uname", ""),
"likes": r.get("like", 0),
"reply_count": r.get("rcount", 0),
"message": msg,
"is_top": False,
"sub_replies": [],
}
for sr in (r.get("replies") or [])[:3]:
sm = sr.get("member", {})
sc = sr.get("content", {})
comment["sub_replies"].append({
"user": sm.get("uname", ""),
"likes": sr.get("like", 0),
"message": sc.get("message", ""),
})
return comment
def get_subtitle(bvid, cid):
"""Try to get subtitle/CC if available."""
try:
data = fetch_json(
f"https://api.bilibili.com/x/player/v2?bvid={bvid}&cid={cid}"
)
subtitles = data.get("data", {}).get("subtitle", {}).get("subtitles", [])
if subtitles:
sub_url = subtitles[0].get("subtitle_url", "")
if sub_url:
if sub_url.startswith("//"):
sub_url = "https:" + sub_url
sub_data = fetch_json(sub_url)
lines = sub_data.get("body", [])
return [{"from": l["from"], "to": l["to"], "content": l["content"]} for l in lines]
except Exception:
pass
return []
def _extract_sessdata(text):
"""Extract SESSDATA value from cookie text (e.g. 'SESSDATA=abc123' or full cookie string)."""
m = re.search(r"SESSDATA=([^\s;]+)", text)
return m.group(1) if m else None
def main():
if len(sys.argv) < 2:
print("Usage: python3 bilibili_extract.py <bvid_or_url> [--comments N] [--cookie-file PATH]", file=sys.stderr)
sys.exit(1)
global COOKIE
bvid = extract_bvid(sys.argv[1])
max_comments = 30
cookie_sessdata = None
if "--comments" in sys.argv:
idx = sys.argv.index("--comments")
if idx + 1 < len(sys.argv):
max_comments = int(sys.argv[idx + 1])
if "--cookie-file" in sys.argv:
idx = sys.argv.index("--cookie-file")
if idx + 1 < len(sys.argv):
with open(sys.argv[idx + 1], "r") as f:
cookie_text = f.read().strip()
COOKIE = cookie_text
cookie_sessdata = _extract_sessdata(cookie_text)
# Also check env
if not COOKIE:
env_cookie = os.environ.get("BILIBILI_COOKIE", "")
if env_cookie:
COOKIE = env_cookie
cookie_sessdata = _extract_sessdata(env_cookie)
info = get_video_info(bvid)
info["tags_from_page"] = get_video_tags(bvid)
info["comments"] = get_comments(info["aid"], max_comments, cookie_sessdata=cookie_sessdata)
info["subtitle"] = get_subtitle(bvid, info["cid"])
info["has_subtitle"] = len(info["subtitle"]) > 0
info["comment_count_fetched"] = len(info["comments"])
json.dump(info, sys.stdout, ensure_ascii=False, indent=2)
if __name__ == "__main__":
main()
FILE:scripts/supadata_fetch.py
#!/usr/bin/env python3
"""Fetch content via Supadata API (transcript, web scrape, metadata).
Usage:
python3 supadata_fetch.py transcript <url> [--lang zh] [--text]
python3 supadata_fetch.py web <url>
python3 supadata_fetch.py metadata <url>
Environment:
SUPADATA_API_KEY - Required. API key for authentication.
Output: JSON to stdout.
"""
import sys
import json
import os
import time
import urllib.request
import urllib.error
import urllib.parse
BASE_URL = "https://api.supadata.ai/v1"
POLL_INTERVAL = 2 # seconds between job status checks
POLL_TIMEOUT = 300 # max wait for async jobs (5 minutes)
MAX_RETRIES = 2 # retries on transient errors
RETRY_DELAY = 1 # initial delay between retries (doubles each retry)
def get_api_key():
key = os.environ.get("SUPADATA_API_KEY", "").strip()
if not key:
print("Error: SUPADATA_API_KEY environment variable not set", file=sys.stderr)
sys.exit(1)
return key
def api_request(path, params=None):
"""Make a GET request to the Supadata API. Returns parsed JSON."""
url = BASE_URL + path
if params:
url += "?" + urllib.parse.urlencode(params)
headers = {
"x-api-key": get_api_key(),
"Accept": "application/json",
"User-Agent": "supadata-fetch/1.0",
}
req = urllib.request.Request(url, headers=headers)
last_err = None
for attempt in range(MAX_RETRIES + 1):
try:
with urllib.request.urlopen(req, timeout=30) as resp:
body = resp.read().decode("utf-8")
status = resp.status
if status == 202:
# async job — return raw data for polling
return {"_async": True, "_status": 202, **json.loads(body)}
return json.loads(body)
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
# don't retry client errors (4xx) except 429
if e.code == 429:
retry_after = int(e.headers.get("Retry-After", RETRY_DELAY * (2 ** attempt)))
print(f"Rate limited, waiting {retry_after}s...", file=sys.stderr)
time.sleep(retry_after)
last_err = e
continue
if 400 <= e.code < 500:
print(f"API error {e.code}: {body}", file=sys.stderr)
sys.exit(1)
# server errors — retry
last_err = e
if attempt < MAX_RETRIES:
delay = RETRY_DELAY * (2 ** attempt)
print(f"Server error {e.code}, retrying in {delay}s...", file=sys.stderr)
time.sleep(delay)
except (urllib.error.URLError, OSError) as e:
last_err = e
if attempt < MAX_RETRIES:
delay = RETRY_DELAY * (2 ** attempt)
print(f"Network error, retrying in {delay}s: {e}", file=sys.stderr)
time.sleep(delay)
print(f"Request failed after {MAX_RETRIES + 1} attempts: {last_err}", file=sys.stderr)
sys.exit(1)
def poll_job(job_id):
"""Poll an async transcript job until completion or timeout."""
print(f"Async job {job_id}, polling...", file=sys.stderr)
start = time.time()
while time.time() - start < POLL_TIMEOUT:
time.sleep(POLL_INTERVAL)
result = api_request(f"/transcript/{job_id}")
status = result.get("status")
if status == "completed":
return result
if status == "failed":
error = result.get("error", "unknown error")
print(f"Job failed: {error}", file=sys.stderr)
sys.exit(1)
elapsed = int(time.time() - start)
print(f" status={status}, elapsed={elapsed}s", file=sys.stderr)
print(f"Timeout: job {job_id} did not complete within {POLL_TIMEOUT}s", file=sys.stderr)
sys.exit(1)
def cmd_transcript(args):
"""Fetch transcript for a video URL."""
if not args:
print("Error: URL required for transcript command", file=sys.stderr)
sys.exit(1)
url = args[0]
params = {"url": url}
if "--lang" in args:
idx = args.index("--lang")
if idx + 1 < len(args):
params["lang"] = args[idx + 1]
if "--text" in args:
params["text"] = "true"
result = api_request("/transcript", params)
# handle async job
if result.get("_async"):
job_id = result.get("jobId")
if not job_id:
print("Error: got 202 but no jobId in response", file=sys.stderr)
sys.exit(1)
result = poll_job(job_id)
result.pop("_async", None)
result.pop("_status", None)
result.pop("_async", None)
result.pop("_status", None)
return result
def cmd_web(args):
"""Scrape a web page and return markdown content."""
if not args:
print("Error: URL required for web command", file=sys.stderr)
sys.exit(1)
return api_request("/web/scrape", {"url": args[0]})
def cmd_metadata(args):
"""Fetch social media metadata."""
if not args:
print("Error: URL required for metadata command", file=sys.stderr)
sys.exit(1)
return api_request("/metadata", {"url": args[0]})
COMMANDS = {
"transcript": cmd_transcript,
"web": cmd_web,
"metadata": cmd_metadata,
}
def main():
if len(sys.argv) < 3:
print(__doc__.strip(), file=sys.stderr)
sys.exit(1)
command = sys.argv[1]
if command not in COMMANDS:
print(f"Error: unknown command '{command}'. Use: {', '.join(COMMANDS)}", file=sys.stderr)
sys.exit(1)
result = COMMANDS[command](sys.argv[2:])
json.dump(result, sys.stdout, ensure_ascii=False, indent=2)
print() # trailing newline
if __name__ == "__main__":
main()
FILE:scripts/sync_to_obsidian.py
#!/usr/bin/env python3
"""将 collections/ 中的收藏同步到 Obsidian vault,做格式适配"""
import os
import re
import shutil
import yaml
# Default paths - customize for your setup
COLLECTIONS_DIR = os.path.expanduser(os.getenv("COLLECTIONS_DIR", "~/.openclaw/workspace/collections"))
OBSIDIAN_DIR = os.path.expanduser(os.getenv("OBSIDIAN_DIR", "~/ObsidianVault/收藏"))
# category -> obsidian subdirectory
CATEGORY_MAP = {
"articles": "文章",
"tweets": "推文",
"videos": "视频",
"wechat": "公众号",
"ideas": "想法",
}
def parse_frontmatter(content):
"""Parse YAML frontmatter from markdown content"""
if not content.startswith("---"):
return {}, content
end = content.find("---", 3)
if end == -1:
return {}, content
fm_str = content[3:end].strip()
try:
fm = yaml.safe_load(fm_str)
except:
fm = {}
body = content[end+3:].lstrip("\n")
return fm or {}, body
def tags_to_obsidian(tags):
"""Convert tag list to Obsidian #tag format inline"""
if not tags:
return ""
return " ".join(f"#{t.replace(' ', '_')}" for t in tags)
def build_obsidian_frontmatter(fm):
"""Build Obsidian-friendly YAML frontmatter"""
obsidian_fm = {}
# Keep essential fields
for key in ["title", "source", "url", "author", "date_published", "date_collected",
"category", "language", "summary", "duration", "platform", "bvid"]:
if key in fm:
obsidian_fm[key] = fm[key]
# Convert tags to Obsidian format (keep as list, Obsidian handles it)
if "tags" in fm:
obsidian_fm["tags"] = fm["tags"]
# Add aliases for search
if "title" in fm:
obsidian_fm["aliases"] = [fm["title"]]
return obsidian_fm
def sanitize_filename(title):
"""Make a safe filename from title"""
# Remove/replace unsafe chars
safe = re.sub(r'[<>:"/\\|?*]', '', title)
safe = re.sub(r'\s+', ' ', safe).strip()
# Truncate if too long
if len(safe) > 80:
safe = safe[:80].rstrip()
return safe
def sync_file(src_path, category):
"""Sync a single collection file to KaiVault"""
with open(src_path, "r", encoding="utf-8") as f:
content = f.read()
fm, body = parse_frontmatter(content)
# Determine filename
title = fm.get("title", os.path.basename(src_path).replace(".md", ""))
safe_title = sanitize_filename(title)
# Determine target directory
subdir = CATEGORY_MAP.get(category, "文章")
target_dir = os.path.join(OBSIDIAN_DIR, subdir)
os.makedirs(target_dir, exist_ok=True)
target_path = os.path.join(target_dir, f"{safe_title}.md")
# Build Obsidian version
obsidian_fm = build_obsidian_frontmatter(fm)
# Build content
fm_str = yaml.dump(obsidian_fm, allow_unicode=True, default_flow_style=False, sort_keys=False).strip()
# Add tag line after frontmatter
tag_line = tags_to_obsidian(fm.get("tags", []))
obsidian_content = f"---\n{fm_str}\n---\n\n{tag_line}\n\n{body}" if tag_line else f"---\n{fm_str}\n---\n\n{body}"
with open(target_path, "w", encoding="utf-8") as f:
f.write(obsidian_content)
print(f" ✅ {safe_title}")
return target_path
def main():
synced = 0
for category in ["articles", "videos", "tweets", "wechat", "ideas"]:
cat_dir = os.path.join(COLLECTIONS_DIR, category)
if not os.path.isdir(cat_dir):
continue
files = [f for f in os.listdir(cat_dir) if f.endswith(".md")]
if not files:
continue
print(f"\n📂 {category} ({len(files)} 篇)")
for fname in sorted(files):
src = os.path.join(cat_dir, fname)
try:
sync_file(src, category)
synced += 1
except Exception as e:
print(f" ❌ {fname}: {e}")
print(f"\n🎉 完成!共同步 {synced} 篇到 Obsidian")
if __name__ == "__main__":
main()
FILE:scripts/video_transcribe.sh
#!/bin/bash
# Universal video transcription tool with native subtitle detection
# Supports: Bilibili, YouTube, Xiaohongshu, Douyin, and local files
#
# Usage:
# bash video_transcribe.sh <url_or_file> [options]
# bash video_transcribe.sh --step download <url>
# bash video_transcribe.sh --step transcribe <audio_file>
#
# Options:
# --platform <name> Force platform: bilibili, youtube, xiaohongshu, douyin, auto (default: auto)
# --step <action> Run specific step: download, transcribe (default: both)
# --model <size> Whisper model: tiny, base (default), small, medium, large-v3
# --force Force transcription even if cached result exists
# --help Show this help message
#
# Two-step workflow:
# 1. Download: bash video_transcribe.sh --step download <url>
# Output: JSON with audio_file path
# 2. Transcribe: bash video_transcribe.sh --step transcribe <audio_file>
# Output: JSON with transcript paths and stats
#
# Full workflow (default):
# bash video_transcribe.sh <url> [--model base]
# Output: Combined JSON with download + transcribe results
#
# Transcription priority:
# 1. Native CC/subtitles (Bilibili/YouTube only)
# 2. Volcengine ASR (if VOLC_ASR_APPID and VOLC_ASR_TOKEN are set)
# 3. Local Whisper (fallback)
# - Output includes subtitle_source: "native_cc", "volc_asr", or "whisper"
set -e
# Default settings
PLATFORM="auto"
STEP="full"
MODEL="base"
FORCE_TRANSCRIBE="false"
OUTDIR="/tmp/video_audio"
mkdir -p "$OUTDIR"
# Parse arguments
show_help() {
head -n 30 "$0" | grep '^#' | sed 's/^# //; s/^#//'
exit 0
}
INPUT=""
while [[ $# -gt 0 ]]; do
case $1 in
--help|-h)
show_help
;;
--platform)
PLATFORM="$2"
shift 2
;;
--step)
STEP="$2"
shift 2
;;
--model)
MODEL="$2"
shift 2
;;
--force)
FORCE_TRANSCRIBE="true"
shift
;;
*)
if [ -z "$INPUT" ]; then
INPUT="$1"
fi
shift
;;
esac
done
if [ -z "$INPUT" ]; then
echo "Error: No input provided (URL or file path)" >&2
show_help
fi
# ============================================================================
# Platform detection
# ============================================================================
detect_platform() {
local input="$1"
# Local file
if [ -f "$input" ]; then
echo "local"
return
fi
# URL pattern matching
case "$input" in
*bilibili.com*|*b23.tv*)
echo "bilibili"
;;
*youtube.com*|*youtu.be*)
echo "youtube"
;;
*xiaohongshu.com*|*xhslink.com*)
echo "xiaohongshu"
;;
*douyin.com*|*v.douyin.com*)
echo "douyin"
;;
*)
echo "Error: Cannot detect platform from: $input" >&2
echo "Please specify --platform manually" >&2
exit 1
;;
esac
}
# Auto-detect platform if needed
if [ "$PLATFORM" = "auto" ]; then
PLATFORM=$(detect_platform "$INPUT")
fi
echo "Platform: $PLATFORM" >&2
# ============================================================================
# ID extraction
# ============================================================================
extract_video_id() {
local platform="$1"
local input="$2"
case "$platform" in
bilibili)
# Extract BVID
local bvid=$(echo "$input" | grep -oE 'BV[a-zA-Z0-9]+' | head -1)
if [ -z "$bvid" ]; then
echo "Error: Cannot extract BVID from: $input" >&2
exit 1
fi
echo "$bvid"
;;
youtube)
# Extract video ID
local vid=$(echo "$input" | grep -oP '(?<=v=)[^&]+' || echo "$input" | grep -oP '(?<=youtu.be/)[^?]+')
if [ -z "$vid" ]; then
# Fallback: use URL hash
echo "yt_$(echo "$input" | md5sum | cut -c1-8)"
else
echo "$vid"
fi
;;
xiaohongshu|douyin)
# Use URL hash for ID
echo "platform_$(echo "$input" | md5sum | cut -c1-8)"
;;
local)
# Use filename without extension
basename "$input" | sed 's/\.[^.]*$//'
;;
*)
echo "unknown_$(echo "$input" | md5sum | cut -c1-8)"
;;
esac
}
# ============================================================================
# Native subtitle detection
# ============================================================================
detect_and_download_native_subtitle() {
local platform="$1"
local url="$2"
local output_file="$3"
# Only for Bilibili and YouTube
if [ "$platform" != "bilibili" ] && [ "$platform" != "youtube" ]; then
return 1
fi
echo "Checking for native subtitles..." >&2
# Build yt-dlp command for subtitle detection
local ytdlp_cmd="yt-dlp --list-subs"
if [ "$platform" = "bilibili" ]; then
ytdlp_cmd="$ytdlp_cmd --cookies-from-browser chrome"
fi
# List available subtitles
local sub_list=$($ytdlp_cmd "$url" 2>/dev/null || true)
# Check for Chinese/AI subtitles
local sub_lang=""
if echo "$sub_list" | grep -qE '(zh-Hans|zh-CN|ai-zh|zh)'; then
# Priority: ai-zh > zh-CN > zh-Hans > zh
if echo "$sub_list" | grep -q 'ai-zh'; then
sub_lang="ai-zh"
elif echo "$sub_list" | grep -q 'zh-CN'; then
sub_lang="zh-CN"
elif echo "$sub_list" | grep -q 'zh-Hans'; then
sub_lang="zh-Hans"
elif echo "$sub_list" | grep -q 'zh'; then
sub_lang="zh"
fi
fi
if [ -z "$sub_lang" ]; then
echo "No native Chinese subtitles found" >&2
return 1
fi
echo "Found native subtitle: $sub_lang" >&2
# Download subtitle
local temp_base="$OUTDIR/subtitle_temp"
local download_cmd="yt-dlp --write-sub --sub-lang $sub_lang --skip-download -o $temp_base"
if [ "$platform" = "bilibili" ]; then
download_cmd="$download_cmd --cookies-from-browser chrome"
fi
$download_cmd "$url" 2>&1 | grep -E '(Downloading|Writing|Error)' >&2 || true
# Find downloaded subtitle file (VTT or SRT)
local sub_file=""
if [ -f "temp_base.sub_lang.vtt" ]; then
sub_file="temp_base.sub_lang.vtt"
elif [ -f "temp_base.sub_lang.srt" ]; then
sub_file="temp_base.sub_lang.srt"
else
echo "Warning: Subtitle download failed" >&2
return 1
fi
# Convert to plain text (remove timestamps and formatting)
echo "Converting subtitle to plain text..." >&2
python3 - "$sub_file" "$output_file" << 'PYEOF'
import sys
import re
sub_file = sys.argv[1]
output_file = sys.argv[2]
with open(sub_file, 'r', encoding='utf-8') as f:
content = f.read()
# Remove VTT header
if content.startswith('WEBVTT'):
content = re.sub(r'^WEBVTT.*?\n\n', '', content, flags=re.DOTALL)
# Remove timestamps and sequence numbers
lines = []
for line in content.split('\n'):
line = line.strip()
# Skip empty, numeric-only, and timestamp lines
if not line or line.isdigit() or '-->' in line:
continue
# Skip VTT formatting tags
if line.startswith('<') and line.endswith('>'):
continue
# Remove inline formatting tags
line = re.sub(r'<[^>]+>', '', line)
if line:
lines.append(line)
# Join and deduplicate consecutive lines
text = ' '.join(lines)
# Remove extra spaces
text = re.sub(r'\s+', ' ', text).strip()
with open(output_file, 'w', encoding='utf-8') as f:
f.write(text)
print(f"Converted {len(lines)} subtitle lines to plain text", file=sys.stderr)
PYEOF
# Cleanup temp files
rm -f "temp_base."*
return 0
}
# ============================================================================
# Download step
# ============================================================================
download_audio() {
local platform="$1"
local url="$2"
local video_id="$3"
local audio_file="$OUTDIR/platform_video_id.mp3"
local subtitle_file="$OUTDIR/platform_video_id_subtitle.txt"
local subtitle_source="none"
# Check if already exists
if [ -f "$audio_file" ]; then
echo "Audio already exists: $audio_file" >&2
else
# Try native subtitle first for Bilibili/YouTube
if [ "$platform" = "bilibili" ] || [ "$platform" = "youtube" ]; then
if detect_and_download_native_subtitle "$platform" "$url" "$subtitle_file"; then
subtitle_source="native_cc"
echo "Native subtitle extracted successfully" >&2
# No need to download audio
audio_file=""
fi
fi
# If no native subtitle, download audio for Whisper
if [ "$subtitle_source" = "none" ]; then
echo "Downloading audio for $platform ($video_id)..." >&2
# Build yt-dlp command based on platform
local ytdlp_cmd="yt-dlp -x --audio-format mp3 --audio-quality 5 -o $OUTDIR/platform_video_id.%(ext)s --no-playlist"
case "$platform" in
bilibili)
ytdlp_cmd="$ytdlp_cmd --cookies-from-browser chrome"
;;
youtube)
# No special options needed
;;
xiaohongshu|douyin)
# Try with cookie first
ytdlp_cmd="$ytdlp_cmd --cookies-from-browser chrome"
;;
esac
$ytdlp_cmd "$url" 2>&1 | grep -E '(Downloading|download|Destination|Deleting|Error)' >&2 || {
# Retry without cookie for xiaohongshu/douyin
if [ "$platform" = "xiaohongshu" ] || [ "$platform" = "douyin" ]; then
echo "Retrying without cookies..." >&2
yt-dlp -x --audio-format mp3 --audio-quality 5 -o "$OUTDIR/platform_video_id.%(ext)s" --no-playlist "$url" 2>&1 | grep -E '(Downloading|download|Destination|Deleting|Error)' >&2
else
exit 1
fi
}
echo "Audio downloaded: $audio_file" >&2
fi
fi
# Output JSON
if [ "$subtitle_source" = "native_cc" ]; then
jq -n --arg subtitle "$subtitle_file" --arg platform "$platform" --arg vid "$video_id" --arg source "$subtitle_source" \
'{subtitle_file: $subtitle, platform: $platform, video_id: $vid, subtitle_source: $source}'
else
jq -n --arg audio "$audio_file" --arg platform "$platform" --arg vid "$video_id" \
'{audio_file: $audio, platform: $platform, video_id: $vid}'
fi
}
# ============================================================================
# Transcribe step
# ============================================================================
check_existing_transcript() {
local base_name="$1"
# Check for any existing transcript file
local cached_files=(
"$OUTDIR/base_name_transcript.txt"
"$OUTDIR/base_name_merged.txt"
"$OUTDIR/base_name_subtitle.txt"
)
for cached in "cached_files[@]"; do
if [ -f "$cached" ]; then
echo "$cached"
return 0
fi
done
return 1
}
transcribe_with_volc_asr() {
local audio_file="$1"
local base_name="$2"
local transcript_json="$3"
local transcript_txt="$4"
# Check credentials
if [ -z "$VOLC_ASR_APPID" ] || [ -z "$VOLC_ASR_TOKEN" ]; then
return 1
fi
echo "Transcribing with Volcengine ASR..." >&2
# Get script directory
local script_dir="$(cd "$(dirname "BASH_SOURCE[0]")" && pwd)"
local volc_script="$script_dir/volc_asr.py"
if [ ! -f "$volc_script" ]; then
echo "Warning: volc_asr.py not found, falling back to whisper" >&2
return 1
fi
# Call Volcengine ASR (stderr goes to stderr, stdout captured as JSON)
local volc_tmpfile="$OUTDIR/.volc_result_$$.json"
python3 "$volc_script" "$audio_file" > "$volc_tmpfile" 2>&2
local volc_exit=$?
if [ $volc_exit -ne 0 ] || [ ! -s "$volc_tmpfile" ]; then
echo "Warning: Volcengine ASR failed (exit=$volc_exit), falling back to whisper" >&2
rm -f "$volc_tmpfile"
return 1
fi
# Parse result - volc_asr.py outputs clean JSON to stdout
local full_text=$(jq -r '.full_text' "$volc_tmpfile")
local duration=$(jq -r '.duration' "$volc_tmpfile")
# Save segments array to transcript JSON (compatible with whisper format)
jq '.segments' "$volc_tmpfile" > "$transcript_json"
echo "$full_text" > "$transcript_txt"
rm -f "$volc_tmpfile"
local seg_count=$(jq length "$transcript_json")
local chars=$(echo -n "$full_text" | wc -c | tr -d ' ')
echo "Volcengine ASR complete: $seg_count segments, $chars chars" >&2
# Output JSON
jq -n --arg json "$transcript_json" --arg txt "$transcript_txt" \
--argjson seg "$seg_count" --argjson dur "$duration" --argjson chars "$chars" \
'{transcript_json: $json, transcript_txt: $txt, segments: $seg, duration_s: $dur, chars: $chars, subtitle_source: "volc_asr"}'
return 0
}
transcribe_audio() {
local audio_file="$1"
local model="$2"
if [ ! -f "$audio_file" ]; then
echo "Error: Audio file not found: $audio_file" >&2
exit 1
fi
# Generate output paths
local base_name=$(basename "$audio_file" | sed 's/\.[^.]*$//')
local transcript_json="$OUTDIR/base_name_transcript.json"
local transcript_txt="$OUTDIR/base_name_transcript.txt"
# Check cache if not forced
if [ "$FORCE_TRANSCRIBE" != "true" ]; then
local cached_file=$(check_existing_transcript "$base_name")
if [ $? -eq 0 ]; then
echo "Using cached transcript: $cached_file" >&2
# Determine source from cached file
local source="unknown"
if [[ "$cached_file" == *"_subtitle.txt" ]]; then
source="native_cc"
elif [ -f "$transcript_json" ]; then
# Try to read source from JSON
source=$(jq -r '.source // "whisper"' "$transcript_json" 2>/dev/null || echo "whisper")
else
source="whisper"
fi
# Return cached result
local chars=$(wc -c < "$cached_file" | tr -d ' ')
if [ -f "$transcript_json" ]; then
local segments=$(jq length "$transcript_json" 2>/dev/null || echo "1")
local duration=$(jq '[-1].end // 0' "$transcript_json" 2>/dev/null || echo "0")
else
segments=1
duration=0
fi
jq -n --arg json "$transcript_json" --arg txt "$cached_file" \
--argjson seg "$segments" --argjson dur "$duration" --argjson chars "$chars" --arg src "$source" \
'{transcript_json: $json, transcript_txt: $txt, segments: $seg, duration_s: $dur, chars: $chars, subtitle_source: $src}'
return
fi
fi
# Try Volcengine ASR first
if transcribe_with_volc_asr "$audio_file" "$base_name" "$transcript_json" "$transcript_txt"; then
return
fi
echo "Transcribing with faster-whisper (model: $model)..." >&2
uv run --with "faster-whisper" --with "opencc-python-reimplemented" python3 - "$audio_file" "$model" "$transcript_json" "$transcript_txt" << 'PYEOF'
import sys, json, time
from faster_whisper import WhisperModel
import opencc
audio_file = sys.argv[1]
model_size = sys.argv[2]
json_out = sys.argv[3]
txt_out = sys.argv[4]
converter = opencc.OpenCC('t2s')
print(f"Loading {model_size} model...", file=sys.stderr, flush=True)
model = WhisperModel(model_size, device="cpu", compute_type="int8")
print("Transcribing...", file=sys.stderr, flush=True)
start = time.time()
segments, info = model.transcribe(audio_file, language="zh", beam_size=5, vad_filter=True)
results = []
for seg in segments:
text = converter.convert(seg.text.strip())
results.append({"start": round(seg.start, 1), "end": round(seg.end, 1), "text": text})
if len(results) % 100 == 0:
print(f" {len(results)} segments ({seg.end:.0f}s)...", file=sys.stderr, flush=True)
elapsed = time.time() - start
print(f"Done: {len(results)} segments in {elapsed:.0f}s", file=sys.stderr, flush=True)
with open(json_out, "w") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
full_text = " ".join([s["text"] for s in results])
with open(txt_out, "w") as f:
f.write(full_text)
# Save source to JSON file
results_with_meta = {
"source": "whisper",
"segments": results
}
# Also save segments-only format for compatibility
with open(json_out, "w") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
# Output JSON to stdout
json.dump({
"transcript_json": json_out,
"transcript_txt": txt_out,
"segments": len(results),
"duration_s": results[-1]["end"] if results else 0,
"chars": len(full_text),
"subtitle_source": "whisper"
}, sys.stdout)
PYEOF
# Step 3: Sentence merger - merge fragmented segments into complete sentences
local merged_json="$OUTDIR/base_name_merged.json"
local merged_txt="$OUTDIR/base_name_merged.txt"
local MERGER_SCRIPT="$HOME/.openclaw/workspace/scripts/sentence_merger.py"
if [ -f "$MERGER_SCRIPT" ] && [ -f "$transcript_json" ]; then
echo "Merging fragmented segments into sentences..." >&2
python3 "$MERGER_SCRIPT" "$transcript_json" -o "$merged_json" 2>&1 | grep -v "^$" >&2 || true
if [ -f "$merged_json" ]; then
# Generate merged plain text
python3 -c "
import json
with open('$merged_json') as f:
sentences = json.load(f)
with open('$merged_txt', 'w') as f:
f.write(' '.join(s['text'] for s in sentences))
" 2>/dev/null
local merged_count=$(python3 -c "import json; print(len(json.load(open('$merged_json'))))" 2>/dev/null || echo "0")
local raw_count=$(python3 -c "import json; print(len(json.load(open('$transcript_json'))))" 2>/dev/null || echo "0")
echo "Sentence merger: $raw_count raw segments → $merged_count sentences" >&2
fi
fi
}
# ============================================================================
# Handle subtitle-only case (no audio to transcribe)
# ============================================================================
process_subtitle_file() {
local subtitle_file="$1"
local platform="$2"
local video_id="$3"
if [ ! -f "$subtitle_file" ]; then
echo "Error: Subtitle file not found: $subtitle_file" >&2
exit 1
fi
# Read subtitle content
local text=$(cat "$subtitle_file")
local chars=$(echo -n "$text" | wc -c | tr -d ' ')
# Create transcript files for consistency
local transcript_json="$OUTDIR/platform_video_id_transcript.json"
local transcript_txt="$subtitle_file"
# Create a simple JSON structure (no segments, just full text)
echo '[{"start": 0, "end": 0, "text": "'"$text"'"}]' > "$transcript_json"
jq -n --arg json "$transcript_json" --arg txt "$transcript_txt" --argjson chars "$chars" \
'{transcript_json: $json, transcript_txt: $txt, segments: 1, duration_s: 0, chars: $chars, subtitle_source: "native_cc"}'
}
# ============================================================================
# Main workflow
# ============================================================================
case "$STEP" in
download)
VIDEO_ID=$(extract_video_id "$PLATFORM" "$INPUT")
download_audio "$PLATFORM" "$INPUT" "$VIDEO_ID"
;;
transcribe)
# Check if input is a subtitle file or audio file
if [[ "$INPUT" == *"_subtitle.txt" ]]; then
# Extract platform and video ID from filename
BASE_NAME=$(basename "$INPUT" "_subtitle.txt")
PLAT=$(echo "$BASE_NAME" | cut -d_ -f1)
VID=$(echo "$BASE_NAME" | cut -d_ -f2-)
process_subtitle_file "$INPUT" "$PLAT" "$VID"
else
transcribe_audio "$INPUT" "$MODEL"
fi
;;
full|*)
# Full workflow: download + transcribe
VIDEO_ID=$(extract_video_id "$PLATFORM" "$INPUT")
echo "=== Step 1: Download ===" >&2
DOWNLOAD_RESULT=$(download_audio "$PLATFORM" "$INPUT" "$VIDEO_ID")
echo "$DOWNLOAD_RESULT" | jq '.' >&2
# Check if we got a subtitle or audio
SUBTITLE_FILE=$(echo "$DOWNLOAD_RESULT" | jq -r '.subtitle_file // empty')
AUDIO_FILE=$(echo "$DOWNLOAD_RESULT" | jq -r '.audio_file // empty')
echo "" >&2
echo "=== Step 2: Transcribe ===" >&2
if [ -n "$SUBTITLE_FILE" ]; then
# Process subtitle file
TRANSCRIBE_RESULT=$(process_subtitle_file "$SUBTITLE_FILE" "$PLATFORM" "$VIDEO_ID")
elif [ -n "$AUDIO_FILE" ]; then
# Transcribe audio
TRANSCRIBE_RESULT=$(transcribe_audio "$AUDIO_FILE" "$MODEL")
else
echo "Error: No audio or subtitle file to process" >&2
exit 1
fi
echo "$TRANSCRIBE_RESULT" | jq '.' >&2
echo "" >&2
echo "=== Complete ===" >&2
# Merge results
jq -n --argjson download "$DOWNLOAD_RESULT" --argjson transcribe "$TRANSCRIBE_RESULT" \
'$download + $transcribe'
;;
esac